diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts
index 19f4da724..384fc3ca3 100644
--- a/apps/electron-backend/src/app/api/main.preload.ts
+++ b/apps/electron-backend/src/app/api/main.preload.ts
@@ -295,6 +295,9 @@ contextBridge.exposeInMainWorld('electron', {
dbGetFavorites: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_FAVORITES', playlistId),
dbGetGlobalFavorites: () => ipcRenderer.invoke('DB_GET_GLOBAL_FAVORITES'),
+ dbReorderGlobalFavorites: (
+ updates: { content_id: number; position: number }[]
+ ) => ipcRenderer.invoke('DB_REORDER_GLOBAL_FAVORITES', updates),
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_RECENT_ITEMS', playlistId),
diff --git a/apps/electron-backend/src/app/events/database/favorites.events.ts b/apps/electron-backend/src/app/events/database/favorites.events.ts
index 85152ecf6..8ad93e1da 100644
--- a/apps/electron-backend/src/app/events/database/favorites.events.ts
+++ b/apps/electron-backend/src/app/events/database/favorites.events.ts
@@ -3,7 +3,7 @@
* Operations for managing user's favorite content
*/
-import { and, desc, eq, sql } from 'drizzle-orm';
+import { and, asc, desc, eq, sql } from 'drizzle-orm';
import { ipcMain } from 'electron';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
@@ -110,7 +110,7 @@ ipcMain.handle('DB_GET_FAVORITES', async (event, playlistId: string) => {
});
/**
- * Get global favorites across all playlists
+ * Get global favorites across all playlists (live TV channels only)
*/
ipcMain.handle('DB_GET_GLOBAL_FAVORITES', async () => {
try {
@@ -128,6 +128,7 @@ ipcMain.handle('DB_GET_GLOBAL_FAVORITES', async () => {
playlist_id: schema.playlists.id,
playlist_name: schema.playlists.name,
added_at: schema.favorites.addedAt,
+ position: schema.favorites.position,
})
.from(schema.favorites)
.innerJoin(
@@ -142,7 +143,11 @@ ipcMain.handle('DB_GET_GLOBAL_FAVORITES', async () => {
schema.playlists,
eq(schema.categories.playlistId, schema.playlists.id)
)
- .orderBy(desc(schema.favorites.addedAt))
+ .where(eq(schema.content.type, 'live'))
+ .orderBy(
+ asc(schema.favorites.position),
+ desc(schema.favorites.addedAt)
+ )
.limit(300);
return result;
} catch (error) {
@@ -150,3 +155,29 @@ ipcMain.handle('DB_GET_GLOBAL_FAVORITES', async () => {
throw error;
}
});
+
+/**
+ * Reorder global favorites by updating the position field on each Xtream favorite row.
+ * Accepts an array of { content_id, position } pairs.
+ */
+ipcMain.handle(
+ 'DB_REORDER_GLOBAL_FAVORITES',
+ async (event, updates: { content_id: number; position: number }[]) => {
+ try {
+ if (!Array.isArray(updates) || updates.length === 0) {
+ return { success: true };
+ }
+ const db = await getDatabase();
+ for (const { content_id, position } of updates) {
+ await db
+ .update(schema.favorites)
+ .set({ position })
+ .where(eq(schema.favorites.contentId, content_id));
+ }
+ return { success: true };
+ } catch (error) {
+ console.error('Error reordering global favorites:', error);
+ throw error;
+ }
+ }
+);
diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts
index 0a0055583..7b418c7bd 100644
--- a/apps/web/src/app/app.routes.ts
+++ b/apps/web/src/app/app.routes.ts
@@ -68,8 +68,8 @@ export const routes: Routes = [
layout: 'workspace',
},
loadComponent: () =>
- import('./home/video-player/video-player.component').then(
- (c) => c.VideoPlayerComponent
+ import('./workspace/global-favorites/global-favorites-page.component').then(
+ (c) => c.GlobalFavoritesPageComponent
),
},
{
diff --git a/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.html b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.html
new file mode 100644
index 000000000..8ba4ff07c
--- /dev/null
+++ b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.html
@@ -0,0 +1,47 @@
+
+
+ search
+
+
+
+
+
+ @if (enrichedChannels().length === 0) {
+
+
star_border
+ @if (searchTerm()) {
+
No results
+
Try a different search term
+ } @else {
+
No live TV favorites yet
+
+ Star live channels from M3U, Xtream, or Stalker playlists to
+ see them here.
+
+ }
+
+ }
+
+ @for (ch of enrichedChannels(); track trackByUid($index, ch)) {
+
+ }
+
diff --git a/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.scss b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.scss
new file mode 100644
index 000000000..ed09e3152
--- /dev/null
+++ b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.scss
@@ -0,0 +1,81 @@
+@use '@angular/material' as mat;
+
+:host {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+}
+
+// ─── Search ────────────────────────────────────────────────────────────────────
+
+.search-panel {
+ margin-top: 10px;
+ display: flex;
+ justify-content: center;
+ margin-bottom: -10px;
+ flex-shrink: 0;
+}
+
+.search-bar {
+ width: 90%;
+ @include mat.form-field-density(-5);
+}
+
+::ng-deep {
+ .search-bar div {
+ border-radius: 20px;
+ }
+
+ div[matformfieldlineripple],
+ .mat-mdc-form-field-subscript-wrapper {
+ display: none;
+ }
+}
+
+// ─── List ──────────────────────────────────────────────────────────────────────
+
+.nav-list {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 8px 8px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+// ─── Empty state ───────────────────────────────────────────────────────────────
+
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ text-align: center;
+ color: var(--mat-sys-on-surface-variant);
+
+ .empty-icon {
+ font-size: 48px;
+ height: 48px;
+ width: 48px;
+ margin-bottom: 16px;
+ opacity: 0.5;
+ }
+
+ .empty-title {
+ font-size: 14px;
+ font-weight: 500;
+ margin: 0 0 8px;
+ }
+
+ .empty-hint {
+ font-size: 12px;
+ opacity: 0.7;
+ margin: 0;
+ max-width: 260px;
+ }
+}
+
diff --git a/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.ts b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.ts
new file mode 100644
index 000000000..23b882c13
--- /dev/null
+++ b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.ts
@@ -0,0 +1,104 @@
+import {
+ CdkDragDrop,
+ DragDropModule,
+ moveItemInArray,
+} from '@angular/cdk/drag-drop';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ input,
+ output,
+ signal,
+} from '@angular/core';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatIconModule } from '@angular/material/icon';
+import { MatInputModule } from '@angular/material/input';
+import { ChannelListItemComponent } from 'components';
+import { EpgProgram } from 'shared-interfaces';
+import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
+
+export interface EnrichedUnifiedFavorite extends UnifiedFavoriteChannel {
+ currentEpgProgram: EpgProgram | null;
+ progressPercentage: number;
+}
+
+@Component({
+ selector: 'app-global-favorites-list',
+ templateUrl: './global-favorites-list.component.html',
+ styleUrl: './global-favorites-list.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ ChannelListItemComponent,
+ DragDropModule,
+ MatFormFieldModule,
+ MatIconModule,
+ MatInputModule,
+ ],
+})
+export class GlobalFavoritesListComponent {
+ readonly channels = input.required();
+ readonly epgMap = input