mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
* feat(playback): record recently viewed only after the stream plays A channel, movie or episode used to enter Recently Viewed (and the dashboard's Continue Watching hero) the moment it was selected or its link was resolved, so streams that failed straight away cluttered the history. Writers now defer the write to a root PlaybackHistoryGate, keyed by the stream URL and/or the playback session key. The inline players confirm those keys once the owned engine's position has advanced by two seconds (seeks, stalls, pauses and a previous stream's progress do not count), the radio player does the same, and a launched MPV/VLC session confirms on `opened`/`playing`. M3U with MPV/VLC configured keeps recording on selection. Covers M3U (live, radio, movie detail), Stalker (live, radio, VOD, series), Xtream VOD and series, and the global live collection. The M3U host's embeddedPlayback is now compared by value: the history write updates the playlist meta mid-playback, and a new but identical playback object remounted the engine and restarted the stream. E2E flows that relied on recording-on-click now play local fixtures (HLS/TS/WebM routed in place of unreachable or public streams) and wait for confirmed playback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): tighten recently viewed confirmation per review - Correlate by session key first: when both the deferred write and the confirmation carry a playbackSessionKey, only that is compared, so the same stream URL played in another playlist no longer records a failed attempt. URLs remain the fallback (portal writes, MPV/VLC sessions). The M3U radio player now receives the host's session key. - Count only playing progress: engines report `playing` (not paused, not seeking) with each time update, so short seeks of paused media no longer confirm a view. - Xtream: a write confirmed after a playlist switch still saves to its own playlist but no longer replaces the current playlist's recent list. - Global live tab: a row confirmed after another row was selected still moves to the top of an open Recently Viewed list (only a disposed tab skips the notification). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): confirm session-keyed history only by its own session A write deferred with a playback session key (M3U) is now confirmed only by that key. The app-wide MPV/VLC session confirmation carries just the URL, so opening the same stream externally from another playlist could still commit an abandoned attempt. An "Open in MPV/VLC" recovery launch is instead confirmed by the WebPlayerViewComponent that requested it, under its own session key, once the launch has opened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(playback): keep the history gate off the initial bundle The `@iptvnator/services` barrel ships in the initial bundle, so adding PlaybackHistoryGate there (and subscribing to it from the app-wide ExternalPlaybackService) grew renderer.initialBytes by 1,141 bytes. - Move the gate to a new lazy-only `playback-data-access` project (`@iptvnator/playback/data-access`; scope:shared, domain:playback, type:data-access) and register it in the coverage policy. - The gate subscribes to MPV/VLC session updates itself; it is created by the first deferred write, which precedes the launch it waits for. ExternalPlaybackService is back to master. - The Xtream "playlist switched before confirmation" check moves to the lazy helper; the initial-path store only takes a `skipListRefresh` flag. Net effect on this branch: +27 bytes over master (master itself is 108 bytes over the ratchet baseline already). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(playback): drop late Xtream confirmations off the initial path The Xtream store ships in the initial bundle, so even the small `skipListRefresh` flag cost 27 bytes there. A confirmation can only arrive after a switch to another playlist from a slow MPV/VLC launch (the inline player goes with the page), so the lazy helper now drops it instead: recording it would misfile the item or replace the other playlist's recent list. with-recent-items is back to master. This branch is now 3 bytes below master on renderer.initialBytes; the ratchet still reports master's pre-existing overage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(playback): pass the spec type-check gate from master - playback-data-access: align tsconfig.spec.json with the epg-data-access config #1705 updated (bundler resolution, global.d.ts for window.electron). - M3U recent-history spec: type the selectSignal override. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): keep late Xtream confirmations in their own playlist history A confirmation that arrives after a switch to another playlist (a slow MPV/VLC launch) is no longer dropped: the lazy helper saves it to the captured playlist through the data source, without reloading the store's recent list, which belongs to the other playlist by then. The store and its barrel ship in the initial bundle, so the save path stays in the feature helper; renderer.initialBytes stays under the baseline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): correlate global live-tab history by its session key The unified Favorites/Recent live tab deferred its history write by stream URL only, so the same URL played from another playlist could confirm a failed selection, and a switch to catch-up before confirmation could never match. It now defers with the tab's playlist-scoped playbackSessionKey (the key its players confirm with), and the tab's radio player receives it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): keep MPV/VLC rows of the live tab confirmable by URL The live tab's session key can only be confirmed by its own inline players; MPV/VLC confirm the launched URL alone. A row that goes to an external player (also later, after a double-click) now defers by URL, and only rows played inline carry the session key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): per-channel M3U history attempts, capability-based Xtream fallback - M3U: the recently-viewed dedupe key now includes the channel id, so a second row of the same URL defers its own write (its session key) and is recorded when it plays after the first row failed. - Xtream late write: key uncached content by Xtream id per supportsXtreamSqliteDataSource (the data-source factory's contract), not by a generic Electron bridge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
935 lines
33 KiB
TypeScript
935 lines
33 KiB
TypeScript
import {
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
ElementRef,
|
|
OnDestroy,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
input,
|
|
output,
|
|
signal,
|
|
untracked,
|
|
viewChild,
|
|
} from '@angular/core';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
|
import { merge } from 'rxjs';
|
|
import {
|
|
EmbeddedMpvAudioTrack,
|
|
RecordingStartMetadata,
|
|
RecordingStoppedEvent,
|
|
ResolvedPortalPlayback,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
|
|
import type {
|
|
PlayerMediaTitle,
|
|
PlayerUpNextItem,
|
|
} from '../player-controls/player-controls.model';
|
|
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
|
import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter';
|
|
import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component';
|
|
import { EmbeddedMpvDockPanelState } from './embedded-mpv-dock-panels';
|
|
import { EmbeddedMpvLegacyInteractions } from './embedded-mpv-legacy-interactions';
|
|
import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service';
|
|
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
|
|
import { EmbeddedMpvShortcuts } from './embedded-mpv-shortcuts';
|
|
import {
|
|
EmbeddedMpvFeedback,
|
|
EmbeddedMpvMenuState,
|
|
} from './embedded-mpv-ui-state';
|
|
import {
|
|
ASPECT_PRESETS,
|
|
HIDDEN_BOUNDS,
|
|
SPEED_PRESETS,
|
|
aspectLabel,
|
|
audioTrackLabel,
|
|
formatTime,
|
|
measureBounds,
|
|
persistVolume,
|
|
readStoredVolume,
|
|
speedLabel,
|
|
subtitleTrackLabel,
|
|
volumeIcon,
|
|
} from './embedded-mpv-format.utils';
|
|
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
|
|
|
|
const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
|
|
|
|
@Component({
|
|
selector: 'app-embedded-mpv-player',
|
|
templateUrl: './embedded-mpv-player.component.html',
|
|
styleUrl: './embedded-mpv-player.component.scss',
|
|
imports: [
|
|
EmbeddedMpvDockPanelComponent,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatProgressSpinnerModule,
|
|
MatTooltipModule,
|
|
PlayerControlsComponent,
|
|
TranslatePipe,
|
|
],
|
|
providers: [EmbeddedMpvControlsAdapter, EmbeddedMpvSessionController],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
host: {
|
|
class: 'embedded-mpv-player-host',
|
|
},
|
|
})
|
|
export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
|
readonly playback = input.required<ResolvedPortalPlayback>();
|
|
readonly showControls = input(true);
|
|
readonly recordingFolder = input('');
|
|
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
|
|
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
|
|
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
|
|
readonly upNext = input<PlayerUpNextItem | null>(null);
|
|
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */
|
|
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
|
|
|
readonly timeUpdate = output<PlayerTimeUpdate>();
|
|
readonly playbackEnded = output<void>();
|
|
readonly previousEpisodeRequested = output<void>();
|
|
readonly nextEpisodeRequested = output<void>();
|
|
/** Clean recording stop — the host answers with EPG stop enrichment. */
|
|
readonly recordingStopped = output<RecordingStoppedEvent>();
|
|
|
|
private readonly overlayVisibility = inject(
|
|
EmbeddedMpvOverlayVisibilityService
|
|
);
|
|
private readonly translate = inject(TranslateService);
|
|
/**
|
|
* Ticks when the active language or a loaded translation file changes.
|
|
* translate.instant() is invisible to the signal graph, so every
|
|
* computed()/template helper that calls it must read this signal first —
|
|
* otherwise labels keep the previous language (or the raw key when the
|
|
* component mounts before the translation file finishes loading).
|
|
*/
|
|
private readonly translationsTick = toSignal(
|
|
merge(
|
|
this.translate.onLangChange,
|
|
this.translate.onTranslationChange,
|
|
this.translate.onDefaultLangChange
|
|
),
|
|
{ initialValue: null }
|
|
);
|
|
readonly controller = inject(EmbeddedMpvSessionController);
|
|
readonly sharedControls = inject(EmbeddedMpvControlsAdapter);
|
|
private readonly shortcuts = new EmbeddedMpvShortcuts();
|
|
readonly menus = new EmbeddedMpvMenuState();
|
|
readonly feedback = new EmbeddedMpvFeedback();
|
|
readonly dockPanels: EmbeddedMpvDockPanelState;
|
|
|
|
readonly viewport = viewChild<ElementRef<HTMLDivElement>>('viewport');
|
|
readonly playerRoot = viewChild<ElementRef<HTMLDivElement>>('playerRoot');
|
|
readonly playerSurface = computed(
|
|
() => this.playerRoot()?.nativeElement ?? null
|
|
);
|
|
/** Element that owns DOM fullscreen: the host's target, else the root. */
|
|
readonly fullscreenSurface = computed(
|
|
() => this.fullscreenTarget() ?? this.playerSurface()
|
|
);
|
|
readonly sharedShortcutsEnabled = computed(
|
|
() => !this.overlayVisibility.overlayActive()
|
|
);
|
|
|
|
readonly support = this.controller.support;
|
|
readonly session = this.controller.session;
|
|
readonly stalled = this.controller.stalled;
|
|
|
|
readonly volume = signal(readStoredVolume());
|
|
readonly isFullscreen = signal(false);
|
|
readonly controlsVisible = signal(true);
|
|
|
|
readonly speedPresets = SPEED_PRESETS;
|
|
readonly aspectPresets = ASPECT_PRESETS;
|
|
|
|
readonly isSupported = computed(() => this.support()?.supported ?? false);
|
|
readonly isFrameCopyEngine = computed(
|
|
() => this.support()?.engine === 'frame-copy'
|
|
);
|
|
readonly capabilities = computed(
|
|
() =>
|
|
this.support()?.capabilities ?? {
|
|
subtitles: false,
|
|
playbackSpeed: false,
|
|
aspectOverride: false,
|
|
screenshot: false,
|
|
recording: false,
|
|
}
|
|
);
|
|
readonly isLoading = computed(
|
|
() =>
|
|
!this.support() ||
|
|
this.session()?.status === 'loading' ||
|
|
(!this.session() && this.isSupported())
|
|
);
|
|
readonly isPaused = computed(
|
|
() =>
|
|
this.session()?.status === 'paused' ||
|
|
this.session()?.status === 'idle' ||
|
|
this.session()?.status === 'ended'
|
|
);
|
|
readonly isPlaying = computed(() => this.session()?.status === 'playing');
|
|
readonly isErrored = computed(() => this.session()?.status === 'error');
|
|
/**
|
|
* The main process is waiting to reload a dropped stream or has the
|
|
* reload in flight (see embedded-mpv-reconnect.ts); shown like an error
|
|
* with a countdown-free "attempt N of M" line, Retry still available.
|
|
*/
|
|
readonly reconnectInfo = computed(() => this.session()?.reconnect ?? null);
|
|
readonly isReconnecting = computed(() => this.reconnectInfo() !== null);
|
|
readonly isLivePlayback = computed(() => {
|
|
const playback = this.playback();
|
|
if (typeof playback.isLive === 'boolean') {
|
|
return playback.isLive;
|
|
}
|
|
|
|
return !playback.contentInfo;
|
|
});
|
|
/**
|
|
* A live stream that ended and is not being reconnected: a broadcast
|
|
* never ends on its own, so this is a loss the viewer must be able to
|
|
* act on — the `ended` state otherwise looks like a paused player.
|
|
*/
|
|
readonly isLiveEnded = computed(
|
|
() => this.isLivePlayback() && this.session()?.status === 'ended'
|
|
);
|
|
readonly canSeek = computed(
|
|
() =>
|
|
!this.isLivePlayback() && (this.session()?.durationSeconds ?? 0) > 0
|
|
);
|
|
readonly canFullscreen = computed(
|
|
() =>
|
|
typeof document !== 'undefined' &&
|
|
Boolean(this.fullscreenSurface()?.requestFullscreen) &&
|
|
Boolean(document.exitFullscreen)
|
|
);
|
|
readonly statusLabel = computed(() => {
|
|
this.translationsTick();
|
|
const session = this.session();
|
|
if (session?.reconnect) {
|
|
return this.translate.instant('EMBEDDED_MPV.PLAYER.RECONNECTING', {
|
|
attempt: session.reconnect.attempt,
|
|
maxAttempts: session.reconnect.maxAttempts,
|
|
});
|
|
}
|
|
if (session?.status === 'error') {
|
|
return (
|
|
session.error ??
|
|
this.translate.instant('EMBEDDED_MPV.PLAYER.PLAYBACK_FAILED')
|
|
);
|
|
}
|
|
if (session?.status === 'ended' && this.isLivePlayback()) {
|
|
return this.translate.instant('EMBEDDED_MPV.PLAYER.STREAM_ENDED');
|
|
}
|
|
if (!this.support()) {
|
|
return this.translate.instant(
|
|
'EMBEDDED_MPV.PLAYER.CHECKING_SUPPORT'
|
|
);
|
|
}
|
|
if (!this.isSupported()) {
|
|
return (
|
|
this.support()?.reason ??
|
|
this.translate.instant('EMBEDDED_MPV.PLAYER.NOT_AVAILABLE')
|
|
);
|
|
}
|
|
if (!session || session.status === 'loading') {
|
|
return this.translate.instant('EMBEDDED_MPV.PLAYER.LOADING_STREAM');
|
|
}
|
|
return '';
|
|
});
|
|
readonly fullscreenLabel = computed(() => {
|
|
this.translationsTick();
|
|
return this.translate.instant(
|
|
this.isFullscreen()
|
|
? 'EMBEDDED_MPV.PLAYER.EXIT_FULLSCREEN'
|
|
: 'EMBEDDED_MPV.PLAYER.ENTER_FULLSCREEN'
|
|
);
|
|
});
|
|
readonly audioTracks = computed(() => this.session()?.audioTracks ?? []);
|
|
readonly hasAudioTracks = computed(() => this.audioTracks().length > 1);
|
|
readonly subtitleTracks = computed(
|
|
() => this.session()?.subtitleTracks ?? []
|
|
);
|
|
readonly hasSubtitleTracks = computed(
|
|
() => this.subtitleTracks().length > 0
|
|
);
|
|
readonly selectedSubtitleTrackId = computed(
|
|
() => this.session()?.selectedSubtitleTrackId ?? null
|
|
);
|
|
readonly playbackSpeed = computed(() => this.session()?.playbackSpeed ?? 1);
|
|
readonly aspectOverride = computed(
|
|
() => this.session()?.aspectOverride ?? 'no'
|
|
);
|
|
readonly volumeIcon = computed(() => volumeIcon(this.volume()));
|
|
readonly volumeLabel = computed(() => {
|
|
this.translationsTick();
|
|
return this.translate.instant('EMBEDDED_MPV.PLAYER.VOLUME_LABEL', {
|
|
percent: Math.round(this.volume() * 100),
|
|
});
|
|
});
|
|
readonly volumePercent = computed(
|
|
() => `${Math.round(this.volume() * 100)}%`
|
|
);
|
|
readonly dockPanelBackLabel = computed(() => {
|
|
this.translationsTick();
|
|
return this.translate.instant('EMBEDDED_MPV.PLAYER.BACK');
|
|
});
|
|
/**
|
|
* Non-null while the user drags the timeline: the slider and time label
|
|
* preview this value locally and the single seek IPC call is deferred to
|
|
* the release (`change`) event instead of firing per drag pixel.
|
|
*/
|
|
readonly scrubPosition = signal<number | null>(null);
|
|
readonly timelineValue = computed(
|
|
() =>
|
|
this.scrubPosition() ??
|
|
Math.max(0, this.session()?.positionSeconds ?? 0)
|
|
);
|
|
readonly controlsAreVisible = computed(
|
|
() =>
|
|
this.showControls() &&
|
|
this.isSupported() &&
|
|
(this.controlsVisible() ||
|
|
this.isLoading() ||
|
|
this.isPaused() ||
|
|
this.menus.anyOpen() ||
|
|
Boolean(this.statusLabel()))
|
|
);
|
|
readonly hideCursor = computed(
|
|
() =>
|
|
this.isFullscreen() &&
|
|
this.isPlaying() &&
|
|
!this.controlsAreVisible()
|
|
);
|
|
readonly canRecord = computed(
|
|
() =>
|
|
this.capabilities().recording &&
|
|
this.isLivePlayback() &&
|
|
this.isSupported() &&
|
|
!this.isErrored()
|
|
);
|
|
readonly isRecording = computed(
|
|
() => this.session()?.recording?.active === true
|
|
);
|
|
readonly showSeriesNavigation = computed(
|
|
() => !this.isLivePlayback() && this.seriesNavigation() !== null
|
|
);
|
|
readonly canPreviousEpisode = computed(
|
|
() =>
|
|
this.showSeriesNavigation() &&
|
|
this.seriesNavigation()?.canPrevious === true
|
|
);
|
|
readonly canNextEpisode = computed(
|
|
() =>
|
|
this.showSeriesNavigation() &&
|
|
this.seriesNavigation()?.canNext === true
|
|
);
|
|
readonly recordingElapsed = computed(() => {
|
|
const startedAt = this.session()?.recording?.startedAt;
|
|
this.recordingTick();
|
|
if (!startedAt) {
|
|
return 0;
|
|
}
|
|
const startedAtMs = Date.parse(startedAt);
|
|
if (!Number.isFinite(startedAtMs)) {
|
|
return 0;
|
|
}
|
|
return Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000));
|
|
});
|
|
readonly recordingStatusText = computed(() => {
|
|
if (this.isRecording()) {
|
|
return `REC ${formatTime(this.recordingElapsed())}`;
|
|
}
|
|
return this.recordingMessage();
|
|
});
|
|
|
|
private mutedVolume = 0;
|
|
private recordingMessageTimer: number | null = null;
|
|
private lastEndedSessionId: string | null = null;
|
|
private readonly recordingTick = signal(Date.now());
|
|
private readonly recordingMessage = signal<string | null>(null);
|
|
private readonly legacyInteractions: EmbeddedMpvLegacyInteractions;
|
|
|
|
private readonly onFullscreenChange = () => {
|
|
this.syncFullscreenState();
|
|
this.legacyInteractions.revealControls();
|
|
this.controller.triggerBoundsSync();
|
|
};
|
|
|
|
private syncFullscreenState(): void {
|
|
const surface = this.fullscreenSurface();
|
|
this.isFullscreen.set(
|
|
Boolean(
|
|
surface &&
|
|
typeof document !== 'undefined' &&
|
|
document.fullscreenElement === surface
|
|
)
|
|
);
|
|
}
|
|
|
|
constructor() {
|
|
this.legacyInteractions = new EmbeddedMpvLegacyInteractions({
|
|
isAvailable: () => !this.isFrameCopyEngine(),
|
|
playerRoot: () => this.playerRoot()?.nativeElement ?? null,
|
|
menus: this.menus,
|
|
controlsVisible: this.controlsVisible,
|
|
isLoading: this.isLoading,
|
|
isErrored: this.isErrored,
|
|
isStalled: this.stalled,
|
|
isPlaying: this.isPlaying,
|
|
statusLabel: this.statusLabel,
|
|
togglePaused: () => this.togglePaused(),
|
|
toggleFullscreen: () => this.toggleFullscreen(),
|
|
});
|
|
this.legacyInteractions.attach();
|
|
|
|
this.dockPanels = new EmbeddedMpvDockPanelState({
|
|
menus: this.menus,
|
|
audioTracks: this.audioTracks,
|
|
subtitleTracks: this.subtitleTracks,
|
|
selectedSubtitleTrackId: this.selectedSubtitleTrackId,
|
|
playbackSpeed: this.playbackSpeed,
|
|
aspectOverride: this.aspectOverride,
|
|
translateLabel: (key) => {
|
|
this.translationsTick();
|
|
return this.translate.instant(key);
|
|
},
|
|
audioTrackLabel: (track, index) => this.trackLabel(track, index),
|
|
subtitleTrackLabel: (track, index) =>
|
|
this.subtitleLabel(track, index),
|
|
aspectLabel: (aspect) => this.aspectLabel(aspect),
|
|
selectAudioTrack: (trackId) => void this.selectAudioTrack(trackId),
|
|
selectSubtitleTrack: (trackId) =>
|
|
void this.selectSubtitleTrack(trackId),
|
|
selectSpeed: (speed) => void this.selectSpeed(speed),
|
|
selectAspect: (aspect) => void this.selectAspect(aspect),
|
|
closePanels: () => this.legacyInteractions.closePopovers(),
|
|
playerRoot: () => this.playerRoot()?.nativeElement ?? null,
|
|
revealControls: () => this.legacyInteractions.revealControls(),
|
|
});
|
|
|
|
this.sharedControls.configure({
|
|
playback: this.playback,
|
|
seriesNavigation: this.seriesNavigation,
|
|
recordingFolder: this.recordingFolder,
|
|
recordingMetadata: this.recordingMetadata,
|
|
});
|
|
|
|
// Single owner of the "a recording just stopped" edge, for every
|
|
// engine and every trigger — the controls button, the download
|
|
// manager's Stop (which talks to the main process directly and never
|
|
// reaches this component's own toggle), or mpv stopping on its own.
|
|
// The host answers with EPG stop enrichment.
|
|
let previousRecording: {
|
|
targetPath: string;
|
|
startedAt: string | null;
|
|
epgChannelId: string | null;
|
|
sourceItemKey: string | null;
|
|
} | null = null;
|
|
effect(() => {
|
|
const recording = this.controller.session()?.recording;
|
|
untracked(() => {
|
|
if (recording?.active && recording.targetPath) {
|
|
previousRecording = {
|
|
targetPath: recording.targetPath,
|
|
startedAt: recording.startedAt ?? null,
|
|
// Captured while it is still the recorded channel: a
|
|
// channel switch auto-stops the recording and moves
|
|
// the host's state on before the stop is handled.
|
|
epgChannelId:
|
|
previousRecording?.epgChannelId ??
|
|
this.recordingMetadata()?.epgChannelId ??
|
|
null,
|
|
sourceItemKey:
|
|
previousRecording?.sourceItemKey ??
|
|
this.recordingMetadata()?.sourceItemKey ??
|
|
null,
|
|
};
|
|
return;
|
|
}
|
|
if (!previousRecording) {
|
|
return;
|
|
}
|
|
const stopped = previousRecording;
|
|
previousRecording = null;
|
|
this.recordingStopped.emit({
|
|
targetPath: stopped.targetPath,
|
|
startedAt: stopped.startedAt,
|
|
endedAt: new Date().toISOString(),
|
|
epgChannelId: stopped.epgChannelId,
|
|
sourceItemKey: stopped.sourceItemKey,
|
|
});
|
|
});
|
|
});
|
|
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener(
|
|
'fullscreenchange',
|
|
this.onFullscreenChange
|
|
);
|
|
}
|
|
// A player remounted for the next episode can start inside the
|
|
// host-owned fullscreen; no `fullscreenchange` fires for it, so the
|
|
// state is read once the fullscreen surface resolves.
|
|
effect(() => {
|
|
void this.fullscreenSurface();
|
|
untracked(() => this.syncFullscreenState());
|
|
});
|
|
|
|
this.controller.setBoundsProvider((host) => {
|
|
// The frame-copy engine paints into an ordinary DOM canvas:
|
|
// dialogs and overlays stack above it natively, so the
|
|
// hide-offscreen compositor workaround must not shrink its
|
|
// render size.
|
|
if (this.isFrameCopyEngine()) {
|
|
return measureBounds(host);
|
|
}
|
|
if (this.overlayVisibility.overlayActive()) {
|
|
return HIDDEN_BOUNDS;
|
|
}
|
|
// Control menus render as horizontal panels inside the
|
|
// fixed-height dock strip below the video host, so open menus
|
|
// never require shrinking the native MPV view.
|
|
return measureBounds(host);
|
|
});
|
|
|
|
this.shortcuts.attach({
|
|
isAvailable: () =>
|
|
this.legacyInteractions.isAvailable() &&
|
|
!this.overlayVisibility.overlayActive(),
|
|
hostElement: () => this.playerRoot()?.nativeElement ?? null,
|
|
arrowKeysBlocked: () => this.menus.dockPanelOpen(),
|
|
onEscape: () => this.legacyInteractions.closePopovers(),
|
|
togglePaused: () => void this.togglePaused(),
|
|
toggleFullscreen: () => void this.toggleFullscreen(),
|
|
seekBy: (delta) => void this.seekBy(delta),
|
|
adjustVolume: (delta) => this.adjustVolume(delta),
|
|
toggleMute: () => this.toggleMute(),
|
|
});
|
|
|
|
effect((onCleanup) => {
|
|
const viewport = this.viewport();
|
|
const playback = this.playback();
|
|
const supported = this.isSupported();
|
|
this.controller.retryToken();
|
|
|
|
if (
|
|
!viewport ||
|
|
!playback.streamUrl ||
|
|
!supported ||
|
|
!window.electron
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// volume is read untracked so adjusting it during playback does
|
|
// not re-trigger this effect (which would tear down and recreate
|
|
// the session, restarting the stream from the beginning).
|
|
// Subsequent volume changes flow through controller.applyVolume.
|
|
this.setRecordingMessage(null);
|
|
const teardown = this.controller.startSession(
|
|
viewport.nativeElement,
|
|
playback,
|
|
untracked(() => this.volume())
|
|
);
|
|
onCleanup(teardown);
|
|
});
|
|
|
|
effect(() => {
|
|
this.overlayVisibility.overlayActive();
|
|
this.controller.triggerBoundsSync();
|
|
});
|
|
|
|
effect(() => {
|
|
const panelOpen = this.menus.dockPanelOpen();
|
|
untracked(() => this.dockPanels.handlePanelOpenChange(panelOpen));
|
|
});
|
|
|
|
effect(() => {
|
|
const session = this.session();
|
|
if (!session) {
|
|
this.lastEndedSessionId = null;
|
|
return;
|
|
}
|
|
// Side effects must not pull in transitive signal deps via
|
|
// scheduleControlsHide — otherwise opening a popover, pausing, or
|
|
// hovering would re-run this body and re-emit timeUpdate (which
|
|
// could feed back into playback inputs and restart the stream).
|
|
untracked(() => {
|
|
this.volume.set(session.volume);
|
|
this.timeUpdate.emit({
|
|
currentTime: session.positionSeconds,
|
|
duration: session.durationSeconds ?? 0,
|
|
playing: session.status === 'playing',
|
|
});
|
|
this.legacyInteractions.scheduleControlsHide();
|
|
});
|
|
});
|
|
|
|
effect(() => {
|
|
const session = this.session();
|
|
if (!session || session.status !== 'ended') {
|
|
return;
|
|
}
|
|
if (this.lastEndedSessionId === session.id) {
|
|
return;
|
|
}
|
|
|
|
this.lastEndedSessionId = session.id;
|
|
this.playbackEnded.emit();
|
|
});
|
|
|
|
effect((onCleanup) => {
|
|
if (this.isFrameCopyEngine() || !this.isRecording()) {
|
|
return;
|
|
}
|
|
this.recordingTick.set(Date.now());
|
|
const intervalId = window.setInterval(
|
|
() => this.recordingTick.set(Date.now()),
|
|
1000
|
|
);
|
|
onCleanup(() => window.clearInterval(intervalId));
|
|
});
|
|
|
|
effect(() => {
|
|
const isFrameCopyEngine = this.isFrameCopyEngine();
|
|
untracked(() => {
|
|
this.feedback.clear();
|
|
this.setRecordingMessage(null);
|
|
this.legacyInteractions.handleEngineTransition(
|
|
isFrameCopyEngine
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.shortcuts.detach();
|
|
this.feedback.dispose();
|
|
if (typeof document !== 'undefined') {
|
|
document.removeEventListener(
|
|
'fullscreenchange',
|
|
this.onFullscreenChange
|
|
);
|
|
}
|
|
this.legacyInteractions.dispose();
|
|
this.clearRecordingMessageTimer();
|
|
}
|
|
|
|
onPlayerInteraction(): void {
|
|
this.legacyInteractions.onPlayerInteraction();
|
|
}
|
|
|
|
onViewportClick(event: MouseEvent): void {
|
|
this.legacyInteractions.onViewportClick(event);
|
|
}
|
|
|
|
onPlayerDblClick(event: MouseEvent): void {
|
|
this.legacyInteractions.onPlayerDblClick(event);
|
|
}
|
|
|
|
async togglePaused(): Promise<void> {
|
|
this.legacyInteractions.revealControls();
|
|
await this.controller.togglePaused();
|
|
}
|
|
|
|
async toggleFullscreen(): Promise<void> {
|
|
this.legacyInteractions.revealControls();
|
|
const surface = this.fullscreenSurface();
|
|
if (!surface || !this.canFullscreen()) {
|
|
return;
|
|
}
|
|
try {
|
|
if (document.fullscreenElement === surface) {
|
|
await document.exitFullscreen();
|
|
} else {
|
|
await surface.requestFullscreen();
|
|
}
|
|
} catch {
|
|
return;
|
|
} finally {
|
|
this.controller.triggerBoundsSync();
|
|
}
|
|
}
|
|
|
|
async seekBy(deltaSeconds: number): Promise<void> {
|
|
this.legacyInteractions.revealControls();
|
|
const ok = await this.controller.seekBy(deltaSeconds);
|
|
if (ok) {
|
|
this.feedback.flash(
|
|
deltaSeconds >= 0 ? 'forward_10' : 'replay_10',
|
|
`${deltaSeconds >= 0 ? '+' : ''}${Math.round(deltaSeconds)}s`
|
|
);
|
|
}
|
|
}
|
|
|
|
requestPreviousEpisode(): void {
|
|
this.legacyInteractions.revealControls();
|
|
if (!this.canPreviousEpisode()) {
|
|
return;
|
|
}
|
|
this.previousEpisodeRequested.emit();
|
|
}
|
|
|
|
requestNextEpisode(): void {
|
|
this.legacyInteractions.revealControls();
|
|
if (!this.canNextEpisode()) {
|
|
return;
|
|
}
|
|
this.nextEpisodeRequested.emit();
|
|
}
|
|
|
|
onTimelineInput(event: Event): void {
|
|
this.legacyInteractions.revealControls();
|
|
this.scrubPosition.set(
|
|
Number((event.target as HTMLInputElement).value)
|
|
);
|
|
}
|
|
|
|
async onTimelineCommit(event: Event): Promise<void> {
|
|
const target = Number((event.target as HTMLInputElement).value);
|
|
this.scrubPosition.set(null);
|
|
await this.controller.seekTo(target);
|
|
}
|
|
|
|
onVolumeInput(event: Event): void {
|
|
const next = Number((event.target as HTMLInputElement).value);
|
|
this.applyVolume(next);
|
|
this.legacyInteractions.revealControls(false);
|
|
}
|
|
|
|
onVolumeWheel(event: WheelEvent): void {
|
|
event.preventDefault();
|
|
this.adjustVolume(event.deltaY > 0 ? -0.05 : 0.05);
|
|
}
|
|
|
|
onVolumeHoverEnter(): void {
|
|
this.legacyInteractions.onVolumeHoverEnter();
|
|
}
|
|
|
|
onVolumeHoverLeave(): void {
|
|
this.legacyInteractions.onVolumeHoverLeave();
|
|
}
|
|
|
|
toggleMute(): void {
|
|
const current = this.volume();
|
|
if (current > 0) {
|
|
this.mutedVolume = current;
|
|
this.applyVolume(0);
|
|
this.feedback.flash(
|
|
'volume_off',
|
|
this.translate.instant('EMBEDDED_MPV.PLAYER.MUTED')
|
|
);
|
|
} else {
|
|
const restored = this.mutedVolume || 0.5;
|
|
this.applyVolume(restored);
|
|
this.feedback.flash(
|
|
volumeIcon(restored),
|
|
`${Math.round(restored * 100)}%`
|
|
);
|
|
}
|
|
this.legacyInteractions.revealControls();
|
|
}
|
|
|
|
toggleAudioMenu(): void {
|
|
this.dockPanels.toggle('audio');
|
|
}
|
|
toggleSubtitleMenu(): void {
|
|
this.dockPanels.toggle('subtitle');
|
|
}
|
|
toggleSpeedMenu(): void {
|
|
this.dockPanels.toggle('speed');
|
|
}
|
|
toggleAspectMenu(): void {
|
|
this.dockPanels.toggle('aspect');
|
|
}
|
|
|
|
async selectAudioTrack(trackId: number): Promise<void> {
|
|
this.legacyInteractions.revealControls(false);
|
|
await this.controller.setAudioTrack(trackId);
|
|
this.menus.close('audio');
|
|
this.legacyInteractions.scheduleControlsHide();
|
|
}
|
|
|
|
async selectSubtitleTrack(trackId: number): Promise<void> {
|
|
this.legacyInteractions.revealControls(false);
|
|
await this.controller.setSubtitleTrack(trackId);
|
|
this.menus.close('subtitle');
|
|
this.legacyInteractions.scheduleControlsHide();
|
|
}
|
|
|
|
async selectSpeed(speed: number): Promise<void> {
|
|
this.legacyInteractions.revealControls(false);
|
|
await this.controller.setSpeed(speed);
|
|
this.menus.close('speed');
|
|
this.legacyInteractions.scheduleControlsHide();
|
|
}
|
|
|
|
async selectAspect(aspect: string): Promise<void> {
|
|
this.legacyInteractions.revealControls(false);
|
|
await this.controller.setAspect(aspect);
|
|
this.menus.close('aspect');
|
|
this.legacyInteractions.scheduleControlsHide();
|
|
}
|
|
|
|
async toggleRecording(): Promise<void> {
|
|
if (!this.canRecord()) {
|
|
return;
|
|
}
|
|
|
|
this.legacyInteractions.revealControls(false);
|
|
if (this.isRecording()) {
|
|
const recording = await this.controller.stopRecording();
|
|
if (recording?.targetPath) {
|
|
// recordingStopped is emitted by the session watcher in the
|
|
// constructor, which also covers stops triggered elsewhere.
|
|
this.setRecordingMessage(
|
|
this.translate.instant('EMBEDDED_MPV.PLAYER.SAVED_TO', {
|
|
path: recording.targetPath,
|
|
}),
|
|
{
|
|
autoDismiss: true,
|
|
}
|
|
);
|
|
this.feedback.flash(
|
|
'check_circle',
|
|
this.translate.instant(
|
|
'EMBEDDED_MPV.PLAYER.RECORDING_SAVED'
|
|
),
|
|
1200
|
|
);
|
|
} else if (recording?.error) {
|
|
this.setRecordingMessage(recording.error);
|
|
this.flashRecordingFailed();
|
|
} else {
|
|
this.setRecordingMessage(
|
|
this.translate.instant(
|
|
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_STOP'
|
|
)
|
|
);
|
|
this.flashRecordingFailed();
|
|
}
|
|
this.legacyInteractions.scheduleControlsHide();
|
|
return;
|
|
}
|
|
|
|
this.setRecordingMessage(null);
|
|
const recording = await this.controller.startRecording(
|
|
this.recordingFolder(),
|
|
this.playback().title,
|
|
this.recordingMetadata() ?? undefined
|
|
);
|
|
if (recording?.active) {
|
|
this.feedback.flash(
|
|
'fiber_manual_record',
|
|
this.translate.instant('EMBEDDED_MPV.PLAYER.RECORDING'),
|
|
900
|
|
);
|
|
} else if (recording?.error) {
|
|
this.setRecordingMessage(recording.error);
|
|
this.flashRecordingFailed();
|
|
} else {
|
|
this.setRecordingMessage(
|
|
this.translate.instant(
|
|
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_START'
|
|
)
|
|
);
|
|
this.flashRecordingFailed();
|
|
}
|
|
}
|
|
|
|
private flashRecordingFailed(): void {
|
|
this.feedback.flash(
|
|
'error_outline',
|
|
this.translate.instant('EMBEDDED_MPV.PLAYER.RECORDING_FAILED'),
|
|
1200
|
|
);
|
|
}
|
|
|
|
retry(): void {
|
|
this.controller.retry();
|
|
}
|
|
|
|
// These helpers run inside the template's reactive context, so the
|
|
// translationsTick() read makes them re-evaluate on language changes.
|
|
formatTime = formatTime;
|
|
trackLabel = (track: EmbeddedMpvAudioTrack, index: number) => {
|
|
this.translationsTick();
|
|
return audioTrackLabel(track, index, {
|
|
fallback: this.translate.instant(
|
|
'EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK',
|
|
{ index: index + 1 }
|
|
),
|
|
defaultLabel: this.translate.instant(
|
|
'EMBEDDED_MPV.PLAYER.TRACK_DEFAULT'
|
|
),
|
|
});
|
|
};
|
|
subtitleLabel = (track: EmbeddedMpvAudioTrack, index: number) => {
|
|
this.translationsTick();
|
|
return subtitleTrackLabel(track, index, {
|
|
fallback: this.translate.instant(
|
|
'EMBEDDED_MPV.PLAYER.SUBTITLE_TRACK_FALLBACK',
|
|
{ index: index + 1 }
|
|
),
|
|
defaultLabel: this.translate.instant(
|
|
'EMBEDDED_MPV.PLAYER.TRACK_DEFAULT'
|
|
),
|
|
});
|
|
};
|
|
speedLabel = speedLabel;
|
|
aspectLabel = (aspect: string) => {
|
|
this.translationsTick();
|
|
return aspect === 'no'
|
|
? this.translate.instant('EMBEDDED_MPV.PLAYER.ASPECT_DEFAULT')
|
|
: aspectLabel(aspect);
|
|
};
|
|
|
|
private setRecordingMessage(
|
|
message: string | null,
|
|
options: { autoDismiss?: boolean } = {}
|
|
): void {
|
|
this.clearRecordingMessageTimer();
|
|
this.recordingMessage.set(message);
|
|
|
|
if (!message || !options.autoDismiss) {
|
|
return;
|
|
}
|
|
|
|
this.recordingMessageTimer = window.setTimeout(() => {
|
|
if (this.recordingMessage() === message) {
|
|
this.recordingMessage.set(null);
|
|
}
|
|
this.recordingMessageTimer = null;
|
|
}, RECORDING_MESSAGE_DISMISS_DELAY_MS);
|
|
}
|
|
|
|
private clearRecordingMessageTimer(): void {
|
|
if (this.recordingMessageTimer === null) {
|
|
return;
|
|
}
|
|
window.clearTimeout(this.recordingMessageTimer);
|
|
this.recordingMessageTimer = null;
|
|
}
|
|
|
|
private adjustVolume(delta: number): void {
|
|
const next = Math.max(0, Math.min(1, this.volume() + delta));
|
|
this.applyVolume(next);
|
|
this.legacyInteractions.revealControls();
|
|
this.feedback.flash(volumeIcon(next), `${Math.round(next * 100)}%`);
|
|
}
|
|
|
|
private applyVolume(value: number): void {
|
|
this.volume.set(value);
|
|
persistVolume(value);
|
|
void this.controller.applyVolume(value);
|
|
}
|
|
}
|
|
|
|
export type EmbeddedMpvAudioTrackView = EmbeddedMpvAudioTrack;
|