feat(player): native video on Linux, and one contract for every player (v0.11.0)
mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick, untrack } from "svelte";
|
||||
import { planFullscreen } from "./fullscreenTarget";
|
||||
import { get } from "svelte/store";
|
||||
import { goto } from "$app/navigation";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { JRayActor, StreamingQuality, BackgroundAction } from "$lib/api/bindings";
|
||||
import type {
|
||||
JRayActor,
|
||||
StreamingQuality,
|
||||
BackgroundAction,
|
||||
StreamSelection,
|
||||
} from "$lib/api/bindings";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Hls from "hls.js";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
@@ -77,12 +83,21 @@
|
||||
type BackgroundAudioState,
|
||||
} from "./backgroundAudioHandoff";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
import { elementSrcFor, loaderForTransport } from "$lib/player/streamTransport";
|
||||
|
||||
const log = createLogger("VideoPlayer");
|
||||
|
||||
interface Props {
|
||||
media: MediaItem | null;
|
||||
streamUrl: string;
|
||||
/**
|
||||
* What to play, as the backend decided it: URL, transport, playback kind and
|
||||
* the quality ladder for this source. Replaces the bare `streamUrl` string,
|
||||
* which forced this component to re-derive the transport by searching for
|
||||
* `.m3u8`.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225, DR-227
|
||||
*/
|
||||
selection: StreamSelection;
|
||||
mediaSourceId?: string; // Media source ID for subtitle URLs
|
||||
initialPosition?: number; // Position in seconds to seek to after load (for resume)
|
||||
needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior
|
||||
@@ -103,7 +118,7 @@
|
||||
|
||||
let {
|
||||
media,
|
||||
streamUrl,
|
||||
selection,
|
||||
mediaSourceId,
|
||||
initialPosition,
|
||||
needsTranscoding = false,
|
||||
@@ -179,7 +194,18 @@
|
||||
// Capture only the initial streamUrl prop; later prop changes are applied via
|
||||
// the $effect below (untrack keeps this a one-time snapshot, matching
|
||||
// reportMediaId above and silencing state_referenced_locally).
|
||||
let currentStreamUrl = $state(untrack(() => streamUrl));
|
||||
// The selection currently loaded. Starts from the prop and is replaced
|
||||
// wholesale by a reload (quality change, audio-track switch, transcoded seek)
|
||||
// so transport and URL can never disagree.
|
||||
// TRACES: UR-079 | DR-225
|
||||
let currentSelection = $state<StreamSelection>(untrack(() => selection));
|
||||
const currentStreamUrl = $derived(currentSelection.url);
|
||||
/**
|
||||
* The transport as a plain string, so effects can depend on its *value*.
|
||||
* A `$derived` primitive only notifies when it actually changes, which is what
|
||||
* keeps the HLS teardown from re-running for an unchanged stream.
|
||||
*/
|
||||
const transportKind = $derived(currentSelection.transport.type);
|
||||
let hasReportedStart = $state(false);
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001)
|
||||
@@ -225,7 +251,6 @@
|
||||
function nativeSeekSettling(): boolean {
|
||||
return Date.now() - lastNativeSeekAt < NATIVE_SEEK_SETTLE_MS;
|
||||
}
|
||||
let didStartNativePlayback = $state(false); // Track if we started playback (to know if we should stop on unmount)
|
||||
let didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
|
||||
let swipeType = $state<"brightness" | null>(null);
|
||||
let hls: Hls | null = null; // HLS.js instance for streaming HLS content
|
||||
@@ -250,14 +275,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A selection identical to the one loaded, but pointing at a different URL.
|
||||
*
|
||||
* Used by the paths that swap the stream without re-negotiating — the
|
||||
* background-audio handoff and its return. Each states the transport it is
|
||||
* moving to rather than letting it be inferred, which is the whole point of
|
||||
* DR-225: the audio handoff really is a progressive mp3, and the rebuilt
|
||||
* video stream really is an HLS transcode, and neither is knowable from the
|
||||
* URL text.
|
||||
*
|
||||
* TRACES: UR-040, UR-079 | DR-225
|
||||
*/
|
||||
function selectionAt(url: string, transport: StreamSelection["transport"]): StreamSelection {
|
||||
// A re-opened stream is a new transcode job; the old session id is stale.
|
||||
return { ...currentSelection, url, transport, playSessionId: null };
|
||||
}
|
||||
|
||||
const adapterBridge: Html5ElementBridge = {
|
||||
getElement: () => videoElement,
|
||||
getSeekOffset: () => seekOffset,
|
||||
setSeekOffset: (o) => {
|
||||
seekOffset = o;
|
||||
},
|
||||
setStreamUrl: (u) => {
|
||||
currentStreamUrl = u;
|
||||
setStreamSelection: (sel) => {
|
||||
currentSelection = sel;
|
||||
},
|
||||
destroyHls: tearDownHls,
|
||||
getMediaSourceId: () => mediaSourceId ?? null,
|
||||
@@ -275,9 +317,46 @@
|
||||
// Rust — the frontend never encodes what a step means.
|
||||
// TRACES: UR-074 | DR-162
|
||||
let showQualityMenu = $state(false);
|
||||
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
|
||||
let selectedQuality = $state<StreamingQuality>("original");
|
||||
let changingQuality = $state(false);
|
||||
/**
|
||||
* The device's durable default, shown when the stream is a direct play and so
|
||||
* has no rendition of its own to report. Read once from Settings.
|
||||
*/
|
||||
let defaultQuality = $state<StreamingQuality>("original");
|
||||
|
||||
/**
|
||||
* The rungs to offer for the stream that is playing, straight from the
|
||||
* backend (DR-227). Rungs whose ceiling is at or above the source bitrate are
|
||||
* dropped: they produce the same bytes as Original, so listing five of them is
|
||||
* five ways to spell one choice. Rust decides which those are — this only
|
||||
* decides not to draw them.
|
||||
*
|
||||
* `Original` is always kept; it is the source, never redundant with it.
|
||||
*
|
||||
* TRACES: UR-070, UR-079 | DR-227, DR-121
|
||||
*/
|
||||
const qualityOptions = $derived(
|
||||
currentSelection.available.filter((o) => !o.exceedsSource || o.quality === "original"),
|
||||
);
|
||||
|
||||
/**
|
||||
* The rung in force. A transcode reports the rendition it was built against;
|
||||
* a direct play has none, because it *is* the source — so it reads as
|
||||
* Original rather than as whatever ceiling happens to be set.
|
||||
*/
|
||||
const selectedQuality = $derived<StreamingQuality>(
|
||||
currentSelection.rendition?.quality ??
|
||||
(currentSelection.playbackKind.type === "transcode" ? defaultQuality : "original"),
|
||||
);
|
||||
|
||||
/** Human line for what the server is doing with this stream. */
|
||||
const playbackKindLabel = $derived(
|
||||
currentSelection.playbackKind.type === "directPlay"
|
||||
? "Direct play — the original file"
|
||||
: currentSelection.playbackKind.type === "directStream"
|
||||
? "Direct stream — repackaged, not re-encoded"
|
||||
: "Transcoding on the server",
|
||||
);
|
||||
|
||||
// Track duration from video element (for when media item doesn't have runTimeTicks)
|
||||
let videoDuration = $state(0);
|
||||
@@ -450,9 +529,9 @@
|
||||
// Update stream URL when prop changes (from parent component, not from internal seeks)
|
||||
$effect(() => {
|
||||
// Only reset when the streamUrl prop actually changes from parent
|
||||
if (streamUrl !== lastStreamUrlProp) {
|
||||
lastStreamUrlProp = streamUrl;
|
||||
currentStreamUrl = streamUrl;
|
||||
if (selection.url !== lastStreamUrlProp) {
|
||||
lastStreamUrlProp = selection.url;
|
||||
currentSelection = selection;
|
||||
seekOffset = 0;
|
||||
isMediaReady = false; // Reset to loading state when stream URL changes
|
||||
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
|
||||
@@ -567,9 +646,21 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const isHlsStream = currentStreamUrl.includes(".m3u8");
|
||||
// The loader comes from the backend's tagged transport, never from the URL.
|
||||
//
|
||||
// Read through the *primitive* `transportKind`, never `currentSelection`
|
||||
// itself: this effect tears down and rebuilds hls.js, and a selection object
|
||||
// is replaced on every reload — so depending on the object re-ran the whole
|
||||
// teardown for an unchanged stream and left the element showing nothing
|
||||
// until a seek forced another cycle.
|
||||
//
|
||||
// TRACES: UR-079 | DR-225 | UT-214
|
||||
const loader = loaderForTransport(transportKind, {
|
||||
hlsJsSupported: Hls.isSupported(),
|
||||
nativeHlsSupported: !!videoElement.canPlayType("application/vnd.apple.mpegurl"),
|
||||
});
|
||||
|
||||
if (isHlsStream && Hls.isSupported()) {
|
||||
if (loader === "hlsjs") {
|
||||
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
|
||||
if (hls) {
|
||||
log.debug("Cleaning up existing HLS instance");
|
||||
@@ -724,13 +815,13 @@
|
||||
videoElement.pause();
|
||||
}
|
||||
};
|
||||
} else if (isHlsStream && videoElement.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
// Native HLS support (Safari)
|
||||
} else if (loader === "nativeHls") {
|
||||
// The element parses the playlist itself (Safari/WebKit).
|
||||
log.debug("Using native HLS support");
|
||||
videoElement.src = currentStreamUrl;
|
||||
} else {
|
||||
// Not an HLS stream, use regular video element
|
||||
log.debug("Using regular video element for non-HLS stream");
|
||||
// Progressive or local: the element loads the URL directly.
|
||||
log.debug("Using regular video element", currentSelection.transport.type);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -801,21 +892,25 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Populate the quality menu. Deliberately its own *synchronous* onMount that
|
||||
// fires the load without awaiting it: an await inside the main onMount below
|
||||
// flips the component into HTML5 mode and breaks native seeking, and nothing
|
||||
// about playback waits on this list.
|
||||
// The quality *ladder* now arrives with the stream selection (DR-227), so all
|
||||
// this still needs is the device default, for the case where the stream is a
|
||||
// direct play and has no rendition of its own.
|
||||
//
|
||||
// TRACES: UR-074 | DR-162
|
||||
// Deliberately its own *synchronous* onMount that fires the load without
|
||||
// awaiting it: an await inside the main onMount below flips the component into
|
||||
// HTML5 mode and breaks native seeking, and nothing about playback waits on
|
||||
// this value.
|
||||
//
|
||||
// TRACES: UR-074, UR-079 | DR-162, DR-227
|
||||
onMount(() => {
|
||||
Promise.all([commands.playerGetStreamingQualities(), commands.playerGetVideoSettings()])
|
||||
.then(([qualities, settings]) => {
|
||||
streamingQualities = qualities;
|
||||
commands
|
||||
.playerGetVideoSettings()
|
||||
.then((settings) => {
|
||||
// Optional on the wire (serde default) — absent means uncapped.
|
||||
selectedQuality = settings.streamingQuality ?? "original";
|
||||
defaultQuality = settings.streamingQuality ?? "original";
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn("Failed to load streaming qualities:", err);
|
||||
log.warn("Failed to load the default streaming quality:", err);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -878,6 +973,9 @@
|
||||
id: media.id,
|
||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||
needsTranscoding: needsTranscoding,
|
||||
// Carry the negotiated transport onto the queue item so a later seek
|
||||
// reads it instead of falling back. TRACES: UR-079 | DR-230
|
||||
transport: currentSelection.transport,
|
||||
// Order matters: player_set_subtitle_track(n) is a position in this
|
||||
// array. Previously this array was built and then dropped, so
|
||||
// ExoPlayer got a MediaItem with no subtitles at all.
|
||||
@@ -933,7 +1031,6 @@
|
||||
"Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions",
|
||||
);
|
||||
// Backend is kept running but should not play audio since HTML5 element handles playback
|
||||
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
||||
}
|
||||
|
||||
// Register the adapter with the facade so control intents (UI, or a
|
||||
@@ -944,7 +1041,9 @@
|
||||
const host = createRustReportHost(media.id, {
|
||||
onEnded: () => notifyEnded(),
|
||||
onStreamUrlChanged: (u) => {
|
||||
currentStreamUrl = u;
|
||||
// Rust re-opened the same stream (a transcoded seek): the
|
||||
// transport is unchanged, only the job behind it.
|
||||
currentSelection = selectionAt(u, currentSelection.transport);
|
||||
},
|
||||
});
|
||||
playerAdapter = createAdapter({
|
||||
@@ -997,7 +1096,6 @@
|
||||
|
||||
if (!useHtml5Element) {
|
||||
// Using native backend, subscribe to player events
|
||||
didStartNativePlayback = true; // Track that we started native playback
|
||||
isPlaying = (response.state?.kind ?? response.state) === "playing";
|
||||
// Cleanup happens in the component's top-level onDestroy. Calling
|
||||
// onDestroy() here — after an await — throws lifecycle_outside_component,
|
||||
@@ -1038,7 +1136,6 @@
|
||||
}
|
||||
} else {
|
||||
// For transcoded content, keep backend for seeking
|
||||
didStartNativePlayback = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1172,14 +1269,25 @@
|
||||
}
|
||||
|
||||
// Stop the player when component is destroyed
|
||||
// Skip if we already stopped the backend early (non-transcoded + HTML5)
|
||||
if (didStartNativePlayback && !didStopBackendEarly) {
|
||||
try {
|
||||
log.debug("Stopping backend player on component unmount");
|
||||
await commands.playerStop();
|
||||
} catch (err) {
|
||||
log.error("Failed to stop backend player:", err);
|
||||
}
|
||||
// Unconditional. Leaving the player means nothing should still be playing,
|
||||
// whichever renderer happened to own it.
|
||||
//
|
||||
// This used to be gated on `didStartNativePlayback && !didStopBackendEarly`
|
||||
// — flags describing what *this component* started. A background-audio
|
||||
// handoff swaps the renderer underneath them, so after one they describe a
|
||||
// player that is no longer the one making sound, and the stop was skipped
|
||||
// while the audio stream kept going. It then reappeared in the mini player
|
||||
// as an audio track.
|
||||
//
|
||||
// `playerStop` is idempotent, so calling it when nothing is playing costs a
|
||||
// no-op IPC round trip. That is a far cheaper failure than the alternative.
|
||||
//
|
||||
// TRACES: UR-040, UR-005 | DR-250
|
||||
try {
|
||||
log.debug("Stopping backend player on component unmount");
|
||||
await commands.playerStop();
|
||||
} catch (err) {
|
||||
log.error("Failed to stop backend player:", err);
|
||||
}
|
||||
|
||||
// Report stop when component is destroyed (skip for live - no resume tracking)
|
||||
@@ -1746,7 +1854,7 @@
|
||||
// whether the item has a picture to lose, which is Rust's to know. This used
|
||||
// to be decided implicitly by Kotlin gating the event on the toggle, which
|
||||
// is why the native path -- whose media service keeps playing regardless --
|
||||
// ignored the toggle entirely (DR-224).
|
||||
// ignored the toggle entirely (DR-225).
|
||||
let action: BackgroundAction;
|
||||
try {
|
||||
action = await commands.playerBackgroundAction(
|
||||
@@ -1885,8 +1993,9 @@
|
||||
|
||||
pendingForegroundPlay = plan.shouldPlay;
|
||||
|
||||
// Determine the target URL + how the element/offset should be positioned.
|
||||
let targetUrl: string;
|
||||
// Determine the target stream + how the element/offset should be
|
||||
// positioned.
|
||||
let targetSelection: StreamSelection;
|
||||
if (needsTranscoding && onSeek) {
|
||||
// Transcoded HLS is rebuilt rather than seeked in place, but the rebuilt
|
||||
// stream starts at the BEGINNING of the item, not at `pos`: a start
|
||||
@@ -1897,13 +2006,16 @@
|
||||
// that really did start there; leaving it would now display `pos` while
|
||||
// playing the opening titles.
|
||||
// TRACES: UR-040, UR-004 | DR-181
|
||||
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
|
||||
// Every transcode this app requests is HLS (DR-140).
|
||||
targetSelection = selectionAt(await onSeek(pos, selectedAudioTrackIndex ?? undefined), {
|
||||
type: "hls",
|
||||
});
|
||||
seekOffset = 0;
|
||||
currentTime = pos;
|
||||
pendingForegroundSeek = pos;
|
||||
} else {
|
||||
// Direct stream: reload the original URL and seek the element to pos.
|
||||
targetUrl = streamUrl;
|
||||
// Direct stream: reload the original selection and seek to pos.
|
||||
targetSelection = selection;
|
||||
seekOffset = 0;
|
||||
pendingForegroundSeek = pos;
|
||||
}
|
||||
@@ -1923,18 +2035,20 @@
|
||||
// than re-fetched.
|
||||
//
|
||||
// TRACES: UR-040, UR-003 | DR-196
|
||||
currentStreamUrl = targetUrl;
|
||||
currentSelection = targetSelection;
|
||||
await commands.playerPlayItem({
|
||||
streamUrl: targetUrl,
|
||||
streamUrl: targetSelection.url,
|
||||
title: media.name,
|
||||
id: media.id,
|
||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||
needsTranscoding,
|
||||
// TRACES: UR-079 | DR-230
|
||||
transport: targetSelection.transport,
|
||||
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
|
||||
});
|
||||
didStartNativePlayback = true;
|
||||
await playerAdapter?.load(targetUrl, {
|
||||
await playerAdapter?.load(targetSelection.url, {
|
||||
mediaId: media.id,
|
||||
selection: targetSelection,
|
||||
mediaSourceId: mediaSourceId ?? null,
|
||||
needsTranscoding,
|
||||
initialPosition: plan.position,
|
||||
@@ -1961,9 +2075,9 @@
|
||||
// blank it first, then set it on the next microtask so Svelte sees a real
|
||||
// transition. Without this, assigning the same value is a no-op and the
|
||||
// player stays stuck on the loading spinner (HLS never re-initialises).
|
||||
currentStreamUrl = "";
|
||||
currentSelection = selectionAt("", targetSelection.transport);
|
||||
await Promise.resolve();
|
||||
currentStreamUrl = targetUrl;
|
||||
currentSelection = targetSelection;
|
||||
} catch (err) {
|
||||
log.error("Background-audio return failed:", err);
|
||||
}
|
||||
@@ -1978,23 +2092,47 @@
|
||||
// Activity, so on its own it left the status and navigation bars painted over
|
||||
// the video. The native bridge is what actually makes fullscreen full screen;
|
||||
// requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
|
||||
function toggleFullscreen() {
|
||||
async function toggleFullscreen() {
|
||||
// A native surface draws the picture *behind* the webview at window size, so
|
||||
// fullscreening the document alone leaves the video at its old size while
|
||||
// the page around it expands. See fullscreenTarget.ts. (DR-240)
|
||||
const plan = planFullscreen(!useHtml5Element);
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
|
||||
// the immersive call below is what matters on Android, so don't let a
|
||||
// rejection here abort it.
|
||||
log.warn("requestFullscreen rejected:", err);
|
||||
});
|
||||
if (plan.document) {
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
|
||||
// the immersive call below is what matters on Android, so don't let a
|
||||
// rejection here abort it.
|
||||
log.warn("requestFullscreen rejected:", err);
|
||||
});
|
||||
}
|
||||
if (plan.osWindow) {
|
||||
await setOsWindowFullscreen(true);
|
||||
}
|
||||
enterImmersive();
|
||||
isFullscreen = true;
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
if (plan.osWindow) {
|
||||
await setOsWindowFullscreen(false);
|
||||
}
|
||||
exitImmersive();
|
||||
isFullscreen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the OS window itself. Best-effort: a platform without a window to
|
||||
/// resize (Android) must not break the rest of the toggle.
|
||||
async function setOsWindowFullscreen(on: boolean) {
|
||||
try {
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
await getCurrentWindow().setFullscreen(on);
|
||||
} catch (err) {
|
||||
log.warn("setFullscreen on the OS window failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
@@ -2285,33 +2423,48 @@
|
||||
*
|
||||
* The backend owns everything about how that happens — it decides whether the
|
||||
* caller reloads (HTML5) or it reloads the native backend itself — so this
|
||||
* only supplies the position to resume at and reverts the selection if the
|
||||
* switch fails.
|
||||
* only supplies the position to resume at.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
* The change applies to this playback alone; the durable Settings default is
|
||||
* untouched (DR-226). Nothing is optimistically assigned here: what the picker
|
||||
* shows comes from the selection the backend hands back, because what you get
|
||||
* is not always what you asked for — a ceiling above the source bitrate is the
|
||||
* source, and claiming otherwise is the kind of lie the old picker told.
|
||||
*
|
||||
* TRACES: UR-074, UR-079 | DR-162, DR-226, DR-227
|
||||
*/
|
||||
async function selectQuality(quality: StreamingQuality) {
|
||||
showQualityMenu = false;
|
||||
if (quality === selectedQuality || changingQuality) return;
|
||||
|
||||
const previous = selectedQuality;
|
||||
selectedQuality = quality;
|
||||
changingQuality = true;
|
||||
try {
|
||||
stopTimeUpdates();
|
||||
await playerController.setStreamQuality(
|
||||
const negotiated = await playerController.setStreamQuality(
|
||||
quality,
|
||||
videoElement ? videoElement.currentTime + seekOffset : null,
|
||||
mediaSourceId ?? null,
|
||||
selectedAudioTrackIndex,
|
||||
);
|
||||
// Adopt whatever the backend says it opened. The HTML5 path has already
|
||||
// set this via the adapter bridge, so this is a no-op there; the native
|
||||
// path reloads inside Rust and this is the only thing that updates the UI.
|
||||
//
|
||||
// Assigning it is what keeps the picker honest: `selectedQuality` reads
|
||||
// the selection's rendition, and a transcode always has one — so without
|
||||
// this the menu stayed on the first stream's rung while the stream itself
|
||||
// changed underneath.
|
||||
//
|
||||
// TRACES: UR-074, UR-079 | DR-226, DR-227
|
||||
if (negotiated) {
|
||||
currentSelection = negotiated;
|
||||
}
|
||||
if (videoElement && !videoElement.paused) {
|
||||
startTimeUpdates();
|
||||
}
|
||||
log.debug("Streaming quality changed:", quality);
|
||||
} catch (err) {
|
||||
log.error("Failed to change streaming quality:", err);
|
||||
selectedQuality = previous;
|
||||
} finally {
|
||||
changingQuality = false;
|
||||
}
|
||||
@@ -2411,12 +2564,30 @@
|
||||
aria-label="Video player"
|
||||
>
|
||||
<!-- Video -->
|
||||
<div class="flex-1 flex items-center justify-center relative">
|
||||
<!--
|
||||
`min-h-0` / `min-w-0` are load-bearing, not defensive. A flex item defaults
|
||||
to `min-height: auto`, which refuses to shrink below its content's intrinsic
|
||||
size — and the <video> inside reports the *media's* natural dimensions. So
|
||||
without them this wrapper grows past the viewport whenever the picture is
|
||||
larger than the window: the overflow goes off the bottom, which reads as the
|
||||
image being cropped and aligned to the top rather than letterboxed and
|
||||
centred. `object-contain` was never the problem; it was doing its job inside
|
||||
a box that was itself the wrong size.
|
||||
|
||||
Reproduces by resizing the window during playback, and by entering
|
||||
fullscreen — where the same overflow put the picture at the bottom.
|
||||
|
||||
TRACES: UR-005 | DR-024
|
||||
-->
|
||||
<div class="flex-1 min-h-0 min-w-0 flex items-center justify-center relative">
|
||||
{#if !!useHtml5Element}
|
||||
<!-- HTML5 video for desktop/non-Android platforms -->
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
src={currentStreamUrl.includes(".m3u8") && Hls.isSupported() ? "" : currentStreamUrl}
|
||||
src={elementSrcFor(currentSelection, {
|
||||
hlsJsSupported: Hls.isSupported(),
|
||||
nativeHlsSupported: true,
|
||||
})}
|
||||
crossorigin={videoCrossOrigin}
|
||||
class={videoFitClass()}
|
||||
class:invisible={!isMediaReady}
|
||||
@@ -2756,8 +2927,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Streaming quality (bandwidth ceiling). TRACES: UR-074 | DR-162 -->
|
||||
{#if streamingQualities.length > 0}
|
||||
<!--
|
||||
Streaming quality (bandwidth ceiling), populated from what this media
|
||||
source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-227
|
||||
-->
|
||||
{#if qualityOptions.length > 1}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={toggleQualityMenu}
|
||||
@@ -2778,22 +2952,30 @@
|
||||
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Quality
|
||||
<div class="px-3 py-2 border-b border-white/20">
|
||||
<div class="text-white text-sm font-semibold">Quality</div>
|
||||
<!--
|
||||
What the server is actually doing. Only knowable now that
|
||||
the backend reports it. TRACES: UR-079 | DR-228
|
||||
-->
|
||||
<div class="text-xs text-gray-400 mt-0.5">{playbackKindLabel}</div>
|
||||
</div>
|
||||
{#each streamingQualities as [quality, label, detail]}
|
||||
{#each qualityOptions as option (option.quality)}
|
||||
<button
|
||||
onclick={() => selectQuality(quality)}
|
||||
onclick={() => selectQuality(option.quality)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
|
||||
quality
|
||||
option.quality
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{label}</span>
|
||||
<span class="text-xs text-gray-400">{detail}</span>
|
||||
<span class="text-sm">{option.label}</span>
|
||||
<span class="text-xs text-gray-400">
|
||||
{option.detail}{#if option.quality === "original" && option.sourceBitrate}
|
||||
· {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if selectedQuality === quality}
|
||||
{#if selectedQuality === option.quality}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
|
||||
Reference in New Issue
Block a user