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 @@
+
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) {