feat(playback): let Rust decide what stream to play, and say so
Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.
One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.
Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:
Linux / WebKitGTK (h264 only, 2ch) 3/40 — 7% direct play
Android / ExoPlayer (hevc, ac3/eac3, 6ch) 34/40 — 85% direct play
The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.
DR-219 StreamSelection: url + tagged Transport (hls/progressive/localFile)
+ PlaybackKind (directPlay/directStream/transcode) + the negotiated
rendition + this source's ladder + a needs_transcoding flag derived
in Rust so the rule is answered once. Both enums are serde-tagged
so the frontend matches a discriminant, not a substring. The paths
that never negotiate get the same shape from Rust rather than
assembling one — media_local_selection for a downloaded file,
LiveStreamInfo.transport for a live channel — so there is no second
place where a transport is decided.
DR-220 The ceiling becomes two levels: a durable device default (Settings,
persisted) and a per-playback override the in-player picker sets.
The picker had called itself a "this film, this connection" control
since it was written but wrote the process-wide default, so dropping
one awkward film to 2 Mbps silently capped every video played
afterwards for the rest of the process, with Settings still showing
the old value. The override is cleared whenever playback moves to a
new item, which stops it surviving into an autoplayed next episode.
effective_streaming_quality() is the single resolution point.
DR-221 The quality picker is filled from what this media source can offer.
Rust marks a rung exceeds_source when its ceiling is at or above the
source's own bitrate — such a rung is another way to spell Original
— and the frontend does not draw those. Original is never marked; a
source whose bitrate the server does not report marks nothing, which
keeps every rung offered.
DR-222 Direct play and direct stream are negotiated, with two client-side
overrides on top because the server's answer is right about the file
and wrong about what this app will do with it: undecodable audio
(Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
codec but ignores its audio codec, so it offers direct play for an
E-AC-3 track the webview renders in silence) and a viewer-pinned
audio track the file does not default to. A direct stream is a remux
and is deliberately not counted as transcoding.
DR-223 Dropped on measurement, not deferred. A master playlist from this
server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
the single rendition the request asked for rather than publishing a
ladder. So there is no adaptation for hls.js to be preserving and
none mpv would lose — the claim that there was, in
playback-backend-unification.md, does not hold. Recorded rather than
deleted because it is a measurement: a server that does publish a
ladder would change the answer.
DR-224 Every backend consumes the same selection. The queue item carries
the transport, so player_seek_video picks its seek strategy from the
backend's decision instead of the last stream_url.contains(".m3u8")
in the codebase. Items queued by a path that never negotiated carry
None and fall back to needs_transcoding, which is exact rather than
a guess because every transcode this app requests is HLS (DR-140).
The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.
Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.
The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.
Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
This commit is contained in:
@@ -4,7 +4,12 @@
|
||||
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 +82,21 @@
|
||||
type BackgroundAudioState,
|
||||
} from "./backgroundAudioHandoff";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
import { elementSrcFor, videoLoaderFor } 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-224, DR-226
|
||||
*/
|
||||
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 +117,7 @@
|
||||
|
||||
let {
|
||||
media,
|
||||
streamUrl,
|
||||
selection,
|
||||
mediaSourceId,
|
||||
initialPosition,
|
||||
needsTranscoding = false,
|
||||
@@ -179,7 +193,12 @@
|
||||
// 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-224
|
||||
let currentSelection = $state<StreamSelection>(untrack(() => selection));
|
||||
const currentStreamUrl = $derived(currentSelection.url);
|
||||
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)
|
||||
@@ -250,14 +269,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-224: 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-224
|
||||
*/
|
||||
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 +311,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-226). 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-226, 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 +523,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 +640,14 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const isHlsStream = currentStreamUrl.includes(".m3u8");
|
||||
// The loader comes from the backend's tagged transport, never from the URL.
|
||||
// TRACES: UR-079 | DR-224 | UT-213
|
||||
const loader = videoLoaderFor(currentSelection, {
|
||||
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 +802,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 +879,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-226), 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-226
|
||||
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 +960,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-229
|
||||
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.
|
||||
@@ -944,7 +1029,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({
|
||||
@@ -1885,8 +1972,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 +1985,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 +2014,21 @@
|
||||
// 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-229
|
||||
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 +2055,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);
|
||||
}
|
||||
@@ -2285,33 +2379,41 @@
|
||||
*
|
||||
* 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-225). 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-225, DR-226
|
||||
*/
|
||||
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,
|
||||
);
|
||||
// The HTML5 path reloads through the adapter, which already set the new
|
||||
// selection via the bridge. The native path reloads inside Rust and
|
||||
// returns nothing, so record what was asked for as the ceiling in force.
|
||||
if (!negotiated) {
|
||||
defaultQuality = quality;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -2416,7 +2518,10 @@
|
||||
<!-- 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 +2861,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-226
|
||||
-->
|
||||
{#if qualityOptions.length > 1}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={toggleQualityMenu}
|
||||
@@ -2778,22 +2886,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-227
|
||||
-->
|
||||
<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