diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index a42953de3..e48bb3113 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -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); diff --git a/apps/web/src/app/home/file-upload/file-upload.component.ts b/apps/web/src/app/home/file-upload/file-upload.component.ts index b4de5ff75..6fea546d4 100644 --- a/apps/web/src/app/home/file-upload/file-upload.component.ts +++ b/apps/web/src/app/home/file-upload/file-upload.component.ts @@ -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 diff --git a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts b/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts index 7312e6997..f0c4d1a90 100644 --- a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts +++ b/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts @@ -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(); } diff --git a/apps/web/src/app/home/video-player/video-player.component.ts b/apps/web/src/app/home/video-player/video-player.component.ts index a7327012f..da9cc285e 100644 --- a/apps/web/src/app/home/video-player/video-player.component.ts +++ b/apps/web/src/app/home/video-player/video-player.component.ts @@ -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 }) ); } }); diff --git a/apps/web/src/app/home/xtream-code-import/xtream-code-import.component.ts b/apps/web/src/app/home/xtream-code-import/xtream-code-import.component.ts index f4f3e20c3..dbdb9e759 100644 --- a/apps/web/src/app/home/xtream-code-import/xtream-code-import.component.ts +++ b/apps/web/src/app/home/xtream-code-import/xtream-code-import.component.ts @@ -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, diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index cee7b310f..3bee601e9 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -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, diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index e4535f2e0..643246807 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -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 { diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 0e0b4ac55..da5e2ff73 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -5,7 +5,7 @@ button { .settings-container { overflow: auto; - height: calc(100vh - 100px); + height: calc(100vh - 70px); } .row { diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 21d578c35..f12c0062e 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -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 { diff --git a/apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.ts b/apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.ts index 69ce685be..8dc1f54b8 100644 --- a/apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.ts +++ b/apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.ts @@ -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'), diff --git a/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html new file mode 100644 index 000000000..0037a9e76 --- /dev/null +++ b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html @@ -0,0 +1,123 @@ + + +
+ + @for (type of playlistTypes; track type.id) { + + } +
+ + +
+ + + + + +
+ + + +
diff --git a/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.scss b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.scss new file mode 100644 index 000000000..119f9028b --- /dev/null +++ b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.scss @@ -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; +} diff --git a/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.ts b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.ts new file mode 100644 index 000000000..6bf906626 --- /dev/null +++ b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.ts @@ -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('filterSortMenu'); + readonly menuTrigger = input(); + readonly filterChanged = output(); + + 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(); + } +} diff --git a/apps/web/src/app/shared/components/header/header.component.html b/apps/web/src/app/shared/components/header/header.component.html index dc81325e0..f45a226e3 100644 --- a/apps/web/src/app/shared/components/header/header.component.html +++ b/apps/web/src/app/shared/components/header/header.component.html @@ -34,23 +34,23 @@ - + - + @if (isDesktop) { - - - - - - -
- - @for (type of playlistTypes; track $index) { -

- {{ type.title }} -

- } -
-
} diff --git a/apps/web/src/app/shared/components/header/header.component.scss b/apps/web/src/app/shared/components/header/header.component.scss index a74b0d8bb..1f43821b3 100644 --- a/apps/web/src/app/shared/components/header/header.component.scss +++ b/apps/web/src/app/shared/components/header/header.component.scss @@ -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 diff --git a/apps/web/src/app/shared/components/header/header.component.ts b/apps/web/src/app/shared/components/header/header.component.ts index b3446e34f..6657cb1cb 100644 --- a/apps/web/src/app/shared/components/header/header.component.ts +++ b/apps/web/src/app/shared/components/header/header.component.ts @@ -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(); readonly subtitle = input.required(); @@ -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); } diff --git a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts index 497d3b38a..caee8a551 100644 --- a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts +++ b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts @@ -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'; diff --git a/apps/web/src/app/xtream-tauri/playlist-error-view/playlist-error-view.component.ts b/apps/web/src/app/xtream-tauri/playlist-error-view/playlist-error-view.component.ts index 6b227adb1..a1c6f1ea8 100644 --- a/apps/web/src/app/xtream-tauri/playlist-error-view/playlist-error-view.component.ts +++ b/apps/web/src/app/xtream-tauri/playlist-error-view/playlist-error-view.component.ts @@ -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'; diff --git a/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts b/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts index 18f038dd3..dd0448586 100644 --- a/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts +++ b/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts @@ -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'; diff --git a/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts b/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts index 9fcb6b741..a66b1acd4 100644 --- a/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts +++ b/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts @@ -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'; diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 8ca0cfe62..31a32ebf1 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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:" }, diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 8747181b2..747ed03ac 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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:" }, diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c7d6f832c..717ddc390 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Ошибка парсинга, недействительный плейлист:" }, diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index b2dd25083..2ebc8a8d7 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -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[] }>(), + }, +}); diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 749d91b05..dadeb30e9 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -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!, }); }) diff --git a/libs/m3u-state/src/lib/reducers.ts b/libs/m3u-state/src/lib/reducers.ts index 4ddd0eb59..bb775cb93 100644 --- a/libs/m3u-state/src/lib/reducers.ts +++ b/libs/m3u-state/src/lib/reducers.ts @@ -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'; diff --git a/libs/m3u-state/src/lib/reducers/channel.reducers.ts b/libs/m3u-state/src/lib/reducers/channel.reducers.ts new file mode 100644 index 000000000..c4d1de1be --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/channel.reducers.ts @@ -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, + }; + }), +]; diff --git a/libs/m3u-state/src/lib/reducers/epg.reducers.ts b/libs/m3u-state/src/lib/reducers/epg.reducers.ts new file mode 100644 index 000000000..e3f971eca --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/epg.reducers.ts @@ -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, + }) + ), +]; diff --git a/libs/m3u-state/src/lib/reducers/favorites.reducers.ts b/libs/m3u-state/src/lib/reducers/favorites.reducers.ts new file mode 100644 index 000000000..9b58df853 --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/favorites.reducers.ts @@ -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, + }, + }, + }, + }; + }), +]; diff --git a/libs/m3u-state/src/lib/reducers/filter.reducers.ts b/libs/m3u-state/src/lib/reducers/filter.reducers.ts new file mode 100644 index 000000000..a4533992c --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/filter.reducers.ts @@ -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, + }, + }; + } + ), +]; diff --git a/libs/m3u-state/src/lib/reducers/index.ts b/libs/m3u-state/src/lib/reducers/index.ts new file mode 100644 index 000000000..dbf3afd18 --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/index.ts @@ -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; diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts new file mode 100644 index 000000000..622cc037d --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts @@ -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 + ), + }; + } + } + ), +]; diff --git a/libs/ui/components/src/lib/audio-player/audio-player.component.ts b/libs/ui/components/src/lib/audio-player/audio-player.component.ts index 73b099235..77a278219 100644 --- a/libs/ui/components/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/components/src/lib/audio-player/audio-player.component.ts @@ -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 })); } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index b5ed9594f..1003de803 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -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, 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); diff --git a/libs/ui/components/src/lib/epg-list/epg-list.component.ts b/libs/ui/components/src/lib/epg-list/epg-list.component.ts index 434d5716f..830e2f9c3 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/components/src/lib/epg-list/epg-list.component.ts @@ -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 diff --git a/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 495e16f1d..61a674046 100644 --- a/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -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'; diff --git a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts index 408c98a13..a87fdcc1a 100644 --- a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts @@ -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, diff --git a/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts index 00956209d..6fca98c18 100644 --- a/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts @@ -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'; diff --git a/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.ts b/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.ts index e29e75479..b0258fd48 100644 --- a/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.ts +++ b/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.ts @@ -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 })); } }