diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 39627cc42..a06edb618 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -102,7 +102,8 @@ export class PlaylistsService { playlist: Partial & { _id?: string; id?: string } ): Playlist { const id = String(playlist._id ?? playlist.id ?? ''); - return { + return this.normalizeStalkerPortalFlags({ + ...playlist, _id: id, title: playlist.title ?? '', count: Number(playlist.count ?? 0), @@ -130,6 +131,29 @@ export class PlaylistsService { stalkerDeviceId2: playlist.stalkerDeviceId2, stalkerSignature1: playlist.stalkerSignature1, stalkerSignature2: playlist.stalkerSignature2, + isFullStalkerPortal: playlist.isFullStalkerPortal, + stalkerToken: playlist.stalkerToken, + stalkerAccountInfo: playlist.stalkerAccountInfo, + } as Playlist); + } + + /** + * Full Stalker portals (e.g. Ministra) require handshake/token auth. + * Infer the mode from URL when legacy records are missing explicit flag. + */ + private normalizeStalkerPortalFlags(playlist: Playlist): Playlist { + if (!playlist?.macAddress || playlist.isFullStalkerPortal !== undefined) { + return playlist; + } + + const portalUrl = playlist.portalUrl ?? playlist.url ?? ''; + const isFullPortal = + portalUrl.includes('/stalker_portal') || + portalUrl.includes('/server/load.php'); + + return { + ...playlist, + isFullStalkerPortal: isFullPortal, }; } @@ -153,7 +177,7 @@ export class PlaylistsService { const playlists = await window.electron.dbGetAppPlaylists(); return (playlists as Playlist[]).map( ({ playlist, items, header, ...rest }) => ({ - ...rest, + ...this.normalizeStalkerPortalFlags(rest as Playlist), }) ); }); @@ -162,7 +186,7 @@ export class PlaylistsService { return this.dbService.getAll(DbStores.Playlists).pipe( map((data) => data.map(({ playlist, items, header, ...rest }) => ({ - ...rest, + ...this.normalizeStalkerPortalFlags(rest as Playlist), })) ) ); @@ -586,7 +610,10 @@ export class PlaylistsService { if (this.isElectronStorageAvailable) { return this.runOnSqlite( async () => - (await window.electron.dbGetAppPlaylists()) as Playlist[] + ((await window.electron.dbGetAppPlaylists()) as Playlist[]) + .map((playlist) => + this.normalizeStalkerPortalFlags(playlist) + ) ); } diff --git a/libs/services/src/lib/stalker-session.service.ts b/libs/services/src/lib/stalker-session.service.ts index d458101c7..31ca48e1f 100644 --- a/libs/services/src/lib/stalker-session.service.ts +++ b/libs/services/src/lib/stalker-session.service.ts @@ -41,6 +41,7 @@ function generateRandom(): string { * Constant value for consistency across all sessions */ export const STALKER_SERIAL_NUMBER = 'BEDACD4569BAF'; +const STALKER_WATCHDOG_INTERVAL_MS = 25_000; export interface StalkerHandshakeResponse { js: { @@ -87,6 +88,10 @@ export class StalkerSessionService { string, Promise<{ token: string; serialNumber?: string }> >(); + private watchdogIntervals = new Map>(); + private watchdogPlaylists = new Map(); + private watchdogInFlight = new Set(); + private activeWatchdogPlaylistId: string | null = null; /** * Generates a consistent 64-character device ID from MAC address @@ -138,6 +143,105 @@ export class StalkerSessionService { this.tokenCache.delete(playlistId); } + /** + * Sets which playlist should receive periodic watchdog pings. + * This keeps some Ministra/Stalker sessions alive for live playback. + */ + setActiveWatchdogPlaylist(playlist?: Playlist | null): void { + const nextPlaylistId = playlist?._id ?? null; + + if ( + this.activeWatchdogPlaylistId && + this.activeWatchdogPlaylistId !== nextPlaylistId + ) { + this.stopWatchdog(this.activeWatchdogPlaylistId); + } + + this.activeWatchdogPlaylistId = nextPlaylistId; + + if ( + !playlist || + !playlist.isFullStalkerPortal || + !playlist.portalUrl || + !playlist.macAddress + ) { + if (nextPlaylistId) { + this.stopWatchdog(nextPlaylistId); + } + return; + } + + this.startWatchdog(playlist); + } + + private startWatchdog(playlist: Playlist): void { + const playlistId = playlist._id; + this.watchdogPlaylists.set(playlistId, playlist); + + if (this.watchdogIntervals.has(playlistId)) { + return; + } + + void this.sendWatchdogPing(playlistId, '1'); + + const intervalId = setInterval(() => { + void this.sendWatchdogPing(playlistId, '0'); + }, STALKER_WATCHDOG_INTERVAL_MS); + + this.watchdogIntervals.set(playlistId, intervalId); + } + + private stopWatchdog(playlistId: string): void { + const intervalId = this.watchdogIntervals.get(playlistId); + if (intervalId) { + clearInterval(intervalId); + this.watchdogIntervals.delete(playlistId); + } + this.watchdogPlaylists.delete(playlistId); + this.watchdogInFlight.delete(playlistId); + } + + private async sendWatchdogPing( + playlistId: string, + init: '0' | '1' + ): Promise { + if (this.watchdogInFlight.has(playlistId)) { + return; + } + + const playlist = this.watchdogPlaylists.get(playlistId); + if ( + !playlist || + !playlist.portalUrl || + !playlist.macAddress || + !playlist.isFullStalkerPortal + ) { + this.stopWatchdog(playlistId); + return; + } + + this.watchdogInFlight.add(playlistId); + try { + await this.makeAuthenticatedRequest( + playlist, + { + type: 'watchdog', + action: 'get_events', + event_active_id: '0', + cur_play_type: '0', + init, + JsHttpRequest: '1-xml', + }, + false + ); + } catch (error) { + // Keep failures non-fatal; next interval can recover after token refresh. + console.warn('[StalkerSession] Watchdog ping failed:', error); + } finally { + this.watchdogInFlight.delete(playlistId); + } + } + /** * Performs handshake to get a session token for a full stalker portal * Returns both the token and the random value for use in subsequent requests @@ -156,8 +260,6 @@ export class StalkerSessionService { JsHttpRequest: '1-xml', }; - const fullUrl = `${portalUrl}?type=stb&action=handshake&token=&prehash=${prehash}&JsHttpRequest=1-xml`; - try { const response: StalkerHandshakeResponse = await this.dataService.sendIpcEvent(STALKER_REQUEST, { diff --git a/libs/shared/interfaces/src/lib/playlist-meta.type.ts b/libs/shared/interfaces/src/lib/playlist-meta.type.ts index 9158b28d3..7128a5012 100644 --- a/libs/shared/interfaces/src/lib/playlist-meta.type.ts +++ b/libs/shared/interfaces/src/lib/playlist-meta.type.ts @@ -23,6 +23,7 @@ export type PlaylistMeta = Pick< | 'macAddress' | 'portalUrl' | 'recentlyViewed' + | 'isFullStalkerPortal' | 'stalkerSerialNumber' | 'stalkerDeviceId1' | 'stalkerDeviceId2' diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index f990547b6..5a886c3aa 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/add-playlist-menu/add-playlist-menu.component'; export * from './lib/art-player/art-player.component'; export * from './lib/audio-player/audio-player.component'; export * from './lib/channel-list-container/channel-list-container.component'; +export * from './lib/channel-list-container/channel-list-item/channel-list-item.component'; export * from './lib/confirm-dialog/confirm-dialog.component'; export * from './lib/confirm-dialog/dialog.service'; export * from './lib/content-hero/content-hero.component'; diff --git a/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.scss b/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.scss index fb8d24679..8df7f827b 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.scss +++ b/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.scss @@ -3,104 +3,70 @@ align-items: center; gap: 8px; width: 100%; + min-width: 0; } .time { - font-family: 'DM Sans', sans-serif; - font-size: 0.7rem; - opacity: 0.6; - letter-spacing: 0.02em; + font-size: 0.76rem; line-height: 1; + font-weight: 600; + letter-spacing: 0.02em; + font-variant-numeric: tabular-nums; white-space: nowrap; + opacity: 0.76; } .badge { - border-radius: 4px; - padding: 3px 8px; - color: white; - text-transform: uppercase; - font-size: 10px; - font-weight: 600; - letter-spacing: 0.08em; display: inline-flex; align-items: center; justify-content: center; - min-width: 40px; - transition: all 0.2s ease; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + min-width: 42px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.68rem; + line-height: 1.35; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + border: 1px solid transparent; } .live { - background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); - animation: livePulse 2s ease-in-out infinite; - box-shadow: 0 0 12px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3); - - &::after { - content: '●'; - margin-left: 4px; - animation: blink 1.5s ease-in-out infinite; - } + color: #ff8f8f; + background: rgba(255, 95, 95, 0.12); + border-color: rgba(255, 95, 95, 0.28); } .rec { - background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); - box-shadow: 0 0 12px rgba(139, 92, 246, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); - - &::after { - content: '◉'; - margin-left: 4px; - } + color: #9abfff; + background: color-mix(in srgb, var(--app-selection-color) 14%, transparent); + border-color: color-mix(in srgb, var(--app-selection-border) 72%, transparent); } .description-icon { - margin-left: auto; - cursor: pointer; - opacity: 0.6; - transition: all 0.2s ease; - padding: 4px; - border-radius: 4px; - display: flex; + margin-inline-start: auto; + width: 30px; + height: 30px; + display: inline-flex; align-items: center; justify-content: center; + border-radius: 9px; + cursor: pointer; + opacity: 0.7; + transition: + opacity 0.15s ease, + background-color 0.15s ease, + transform 0.15s ease; mat-icon { - font-size: 18px; width: 18px; height: 18px; + font-size: 18px; } &:hover { opacity: 1; - background: rgba(0, 0, 0, 0.04); - } - - &:active { - transform: scale(0.95); - } -} - -// Dark theme support -::ng-deep .dark-theme { - .description-icon:hover { - background: rgba(255, 255, 255, 0.04); - } -} - -// Animations -@keyframes livePulse { - 0%, 100% { - box-shadow: 0 0 12px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3); - } - 50% { - box-shadow: 0 0 20px rgba(239, 68, 68, 0.8), 0 2px 12px rgba(0, 0, 0, 0.4); - } -} - -@keyframes blink { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.3; + background: color-mix(in srgb, currentColor 10%, transparent); + transform: translateY(-1px); } } diff --git a/libs/ui/components/src/lib/epg-list/epg-list.component.html b/libs/ui/components/src/lib/epg-list/epg-list.component.html index a1ce59b25..b72043b33 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list.component.html +++ b/libs/ui/components/src/lib/epg-list/epg-list.component.html @@ -4,9 +4,17 @@
-
+
@@ -48,13 +56,16 @@ @if (timeshiftUntil) { -
+
@if (items?.length > 0) { @for (program of items; track $index) { @if ( program.start < timeshiftUntil || program.start >= timeNow ) { -
+
( moment().format(DATE_FORMAT) ); + readonly programList = viewChild>('programList'); + private scrollScheduled = false; /** Filtered EPG programs based on selected date */ filteredItems$ = combineLatest([this.items$, this.selectedDate$]).pipe( @@ -135,10 +151,47 @@ export class EpgListComponent implements OnInit { return channel.icon[0]?.src || ''; } + showChannelIconFallback(channel: EpgChannel): boolean { + return !this.getChannelIcon(channel) || this.channelIconFailed; + } + + onChannelIconError(event: Event): void { + this.channelIconFailed = true; + (event.target as HTMLImageElement | null)?.style.setProperty( + 'display', + 'none' + ); + this.cdr.markForCheck(); + } + /** * Subscribe for values from the store on component init */ ngOnInit(): void { + effect( + (onCleanup) => { + const programList = this.programList()?.nativeElement; + if (!programList || typeof ResizeObserver === 'undefined') { + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + const [entry] = entries; + if ( + entry && + entry.contentRect.height > 0 && + entry.contentRect.width > 0 + ) { + this.scheduleScrollToCurrentProgram(); + } + }); + + resizeObserver.observe(programList); + onCleanup(() => resizeObserver.disconnect()); + }, + { injector: this.injector } + ); + this.items$.subscribe((programs) => this.handleEpgData(programs)); this.dateToday = moment().format(DATE_FORMAT); this.selectedDate$.next(this.dateToday); @@ -167,6 +220,7 @@ export class EpgListComponent implements OnInit { // Trigger change detection for OnPush strategy this.cdr.markForCheck(); + this.scheduleScrollToCurrentProgram(); } /** @@ -198,6 +252,7 @@ export class EpgListComponent implements OnInit { this.dateToday = newDate; this.selectedDate$.next(newDate); + this.scheduleScrollToCurrentProgram(); } /** @@ -274,4 +329,47 @@ export class EpgListComponent implements OnInit { return isPlaying; } + + private scheduleScrollToCurrentProgram(): void { + if (this.selectedDate$.value !== moment().format(DATE_FORMAT)) { + return; + } + + if (this.scrollScheduled) { + return; + } + + this.scrollScheduled = true; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.scrollScheduled = false; + this.scrollCurrentProgramIntoView(); + }); + }); + } + + private scrollCurrentProgramIntoView(): void { + const container = this.programList()?.nativeElement; + const currentProgram = container?.querySelector( + '.program-item.current-program' + ); + + if (!container || !currentProgram) { + return; + } + + const viewTop = container.scrollTop; + const viewBottom = viewTop + container.clientHeight; + const itemTop = currentProgram.offsetTop; + const itemBottom = itemTop + currentProgram.offsetHeight; + + if (itemTop >= viewTop && itemBottom <= viewBottom) { + return; + } + + currentProgram.scrollIntoView({ + block: 'center', + inline: 'nearest', + }); + } } diff --git a/libs/ui/components/src/lib/html-video-player/html-video-player.component.scss b/libs/ui/components/src/lib/html-video-player/html-video-player.component.scss index 4a323f07d..08a451024 100644 --- a/libs/ui/components/src/lib/html-video-player/html-video-player.component.scss +++ b/libs/ui/components/src/lib/html-video-player/html-video-player.component.scss @@ -1,12 +1,12 @@ -#video-player { +:host { + display: block; width: 100%; - height: calc(100vh - 60px); + height: 100%; + min-height: 0; + overflow: hidden; } - -@media only screen and (max-width: 599px) { - #video-player { - width: 100%; - height: calc(50vh - 60px) !important; - } -} \ No newline at end of file +#video-player { + width: 100%; + height: 100%; +} diff --git a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html index b9d5e1d92..1a8324577 100644 --- a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html @@ -2,6 +2,7 @@ cdkDrag [cdkDragDisabled]="!isDraggable()" cdkDragPreviewContainer="parent" + [class.selected]="isSelected()" (click)="playlistClicked.emit(item._id)" > @if (isDraggable()) { diff --git a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.scss b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.scss index ac3d51c2c..029d83ba3 100644 --- a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.scss +++ b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.scss @@ -3,10 +3,41 @@ mat-list-item { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); border-radius: 12px; margin: 2px 4px; + border: 1px solid transparent; &:hover { background-color: rgba(255, 255, 255, 0.03) !important; } + + &.selected { + background: linear-gradient( + 135deg, + var(--app-selection-surface-strong), + var(--app-selection-surface) + ) !important; + border-color: var(--app-selection-border) !important; + color: var(--app-selection-color) !important; + box-shadow: + inset 0 0 0 1px var(--app-selection-border), + 0 12px 20px -18px var(--app-selection-glow); + transform: translateY(-1px); + + .meta, + .upload-type-icon, + .drag-icon, + .action-buttons button, + .action-buttons mat-icon { + color: inherit !important; + } + + .status-dot { + border-color: color-mix( + in srgb, + var(--app-selection-surface-strong) 85%, + transparent + ); + } + } } // Light theme hover diff --git a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.ts index 759f28381..a1f3706a4 100644 --- a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.ts +++ b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.ts @@ -27,6 +27,7 @@ export class PlaylistItemComponent implements OnInit { @Input() item!: PlaylistMeta; readonly showActions = input(true); readonly isDraggable = input(false); + readonly isSelected = input(false); readonly editPlaylistClicked = output(); readonly playlistClicked = output(); diff --git a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.html b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.html index 90b045c70..58ce18e7b 100644 --- a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.html +++ b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.html @@ -26,6 +26,7 @@ ) {