Merge pull request #694 from 4gray/refactor/playlist-filter

refactor/merge-playlist-filter-and-sort-into-one-menu
This commit is contained in:
4gray authored and GitHub committed 2025-12-21 01:04:12 +01:00
commit 13e3ef96e0
39 files changed
+839 -679

No files matched your search

+4 -4
View File
@@ -4,10 +4,8 @@ import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state';
import { filter, take } from 'rxjs';
/* import * as semver from 'semver'; */
import * as PlaylistActions from 'm3u-state';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { DataService, EpgService } from 'services';
import {
AUTO_UPDATE_PLAYLISTS,
@@ -28,7 +26,9 @@ import { SearchResultsComponent } from './xtream-tauri/search-results/search-res
})
export class AppComponent implements OnInit {
@HostBinding('class.macos-platform') get isMacOS() {
return window.electron && navigator.platform.toLowerCase().includes('mac');
return (
window.electron && navigator.platform.toLowerCase().includes('mac')
);
}
private actions$ = inject(Actions);
private dataService = inject(DataService);
@@ -2,7 +2,7 @@ import { Component, EventEmitter, inject, Output } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { addPlaylist } from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import { DragDropFileUploadDirective } from './drag-drop-file-upload.directive';
@Component({
@@ -45,7 +45,7 @@ export class FileUploadComponent {
playlistObject
);
this.store.dispatch(
addPlaylist({ playlist: playlistObject })
PlaylistActions.addPlaylist({ playlist: playlistObject })
);
} else {
// User canceled the dialog
@@ -11,7 +11,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { addPlaylist } from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import { DataService } from 'services';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
@@ -67,7 +67,7 @@ export class StalkerPortalImportComponent {
this.form.value.portalUrl
);
this.store.dispatch(
addPlaylist({ playlist: this.form.value as Playlist })
PlaylistActions.addPlaylist({ playlist: this.form.value as Playlist })
);
this.addClicked.emit();
}
@@ -27,7 +27,7 @@ import {
ToolbarComponent,
VjsPlayerComponent,
} from 'components';
import * as PlaylistActions from 'm3u-state';
import { PlaylistActions, ChannelActions, FavoritesActions } from 'm3u-state';
import {
selectActive,
selectChannels,
@@ -169,7 +169,7 @@ export class VideoPlayerComponent implements OnInit {
}
this.store.dispatch(
PlaylistActions.setChannels({
ChannelActions.setChannels({
channels: playlist.playlist.items,
})
);
@@ -180,14 +180,14 @@ export class VideoPlayerComponent implements OnInit {
playlist.favorites.length > 0
) {
this.store.dispatch(
PlaylistActions.setFavorites({
FavoritesActions.setFavorites({
channelIds: playlist.favorites,
})
);
} else {
// Clear favorites if playlist has none
this.store.dispatch(
PlaylistActions.setFavorites({
FavoritesActions.setFavorites({
channelIds: [],
})
);
@@ -255,7 +255,7 @@ export class VideoPlayerComponent implements OnInit {
// Dispatch action to change channel
const nextChannel = channels[nextIndex];
this.store.dispatch(
PlaylistActions.setActiveChannel({
ChannelActions.setActiveChannel({
channel: nextChannel,
})
);
@@ -444,7 +444,7 @@ export class VideoPlayerComponent implements OnInit {
.subscribe((channel) => {
if (channel) {
this.store.dispatch(
PlaylistActions.setActiveChannel({ channel })
ChannelActions.setActiveChannel({ channel })
);
}
});
@@ -12,7 +12,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { addPlaylist } from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import { PortalStatus, PortalStatusService } from 'services';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
@@ -137,7 +137,7 @@ export class XtreamCodeImportComponent {
url.port ? ':' + url.port : ''
}`;
this.store.dispatch(
addPlaylist({
PlaylistActions.addPlaylist({
playlist: {
...this.form.value,
serverUrl,
@@ -3,7 +3,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Params } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import * as PlaylistActions from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import { DataService } from 'services';
import {
AUTO_UPDATE_PLAYLISTS,
+1 -1
View File
@@ -5,7 +5,7 @@ import { Params } from '@angular/router';
import { SwUpdate } from '@angular/service-worker';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import * as PlaylistActions from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import { catchError, firstValueFrom, throwError } from 'rxjs';
import { DataService } from 'services';
import {
@@ -5,7 +5,7 @@ button {
.settings-container {
overflow: auto;
height: calc(100vh - 100px);
height: calc(100vh - 70px);
}
.row {
@@ -35,8 +35,7 @@ import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { QRCodeComponent } from 'angularx-qrcode';
import { DialogService } from 'components';
import * as PlaylistActions from 'm3u-state';
import { selectIsEpgAvailable } from 'm3u-state';
import { PlaylistActions, selectIsEpgAvailable } from 'm3u-state';
import { take } from 'rxjs';
import { DataService, EpgService, PlaylistsService } from 'services';
import {
@@ -8,7 +8,7 @@ import {
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { parsePlaylist } from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import { getFilenameFromUrl } from 'm3u-utils';
import { DataService } from 'services';
import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces';
@@ -57,11 +57,11 @@ export class AddPlaylistDialogComponent {
.result as string;
this.store.dispatch(
parsePlaylist({
PlaylistActions.parsePlaylist({
uploadType: 'FILE',
playlist,
title: payload.file.name,
path: (payload.file as any).path,
path: (payload.file as File & { path?: string }).path,
})
);
this.closeDialog();
@@ -93,7 +93,7 @@ export class AddPlaylistDialogComponent {
*/
uploadAsText(playlist: string): void {
this.store.dispatch(
parsePlaylist({
PlaylistActions.parsePlaylist({
uploadType: 'TEXT',
playlist,
title: this.translateService.instant('HOME.IMPORTED_AS_TEXT'),
@@ -0,0 +1,123 @@
<mat-menu #filterSortMenu="matMenu" class="filter-sort-menu" xPosition="before">
<!-- Filter section -->
<section class="filter-section">
<div class="menu-section-header">
<mat-icon class="section-icon">filter_list</mat-icon>
<span>{{ 'HOME.FILTER_BY_TYPE' | translate }}</span>
</div>
@for (type of playlistTypes; track type.id) {
<button
mat-menu-item
(click)="togglePlaylistType(type); $event.stopPropagation()"
[class.active-option]="type.checked"
>
<mat-icon>{{ type.icon }}</mat-icon>
<span class="menu-item-text">{{
type.translationKey | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="type.checked"
>check</mat-icon
>
</button>
}
</section>
<!-- Sort section -->
<section class="sort-section">
<div class="menu-section-header">
<mat-icon class="section-icon">sort</mat-icon>
<span>{{ 'HOME.SORT_BY' | translate }}</span>
</div>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.NAME, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.ASC)"
>
<mat-icon>sort_by_alpha</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NAME_ASC' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="isSortActive(SortBy.NAME, SortOrder.ASC)"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.NAME, SortOrder.DESC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.DESC)"
>
<mat-icon>sort_by_alpha</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NAME_DESC' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.NAME, SortOrder.DESC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.DATE_ADDED, SortOrder.DESC);
$event.stopPropagation()
"
[class.active-option]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>
<mat-icon>date_range</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NEWEST' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.DATE_ADDED, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>
<mat-icon>date_range</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.OLDEST' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>check</mat-icon
>
</button>
</section>
<!-- Close button -->
<div class="menu-footer">
<button mat-button (click)="closeMenu()">
{{ 'CLOSE' | translate }}
</button>
</div>
</mat-menu>
@@ -0,0 +1,90 @@
// Combined Filter & Sort menu styles
.filter-section,
.sort-section {
padding: 4px 0;
}
.menu-section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
pointer-events: none;
.section-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: rgba(139, 92, 246, 0.6);
}
}
// Menu item layout with right-aligned checkmark
::ng-deep .filter-sort-menu {
.mat-mdc-menu-item {
display: flex !important;
align-items: center !important;
gap: 12px !important;
min-height: 40px !important;
padding: 0 16px !important;
.mat-icon:first-child {
margin-right: 0 !important;
flex-shrink: 0;
}
.menu-item-text {
flex: 1;
text-align: left;
}
.check-icon {
margin-left: auto !important;
margin-right: 0 !important;
flex-shrink: 0;
opacity: 0;
transform: scale(0.8);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&.check-visible {
opacity: 1;
transform: scale(1);
}
}
}
}
.active-option {
background-color: rgba(139, 92, 246, 0.12) !important;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background: rgba(139, 92, 246, 0.8);
border-radius: 0 2px 2px 0;
}
}
.check-icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
color: rgba(139, 92, 246, 1) !important;
}
// Menu footer
.menu-footer {
padding: 4px 8px 4px;
display: flex;
justify-content: center;
}
@@ -0,0 +1,119 @@
import {
Component,
effect,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { FilterActions, selectActiveTypeFilters } from 'm3u-state';
import { SortBy, SortOrder, SortService } from 'services';
@Component({
selector: 'app-filter-sort-menu',
templateUrl: './filter-sort-menu.component.html',
styleUrls: ['./filter-sort-menu.component.scss'],
imports: [MatButtonModule, MatIconModule, MatMenuModule, TranslateModule],
})
export class FilterSortMenuComponent {
private store = inject(Store);
private sortService = inject(SortService);
readonly menu = viewChild.required<MatMenu>('filterSortMenu');
readonly menuTrigger = input<MatMenuTrigger>();
readonly filterChanged = output<void>();
playlistTypes = [
{
id: 'm3u',
icon: 'playlist_play',
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
checked: true,
},
{
id: 'xtream',
icon: 'cloud',
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
checked: true,
},
{
id: 'stalker',
icon: 'router',
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
checked: true,
},
];
private readonly selectedTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
readonly SortBy = SortBy;
readonly SortOrder = SortOrder;
private readonly currentSortOptions = toSignal(
this.sortService.getSortOptions(),
{
requireSync: true,
}
);
constructor() {
effect(() => {
if (this.selectedTypeFilters) {
this.playlistTypes = this.playlistTypes.map((type) => {
type.checked = this.selectedTypeFilters().includes(type.id);
return type;
});
}
});
}
togglePlaylistType(type: {
id: string;
icon: string;
translationKey: string;
checked: boolean;
}) {
const currentlySelectedCount = this.playlistTypes.filter(
(f) => f.checked
).length;
// Prevent deselecting if it's the last selected option
if (type.checked && currentlySelectedCount === 1) {
return;
}
type.checked = !type.checked;
this.store.dispatch(
FilterActions.setSelectedFilters({
selectedFilters: this.playlistTypes
.filter((f) => f.checked)
.map((f) => f.id),
})
);
this.filterChanged.emit();
}
setSortOptions(by: SortBy, order: SortOrder): void {
this.sortService.setSortOptions({ by, order });
}
isSortActive(by: SortBy, order: SortOrder): boolean {
const options = this.currentSortOptions();
return options?.by === by && options?.order === order;
}
get activeFiltersCount(): number {
return this.playlistTypes.filter((type) => !type.checked).length;
}
closeMenu(): void {
this.menuTrigger()?.closeMenu();
}
}
@@ -34,23 +34,23 @@
<app-add-playlist-menu
(playlistTypeSelected)="openAddPlaylistDialog($event)"
/>
<!-- Sort button -->
<!-- Combined Filter & Sort button -->
<button
mat-icon-button
[matMenuTriggerFor]="sortMenu"
[matTooltip]="'HOME.SORT_PLAYLISTS' | translate"
data-test-id="sort-playlists"
#filterMenuTrigger="matMenuTrigger"
[matMenuTriggerFor]="filterSortMenuComponent().menu()"
[matTooltip]="'HOME.FILTER_AND_SORT' | translate"
[matBadge]="filterSortMenuComponent().activeFiltersCount"
[matBadgeHidden]="
filterSortMenuComponent().activeFiltersCount === 0
"
matBadgeSize="small"
matBadgeColor="accent"
data-test-id="filter-sort-playlists"
>
<mat-icon>sort</mat-icon>
</button>
<button
mat-icon-button
[matMenuTriggerFor]="filterPlaylistMenu"
[matTooltip]="'HOME.FILTER_BY_TYPE' | translate"
data-test-id="filter-playlist-by-type"
>
<mat-icon>filter_alt</mat-icon>
<mat-icon>tune</mat-icon>
</button>
<app-filter-sort-menu [menuTrigger]="filterMenuTrigger" />
@if (isDesktop) {
<button
mat-icon-button
@@ -120,80 +120,6 @@
</button>
</mat-menu>
}
<!-- Sort menu -->
<mat-menu #sortMenu="matMenu">
<div
class="dropdown-menu-header"
(click)="$event.stopPropagation()"
>
{{ 'HOME.SORT_BY' | translate }}
</div>
<button
mat-menu-item
(click)="setSortOptions(SortBy.NAME, SortOrder.ASC)"
[class.active-sort]="
isSortActive(SortBy.NAME, SortOrder.ASC)
"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NAME_ASC' | translate }}</span>
</button>
<button
mat-menu-item
(click)="setSortOptions(SortBy.NAME, SortOrder.DESC)"
[class.active-sort]="
isSortActive(SortBy.NAME, SortOrder.DESC)
"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NAME_DESC' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
(click)="setSortOptions(SortBy.DATE_ADDED, SortOrder.DESC)"
[class.active-sort]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>
<mat-icon>date_range</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NEWEST' | translate }}</span>
</button>
<button
mat-menu-item
(click)="setSortOptions(SortBy.DATE_ADDED, SortOrder.ASC)"
[class.active-sort]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>
<mat-icon>date_range</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.OLDEST' | translate }}</span>
</button>
</mat-menu>
<mat-menu #filterPlaylistMenu="matMenu">
<section
(click)="$event.stopPropagation()"
class="filter-container"
>
<div
class="dropdown-menu-header"
(click)="$event.stopPropagation()"
>
{{ 'HOME.FILTER_BY_TYPE' | translate }}
</div>
@for (type of playlistTypes; track $index) {
<p>
<mat-checkbox
(click)="$event.stopPropagation()"
[checked]="true"
(change)="onPlaylistFilterChange()"
[(ngModel)]="type.checked"
>{{ type.title }}</mat-checkbox
>
</p>
}
</section>
</mat-menu>
</div>
}
</div>
@@ -344,10 +344,6 @@
app-region: no-drag; // Allow button interactions
}
.filter-container {
padding: 0 10px;
}
.add-playlist-btn {
background-color: rgba(139, 92, 246, 0.12) !important;
color: rgba(139, 92, 246, 1) !important;
@@ -365,35 +361,10 @@
}
}
.dropdown-menu-header {
padding: 0 16px;
margin: 8px 0;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.active-sort {
background-color: rgba(139, 92, 246, 0.12) !important;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background: rgba(139, 92, 246, 0.8);
border-radius: 0 2px 2px 0;
}
}
.check-icon {
margin-left: 8px;
// Badge styling for filter count
::ng-deep .mat-badge-accent .mat-badge-content {
background: rgba(139, 92, 246, 1);
color: white;
}
// Refined Material button and icon overrides
@@ -2,30 +2,28 @@ import {
Component,
HostBinding,
OnInit,
effect,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { DataService, SortBy, SortOrder, SortService } from 'services';
import { DataService } from 'services';
//import { shell } from 'electron';
import { AddPlaylistMenuComponent, PlaylistType } from 'components';
import { selectActiveTypeFilters, setSelectedFilters } from 'm3u-state';
import { HomeComponent } from '../../../home/home.component';
import { AboutDialogComponent } from '../about-dialog/about-dialog.component';
import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog.component';
import { FilterSortMenuComponent } from '../filter-sort-menu/filter-sort-menu.component';
@Component({
selector: 'app-header',
@@ -33,9 +31,10 @@ import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog.
styleUrls: ['./header.component.scss'],
imports: [
AddPlaylistMenuComponent,
FilterSortMenuComponent,
FormsModule,
MatBadgeModule,
MatButtonModule,
MatCheckboxModule,
MatDividerModule,
MatIconModule,
MatMenuModule,
@@ -53,12 +52,13 @@ export class HeaderComponent implements OnInit {
private dialog = inject(MatDialog);
private dataService = inject(DataService);
private router = inject(Router);
private store = inject(Store);
private sortService = inject(SortService);
readonly addPlaylistMenuComponent = viewChild.required(
AddPlaylistMenuComponent
);
readonly filterSortMenuComponent = viewChild.required(
FilterSortMenuComponent
);
readonly isDesktop = !!window.electron;
readonly title = input.required<string>();
readonly subtitle = input.required<string>();
@@ -66,47 +66,6 @@ export class HeaderComponent implements OnInit {
isHome = true;
playlistTypes = [
{
title: 'M3U (local, url, text)',
id: 'm3u',
checked: true,
},
{
title: 'Xtream',
id: 'xtream',
checked: true,
},
{
title: 'Stalker',
id: 'stalker',
checked: true,
},
];
private readonly selectedTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
readonly SortBy = SortBy;
readonly SortOrder = SortOrder;
private currentSortOptions: { by: SortBy; order: SortOrder };
constructor() {
effect(() => {
if (this.selectedTypeFilters) {
this.playlistTypes = this.playlistTypes.map((type) => {
type.checked = this.selectedTypeFilters().includes(type.id);
return type;
});
}
});
this.sortService.getSortOptions().subscribe((options) => {
this.currentSortOptions = options;
});
}
ngOnInit() {
this.isHome =
this.activatedRoute.snapshot.component.name === HomeComponent.name;
@@ -154,27 +113,6 @@ export class HeaderComponent implements OnInit {
);
}
onPlaylistFilterChange() {
this.store.dispatch(
setSelectedFilters({
selectedFilters: this.playlistTypes
.filter((f) => f.checked)
.map((f) => f.id),
})
);
}
setSortOptions(by: SortBy, order: SortOrder): void {
this.sortService.setSortOptions({ by, order });
}
isSortActive(by: SortBy, order: SortOrder): boolean {
return (
this.currentSortOptions?.by === by &&
this.currentSortOptions?.order === order
);
}
onSearchQueryUpdate(query: string): void {
this.searchQuery.emit(query);
}
@@ -1,7 +1,7 @@
import { Component, effect, inject, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { Store } from '@ngrx/store';
import * as PlaylistActions from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import { selectPlaylistById } from 'm3u-state';
import { NavigationComponent } from '../../xtream-tauri/navigation/navigation.component';
import { NavigationItem } from '../../xtream-tauri/navigation/navigation.interface';
@@ -6,7 +6,7 @@ import { Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { DialogService, PlaylistInfoComponent } from 'components';
import * as PlaylistActions from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import { selectCurrentPlaylist } from 'm3u-state';
import { DatabaseService } from 'services';
import { Playlist } from 'shared-interfaces';
@@ -2,7 +2,7 @@ import { Component, effect, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import * as PlaylistActions from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import { LoadingOverlayComponent } from '../loading-overlay/loading-overlay.component';
import { NavigationComponent } from '../navigation/navigation.component';
import { NavigationItem } from '../navigation/navigation.interface';
@@ -6,7 +6,7 @@ import { Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { DialogService, PlaylistInfoComponent } from 'components';
import * as PlaylistActions from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import { selectCurrentPlaylist } from 'm3u-state';
import { Playlist } from 'shared-interfaces';
+7 -1
View File
@@ -150,8 +150,9 @@
"MAC_ADDRESS": "Mac-Adresse",
"URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)"
},
"FILTER_BY_TYPE": "Nach Typ filtern",
"FILTER_BY_TYPE": "Playlist-Typen",
"FILTER_BY_NAME": "Nach Namen filtern",
"FILTER_AND_SORT": "Filtern & Sortieren",
"SORT_PLAYLISTS": "Playlisten sortieren",
"SORT_BY": "Sortieren nach",
"SORT_OPTIONS": {
@@ -160,6 +161,11 @@
"NEWEST": "Hinzugefügt (Neueste zuerst)",
"OLDEST": "Hinzugefügt (Älteste zuerst)"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (lokal, URL, Text)",
"XTREAM": "Xtream",
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Als Text importiert",
"PARSING_ERROR": "Fehler: Parsen fehlgeschlagen, keine gültige Playlist:"
},
+8 -2
View File
@@ -112,15 +112,21 @@
"ADD": "Add"
},
"FILTER_BY_NAME": "Filter by name",
"FILTER_BY_TYPE": "Filter by type",
"FILTER_BY_TYPE": "Playlist Types",
"FILTER_AND_SORT": "Filter & Sort",
"SORT_PLAYLISTS": "Sort playlists",
"SORT_BY": "Sort by",
"SORT_BY": "Sort By",
"SORT_OPTIONS": {
"NAME_ASC": "Name (A-Z)",
"NAME_DESC": "Name (Z-A)",
"NEWEST": "Date added (Newest first)",
"OLDEST": "Date added (Oldest first)"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, url, text)",
"XTREAM": "Xtream",
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Imported as text",
"PARSING_ERROR": "Error: Parsing failed, not a valid playlist:"
},
+7 -1
View File
@@ -109,8 +109,9 @@
"SERVER_URL": "URL сервера",
"URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c)."
},
"FILTER_BY_TYPE": "Фильтровать по типу",
"FILTER_BY_TYPE": "Типы плейлистов",
"FILTER_BY_NAME": "Фильтровать по имени",
"FILTER_AND_SORT": "Фильтр и сортировка",
"SORT_PLAYLISTS": "Сортировать плейлисты",
"SORT_BY": "Сортировать по",
"SORT_OPTIONS": {
@@ -119,6 +120,11 @@
"NEWEST": "Дате добавления (сначала новые)",
"OLDEST": "Дате добавления (сначала старые)"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (локально, URL, текст)",
"XTREAM": "Xtream",
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Импортировано как текст",
"PARSING_ERROR": "Ошибка парсинга, недействительный плейлист:"
},
+64 -134
View File
@@ -1,140 +1,70 @@
import { createAction, props } from '@ngrx/store';
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Channel, EpgProgram, Playlist, PlaylistMeta } from 'shared-interfaces';
const STORE_KEY = '[GLOBAL STORE]';
const PLAYLISTS_STORE_KEY = '[PLAYLISTS STORE]';
export const PlaylistActions = createActionGroup({
source: 'Playlists',
events: {
'Load Playlists': emptyProps(),
'Load Playlists Success': props<{ playlists: PlaylistMeta[] }>(),
'Add Playlist': props<{ playlist: Playlist }>(),
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(),
'Update Playlist': props<{ playlist: Playlist; playlistId: string }>(),
'Update Many Playlists': props<{ playlists: Playlist[] }>(),
'Parse Playlist': props<{
uploadType: 'FILE' | 'URL' | 'TEXT';
playlist: string;
title: string;
path?: string;
}>(),
'Set Active Playlist': props<{ playlistId: string }>(),
'Update Playlist Positions': props<{
positionUpdates: { id: string; changes: { position: number } }[];
}>(),
'Remove All Playlists': emptyProps(),
'Set Current Playlist Id': props<{ playlistId: string | undefined }>(),
'Handle Adding Playlist By Url': props<{
isTemporary: boolean;
playlist: Playlist;
}>(),
},
});
export const loadPlaylists = createAction(
`${PLAYLISTS_STORE_KEY} Load playlists from db`
);
export const ChannelActions = createActionGroup({
source: 'Channels',
events: {
'Set Channels': props<{ channels: Channel[] }>(),
'Set Active Channel': props<{ channel: Channel }>(),
'Set Active Channel Success': props<{ channel: Channel }>(),
'Reset Active Channel': emptyProps(),
'Set Adjacent Channel As Active': props<{
direction: 'next' | 'previous';
}>(),
},
});
export const loadPlaylistsSuccess = createAction(
`${PLAYLISTS_STORE_KEY} Successfully loaded playlists from db`,
props<{ playlists: PlaylistMeta[] }>()
);
export const EpgActions = createActionGroup({
source: 'EPG',
events: {
'Set Active Epg Program': props<{ program: EpgProgram }>(),
'Set Current Epg Program': props<{ program: EpgProgram }>(),
'Reset Active Epg Program': emptyProps(),
'Set Epg Available Flag': props<{ value: boolean }>(),
},
});
export const addPlaylist = createAction(
`${PLAYLISTS_STORE_KEY} Add new playlist`,
props<{ playlist: Playlist }>()
);
export const FavoritesActions = createActionGroup({
source: 'Favorites',
events: {
'Update Favorites': props<{ channel: Channel }>(),
'Set Favorites': props<{ channelIds: string[] }>(),
},
});
export const addManyPlaylists = createAction(
`${PLAYLISTS_STORE_KEY} Add many playlists`,
props<{ playlists: Playlist[] }>()
);
export const removePlaylist = createAction(
`${PLAYLISTS_STORE_KEY} Remove playlist by id`,
props<{ playlistId: string }>()
);
export const updatePlaylistMeta = createAction(
`${PLAYLISTS_STORE_KEY} update playlist meta`,
props<{ playlist: PlaylistMeta }>()
);
export const updatePlaylist = createAction(
`${PLAYLISTS_STORE_KEY} update playlist`,
props<{ playlist: Playlist; playlistId: string }>()
);
export const updateManyPlaylists = createAction(
`${PLAYLISTS_STORE_KEY} Update many playlists (auto-update mechanism)`,
props<{ playlists: Playlist[] }>()
);
export const parsePlaylist = createAction(
`${PLAYLISTS_STORE_KEY} parse playlist`,
props<{
uploadType: 'FILE' | 'URL' | 'TEXT';
playlist: string;
title: string;
path?: string;
}>()
);
export const setActivePlaylist = createAction(
`${PLAYLISTS_STORE_KEY} set active playlist`,
props<{ playlistId: string }>()
);
export const updateFavorites = createAction(
`${STORE_KEY} Add/remove provided channel to the favorites`,
props<{ channel: Channel }>()
);
export const setFavorites = createAction(
`${STORE_KEY} Set favorites`,
props<{ channelIds: string[] }>()
);
export const setActiveChannel = createAction(
`${STORE_KEY} Set active channel`,
props<{ channel: Channel }>()
);
export const resetActiveChannel = createAction(
`${STORE_KEY} Reset active channel`
);
export const setActiveChannelSuccess = createAction(
`${STORE_KEY} Set active channel success`,
props<{ channel: Channel }>()
);
export const setActiveEpgProgram = createAction(
`${STORE_KEY} Sets the given timestamp for the epg program`,
props<{ program: EpgProgram }>()
);
export const setCurrentEpgProgram = createAction(
`${STORE_KEY} Updates the active epg program for the active channel`,
props<{ program: EpgProgram }>()
);
export const resetActiveEpgProgram = createAction(
`${STORE_KEY} Reset active epg program`
);
export const setEpgAvailableFlag = createAction(
`${STORE_KEY} Reset active epg program`,
props<{ value: boolean }>()
);
export const setChannels = createAction(
`${STORE_KEY} Set channels`,
props<{ channels: Channel[] }>()
);
export const updatePlaylistPositions = createAction(
`${STORE_KEY} Update playlist positions`,
props<{
positionUpdates: { id: string; changes: { position: number } }[];
}>()
);
export const removeAllPlaylists = createAction(
`${STORE_KEY} Remove all playlists`
);
export const setAdjacentChannelAsActive = createAction(
`${STORE_KEY} Set adjacent channel as active`,
props<{
direction: 'next' | 'previous';
}>()
);
export const setSelectedFilters = createAction(
`${STORE_KEY} Set selected filters`,
props<{ selectedFilters: string[] }>()
);
export const setCurrentPlaylistId = createAction(
`${STORE_KEY} Set current playlist id`,
props<{ playlistId: string | undefined }>()
);
export const handleAddingPlaylistByUrl = createAction(
`${PLAYLISTS_STORE_KEY} Handle adding playlist by URL`,
props<{ isTemporary: boolean; playlist: Playlist }>()
);
export const FilterActions = createActionGroup({
source: 'Filters',
events: {
'Set Selected Filters': props<{ selectedFilters: string[] }>(),
},
});
+13 -8
View File
@@ -24,7 +24,12 @@ import {
STORE_KEY,
VideoPlayer,
} from 'shared-interfaces';
import * as PlaylistActions from './actions';
import {
PlaylistActions,
ChannelActions,
EpgActions,
FavoritesActions,
} from './actions';
import {
selectActive,
selectActivePlaylistId,
@@ -47,7 +52,7 @@ export class PlaylistEffects {
updateFavorites$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updateFavorites),
ofType(FavoritesActions.updateFavorites),
combineLatestWith(
this.store.select(selectFavorites),
this.store.select(selectActivePlaylistId)
@@ -70,7 +75,7 @@ export class PlaylistEffects {
setFavorites$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.setFavorites),
ofType(FavoritesActions.setFavorites),
combineLatestWith(this.store.select(selectActivePlaylistId)),
filter(
([, playlistId]) =>
@@ -92,7 +97,7 @@ export class PlaylistEffects {
setActiveEpgProgram$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.setActiveEpgProgram),
ofType(EpgActions.setActiveEpgProgram),
combineLatestWith(this.store.select(selectActive)),
map(([, activeChannel]) => {
firstValueFrom(this.storage.get(STORE_KEY.Settings)).then(
@@ -135,7 +140,7 @@ export class PlaylistEffects {
setActiveChannel$ = createEffect(() => {
return this.actions$.pipe(
ofType(PlaylistActions.setActiveChannel),
ofType(ChannelActions.setActiveChannel),
// Skip the effect entirely when channel is falsy
filter((action) => !!action.channel),
map((action) => {
@@ -186,7 +191,7 @@ export class PlaylistEffects {
}
);
return PlaylistActions.setActiveChannelSuccess({
return ChannelActions.setActiveChannelSuccess({
channel: action.channel,
});
})
@@ -400,7 +405,7 @@ export class PlaylistEffects {
setAdjacentChannelAsActive$ = createEffect(() => {
return this.actions$.pipe(
ofType(PlaylistActions.setAdjacentChannelAsActive),
ofType(ChannelActions.setAdjacentChannelAsActive),
withLatestFrom(
this.store.select(selectChannels),
this.store.select(selectActive)
@@ -419,7 +424,7 @@ export class PlaylistEffects {
adjacentChannel = activeChannel;
adjacentChannel = channels[index - 1];
}
return PlaylistActions.setActiveChannelSuccess({
return ChannelActions.setActiveChannelSuccess({
channel: adjacentChannel!,
});
})
+2 -292
View File
@@ -1,292 +1,2 @@
import { createReducer, on } from '@ngrx/store';
import moment from 'moment';
import { Channel } from 'shared-interfaces';
import * as PlaylistActions from './actions';
import { playlistsAdapter } from './playlists.state';
import { PlaylistState, initialState } from './state';
export const playlistReducer = createReducer(
initialState,
on(PlaylistActions.setActiveEpgProgram, (state, action): PlaylistState => {
const { program } = action;
const from = moment(program.start, 'YYYYMMDDHHmm ZZ').unix();
const now = moment(Date.now()).unix();
const epgParams = `?utc=${from}&lutc=${now}`;
return {
...state,
active: { ...state.active, epgParams } as Channel,
};
}),
on(
PlaylistActions.resetActiveEpgProgram,
(state): PlaylistState => ({
...state,
active: { ...state.active, epgParams: '' } as Channel,
})
),
on(
PlaylistActions.setActiveChannelSuccess,
(state, action): PlaylistState => {
const { channel } = action;
return {
...state,
active: { ...channel, epgParams: '' } as Channel,
};
}
),
on(PlaylistActions.resetActiveChannel, (state): PlaylistState => {
return {
...state,
active: undefined,
};
}),
on(
PlaylistActions.setCurrentEpgProgram,
(state, action): PlaylistState => ({
...state,
currentEpgProgram: action.program,
})
),
on(
PlaylistActions.setEpgAvailableFlag,
(state, action): PlaylistState => ({
...state,
epgAvailable: action.value,
})
),
on(PlaylistActions.updateFavorites, (state, action): PlaylistState => {
let favorites;
const { channel } = action;
const playlistFavorites =
state.playlists.entities[state.playlists.selectedId]?.favorites;
if (playlistFavorites?.includes(channel.url)) {
favorites = [
...(playlistFavorites ?? []).filter(
(url: string) => url !== channel.url
),
];
} else {
favorites = [...(playlistFavorites ?? []), channel.url];
}
return {
...state,
playlists: {
...state.playlists,
entities: {
...state.playlists.entities,
[state.playlists.selectedId]: {
...state.playlists.entities[state.playlists.selectedId],
favorites,
},
},
},
};
}),
on(PlaylistActions.setFavorites, (state, action): PlaylistState => {
const { channelIds } = action;
return {
...state,
playlists: {
...state.playlists,
entities: {
...state.playlists.entities,
[state.playlists.selectedId]: {
...state.playlists.entities[state.playlists.selectedId],
favorites: channelIds,
},
},
},
};
}),
on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(action.playlists, {
...state.playlists,
allPlaylistsLoaded: true,
}),
};
}),
on(PlaylistActions.removePlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.removeOne(
action.playlistId,
state.playlists
),
};
}),
on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateOne(
{
id: action.playlistId,
changes: {
...action.playlist,
_id: action.playlistId,
updateDate: Date.now(),
count: action.playlist.playlist.items.length,
userAgent: action.playlist.userAgent,
favorites:
state.playlists.entities[action.playlistId]
?.favorites ?? [],
},
},
state.playlists
),
};
}),
on(PlaylistActions.addPlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addOne(
action.playlist,
state.playlists
),
};
}),
on(PlaylistActions.addManyPlaylists, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(
action.playlists,
state.playlists
),
};
}),
on(PlaylistActions.setActivePlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedId: action.playlistId,
},
};
}),
on(
PlaylistActions.updatePlaylistPositions,
(state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateMany(
action.positionUpdates,
state.playlists
),
};
}
),
on(PlaylistActions.updatePlaylistMeta, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateOne(
{
id: action.playlist._id,
changes: {
title: action.playlist.title,
autoRefresh: action.playlist.autoRefresh || false,
userAgent: action.playlist.userAgent,
...(action.playlist.serverUrl !== null
? { serverUrl: action.playlist.serverUrl }
: {}),
...(action.playlist.username !== null
? { username: action.playlist.username }
: {}),
...(action.playlist.password !== null
? { password: action.playlist.password }
: {}),
...(action.playlist.macAddress !== null
? { macAddress: action.playlist.macAddress }
: {}),
...(action.playlist.portalUrl !== null
? { portalUrl: action.playlist.portalUrl }
: {}),
},
},
state.playlists
),
};
}),
on(PlaylistActions.setChannels, (state, action): PlaylistState => {
return {
...state,
channels: action.channels,
};
}),
on(PlaylistActions.updateManyPlaylists, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateMany(
action.playlists.map((updatedPlaylist) => ({
id: updatedPlaylist._id,
changes: {
...updatedPlaylist,
updateDate: Date.now(),
},
})),
state.playlists
),
};
}),
on(PlaylistActions.removeAllPlaylists, (state): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.removeAll(state.playlists),
};
}),
on(
PlaylistActions.setSelectedFilters,
(state, { selectedFilters }): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedFilters,
},
};
}
),
on(
PlaylistActions.setCurrentPlaylistId,
(state, { playlistId }): PlaylistState => {
return {
...state,
currentPlaylistId: playlistId,
};
}
),
on(
PlaylistActions.handleAddingPlaylistByUrl,
(state, action): PlaylistState => {
if (action.isTemporary) {
return {
...state,
channels: action.playlist.playlist.items as Channel[],
};
} else {
return {
...state,
playlists: playlistsAdapter.addOne(
action.playlist,
state.playlists
),
};
}
}
)
);
export const selectPlaylists = (state: PlaylistState) => state.playlists;
export const selectPlaylistId = (state: PlaylistState) =>
state.playlists?.selectedId;
export const selectIsEpgAvailableReducer = (state: PlaylistState) =>
state.epgAvailable;
export const selectActiveReducer = (state: PlaylistState) => state.active;
export const selectCurrentEpgProgramReducer = (state: PlaylistState) =>
state.currentEpgProgram;
export const selectChannelsReducer = (state: PlaylistState) => state.channels;
export const selectPlaylistsLoadingFlagReducer = (state: PlaylistState) =>
state.playlists?.allPlaylistsLoaded;
export const selectCurrentPlaylistIdReducer = (state: PlaylistState) =>
state.currentPlaylistId;
// Re-export from the new modular reducers structure
export * from './reducers/index';
@@ -0,0 +1,29 @@
import { on } from '@ngrx/store';
import { Channel } from 'shared-interfaces';
import { ChannelActions } from '../actions';
import { PlaylistState } from '../state';
export const channelReducers = [
on(
ChannelActions.setActiveChannelSuccess,
(state, action): PlaylistState => {
const { channel } = action;
return {
...state,
active: { ...channel, epgParams: '' } as Channel,
};
}
),
on(ChannelActions.resetActiveChannel, (state): PlaylistState => {
return {
...state,
active: undefined,
};
}),
on(ChannelActions.setChannels, (state, action): PlaylistState => {
return {
...state,
channels: action.channels,
};
}),
];
@@ -0,0 +1,39 @@
import { on } from '@ngrx/store';
import moment from 'moment';
import { Channel } from 'shared-interfaces';
import { EpgActions } from '../actions';
import { PlaylistState } from '../state';
export const epgReducers = [
on(EpgActions.setActiveEpgProgram, (state, action): PlaylistState => {
const { program } = action;
const from = moment(program.start, 'YYYYMMDDHHmm ZZ').unix();
const now = moment(Date.now()).unix();
const epgParams = `?utc=${from}&lutc=${now}`;
return {
...state,
active: { ...state.active, epgParams } as Channel,
};
}),
on(
EpgActions.resetActiveEpgProgram,
(state): PlaylistState => ({
...state,
active: { ...state.active, epgParams: '' } as Channel,
})
),
on(
EpgActions.setCurrentEpgProgram,
(state, action): PlaylistState => ({
...state,
currentEpgProgram: action.program,
})
),
on(
EpgActions.setEpgAvailableFlag,
(state, action): PlaylistState => ({
...state,
epgAvailable: action.value,
})
),
];
@@ -0,0 +1,50 @@
import { on } from '@ngrx/store';
import { FavoritesActions } from '../actions';
import { PlaylistState } from '../state';
export const favoritesReducers = [
on(FavoritesActions.updateFavorites, (state, action): PlaylistState => {
let favorites;
const { channel } = action;
const playlistFavorites =
state.playlists.entities[state.playlists.selectedId]?.favorites;
if (playlistFavorites?.includes(channel.url)) {
favorites = [
...(playlistFavorites ?? []).filter(
(url: string) => url !== channel.url
),
];
} else {
favorites = [...(playlistFavorites ?? []), channel.url];
}
return {
...state,
playlists: {
...state.playlists,
entities: {
...state.playlists.entities,
[state.playlists.selectedId]: {
...state.playlists.entities[state.playlists.selectedId],
favorites,
},
},
},
};
}),
on(FavoritesActions.setFavorites, (state, action): PlaylistState => {
const { channelIds } = action;
return {
...state,
playlists: {
...state.playlists,
entities: {
...state.playlists.entities,
[state.playlists.selectedId]: {
...state.playlists.entities[state.playlists.selectedId],
favorites: channelIds,
},
},
},
};
}),
];
@@ -0,0 +1,18 @@
import { on } from '@ngrx/store';
import { FilterActions } from '../actions';
import { PlaylistState } from '../state';
export const filterReducers = [
on(
FilterActions.setSelectedFilters,
(state, { selectedFilters }): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedFilters,
},
};
}
),
];
+32
View File
@@ -0,0 +1,32 @@
import { createReducer } from '@ngrx/store';
import { initialState, PlaylistState } from '../state';
import { channelReducers } from './channel.reducers';
import { epgReducers } from './epg.reducers';
import { favoritesReducers } from './favorites.reducers';
import { filterReducers } from './filter.reducers';
import { playlistReducers } from './playlist.reducers';
export const playlistReducer = createReducer(
initialState,
...epgReducers,
...channelReducers,
...playlistReducers,
...favoritesReducers,
...filterReducers
);
// Selectors
export const selectPlaylists = (state: PlaylistState) => state.playlists;
export const selectPlaylistId = (state: PlaylistState) =>
state.playlists?.selectedId;
export const selectIsEpgAvailableReducer = (state: PlaylistState) =>
state.epgAvailable;
export const selectActiveReducer = (state: PlaylistState) => state.active;
export const selectCurrentEpgProgramReducer = (state: PlaylistState) =>
state.currentEpgProgram;
export const selectChannelsReducer = (state: PlaylistState) => state.channels;
export const selectPlaylistsLoadingFlagReducer = (state: PlaylistState) =>
state.playlists?.allPlaylistsLoaded;
export const selectCurrentPlaylistIdReducer = (state: PlaylistState) =>
state.currentPlaylistId;
@@ -0,0 +1,166 @@
import { on } from '@ngrx/store';
import { Channel } from 'shared-interfaces';
import { PlaylistActions } from '../actions';
import { playlistsAdapter } from '../playlists.state';
import { PlaylistState } from '../state';
export const playlistReducers = [
on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(action.playlists, {
...state.playlists,
allPlaylistsLoaded: true,
}),
};
}),
on(PlaylistActions.removePlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.removeOne(
action.playlistId,
state.playlists
),
};
}),
on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateOne(
{
id: action.playlistId,
changes: {
...action.playlist,
_id: action.playlistId,
updateDate: Date.now(),
count: action.playlist.playlist.items.length,
userAgent: action.playlist.userAgent,
favorites:
state.playlists.entities[action.playlistId]
?.favorites ?? [],
},
},
state.playlists
),
};
}),
on(PlaylistActions.addPlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addOne(
action.playlist,
state.playlists
),
};
}),
on(PlaylistActions.addManyPlaylists, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(
action.playlists,
state.playlists
),
};
}),
on(PlaylistActions.setActivePlaylist, (state, action): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedId: action.playlistId,
},
};
}),
on(
PlaylistActions.updatePlaylistPositions,
(state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateMany(
action.positionUpdates,
state.playlists
),
};
}
),
on(PlaylistActions.updatePlaylistMeta, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateOne(
{
id: action.playlist._id,
changes: {
title: action.playlist.title,
autoRefresh: action.playlist.autoRefresh || false,
userAgent: action.playlist.userAgent,
...(action.playlist.serverUrl !== null
? { serverUrl: action.playlist.serverUrl }
: {}),
...(action.playlist.username !== null
? { username: action.playlist.username }
: {}),
...(action.playlist.password !== null
? { password: action.playlist.password }
: {}),
...(action.playlist.macAddress !== null
? { macAddress: action.playlist.macAddress }
: {}),
...(action.playlist.portalUrl !== null
? { portalUrl: action.playlist.portalUrl }
: {}),
},
},
state.playlists
),
};
}),
on(PlaylistActions.updateManyPlaylists, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.updateMany(
action.playlists.map((updatedPlaylist) => ({
id: updatedPlaylist._id,
changes: {
...updatedPlaylist,
updateDate: Date.now(),
},
})),
state.playlists
),
};
}),
on(PlaylistActions.removeAllPlaylists, (state): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.removeAll(state.playlists),
};
}),
on(
PlaylistActions.setCurrentPlaylistId,
(state, { playlistId }): PlaylistState => {
return {
...state,
currentPlaylistId: playlistId,
};
}
),
on(
PlaylistActions.handleAddingPlaylistByUrl,
(state, action): PlaylistState => {
if (action.isTemporary) {
return {
...state,
channels: action.playlist.playlist.items as Channel[],
};
} else {
return {
...state,
playlists: playlistsAdapter.addOne(
action.playlist,
state.playlists
),
};
}
}
),
];
@@ -12,7 +12,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatSliderModule } from '@angular/material/slider';
import { Store } from '@ngrx/store';
import { setAdjacentChannelAsActive } from 'm3u-state';
import { ChannelActions } from 'm3u-state';
@Component({
selector: 'app-audio-player',
@@ -125,6 +125,6 @@ export class AudioPlayerComponent implements OnChanges {
}
switchChannel(direction: 'next' | 'previous') {
this.store.dispatch(setAdjacentChannelAsActive({ direction }));
this.store.dispatch(ChannelActions.setAdjacentChannelAsActive({ direction }));
}
}
@@ -28,7 +28,7 @@ import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import * as _ from 'lodash';
import * as PlaylistActions from 'm3u-state';
import { ChannelActions, FavoritesActions } from 'm3u-state';
import {
selectActive,
selectActivePlaylistId,
@@ -165,7 +165,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
* @param channel selected channel
*/
selectChannel(channel: Channel): void {
this.store.dispatch(PlaylistActions.setActiveChannel({ channel }));
this.store.dispatch(ChannelActions.setActiveChannel({ channel }));
// Use tvg-id for EPG matching, fallback to channel name if not available
const epgChannelId = channel?.tvg?.id?.trim() || channel?.name.trim();
@@ -187,7 +187,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
undefined,
{ duration: 2000 }
);
this.store.dispatch(PlaylistActions.updateFavorites({ channel }));
this.store.dispatch(FavoritesActions.updateFavorites({ channel }));
}
trackByFn(_: number, channel: Channel): string {
@@ -197,7 +197,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
drop(event: CdkDragDrop<Channel[]>, favorites: Channel[]) {
moveItemInArray(favorites, event.previousIndex, event.currentIndex);
this.store.dispatch(
PlaylistActions.setFavorites({
FavoritesActions.setFavorites({
channelIds: favorites.map((item) => item.url),
})
);
@@ -230,7 +230,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
}
ngOnDestroy() {
this.store.dispatch(PlaylistActions.setChannels({ channels: [] }));
this.store.dispatch(ChannelActions.setChannels({ channels: [] }));
if (this.epgRefreshInterval) {
clearInterval(this.epgRefreshInterval);
@@ -8,11 +8,8 @@ import { MomentDatePipe } from '@iptvnator/pipes';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import {
resetActiveEpgProgram,
selectActive,
setActiveEpgProgram,
setCurrentEpgProgram,
setEpgAvailableFlag,
EpgActions,
} from 'm3u-state';
import moment from 'moment';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
@@ -157,7 +154,7 @@ export class EpgListComponent implements OnInit {
// Dispatch EPG availability flag
this.store.dispatch(
setEpgAvailableFlag({ value: programs.length > 0 })
EpgActions.setEpgAvailableFlag({ value: programs.length > 0 })
);
if (programs.length > 0) {
@@ -165,7 +162,7 @@ export class EpgListComponent implements OnInit {
} else {
this.channel = {} as EpgChannel;
// Clear the current EPG program when no programs available
this.store.dispatch(resetActiveEpgProgram());
this.store.dispatch(EpgActions.resetActiveEpgProgram());
}
// Trigger change detection for OnPush strategy
@@ -223,7 +220,7 @@ export class EpgListComponent implements OnInit {
// Always dispatch the action, even when playingNow is null/undefined
// This ensures the store is updated and old EPG data is cleared
this.store.dispatch(
setCurrentEpgProgram({ program: this.playingNow })
EpgActions.setCurrentEpgProgram({ program: this.playingNow })
);
// Trigger change detection for OnPush strategy
this.cdr.markForCheck();
@@ -242,10 +239,10 @@ export class EpgListComponent implements OnInit {
timeshift?: boolean
): void {
if (isLive) {
this.store.dispatch(resetActiveEpgProgram());
this.store.dispatch(EpgActions.resetActiveEpgProgram());
} else {
if (!timeshift) return;
this.store.dispatch(setActiveEpgProgram({ program }));
this.store.dispatch(EpgActions.setActiveEpgProgram({ program }));
}
this.playingNow = program;
// Trigger change detection for OnPush strategy
@@ -17,7 +17,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import * as PlaylistActions from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import { firstValueFrom } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { Playlist, PlaylistMeta } from 'shared-interfaces';
@@ -9,7 +9,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import * as PlaylistActions from 'm3u-state';
import { PlaylistActions } from 'm3u-state';
import {
selectActiveTypeFilters,
selectAllPlaylistsMeta,
@@ -6,7 +6,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import * as PlaylistActions from 'm3u-state';
import { ChannelActions } from 'm3u-state';
import { selectPlaylistTitle } from 'm3u-state';
import { Channel, SidebarView } from 'shared-interfaces';
import { RecentPlaylistsComponent } from '../../recent-playlists/recent-playlists.component';
@@ -38,7 +38,7 @@ export class SidebarComponent {
goBack() {
if (this.sidebarView === 'PLAYLISTS') {
this.store.dispatch(PlaylistActions.resetActiveChannel());
this.store.dispatch(ChannelActions.resetActiveChannel());
this.router.navigate(['/']);
} else {
this.sidebarView = 'PLAYLISTS';
@@ -11,7 +11,7 @@ import {
selectActivePlaylistId,
selectFavorites,
selectIsEpgAvailable,
updateFavorites,
FavoritesActions,
} from 'm3u-state';
import { Channel } from 'shared-interfaces';
@@ -46,6 +46,6 @@ export class ToolbarComponent {
readonly playlistId$ = this.store.select(selectActivePlaylistId);
updateFavoriteStatus(channel: Channel) {
this.store.dispatch(updateFavorites({ channel }));
this.store.dispatch(FavoritesActions.updateFavorites({ channel }));
}
}