mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
Audit followup to the support-loop and volume-restart fixes. Two more effects pulled in transitive signal deps that would have caused the same class of regression the next time the surrounding helpers grew. 1. Component session-fan-out effect: the body called scheduleControlsHide(), which reads isPlaying/menus.anyOpen/statusLabel/ controlsVisible. Those became tracked deps of the effect, so opening a popover, pausing, or hovering re-ran the whole body — re-emitting timeUpdate. If a parent ever wires timeUpdate back into playback.startTime as a "resume where I left off" feature, this would have been the next stream-restart bug. Wrap the side-effect block in untracked() so the effect only listens to session changes. 2. Controller stalled-tracker effect: tracked the full session signal even though only status was needed. The session payload updates ~2 Hz during playback (positionSeconds advances), making the effect re-run constantly to call a no-op. Add a sessionStatus computed and track that instead — fires only on real status transitions. No behavior change for current users; both fixes are preventative. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: f957cd9849e0
429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
import {
|
|
DestroyRef,
|
|
Injectable,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
signal,
|
|
untracked,
|
|
} from '@angular/core';
|
|
import {
|
|
EmbeddedMpvBounds,
|
|
EmbeddedMpvSession,
|
|
EmbeddedMpvSupport,
|
|
ResolvedPortalPlayback,
|
|
} from 'shared-interfaces';
|
|
import { measureBounds } from './embedded-mpv-format.utils';
|
|
|
|
export type EmbeddedMpvBoundsProvider = (host: HTMLElement) => EmbeddedMpvBounds;
|
|
|
|
const STALLED_TIMEOUT_MS = 30_000;
|
|
|
|
@Injectable()
|
|
export class EmbeddedMpvSessionController {
|
|
readonly support = signal<EmbeddedMpvSupport | null>(null);
|
|
readonly session = signal<EmbeddedMpvSession | null>(null);
|
|
readonly sessionId = signal<string | null>(null);
|
|
readonly stalled = signal(false);
|
|
readonly retryToken = signal(0);
|
|
|
|
private readonly sessionStatus = computed(
|
|
() => this.session()?.status ?? null
|
|
);
|
|
|
|
private readonly destroyRef = inject(DestroyRef);
|
|
private readonly unsubscribeSessionUpdate?: () => void;
|
|
|
|
private boundsProvider: EmbeddedMpvBoundsProvider = (host) =>
|
|
measureBounds(host);
|
|
private activeBoundsSync: (() => void) | null = null;
|
|
private boundsAnimationFrame: number | null = null;
|
|
private stalledTimer: number | null = null;
|
|
|
|
constructor() {
|
|
this.unsubscribeSessionUpdate =
|
|
window.electron?.onEmbeddedMpvSessionUpdate?.((session) => {
|
|
if (session.id !== this.sessionId()) {
|
|
return;
|
|
}
|
|
this.session.set(session);
|
|
});
|
|
|
|
if (window.electron?.getEmbeddedMpvSupport) {
|
|
void this.loadSupport();
|
|
} else {
|
|
this.support.set({
|
|
supported: false,
|
|
platform: typeof window === 'undefined' ? 'web' : 'unknown',
|
|
reason: 'Embedded MPV requires the Electron desktop build.',
|
|
});
|
|
}
|
|
|
|
// Track the narrowest possible signal — status only — so this effect
|
|
// does not re-run on every position-poll snapshot (~2 Hz during play)
|
|
// even though handleStalledTracking would be a no-op for those.
|
|
effect(() => {
|
|
const status = this.sessionStatus();
|
|
untracked(() => this.handleStalledTracking(status));
|
|
});
|
|
|
|
this.destroyRef.onDestroy(() => {
|
|
this.unsubscribeSessionUpdate?.();
|
|
this.cancelStalledTimer();
|
|
if (this.boundsAnimationFrame !== null) {
|
|
cancelAnimationFrame(this.boundsAnimationFrame);
|
|
this.boundsAnimationFrame = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
setBoundsProvider(provider: EmbeddedMpvBoundsProvider): void {
|
|
this.boundsProvider = provider;
|
|
}
|
|
|
|
triggerBoundsSync(): void {
|
|
this.activeBoundsSync?.();
|
|
}
|
|
|
|
retry(): void {
|
|
this.stalled.set(false);
|
|
this.session.set(null);
|
|
this.sessionId.set(null);
|
|
this.retryToken.update((value) => value + 1);
|
|
}
|
|
|
|
/**
|
|
* Spin up an embedded MPV session bound to `host`. Returns a teardown
|
|
* function the caller must invoke when the host or playback changes (or
|
|
* the component tears down). All bounds and lifecycle bookkeeping lives
|
|
* here so the component can stay view-focused.
|
|
*/
|
|
startSession(
|
|
host: HTMLElement,
|
|
playback: ResolvedPortalPlayback,
|
|
initialVolume: number
|
|
): () => void {
|
|
let disposed = false;
|
|
let activeSessionId: string | null = null;
|
|
|
|
const syncBounds = () => {
|
|
if (!activeSessionId) {
|
|
return;
|
|
}
|
|
const bounds = this.boundsProvider(host);
|
|
void window.electron
|
|
?.setEmbeddedMpvBounds(activeSessionId, bounds)
|
|
.catch(() => undefined);
|
|
};
|
|
|
|
const scheduleBoundsSync = () => {
|
|
if (this.boundsAnimationFrame !== null) {
|
|
cancelAnimationFrame(this.boundsAnimationFrame);
|
|
}
|
|
this.boundsAnimationFrame = requestAnimationFrame(() => {
|
|
this.boundsAnimationFrame = null;
|
|
syncBounds();
|
|
});
|
|
};
|
|
|
|
this.activeBoundsSync = scheduleBoundsSync;
|
|
|
|
const resizeObserver = new ResizeObserver(() => scheduleBoundsSync());
|
|
resizeObserver.observe(host);
|
|
window.addEventListener('resize', scheduleBoundsSync);
|
|
window.addEventListener('scroll', scheduleBoundsSync, true);
|
|
|
|
const create = async () => {
|
|
this.session.set(this.createLoadingSession(playback, initialVolume));
|
|
await this.waitForStartupPaint();
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
|
|
const prepared = await window.electron!.prepareEmbeddedMpv?.();
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
if (prepared && !prepared.supported) {
|
|
throw new Error(
|
|
prepared.reason ??
|
|
'Embedded MPV is not available in this environment.'
|
|
);
|
|
}
|
|
// Do not call this.support.set(prepared) here: support is already
|
|
// populated by loadSupport() in the constructor, and writing it
|
|
// again would re-trigger the component's session-creation effect
|
|
// (which depends on this.support()), tearing down the session we
|
|
// just created and looping forever.
|
|
|
|
const created = await window.electron!.createEmbeddedMpvSession(
|
|
measureBounds(host),
|
|
playback.title,
|
|
initialVolume
|
|
);
|
|
|
|
if (disposed) {
|
|
await window.electron!.disposeEmbeddedMpvSession(created.id);
|
|
return;
|
|
}
|
|
|
|
activeSessionId = created.id;
|
|
this.sessionId.set(created.id);
|
|
this.session.set(created);
|
|
await window.electron!.loadEmbeddedMpvPlayback(created.id, playback);
|
|
scheduleBoundsSync();
|
|
};
|
|
|
|
void create().catch((error) =>
|
|
this.session.set(this.createErrorSession(playback, initialVolume, error))
|
|
);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
resizeObserver.disconnect();
|
|
window.removeEventListener('resize', scheduleBoundsSync);
|
|
window.removeEventListener('scroll', scheduleBoundsSync, true);
|
|
|
|
if (this.activeBoundsSync === scheduleBoundsSync) {
|
|
this.activeBoundsSync = null;
|
|
}
|
|
if (this.boundsAnimationFrame !== null) {
|
|
cancelAnimationFrame(this.boundsAnimationFrame);
|
|
this.boundsAnimationFrame = null;
|
|
}
|
|
|
|
const id = activeSessionId;
|
|
activeSessionId = null;
|
|
this.sessionId.set(null);
|
|
this.session.set(null);
|
|
|
|
if (id) {
|
|
void window.electron?.disposeEmbeddedMpvSession(id);
|
|
}
|
|
};
|
|
}
|
|
|
|
async togglePaused(): Promise<void> {
|
|
const id = this.sessionId();
|
|
const session = this.session();
|
|
if (!id || !session || !window.electron?.setEmbeddedMpvPaused) {
|
|
return;
|
|
}
|
|
const updated = await this.guardIpc(() =>
|
|
window.electron!.setEmbeddedMpvPaused(
|
|
id,
|
|
session.status !== 'paused'
|
|
)
|
|
);
|
|
if (updated) {
|
|
this.session.set(updated);
|
|
}
|
|
}
|
|
|
|
async seekBy(deltaSeconds: number): Promise<boolean> {
|
|
const id = this.sessionId();
|
|
const session = this.session();
|
|
if (!id || !session || !window.electron?.seekEmbeddedMpv) {
|
|
return false;
|
|
}
|
|
const next = Math.max(0, session.positionSeconds + deltaSeconds);
|
|
const updated = await this.guardIpc(() =>
|
|
window.electron!.seekEmbeddedMpv(id, next)
|
|
);
|
|
if (updated) {
|
|
this.session.set(updated);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async seekTo(seconds: number): Promise<void> {
|
|
const id = this.sessionId();
|
|
if (!id || !window.electron?.seekEmbeddedMpv) {
|
|
return;
|
|
}
|
|
const updated = await this.guardIpc(() =>
|
|
window.electron!.seekEmbeddedMpv(id, seconds)
|
|
);
|
|
if (updated) {
|
|
this.session.set(updated);
|
|
}
|
|
}
|
|
|
|
async applyVolume(value: number): Promise<void> {
|
|
const id = this.sessionId();
|
|
if (!id || !window.electron?.setEmbeddedMpvVolume) {
|
|
return;
|
|
}
|
|
const updated = await this.guardIpc(() =>
|
|
window.electron!.setEmbeddedMpvVolume(id, value)
|
|
);
|
|
if (updated) {
|
|
this.session.set(updated);
|
|
}
|
|
}
|
|
|
|
async setAudioTrack(trackId: number): Promise<void> {
|
|
const id = this.sessionId();
|
|
if (!id || !window.electron?.setEmbeddedMpvAudioTrack) {
|
|
return;
|
|
}
|
|
const updated = await this.guardIpc(() =>
|
|
window.electron!.setEmbeddedMpvAudioTrack(id, trackId)
|
|
);
|
|
if (updated) {
|
|
this.session.set(updated);
|
|
}
|
|
}
|
|
|
|
async setSubtitleTrack(trackId: number): Promise<void> {
|
|
const id = this.sessionId();
|
|
if (!id || !window.electron?.setEmbeddedMpvSubtitleTrack) {
|
|
return;
|
|
}
|
|
const updated = await this.guardIpc(() =>
|
|
window.electron!.setEmbeddedMpvSubtitleTrack!(id, trackId)
|
|
);
|
|
if (updated) {
|
|
this.session.set(updated);
|
|
}
|
|
}
|
|
|
|
async setSpeed(speed: number): Promise<void> {
|
|
const id = this.sessionId();
|
|
if (!id || !window.electron?.setEmbeddedMpvSpeed) {
|
|
return;
|
|
}
|
|
const updated = await this.guardIpc(() =>
|
|
window.electron!.setEmbeddedMpvSpeed!(id, speed)
|
|
);
|
|
if (updated) {
|
|
this.session.set(updated);
|
|
}
|
|
}
|
|
|
|
async setAspect(aspect: string): Promise<void> {
|
|
const id = this.sessionId();
|
|
if (!id || !window.electron?.setEmbeddedMpvAspect) {
|
|
return;
|
|
}
|
|
const updated = await this.guardIpc(() =>
|
|
window.electron!.setEmbeddedMpvAspect!(id, aspect)
|
|
);
|
|
if (updated) {
|
|
this.session.set(updated);
|
|
}
|
|
}
|
|
|
|
private async guardIpc<T>(
|
|
call: () => Promise<T>
|
|
): Promise<T | null> {
|
|
try {
|
|
return await call();
|
|
} catch {
|
|
// The session may have been torn down or an addon-side throw
|
|
// raced the IPC. Swallow — the next snapshot will resync state.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async loadSupport(): Promise<void> {
|
|
try {
|
|
this.support.set(await window.electron!.getEmbeddedMpvSupport());
|
|
} catch (error) {
|
|
this.support.set({
|
|
supported: false,
|
|
platform: window.electron?.platform ?? 'unknown',
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
private handleStalledTracking(
|
|
status: EmbeddedMpvSession['status'] | null
|
|
): void {
|
|
if (status === 'loading') {
|
|
if (this.stalledTimer === null) {
|
|
this.stalledTimer = window.setTimeout(() => {
|
|
this.stalled.set(true);
|
|
this.stalledTimer = null;
|
|
}, STALLED_TIMEOUT_MS);
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.cancelStalledTimer();
|
|
if (this.stalled()) {
|
|
this.stalled.set(false);
|
|
}
|
|
}
|
|
|
|
private cancelStalledTimer(): void {
|
|
if (this.stalledTimer !== null) {
|
|
clearTimeout(this.stalledTimer);
|
|
this.stalledTimer = null;
|
|
}
|
|
}
|
|
|
|
private createLoadingSession(
|
|
playback: ResolvedPortalPlayback,
|
|
volume: number
|
|
): EmbeddedMpvSession {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
id: 'embedded-mpv-starting',
|
|
title: playback.title,
|
|
streamUrl: playback.streamUrl,
|
|
status: 'loading',
|
|
positionSeconds: 0,
|
|
durationSeconds: null,
|
|
volume,
|
|
audioTracks: [],
|
|
selectedAudioTrackId: null,
|
|
subtitleTracks: [],
|
|
selectedSubtitleTrackId: null,
|
|
playbackSpeed: 1,
|
|
aspectOverride: 'no',
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
private createErrorSession(
|
|
playback: ResolvedPortalPlayback,
|
|
volume: number,
|
|
error: unknown
|
|
): EmbeddedMpvSession {
|
|
const now = new Date().toISOString();
|
|
this.sessionId.set(null);
|
|
return {
|
|
id: 'embedded-mpv-error',
|
|
title: playback.title,
|
|
streamUrl: playback.streamUrl,
|
|
status: 'error',
|
|
positionSeconds: 0,
|
|
durationSeconds: null,
|
|
volume,
|
|
audioTracks: [],
|
|
selectedAudioTrackId: null,
|
|
subtitleTracks: [],
|
|
selectedSubtitleTrackId: null,
|
|
playbackSpeed: 1,
|
|
aspectOverride: 'no',
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
|
|
private waitForStartupPaint(): Promise<void> {
|
|
if (typeof requestAnimationFrame !== 'function') {
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise((resolve) => {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => resolve());
|
|
});
|
|
});
|
|
}
|
|
}
|