mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* fix(playback): keep the saved Embedded MPV player when the mpv check is inconclusive On Linux native-view the support check runs `mpv --version` by bare name and waits for the login shell PATH first. Since #1784 that lookup is asynchronous with a 10 s budget; when it ran out, the check ran on the inherited PATH and answered a plain `supported: false`. The settings store took that as a verdict and persisted the default player over a saved Embedded MPV selection. The main process probed again once the shell answered, but nothing restored the setting. `EmbeddedMpvSupport` now carries `inconclusive`. The native service sets it on a missing mpv while its probe has only seen the inherited PATH; the IPC handler declares that state before probing and registers the re-probe before the check, so a throwing check cannot leave it stuck. Every other answer stays final. Consumers no longer settle on an inconclusive answer: the settings store keeps the saved player, and the command palette and the settings search probe again on their next use instead of caching it for the session. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(playback): keep asking for Embedded MPV support while the answer is inconclusive Keeping the saved player on an inconclusive answer left a mounted player stuck on it: the session controller asked for support once, in its constructor, and the session effect never starts while unsupported, so the player did not recover after the login shell answered. The settings page held its one answer the same way. `watchEmbeddedMpvSupport()` asks again every 3 s until the answer is final or the surface is destroyed. The player controller and the settings page facade load support through it, so playback starts by itself and the Embedded MPV option appears without reopening the page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): follow an inconclusive Embedded MPV answer to a final decision The settings store checked a saved Embedded MPV selection once. After an inconclusive answer it kept the selection and never looked again, so when mpv turned out to be really missing the player stayed on Embedded MPV instead of falling back to the default one. The store now follows the answer with `watchEmbeddedMpvSupport()` until it is final and only then decides. It acts on an answer only while Embedded MPV is still the saved player, so a player picked meanwhile, also while the first answer was pending, is never overwritten. The watch backs off from 3 s to 30 s between rechecks, so a login shell that never answers does not keep the app polling at the first rate, and it no longer schedules a recheck after its answer handler stopped it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): keep the settings search following an inconclusive Embedded MPV answer The settings page asks the search service for Embedded MPV support once, when its search facade is created. After an inconclusive answer the service only probed again on its next call, so with the page left open the Embedded MPV rows stayed unsearchable after the login shell answered, while the player option on the same page already updated. The service now follows the answer with `watchEmbeddedMpvSupport()` until it is final, which updates the open page and the command palette alike. A call made while the answer is still inconclusive restarts the watch, so it asks at once as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): follow Embedded MPV support for search only while the settings page is open The settings search service is provided in the root injector, so the watch it started on its first use had no owner: with a login shell that never answers it kept asking every 30 s until the app quit, long after the settings page or the command palette that needed the answer was closed. A failed recheck also ended the watch as if it were a final answer, hiding the Embedded MPV rows for the rest of the session. The service now separates the two uses. `ensureEmbeddedMpvSupportLoaded()` is a single request again, for the command palette. The settings page calls `followEmbeddedMpvSupport()` and ends it when the page is destroyed. Only a final answer is kept: after an inconclusive one or a failed request the next use asks again, for the palette's player commands too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
import {
|
|
DestroyRef,
|
|
Injectable,
|
|
NgZone,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
signal,
|
|
untracked,
|
|
} from '@angular/core';
|
|
import {
|
|
EmbeddedMpvBounds,
|
|
EmbeddedMpvSession,
|
|
EmbeddedMpvSubtitleStyle,
|
|
EmbeddedMpvSupport,
|
|
RecordingStartMetadata,
|
|
ResolvedPortalPlayback,
|
|
watchEmbeddedMpvSupport,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { EmbeddedMpvCommandRunner } from './embedded-mpv-command-runner';
|
|
import { measureBounds } from './embedded-mpv-format.utils';
|
|
import {
|
|
createErrorSession,
|
|
createLoadingSession,
|
|
waitForStartupPaint,
|
|
} from './embedded-mpv-session-factory';
|
|
import { EmbeddedMpvStalledTracker } from './embedded-mpv-stalled-tracker';
|
|
|
|
export type EmbeddedMpvBoundsProvider = (
|
|
host: HTMLElement
|
|
) => EmbeddedMpvBounds;
|
|
|
|
type ElectronBridge = Window['electron'];
|
|
|
|
/**
|
|
* Cadence of the host-position drift poll. 500 ms keeps a stuck native
|
|
* window user-invisible (it snaps back within half a second) while costing
|
|
* one getBoundingClientRect per tick and zero IPC while nothing moves.
|
|
*/
|
|
const POSITION_POLL_INTERVAL_MS = 500;
|
|
|
|
/**
|
|
* Sub-pixel measurement noise must not re-send bounds every tick: after a
|
|
* sync the next poll re-measures the same layout, so anything below half a
|
|
* CSS pixel is the same position. Real layout shifts move by whole pixels.
|
|
*/
|
|
const POSITION_POLL_EPSILON_PX = 0.5;
|
|
|
|
function boundsDiffer(a: EmbeddedMpvBounds, b: EmbeddedMpvBounds): boolean {
|
|
return (
|
|
Math.abs(a.x - b.x) > POSITION_POLL_EPSILON_PX ||
|
|
Math.abs(a.y - b.y) > POSITION_POLL_EPSILON_PX ||
|
|
Math.abs(a.width - b.width) > POSITION_POLL_EPSILON_PX ||
|
|
Math.abs(a.height - b.height) > POSITION_POLL_EPSILON_PX
|
|
);
|
|
}
|
|
|
|
@Injectable()
|
|
export class EmbeddedMpvSessionController {
|
|
readonly support = signal<EmbeddedMpvSupport | null>(null);
|
|
readonly session = signal<EmbeddedMpvSession | null>(null);
|
|
readonly sessionId = signal<string | null>(null);
|
|
readonly retryToken = signal(0);
|
|
|
|
private readonly stalledTracker = new EmbeddedMpvStalledTracker();
|
|
readonly stalled = this.stalledTracker.stalled;
|
|
|
|
private readonly commands = new EmbeddedMpvCommandRunner({
|
|
sessionId: this.sessionId,
|
|
session: this.session,
|
|
});
|
|
|
|
readonly isFrameCopyEngine = computed(
|
|
() => this.support()?.engine === 'frame-copy'
|
|
);
|
|
|
|
private readonly sessionStatus = computed(
|
|
() => this.session()?.status ?? null
|
|
);
|
|
|
|
private readonly destroyRef = inject(DestroyRef);
|
|
private readonly zone = inject(NgZone);
|
|
private readonly unsubscribeSessionUpdate?: () => void;
|
|
|
|
private boundsProvider: EmbeddedMpvBoundsProvider = (host) =>
|
|
measureBounds(host);
|
|
private activeBoundsSync: (() => void) | null = null;
|
|
private boundsAnimationFrame: number | null = null;
|
|
|
|
constructor() {
|
|
this.unsubscribeSessionUpdate =
|
|
window.electron?.onEmbeddedMpvSessionUpdate?.((session) => {
|
|
if (session.id !== this.sessionId()) {
|
|
return;
|
|
}
|
|
this.session.set(session);
|
|
});
|
|
|
|
let stopSupportWatch: (() => void) | undefined;
|
|
if (typeof window.electron?.getEmbeddedMpvSupport === 'function') {
|
|
stopSupportWatch = this.watchSupport();
|
|
} else {
|
|
this.support.set({
|
|
supported: false,
|
|
platform: typeof window === 'undefined' ? 'web' : 'unknown',
|
|
reason: 'Embedded MPV requires the Electron desktop build.',
|
|
});
|
|
}
|
|
|
|
// Track status only so this effect does not re-run on every
|
|
// position-poll snapshot (~2 Hz) where stalled tracking is a no-op.
|
|
effect(() => {
|
|
const status = this.sessionStatus();
|
|
untracked(() => this.stalledTracker.track(status));
|
|
});
|
|
|
|
this.destroyRef.onDestroy(() => {
|
|
this.unsubscribeSessionUpdate?.();
|
|
stopSupportWatch?.();
|
|
this.stalledTracker.cancel();
|
|
if (this.boundsAnimationFrame !== null) {
|
|
cancelAnimationFrame(this.boundsAnimationFrame);
|
|
this.boundsAnimationFrame = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
setBoundsProvider(provider: EmbeddedMpvBoundsProvider): void {
|
|
this.boundsProvider = provider;
|
|
}
|
|
|
|
triggerBoundsSync(): void {
|
|
this.activeBoundsSync?.();
|
|
}
|
|
|
|
retry(): void {
|
|
this.stalledTracker.reset();
|
|
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 the
|
|
* caller invokes when host/playback changes or the component tears down.
|
|
*/
|
|
startSession(
|
|
host: HTMLElement,
|
|
playback: ResolvedPortalPlayback,
|
|
initialVolume: number
|
|
): () => void {
|
|
let disposed = false;
|
|
let activeSessionId: string | null = null;
|
|
let lastSyncedBounds: EmbeddedMpvBounds | null = null;
|
|
|
|
const syncBounds = () => {
|
|
if (!activeSessionId) {
|
|
return;
|
|
}
|
|
const bounds = this.boundsProvider(host);
|
|
lastSyncedBounds = bounds;
|
|
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);
|
|
|
|
// ResizeObserver reports size changes only: an ancestor re-layout
|
|
// that translates the host without resizing it (sidebar content
|
|
// settling, panels loading below the player) moves the DOM while the
|
|
// native child window keeps its old coordinates, and no DOM event
|
|
// observes "position changed" (#1428). A low-frequency poll compares
|
|
// the measured bounds against the last synced ones and re-syncs only
|
|
// on drift, so the idle cost is one getBoundingClientRect per tick
|
|
// with no IPC.
|
|
//
|
|
// The interval runs outside Angular's zone: with zone change
|
|
// detection a zone-registered timer would run app-wide change
|
|
// detection every tick for the whole stream. It never re-enters the
|
|
// zone — the drift path is rAF → setEmbeddedMpvBounds IPC and
|
|
// touches no Angular state. Frame-copy paints into a DOM canvas
|
|
// that moves with the layout, so only native-view can go stale on a
|
|
// position-only shift; the poll skips the measurement there.
|
|
const positionPoll = this.zone.runOutsideAngular(() =>
|
|
window.setInterval(() => {
|
|
if (!activeSessionId || !lastSyncedBounds) {
|
|
return;
|
|
}
|
|
if (untracked(() => this.isFrameCopyEngine())) {
|
|
return;
|
|
}
|
|
if (
|
|
boundsDiffer(this.boundsProvider(host), lastSyncedBounds)
|
|
) {
|
|
scheduleBoundsSync();
|
|
}
|
|
}, POSITION_POLL_INTERVAL_MS)
|
|
);
|
|
|
|
// Page zoom and monitor DPI rescale the CSS→native-pixel mapping the
|
|
// backend applies to these bounds. Moving the window to a display
|
|
// with a different scale can keep the CSS layout identical (no
|
|
// resize, no ResizeObserver), so watch devicePixelRatio through a
|
|
// re-armed matchMedia query and re-sync when it changes.
|
|
let detachDprWatch: (() => void) | null = null;
|
|
const watchDevicePixelRatio = () => {
|
|
detachDprWatch?.();
|
|
detachDprWatch = null;
|
|
const query = window.matchMedia?.(
|
|
`(resolution: ${window.devicePixelRatio}dppx)`
|
|
);
|
|
if (!query) {
|
|
return;
|
|
}
|
|
const onChange = () => {
|
|
watchDevicePixelRatio();
|
|
scheduleBoundsSync();
|
|
};
|
|
query.addEventListener('change', onChange);
|
|
detachDprWatch = () =>
|
|
query.removeEventListener('change', onChange);
|
|
};
|
|
watchDevicePixelRatio();
|
|
|
|
const create = async () => {
|
|
this.session.set(createLoadingSession(playback, initialVolume));
|
|
await waitForStartupPaint();
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
|
|
const electron = this.getElectronBridge();
|
|
if (!electron) {
|
|
throw new Error(
|
|
'Embedded MPV requires the Electron desktop build.'
|
|
);
|
|
}
|
|
|
|
const prepared = await electron.prepareEmbeddedMpv?.();
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
if (prepared && !prepared.supported) {
|
|
throw new Error(
|
|
prepared.reason ??
|
|
'Embedded MPV is not available in this environment.'
|
|
);
|
|
}
|
|
|
|
const created = await electron.createEmbeddedMpvSession(
|
|
measureBounds(host),
|
|
playback.title,
|
|
initialVolume
|
|
);
|
|
|
|
if (disposed) {
|
|
await electron.disposeEmbeddedMpvSession(created.id);
|
|
return;
|
|
}
|
|
|
|
activeSessionId = created.id;
|
|
this.sessionId.set(created.id);
|
|
this.session.set(created);
|
|
await electron.loadEmbeddedMpvPlayback(created.id, playback);
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
if (untracked(() => this.isFrameCopyEngine())) {
|
|
// Frame-copy engine: start the preload frame pump that
|
|
// paints helper frames onto the component's canvas. A failed
|
|
// attach (no canvas, no WebGL2, reader missing) must surface
|
|
// as a session error — otherwise the helper keeps playing
|
|
// audio behind a black canvas with no recovery UI.
|
|
const attached = await electron
|
|
.attachEmbeddedMpvFrameView?.(created.id)
|
|
.catch(() => false);
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
if (attached === false && !disposed) {
|
|
await electron
|
|
.disposeEmbeddedMpvSession(created.id)
|
|
.catch(() => undefined);
|
|
throw new Error(
|
|
'The embedded MPV frame view failed to initialize.'
|
|
);
|
|
}
|
|
}
|
|
scheduleBoundsSync();
|
|
};
|
|
|
|
void create().catch((error) => {
|
|
// A rejection can land after teardown (fast channel zapping):
|
|
// writing the error session then would clobber the state of the
|
|
// session that replaced this one and null its sessionId.
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
// Factory is pure; clear sessionId here (controller owns mutation).
|
|
this.sessionId.set(null);
|
|
this.session.set(
|
|
createErrorSession(playback, initialVolume, error)
|
|
);
|
|
});
|
|
|
|
return () => {
|
|
disposed = true;
|
|
resizeObserver.disconnect();
|
|
window.removeEventListener('resize', scheduleBoundsSync);
|
|
window.removeEventListener('scroll', scheduleBoundsSync, true);
|
|
window.clearInterval(positionPoll);
|
|
detachDprWatch?.();
|
|
detachDprWatch = null;
|
|
|
|
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) {
|
|
if (untracked(() => this.isFrameCopyEngine())) {
|
|
window.electron?.detachEmbeddedMpvFrameView?.();
|
|
}
|
|
void window.electron?.disposeEmbeddedMpvSession(id);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Transport/track/recording commands delegate to the command runner (owns
|
|
// IPC + snapshot reconciliation). Bound fields keep the public API stable.
|
|
readonly togglePaused = (): Promise<void> => this.commands.togglePaused();
|
|
readonly seekBy = (deltaSeconds: number): Promise<boolean> =>
|
|
this.commands.seekBy(deltaSeconds);
|
|
readonly seekTo = (seconds: number): Promise<void> =>
|
|
this.commands.seekTo(seconds);
|
|
readonly applyVolume = (value: number): Promise<void> =>
|
|
this.commands.applyVolume(value);
|
|
readonly setAudioTrack = (trackId: number): Promise<void> =>
|
|
this.commands.setAudioTrack(trackId);
|
|
readonly setSubtitleTrack = (trackId: number): Promise<void> =>
|
|
this.commands.setSubtitleTrack(trackId);
|
|
readonly addExternalSubtitle = (): Promise<boolean> =>
|
|
this.commands.addExternalSubtitle();
|
|
readonly setSubtitleDelay = (seconds: number): Promise<void> =>
|
|
this.commands.setSubtitleDelay(seconds);
|
|
readonly setSubtitleStyle = (
|
|
style: EmbeddedMpvSubtitleStyle
|
|
): Promise<void> => this.commands.setSubtitleStyle(style);
|
|
readonly setSpeed = (speed: number): Promise<void> =>
|
|
this.commands.setSpeed(speed);
|
|
readonly setAspect = (aspect: string): Promise<void> =>
|
|
this.commands.setAspect(aspect);
|
|
readonly startRecording = (
|
|
directory: string | undefined,
|
|
title: string,
|
|
metadata?: RecordingStartMetadata
|
|
): Promise<EmbeddedMpvSession['recording'] | null> =>
|
|
this.commands.startRecording(directory, title, metadata);
|
|
readonly stopRecording = (): Promise<
|
|
EmbeddedMpvSession['recording'] | null
|
|
> => this.commands.stopRecording();
|
|
|
|
/**
|
|
* Loads support and keeps asking while the answer is inconclusive: the
|
|
* session effect starts playback as soon as `support` turns supported,
|
|
* so a player mounted during that window recovers by itself.
|
|
*/
|
|
private watchSupport(): () => void {
|
|
return watchEmbeddedMpvSupport(
|
|
() => {
|
|
const electron = this.getElectronBridge();
|
|
if (!electron?.getEmbeddedMpvSupport) {
|
|
throw new Error(
|
|
'Embedded MPV requires the Electron desktop build.'
|
|
);
|
|
}
|
|
return electron.getEmbeddedMpvSupport();
|
|
},
|
|
(support) => this.support.set(support),
|
|
(error) =>
|
|
this.support.set({
|
|
supported: false,
|
|
platform: window.electron?.platform ?? 'unknown',
|
|
reason:
|
|
error instanceof Error ? error.message : String(error),
|
|
})
|
|
);
|
|
}
|
|
|
|
private getElectronBridge(): ElectronBridge | undefined {
|
|
return window.electron;
|
|
}
|
|
}
|