import { Component, OnDestroy, Signal, ViewEncapsulation, computed, effect, inject, input, output, signal, untracked, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { StorageMap } from '@ngx-pwa/local-storage'; import { type PlaybackDiagnostic, type PlaybackDiagnosticCode, type PlaybackFallbackRequest, type PlaybackRecommendationTarget, } from '@iptvnator/playback/util'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { STORE_KEY, VideoPlayer, type Channel, type ResolvedPortalPlayback, type Settings, type VodSourceDescriptor, } from '@iptvnator/shared/interfaces'; import { ArtPlayerComponent } from '../art-player/art-player.component'; import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component'; import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component'; import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component'; import { type PlayerMediaTitle, WEB_PLAYER_SHARED_CONTROLS, WEB_PLAYER_SHARED_CONTROLS_ENABLED, } from '../player-controls'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; import { VjsPlayerComponent } from '../vjs-player/vjs-player.component'; import type { VideoPlayerOptions } from '../vjs-player/vjs-player.types'; import { ElectronStreamHeadersService } from './electron-stream-headers.service'; import { type PlaybackBinding, PlaybackRecoverySession, } from './playback-recovery-session'; import { WebPlayerApplicationHandoffCoordinator } from './web-player-application-handoff'; import { createWebPlayerApplicationState } from './web-player-application-state'; import { resolveWebPlayerMediaTitle } from './web-player-playback-state'; import { createWebPlayerRecommendations, toInlinePlaybackPlayer, toVideoPlayer, } from './web-player-recovery-policy'; function resolveWebPlayerSharedControls(): boolean { const storedValue = inject(SettingsStore).webPlayerSharedControls?.(); return typeof storedValue === 'boolean' ? storedValue : WEB_PLAYER_SHARED_CONTROLS_ENABLED; } @Component({ selector: 'app-web-player-view', templateUrl: './web-player-view.component.html', styleUrls: ['./web-player-view.component.scss'], host: { class: 'web-player-view' }, imports: [ ArtPlayerComponent, EmbeddedMpvPlayerComponent, HtmlVideoPlayerComponent, PlaybackDiagnosticPanelComponent, VjsPlayerComponent, ], providers: [ { provide: WEB_PLAYER_SHARED_CONTROLS, useFactory: resolveWebPlayerSharedControls, }, ], encapsulation: ViewEncapsulation.None, }) export class WebPlayerViewComponent implements OnDestroy { private readonly storage = inject(StorageMap); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); private readonly recoverySession = new PlaybackRecoverySession(); private readonly applicationHandoff = new WebPlayerApplicationHandoffCoordinator( inject(ElectronStreamHeadersService), this.recoverySession ); readonly streamUrl = input.required(); readonly playbackSessionKey = input.required(); readonly title = input(''); readonly playback = input(null); readonly startTime = input(0); readonly volume = input(1); readonly playerOverride = input(null); readonly seriesNavigation = input(null); readonly mediaTitle = input(null); readonly alternativeSources = input([]); readonly timeUpdate = output<{ currentTime: number; duration: number; }>(); readonly externalFallbackRequested = output(); readonly alternativeSourceRequested = output(); readonly sourceCheckRequested = output(); readonly playbackFailed = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); readonly settings = toSignal( this.storage.get(STORE_KEY.Settings) ) as Signal; readonly showCaptions = computed( () => this.settingsStore.showCaptions?.() ?? false ); readonly reloadToken = signal(0); readonly playbackDiagnostic = signal(null); readonly recoveryPending = this.recoverySession.switchPending; readonly activeBinding = this.recoverySession.activeBinding; channel: Channel | undefined; vjsOptions: VideoPlayerOptions | undefined; readonly selectedPlayer = computed(() => { const temporary = this.recoverySession.temporaryPlayerOverride(); return temporary ? toVideoPlayer(temporary) : (this.playerOverride() ?? this.settings()?.player ?? VideoPlayer.VideoJs); }); private readonly applicationState = createWebPlayerApplicationState({ playback: this.playback, streamUrl: this.streamUrl, title: this.title, startTime: this.startTime, selectedPlayer: this.selectedPlayer, reloadToken: this.reloadToken, }); readonly resolvedPlayback = this.applicationState.playback; readonly resolvedIsLive = this.applicationState.isLive; readonly playbackSourceRevisionToken = this.applicationState.sourceRevision; readonly playbackApplicationToken = this.applicationState.token; readonly effectiveStartTime = computed(() => this.recoverySession.resumeStartTime( this.startTime(), this.resolvedIsLive() ) ); readonly visiblePlaybackDiagnostic = computed(() => this.selectedPlayer() === VideoPlayer.EmbeddedMpv ? null : this.playbackDiagnostic() ); readonly playbackInteractionEnabled = computed( () => this.visiblePlaybackDiagnostic() === null ); readonly resolvedMediaTitle = computed(() => resolveWebPlayerMediaTitle(this.mediaTitle(), this.resolvedPlayback()) ); readonly recordingFolder = computed( () => this.settings()?.recordingFolder ?? '' ); readonly recommendations = computed(() => { const binding = this.activeBinding(); const token = this.playbackApplicationToken(); return createWebPlayerRecommendations({ diagnostic: this.visiblePlaybackDiagnostic(), binding: binding && this.applicationHandoff.owns(binding, token) ? binding : null, attemptedTargets: this.recoverySession.attemptedTargets(), managedExternalPlayersAvailable: this.runtime.supportsManagedExternalPlayers, playback: this.resolvedPlayback(), isLive: this.resolvedIsLive(), alternativeSourceCount: this.alternativeSources().length, }); }); readonly renderedBindings = computed(() => { const binding = this.activeBinding(); return binding ? [binding] : []; }); constructor() { effect(() => { this.syncRecoverySession(); const sourceRevision = this.playbackSourceRevisionToken(); untracked(() => this.recoverySession.syncSourceRevision(sourceRevision) ); const token = this.playbackApplicationToken(); const playback = untracked(this.resolvedPlayback); const isLive = untracked(this.resolvedIsLive); const selectedPlayer = untracked(this.selectedPlayer); const reloadToken = untracked(this.reloadToken); const target = toInlinePlaybackPlayer(selectedPlayer); this.channel = undefined; this.vjsOptions = undefined; if (target === null) { this.applicationHandoff.release(); this.recoverySession.clearPlaybackBinding(); this.playbackDiagnostic.set(null); return; } const binding = untracked(() => this.recoverySession.beginPlayback(target) ); this.applicationHandoff.apply( playback, isLive, reloadToken, binding, token, () => this.playbackApplicationToken(), (handoff) => { this.channel = handoff.channel; this.vjsOptions = handoff.vjsOptions; this.playbackDiagnostic.set(null); } ); }); } ngOnDestroy(): void { this.applicationHandoff.destroy(); } handlePlaybackIssue( issue: PlaybackDiagnostic | null, binding: PlaybackBinding ): void { this.syncRecoverySession(); if (!this.recoverySession.accepts(binding)) { return; } if ( !this.applicationHandoff.owns( binding, this.playbackApplicationToken() ) ) { this.applicationHandoff.invalidate(); this.recoverySession.clearPlaybackBinding(); this.playbackDiagnostic.set(null); return; } if (!issue) { this.recoverySession.settle(binding); this.playbackDiagnostic.set(null); return; } if (!this.recoverySession.recordFailure(binding)) { return; } this.playbackDiagnostic.set(issue); this.playbackFailed.emit(issue.code); } handleTimeUpdate(event: { currentTime: number; duration: number }): void { this.recoverySession.recordTimeUpdate(event, this.resolvedIsLive()); this.timeUpdate.emit(event); } requestRecommendedPlayer(target: PlaybackRecommendationTarget): void { const diagnostic = this.visiblePlaybackDiagnostic(); if (!diagnostic) { return; } const available = this.recommendations().some( (item) => item.action === 'player' && item.target === target ); if (!available) { if (target !== 'mpv' && target !== 'vlc') { this.recoverySession.recordInlineAttempt(target); } return; } if (target === 'mpv' || target === 'vlc') { this.recoverySession.recordExternalAttempt(target); this.externalFallbackRequested.emit({ player: target, playback: this.resolvedPlayback(), diagnostic, }); return; } this.recoverySession.beginPlayerSwitch(target, this.resolvedIsLive()); } retryPlayback(): void { if (!this.recoverySession.beginRetry()) { return; } this.reloadToken.update((value) => value + 1); } private syncRecoverySession(): void { const sessionKey = this.playbackSessionKey(); if (untracked(() => this.recoverySession.syncSession(sessionKey))) { this.playbackDiagnostic.set(null); } } }