Files
iptvnator/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts
T
4grayandClaude Fable 5 8eb0fe3261 feat(playback): embedded mpv pre-0.22 hardening, Linux parity, and localization (#1122)
* 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>
2026-07-04 14:22:48 +02:00

129 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)),
};
}