mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
* fix(playback): harden embedded mpv session handling and support detection - guard the session controller against late startup rejections clobbering a newer session during fast channel zapping - exclude the refresh timestamp from the session-update dedup key so idle sessions stop re-emitting IPC updates every 500 ms - macOS: reconcile async loadfile replies by request id so a rejected seek/aid/speed on a live stream no longer flips the session to error - append --ozone-platform=x11 on Linux in main.ts so direct binary and AppImage launches match the packaged .desktop launcher behavior - return a sandbox-specific unsupported reason in Flatpak/Snap instead of asking the user to install mpv inside the sandbox - update the stale "macOS only" embedded MPV claim in CLAUDE.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(playback): populate Linux audio tracks and fix ARM Linux packaging - Linux poller now reads track-list/count each tick and walks the scalar track-list/N/* sub-properties when the count changes, so the audio-track menu is no longer empty; selection reconciles from the aid property - afterPack replaces the x64 embedded_mpv.node with an embedded-mpv-unavailable.txt marker in arm64/armv7l Linux packages, and package-layout verification rejects foreign-architecture addons while requiring the marker - extend native source invariants for the non-fatal async-reply rule (macOS) and the Linux track-list polling contract - document the Linux track-list mechanics and ARM packaging behavior in docs/architecture/embedded-mpv-native.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(playback): embedded mpv player UX polish and full localization - click on the video toggles pause (same action as Space) with a 250 ms grace period so double-click fullscreen cancels the pending pause; no DOM overlay is drawn — the dock transport icon is the feedback - timeline scrubbing previews the drag position locally and commits a single seek on release instead of one IPC seek per drag pixel - translate all player UI strings (controls, tooltips, aria-labels, status and recording messages) via new EMBEDDED_MPV.PLAYER.* keys, synced across en + 17 locales through the i18n-fill workflow - replace the legacy @Output() EventEmitter with the signal output() API Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): react to language changes and respect ozone platform hint Address review feedback on #1122: - add a translationsTick signal (onLangChange/onTranslationChange/ onDefaultLangChange) read by every computed() and template helper that calls translate.instant(), so labels re-evaluate on a runtime language switch and when the translation file finishes loading after mount - suppress the Linux --ozone-platform=x11 fallback when the user set ELECTRON_OZONE_PLATFORM_HINT, matching the existing respect for an explicit --ozone-platform switch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
129 lines
3.8 KiB
TypeScript
129 lines
3.8 KiB
TypeScript
import { EmbeddedMpvAudioTrack, EmbeddedMpvBounds } from '@iptvnator/shared/interfaces';
|
||
|
||
export const HIDDEN_BOUNDS: EmbeddedMpvBounds = Object.freeze({
|
||
x: -100000,
|
||
y: -100000,
|
||
width: 1,
|
||
height: 1,
|
||
}) as EmbeddedMpvBounds;
|
||
|
||
/**
|
||
* Vertical pixels to subtract from the MPV view's height when a control
|
||
* popover (volume, audio, subtitle, speed, aspect) is open above the
|
||
* controls strip. The native NSView paints over the WebContents, so we
|
||
* shrink it from the bottom to expose the popover region in DOM. Sized to
|
||
* cover the tallest popover (audio/subtitle list capped at ~240 px plus
|
||
* title + padding); video keeps playing in the upper region.
|
||
*/
|
||
export const MENU_OPEN_BOTTOM_CUTOUT_PX = 300;
|
||
|
||
export const SPEED_PRESETS: ReadonlyArray<{ value: number; label: string }> = [
|
||
{ value: 0.5, label: '0.5×' },
|
||
{ value: 0.75, label: '0.75×' },
|
||
{ value: 1, label: '1×' },
|
||
{ value: 1.25, label: '1.25×' },
|
||
{ value: 1.5, label: '1.5×' },
|
||
{ value: 2, label: '2×' },
|
||
];
|
||
|
||
export const ASPECT_PRESETS: ReadonlyArray<{ value: string; label: string }> = [
|
||
{ value: 'no', label: 'Default' },
|
||
{ value: '16:9', label: '16:9' },
|
||
{ value: '4:3', label: '4:3' },
|
||
{ value: '21:9', label: '21:9' },
|
||
{ value: '2.35:1', label: '2.35:1' },
|
||
];
|
||
|
||
export function formatTime(value: number | null | undefined): string {
|
||
const safeValue = Math.max(0, Math.floor(value ?? 0));
|
||
const hours = Math.floor(safeValue / 3600);
|
||
const minutes = Math.floor((safeValue % 3600) / 60);
|
||
const seconds = safeValue % 60;
|
||
|
||
if (hours > 0) {
|
||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(
|
||
seconds
|
||
).padStart(2, '0')}`;
|
||
}
|
||
|
||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||
}
|
||
|
||
export interface TrackLabelTexts {
|
||
/** Used when the track carries neither title nor language. */
|
||
fallback?: string;
|
||
/** Suffix marker for the stream's default track. */
|
||
defaultLabel?: string;
|
||
}
|
||
|
||
export function audioTrackLabel(
|
||
track: EmbeddedMpvAudioTrack,
|
||
index: number,
|
||
texts: TrackLabelTexts = {}
|
||
): string {
|
||
const label =
|
||
track.title ||
|
||
track.language ||
|
||
(texts.fallback ?? `Audio ${index + 1}`);
|
||
return track.defaultTrack
|
||
? `${label} · ${texts.defaultLabel ?? 'Default'}`
|
||
: label;
|
||
}
|
||
|
||
export function subtitleTrackLabel(
|
||
track: EmbeddedMpvAudioTrack,
|
||
index: number,
|
||
texts: TrackLabelTexts = {}
|
||
): string {
|
||
const label =
|
||
track.title ||
|
||
track.language ||
|
||
(texts.fallback ?? `Subtitle ${index + 1}`);
|
||
return track.defaultTrack
|
||
? `${label} · ${texts.defaultLabel ?? 'Default'}`
|
||
: label;
|
||
}
|
||
|
||
export function speedLabel(speed: number): string {
|
||
const value = Math.round(speed * 100) / 100;
|
||
return `${value}×`;
|
||
}
|
||
|
||
export function aspectLabel(aspect: string): string {
|
||
const preset = ASPECT_PRESETS.find((p) => p.value === aspect);
|
||
return preset?.label ?? aspect;
|
||
}
|
||
|
||
export function volumeIcon(value: number): string {
|
||
if (value <= 0) {
|
||
return 'volume_off';
|
||
}
|
||
return value < 0.5 ? 'volume_down' : 'volume_up';
|
||
}
|
||
|
||
export function volumeLabel(value: number): string {
|
||
return `Volume ${Math.round(value * 100)}%`;
|
||
}
|
||
|
||
export function readStoredVolume(): number {
|
||
const rawValue = Number(localStorage.getItem('volume') ?? '1');
|
||
if (Number.isNaN(rawValue)) {
|
||
return 1;
|
||
}
|
||
return Math.max(0, Math.min(1, rawValue));
|
||
}
|
||
|
||
export function persistVolume(value: number): void {
|
||
localStorage.setItem('volume', String(value));
|
||
}
|
||
|
||
export function measureBounds(host: HTMLElement): EmbeddedMpvBounds {
|
||
const rect = host.getBoundingClientRect();
|
||
return {
|
||
x: Math.round(rect.left),
|
||
y: Math.round(rect.top),
|
||
width: Math.max(1, Math.round(rect.width)),
|
||
height: Math.max(1, Math.round(rect.height)),
|
||
};
|
||
}
|