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:
2026-08-23 10:51:45 +02:00
parent 5fede123e7
commit 11d9d760d8
87 changed files with 15968 additions and 7508 deletions
+266 -15
View File
@@ -39,7 +39,7 @@ async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
* playing there is nothing to pause, and an error would make the frontend
* handle a case that is not a failure.
*
* TRACES: UR-040, UR-041 | DR-224 | UT-211
* TRACES: UR-040, UR-041 | DR-225 | UT-212
*/
async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture: boolean) : Promise<BackgroundAction> {
return await TAURI_INVOKE("player_background_action", { backgroundAudioArmed, inPictureInPicture });
@@ -120,7 +120,7 @@ async playerSeek(position: number) : Promise<PlayerStatus> {
*
* This command analyzes the current video stream and automatically chooses
* the best seeking strategy:
* - HLS streams (.m3u8): Use native seeking
* - HLS streams: Use native seeking
* - Direct play streams: Use native seeking
* - Transcoded non-HLS: Request new stream URL from server starting at seek position
*
@@ -263,13 +263,17 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
* native backend is reloaded here.
*
* The change applies to this playback *and* to everything started afterwards
* (it sets the process-wide ceiling), but it is deliberately **not** persisted:
* the in-player picker is a "this film, this connection" control, and the
* durable default belongs to Settings. `player_set_video_settings` is the one
* that writes to the database.
* The change applies to **this playback only**. The in-player picker is a
* "this film, this connection" control and its doc has always said so, but it
* used to be implemented by writing the process-wide ceiling — so choosing
* 2 Mbps to get one awkward film moving silently capped every video played
* afterwards for the rest of the process, with the Settings screen still
* showing the old value and nothing in the UI admitting the change. It now
* sets a per-playback override that the next item clears; the durable default
* belongs to Settings, and `player_set_video_settings` is the one that writes
* to the database.
*
* TRACES: UR-074 | DR-162
* TRACES: UR-074, UR-079 | DR-162, DR-226
*/
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
@@ -1033,6 +1037,22 @@ async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<nul
async mediaLocalUrl(path: string) : Promise<string> {
return await TAURI_INVOKE("media_local_url", { path });
},
/**
* The stream selection for a downloaded file.
*
* The local-playback counterpart to `repository_get_stream_selection`. A file
* on disk needs no negotiation — it is a direct play over a local transport,
* with no quality ladder, because nothing about it can be re-negotiated — but
* the *frontend must not be the one to say so*. It gets the same
* [`StreamSelection`] shape as a streamed source so the player has one contract
* to consume rather than two, and so no caller has to infer a transport from a
* loopback URL.
*
* TRACES: UR-071, UR-079 | DR-225
*/
async mediaLocalSelection(path: string) : Promise<StreamSelection> {
return await TAURI_INVOKE("media_local_selection", { path });
},
/**
* Start downloading a file immediately
* This command actually downloads the file using the worker
@@ -1618,6 +1638,24 @@ async repositoryGetPlaybackInfo(handle: string, itemId: string) : Promise<Playba
async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, audioStreamIndex });
},
/**
* Decide what stream to play for a video, and describe it.
*
* Replaces `repository_get_video_stream_url` for playback. The returned
* [`StreamSelection`] carries the transport explicitly, so the frontend picks
* its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
* carries the quality ladder as it applies to *this* source, so the picker can
* stop offering rungs that produce the same bytes as Original.
*
* No start-position parameter, for the same reason as the URL builder: a
* position on an HLS playlist is copied onto every segment URI and the server
* rejects each with `400` (DR-181). Callers resume by seeking after load.
*
* TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
*/
async repositoryGetStreamSelection(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamSelection> {
return await TAURI_INVOKE("repository_get_stream_selection", { handle, itemId, mediaSourceId, audioStreamIndex });
},
/**
* Get audio stream URL for a track
*/
@@ -1965,7 +2003,7 @@ export type AudioTrackSwitchResponse =
/**
* HTML5 needs to reload stream with new audio track
*/
{ strategy: "reloadStream"; new_url: string; position: number }
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
/**
* Authentication result
*/
@@ -2329,7 +2367,18 @@ excludedItemIds?: string[] }
* streamed; the server returns a transcoding URL (already absolute) plus a
* `live_stream_id` that can later be used to close the stream.
*/
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null }
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null;
/**
* How to open `stream_url`.
*
* A live channel is always an HLS transcode — the server has to repackage a
* broadcast mux into something a browser can play, and there is no static
* file to direct-play. Saying so here means the player page never has to
* work it out from the URL, which is the whole of DR-225.
*
* TRACES: UR-079 | DR-225
*/
transport: Transport }
/**
* An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
*
@@ -2568,6 +2617,18 @@ videoCodec: string;
* Whether the video requires server-side transcoding
*/
needsTranscoding: boolean;
/**
* How this item's stream is fetched, as the backend decided it.
*
* Carried on the queue item so a later seek/reload does not have to guess.
* `None` for items queued by a path that never negotiated (audio tracks,
* direct URLs) and for anything queued before this field existed, where the
* caller falls back to `needs_transcoding` — every transcode this app
* requests is HLS (DR-140), so that fallback is exact rather than a guess.
*
* TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
*/
transport?: Transport | null;
/**
* Optional now-playing metadata. Used by the background-audio handoff so the
* lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
@@ -2680,6 +2741,32 @@ supportsNativeVideo: boolean }
* Playback information
*/
export type PlaybackInfo = { mediaSourceId: string; playSessionId: string; streamUrl: string; directPlay: boolean; needsTranscoding: boolean }
/**
* What the server is doing to the source to produce this stream.
*
* Distinct from [`Transport`] because the two are genuinely independent: a
* direct-streamed remux and a transcode can both arrive over HLS, and a direct
* play can arrive progressively or as a local file. Keeping them apart is what
* lets the UI say "this is not costing the server anything" without inferring
* it from a URL shape.
*
* TRACES: UR-079 | DR-228
*/
export type PlaybackKind =
/**
* The source file is served untouched. No server CPU, no quality loss.
*/
{ type: "directPlay" } |
/**
* The container is repackaged but the codecs are copied — cheap, and
* visually identical to the source.
*/
{ type: "directStream" } |
/**
* The server is re-encoding. The only case where a bitrate ceiling can
* actually be honoured, and the only one that costs the server real work.
*/
{ type: "transcode" }
/**
* Playback mode - local device, remote session, or idle
*/
@@ -2779,6 +2866,18 @@ videoCodec?: string | null;
* Whether the video requires server-side transcoding
*/
needsTranscoding?: boolean;
/**
* How this item's stream is fetched, as the backend decided it.
*
* Carried on the queue item so a later seek/reload does not have to guess.
* `None` for items queued by a path that never negotiated (audio tracks,
* direct URLs) and for anything queued before this field existed, where the
* caller falls back to `needs_transcoding` — every transcode this app
* requests is HLS (DR-140), so that fallback is exact rather than a guess.
*
* TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
*/
transport?: Transport | null;
/**
* Video width in pixels
*/
@@ -3061,6 +3160,38 @@ alreadyDownloaded: number;
* Number of tracks skipped (no jellyfin ID or other reasons)
*/
skipped: number }
/**
* One rung of the quality picker, as it applies to *this* media source.
*
* The picker used to be filled from the fixed [`StreamingQuality::ALL`] ladder,
* which meant offering "20 Mbps" for a 1.1 Mbps podcast — eight rungs, six of
* them indistinguishable from Original. `exceeds_source` is what lets the
* frontend render that honestly without knowing anything about bitrates.
*
* TRACES: UR-070, UR-079 | DR-227, DR-121
*/
export type QualityOption = { quality: StreamingQuality;
/**
* Human label ("8 Mbps"). Lives in Rust beside the number it describes.
*/
label: string;
/**
* Secondary line ("1080p").
*/
detail: string;
/**
* True when this rung's ceiling is at or above what the source itself
* carries, so selecting it yields the same stream as `Original`.
*
* The frontend renders these differently (or hides them); it does not
* decide which they are.
*/
exceedsSource: boolean;
/**
* The source's own bitrate, when the server reported one. Presentation
* only — the picker shows "Original (6.7 Mbps)" rather than a bare word.
*/
sourceBitrate: number | null }
/**
* Response for queue queries
*/
@@ -3069,6 +3200,36 @@ export type QueueStatus = { items: PlayerMediaItem[]; currentIndex: number | nul
* Remote session status for UI updates
*/
export type RemoteSessionStatus = { position: number; duration: number | null; isPlaying: boolean; nowPlayingItem: NowPlayingItem | null }
/**
* The rendition actually negotiated — what the viewer is receiving right now.
*
* `None` on a [`StreamSelection`] when the source is being direct-played as-is:
* there is no *chosen* rendition in that case, only the file itself, and
* reporting the ceiling that happened to be set would misdescribe it.
*
* TRACES: UR-079 | DR-225, DR-226
*/
export type Rendition = {
/**
* The rung of the ladder this stream was built against.
*/
quality: StreamingQuality;
/**
* Total bits per second the stream may use, when a ceiling applies.
*/
maxBitrate: number | null;
/**
* Resolution ceiling, when one applies. `None` preserves the source's.
*/
maxHeight: number | null;
/**
* Video codec the server was asked to produce.
*/
videoCodec: string | null;
/**
* Audio codec the server was asked to produce.
*/
audioCodec: string | null }
/**
* Repeat mode for the queue
*
@@ -3182,13 +3343,73 @@ export type StreamKind = "audio" | "video" | "subtitle" |
*/
export type StreamQualityResponse =
/**
* The native backend was reloaded here; nothing left for the frontend.
* The native backend was reloaded here; nothing left for the frontend to
* *do* — but it still has to be told what was negotiated.
*
* This carried only a position at first, which left the picker on Android
* pinned to the rendition of the *first* stream: the UI derives the rung in
* force from the selection it holds, nothing replaced that selection on the
* native path, and a transcode always has a rendition — so the fallback
* that would have used the requested value was never reached. The stream
* changed and the menu did not.
*
* TRACES: UR-074, UR-079 | DR-226, DR-227
*/
{ strategy: "native"; position: number } |
{ strategy: "native"; selection: StreamSelection; position: number } |
/**
* HTML5 must reload its element with this URL.
* HTML5 must reload its element with this selection.
*/
{ strategy: "reloadStream"; new_url: string; position: number }
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
/**
* Everything a player backend needs to open a stream, and everything the UI
* needs to describe it.
*
* Replaces the bare `String` URL that `get_video_stream_url` used to return.
*
* TRACES: UR-079 | DR-225, DR-227, DR-228
*/
export type StreamSelection = {
/**
* The URL (or loopback URL) to open.
*/
url: string;
/**
* How to fetch it. Replaces the `.m3u8` substring check.
*/
transport: Transport;
/**
* What the server is doing to the source to produce it.
*/
playbackKind: PlaybackKind;
/**
* The negotiated rendition; `None` when direct-playing the source as-is.
*/
rendition: Rendition | null;
/**
* What this media source can offer, for the quality picker (DR-227).
*/
available: QualityOption[];
/**
* The media source this selection is for, so a later re-open (quality
* change, audio-track switch, transcoded seek) targets the same one.
*/
mediaSourceId: string | null;
/**
* The transcode identity the server keyed this job by, when there is one.
*/
playSessionId: string | null;
/**
* Whether the server is spending encoder time on this stream.
*
* Derived from [`playback_kind`](Self::playback_kind) rather than left for
* the frontend to compute: "which kinds count as transcoding" is a domain
* rule, and a direct *stream* is a remux that must not be counted. The
* queue's long-standing `needs_transcoding` flag and the seek strategy both
* read this, so there is one answer rather than three.
*
* TRACES: UR-079 | DR-225, DR-228
*/
needsTranscoding: boolean }
/**
* A ceiling on how much bandwidth a *video* stream may consume.
*
@@ -3269,6 +3490,36 @@ itemName: string | null }
* Statistics about the thumbnail cache
*/
export type ThumbnailCacheStats = { totalSizeBytes: number; itemCount: number; limitBytes: number }
/**
* How the bytes of a chosen stream are fetched.
*
* This field exists to delete a substring search. The frontend previously
* decided which loader to attach by testing `url.contains(".m3u8")`, which is a
* domain fact reconstructed in the presentation layer — the same class of leak
* as the item-type taxonomy that `check:boundary` guards, and one that breaks
* silently the moment a server serves a playlist from a path that does not end
* in `.m3u8`, or serves a progressive file from one that does.
*
* Tagged (`{"type":"hls"}`) rather than a bare string so the frontend matches a
* discriminant instead of comparing text.
*
* TRACES: UR-079 | DR-225
*/
export type Transport =
/**
* An HLS playlist. The webview attaches hls.js (or Safari's native loader);
* ExoPlayer uses its HLS media source.
*/
{ type: "hls" } |
/**
* A single progressive HTTP resource, seekable by byte range.
*/
{ type: "progressive" } |
/**
* A file already on disk — a completed download, or the loopback media
* server standing in front of one.
*/
{ type: "localFile" }
/**
* User information
*/
@@ -3316,7 +3567,7 @@ export type VideoSeekResponse =
/**
* Reload stream from new position (transcoded non-HLS)
*/
{ strategy: "reloadStream"; new_url: string; seek_offset: number }
{ strategy: "reloadStream"; selection: StreamSelection; seek_offset: number }
/**
* Video playback settings
*/
+29 -1
View File
@@ -3,7 +3,7 @@
// NO direct HTTP calls - everything routes through Rust backend
import { commands } from "./bindings";
import type { JRayActor, DownloadDiskUsage, SearchScope } from "./bindings";
import type { DownloadDiskUsage, JRayActor, SearchScope, StreamSelection } from "./bindings";
import type { QualityPreset } from "./quality-presets";
import type {
Library,
@@ -247,6 +247,34 @@ export class RepositoryClient {
);
}
/**
* Decide what stream to play, and describe it.
*
* The playback counterpart to {@link getVideoStreamUrl}, which returns only a
* URL and therefore forces its caller to work out the rest. This returns the
* transport (so the player picks a loader from a tagged enum rather than by
* searching the URL for `.m3u8`), the playback kind (direct play / direct
* stream / transcode), and the quality ladder as it applies to this source.
*
* No position parameter, for the same reason as {@link getVideoStreamUrl}: a
* start position on an HLS playlist makes Jellyfin reject every segment behind
* it with `400` (DR-181). Resume by seeking once loaded.
*
* TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
*/
async getStreamSelection(
itemId: string,
mediaSourceId?: string | null,
audioStreamIndex?: number | null,
): Promise<StreamSelection> {
return commands.repositoryGetStreamSelection(
this.ensureHandle(),
itemId,
mediaSourceId ?? null,
audioStreamIndex ?? null,
);
}
/**
* Audio-only stream URL for a video item, for the background-audio handoff.
* The server extracts just the audio track — no video is decoded on-device.
@@ -8,6 +8,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { formatDuration } from "$lib/utils/duration";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -88,18 +89,6 @@
: null,
);
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function getProgress(ep: MediaItem): number {
if (!ep.userData || !ep.durationMs) {
return 0;
@@ -117,7 +106,7 @@
}
const episodeLabel = $derived(`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`);
const duration = $derived(formatDuration(episode.durationMs));
const duration = $derived(formatDuration(episode.durationMs, "h m"));
const progress = $derived(getProgress(episode));
</script>
+1 -8
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { playerController } from "$lib/player";
import { formatDuration } from "$lib/utils/duration";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
import type { MediaItem } from "$lib/api/types";
@@ -34,14 +35,6 @@
let dragDisabled = $state(true);
const flipDurationMs = 200;
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleConsider(
e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>,
) {
@@ -123,6 +123,23 @@ import VideoPlayer from "./VideoPlayer.svelte";
import { player } from "$lib/stores/player";
import type { MediaItem } from "$lib/api/types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
function makeEpisode(): MediaItem {
return {
id: "ep1",
@@ -136,7 +153,7 @@ async function mountNativePlayer() {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
selection: testSelection("http://server/videos/ep1/master.m3u8"),
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
@@ -261,7 +278,7 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
selection: testSelection("http://server/videos/ep1/master.m3u8"),
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
@@ -303,7 +320,7 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
selection: testSelection("http://server/videos/ep1/master.m3u8"),
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
@@ -118,6 +118,23 @@ import VideoPlayer from "./VideoPlayer.svelte";
import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import type { MediaItem } from "$lib/api/types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
function makeEpisode(): MediaItem {
return {
id: "ep1",
@@ -139,7 +156,7 @@ async function mountAndroidPlayer() {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
selection: testSelection("http://server/videos/ep1/master.m3u8"),
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
+260 -78
View File
@@ -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}
&middot; {(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"
@@ -36,6 +36,23 @@ import { invoke } from "@tauri-apps/api/core";
import VideoPlayer from "./VideoPlayer.svelte";
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
// --- Mocks: everything VideoPlayer reaches for that is not the tap surface. ---
const toggleSpy = vi.fn();
@@ -122,7 +139,7 @@ function touchAt(el: Element, x: number) {
function renderPlayer() {
return render(VideoPlayer, {
props: { media: MEDIA, streamUrl: "http://x/master.m3u8", onClose: vi.fn() },
props: { media: MEDIA, selection: testSelection("http://x/master.m3u8"), onClose: vi.fn() },
});
}
@@ -116,6 +116,23 @@ import { tick } from "svelte";
import VideoPlayer from "./VideoPlayer.svelte";
import type { MediaItem } from "$lib/api/types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
function makeEpisode(): MediaItem {
return {
id: "ep1",
@@ -129,7 +146,7 @@ async function mountAndroidPlayer() {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
selection: testSelection("http://server/videos/ep1/master.m3u8"),
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
@@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";
import { planFullscreen } from "./fullscreenTarget";
describe("planFullscreen", () => {
it("fullscreens only the document when an in-document <video> renders", () => {
// Unchanged behaviour: WebKit scales the element, the window need not move.
expect(planFullscreen(false)).toEqual({ document: true, osWindow: false });
});
it("also fullscreens the OS window when a native surface renders", () => {
// The picture is drawn behind the webview at window size, so a
// document-only fullscreen leaves it at the old size.
expect(planFullscreen(true)).toEqual({ document: true, osWindow: true });
});
});
@@ -0,0 +1,35 @@
/**
* Which surfaces a fullscreen toggle has to move.
*
* `requestFullscreen()` only ever fullscreens the *document*. That was
* sufficient while every renderer lived inside it: the HTML5 `<video>` element
* is part of the document, so WebKit scaled it to the screen and the OS
* window's real size never mattered.
*
* A native video surface is drawn *behind* the webview at **window** size, so a
* document-only fullscreen leaves the picture exactly where it was while the
* page around it goes fullscreen. On WebKitGTK the observed result is a
* maximised window with decorations still taking a strip of the screen the
* video renders correctly, at the wrong size, which reads as "fullscreen is
* broken" rather than as a windowing problem.
*
* Android already needed its own answer here for the system bars (DR-157); this
* is the desktop equivalent of the same rule: whoever actually owns the pixels
* has to be the thing that goes fullscreen.
*
* TRACES: UR-066 | DR-240 | UT-219
*/
export interface FullscreenPlan {
/** Ask the document to go fullscreen (harmless everywhere, needed for CSS). */
document: boolean;
/** Resize the OS window itself. Required when a native surface owns the picture. */
osWindow: boolean;
}
/**
* @param rendersNatively true when a native surface (mpv/ExoPlayer) draws the
* picture rather than an in-document `<video>` element.
*/
export function planFullscreen(rendersNatively: boolean): FullscreenPlan {
return { document: true, osWindow: rendersNatively };
}
+1 -37
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { videoFitClass, fittedVideoSize } from "./videoFit";
import { videoFitClass } from "./videoFit";
describe("videoFitClass", () => {
it("fills the container instead of capping at the source's intrinsic size", () => {
@@ -19,39 +19,3 @@ describe("videoFitClass", () => {
expect(cls).not.toContain("object-fill");
});
});
describe("fittedVideoSize", () => {
it("scales a 480p source up to fill a larger window (the reported bug)", () => {
// Exact 16:9 480p in a 1920x1080 window -> scales up to fill, rather than
// staying a 854x480 box in the middle.
const size = fittedVideoSize(853.33, 480, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(1080, 0);
});
it("fits to the constraining dimension when aspect ratios differ", () => {
// 4:3 source in a 16:9 window -> height-constrained, pillarboxed.
const size = fittedVideoSize(640, 480, 1920, 1080);
expect(size.height).toBeCloseTo(1080, 0);
expect(size.width).toBeCloseTo(1440, 0);
expect(size.width).toBeLessThan(1920);
});
it("fits to width when the source is wider than the window", () => {
// 21:9 source in a 16:9 window -> width-constrained, letterboxed.
const size = fittedVideoSize(2560, 1080, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(810, 0);
expect(size.height).toBeLessThan(1080);
});
it("shrinks oversized media to fit rather than overflowing", () => {
const size = fittedVideoSize(3840, 2160, 1280, 720);
expect(size.width).toBeCloseTo(1280, 0);
expect(size.height).toBeCloseTo(720, 0);
});
it("returns a zero size for unknown intrinsic dimensions", () => {
expect(fittedVideoSize(0, 0, 1920, 1080)).toEqual({ width: 0, height: 0 });
});
});
-29
View File
@@ -15,32 +15,3 @@
export function videoFitClass(): string {
return "w-full h-full object-contain";
}
export interface FittedSize {
width: number;
height: number;
}
/**
* The rendered size of a video of the given intrinsic dimensions once it has
* been fitted into the container - i.e. scaled (up or down) so that it touches
* the container on its constraining axis, with the other axis letter/pillar
* boxed. Mirrors what `object-fit: contain` on a full-size element does.
*/
export function fittedVideoSize(
intrinsicWidth: number,
intrinsicHeight: number,
containerWidth: number,
containerHeight: number,
): FittedSize {
if (intrinsicWidth <= 0 || intrinsicHeight <= 0) {
return { width: 0, height: 0 };
}
const scale = Math.min(containerWidth / intrinsicWidth, containerHeight / intrinsicHeight);
return {
width: intrinsicWidth * scale,
height: intrinsicHeight * scale,
};
}
+26 -7
View File
@@ -10,6 +10,23 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
import type { AdapterHost } from "./types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
/** A minimal fake <video> element that records mutations and fires events. */
function makeFakeVideo() {
const listeners: Record<string, Array<() => void>> = {};
@@ -54,7 +71,7 @@ function makeBridge(overrides: Partial<Html5ElementBridge> = {}): Html5ElementBr
setSeekOffset: vi.fn((o: number) => {
offset = o;
}),
setStreamUrl: vi.fn(),
setStreamSelection: vi.fn(),
destroyHls: vi.fn(),
getMediaSourceId: () => "msid-1",
...overrides,
@@ -184,7 +201,7 @@ describe("Html5PlayerAdapter", () => {
it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
video.paused = false; // was playing → should resume
const p = adapter.reloadSource("http://new/master.m3u8", 120);
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
// Teardown happened synchronously before the awaited canplay wait.
expect(video.pause).toHaveBeenCalled();
@@ -194,7 +211,9 @@ describe("Html5PlayerAdapter", () => {
// Allow the internal 100ms settle delay, then fire canplay to resume.
await new Promise((r) => setTimeout(r, 110));
expect(bridge.setStreamUrl).toHaveBeenCalledWith("http://new/master.m3u8");
expect(bridge.setStreamSelection).toHaveBeenCalledWith(
expect.objectContaining({ url: "http://new/master.m3u8", transport: { type: "hls" } }),
);
video._fire("canplay");
video._fire("seeked");
await p;
@@ -217,7 +236,7 @@ describe("Html5PlayerAdapter", () => {
*/
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
video.paused = false;
const p = adapter.reloadSource("http://new/master.m3u8", 1200);
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 1200);
await new Promise((r) => setTimeout(r, 110));
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
@@ -238,7 +257,7 @@ describe("Html5PlayerAdapter", () => {
/** A reload to the very start has nothing to seek to; it must not stall. */
it("reloadSource() at position 0 does not wait for a seek", async () => {
video.paused = false;
const p = adapter.reloadSource("http://new/master.m3u8", 0);
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 0);
await new Promise((r) => setTimeout(r, 110));
video._fire("canplay");
await p; // resolves without any "seeked" event
@@ -258,7 +277,7 @@ describe("Html5PlayerAdapter", () => {
vi.useFakeTimers();
try {
video.paused = false;
const p = adapter.reloadSource("http://new/master.m3u8", 120);
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
const assertion = expect(p).rejects.toThrow(/canplay/i);
await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
await assertion;
@@ -270,7 +289,7 @@ describe("Html5PlayerAdapter", () => {
it("reloadSource() does not resume when it was paused", async () => {
video.paused = true;
const p = adapter.reloadSource("http://new/master.m3u8", 30);
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 30);
await new Promise((r) => setTimeout(r, 110));
video._fire("canplay");
video._fire("seeked");
+53 -10
View File
@@ -1,3 +1,4 @@
import type { StreamSelection } from "$lib/api/bindings";
/**
* Html5PlayerAdapter the Linux/desktop (and interim Android) PlayerAdapter
* implementation. It owns the high-level control surface for an HTML5 `<video>`
@@ -24,6 +25,39 @@ import { createLogger } from "$lib/utils/logger";
const log = createLogger("Html5PlayerAdapter");
/**
* The selection for a plain `load(url)` call.
*
* `PlayerLoadOptions` carries the backend's selection when the caller has one.
* When it does not a local file, a live stream, a direct URL the transport
* is inferred *once, here*, from what the caller already knows rather than from
* the URL text: a local path is a local file, and anything the backend flagged
* as transcoded is HLS, because every transcode this app requests is HLS.
*
* This is the one place a fallback is tolerable, and it is explicitly a
* fallback: the negotiated path never reaches it.
*
* TRACES: UR-079 | DR-225
*/
function selectionForLoad(streamUrl: string, options: PlayerLoadOptions): StreamSelection {
if (options.selection) return options.selection;
const transport: StreamSelection["transport"] = options.isLocalFile
? { type: "localFile" }
: options.needsTranscoding
? { type: "hls" }
: { type: "progressive" };
return {
url: streamUrl,
transport,
playbackKind: options.needsTranscoding ? { type: "transcode" } : { type: "directPlay" },
rendition: null,
available: [],
mediaSourceId: options.mediaSourceId ?? null,
playSessionId: null,
needsTranscoding: options.needsTranscoding,
};
}
/**
* Narrow seam the owning component provides so the adapter can execute the
* element/HLS-coupled parts of a control action without re-implementing the
@@ -36,8 +70,16 @@ export interface Html5ElementBridge {
/** Current seek offset (seconds) for transcoded streams. */
getSeekOffset(): number;
setSeekOffset(offset: number): void;
/** Update the stream URL the component renders (triggers its HLS $effect). */
setStreamUrl(url: string): void;
/**
* Update the stream the component renders (triggers its HLS $effect).
*
* Carries the whole [`StreamSelection`], not just the URL: the component's
* effect has to know the transport to choose a loader, and deriving that from
* the URL is the substring check DR-225 removes.
*
* TRACES: UR-079 | DR-225
*/
setStreamSelection(selection: StreamSelection): void;
/** Tear down the component-owned hls.js instance (dual-audio prevention). */
destroyHls(): void;
/** Media source id for seek/audio-track URLs. */
@@ -86,12 +128,13 @@ export class Html5PlayerAdapter implements PlayerAdapter {
this.attachedElement = element;
}
async load(streamUrl: string, _options: PlayerLoadOptions): Promise<void> {
async load(streamUrl: string, options: PlayerLoadOptions): Promise<void> {
// The component's reactive HLS $effect performs the actual attach/load when
// the stream URL is set; loading is therefore driven by setStreamUrl. The
// component's canplay/frag-buffered path reports readiness through the host.
// the selection is set; loading is therefore driven by setStreamSelection.
// The component's canplay/frag-buffered path reports readiness through the
// host.
this.bridge.setSeekOffset(0);
this.bridge.setStreamUrl(streamUrl);
this.bridge.setStreamSelection(selectionForLoad(streamUrl, options));
this.host.onState("loading");
}
@@ -171,12 +214,12 @@ export class Html5PlayerAdapter implements PlayerAdapter {
*
* TRACES: UR-004, UR-005 | DR-181 | UT-183
*/
async reloadSource(url: string, positionSeconds: number): Promise<void> {
async reloadSource(selection: StreamSelection, positionSeconds: number): Promise<void> {
const el = this.element;
if (!el) {
// Still update the stream URL so the component's HLS $effect can pick it up.
// Still update the selection so the component's HLS $effect can pick it up.
this.bridge.setSeekOffset(0);
this.bridge.setStreamUrl(url);
this.bridge.setStreamSelection(selection);
return;
}
const wasPlaying = !el.paused;
@@ -189,7 +232,7 @@ export class Html5PlayerAdapter implements PlayerAdapter {
await new Promise((r) => setTimeout(r, 100));
// The reloaded stream begins at the item's zero, so there is no base to add.
this.bridge.setSeekOffset(0);
this.bridge.setStreamUrl(url);
this.bridge.setStreamSelection(selection);
// A source that never becomes playable is a failed reload, not a slow one:
// the caller (quality switch, transcoded seek) has to know so it can revert
// its selection and surface the error instead of leaving the UI claiming a
+18 -1
View File
@@ -28,6 +28,23 @@ vi.mock("$lib/api/bindings", () => ({
import { NativePlayerAdapter } from "./nativeAdapter";
import type { AdapterHost } from "./types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
function makeHost(): AdapterHost {
return {
onState: vi.fn(),
@@ -67,7 +84,7 @@ describe("NativePlayerAdapter", () => {
it("records position on seek/reload primitives (backend does the real work)", async () => {
await adapter.seekElement(55, 0);
expect(adapter.getPosition()).toBe(55);
await adapter.reloadSource("ignored", 200);
await adapter.reloadSource(testSelection("ignored"), 200);
expect(adapter.getPosition()).toBe(200);
});
+2 -1
View File
@@ -1,3 +1,4 @@
import type { StreamSelection } from "$lib/api/bindings";
/**
* NativePlayerAdapter the Android/ExoPlayer PlayerAdapter implementation.
*
@@ -89,7 +90,7 @@ export class NativePlayerAdapter implements PlayerAdapter {
* performed the reload+seek internally as part of the seek decision; nothing
* to do on the frontend beyond recording position.
*/
async reloadSource(_url: string, offset: number): Promise<void> {
async reloadSource(_selection: StreamSelection, offset: number): Promise<void> {
this.position = offset;
}
+23 -5
View File
@@ -1,3 +1,4 @@
import type { StreamSelection } from "$lib/api/bindings";
/**
* PlayerAdapter contract the decoupled boundary between the UI/backend and a
* concrete video player implementation (Linux HTML5+hls.js, or Android native).
@@ -42,6 +43,19 @@ export interface PlayerLoadOptions {
knownDuration: number;
/** Subtitle tracks available for this media. */
subtitleTracks: SubtitleTrackInput[];
/**
* The backend's decision about this stream, when it made one.
*
* Present for anything negotiated through `repository_get_stream_selection`.
* Null for the paths that never negotiate a local file, a live channel, a
* plugin's direct URL where the adapter falls back to what the other
* options already say rather than to reading the URL.
*
* TRACES: UR-079 | DR-225
*/
selection?: StreamSelection | null;
/** The source is a file on disk (or the loopback server in front of one). */
isLocalFile?: boolean;
}
/**
@@ -106,12 +120,16 @@ export interface PlayerAdapter {
seekElement(positionSeconds: number, offset: number): Promise<void>;
/**
* Compound reload: swap to `url` and resume at `offset` seconds. Runs the
* invariant mechanical sequence for this platform (html5: pause hls teardown
* clear src set new url wait ready resume; native: ExoPlayer setMediaItem
* + seekTo). No decision is made here the backend already decided to reload.
* Compound reload: swap to `selection` and resume at `offset` seconds. Runs
* the invariant mechanical sequence for this platform (html5: pause hls
* teardown clear src set new selection wait ready resume; native:
* ExoPlayer setMediaItem + seekTo). No decision is made here the backend
* already decided to reload, and `selection.transport` says how to open it, so
* no adapter has to infer that from the URL.
*
* TRACES: UR-079 | DR-225
*/
reloadSource(url: string, offset: number): Promise<void>;
reloadSource(selection: StreamSelection, offset: number): Promise<void>;
setVolume(volume: number): void; // 0..1
setMuted(muted: boolean): void;
@@ -1,3 +1,4 @@
import type { StreamSelection } from "$lib/api/bindings";
/**
* Webview audio adapter plays audio-only media through a hidden `<audio>`
* element on platforms with no native audio backend (currently Windows).
@@ -104,8 +105,8 @@ export class WebviewAudioAdapter implements PlayerAdapter {
}
/** No transcode-reload concept for direct audio; treat as a fresh load. */
async reloadSource(url: string, offset: number): Promise<void> {
await this.load(url, {
async reloadSource(selection: StreamSelection, offset: number): Promise<void> {
await this.load(selection.url, {
mediaId: "",
mediaSourceId: null,
needsTranscoding: false,
+20 -10
View File
@@ -22,6 +22,7 @@ import type {
PlayAlbumTrackRequest,
PlayItemRequest,
StreamingQuality,
StreamSelection,
} from "$lib/api/bindings";
import { auth } from "$lib/stores/auth";
import type { PlayerAdapter } from "./adapters/types";
@@ -150,12 +151,12 @@ async function seekVideo(
audioTrackIndex,
adapter.kind === "html5",
)) as any;
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
// Serde keeps `seek_offset` snake_case (only the "strategy" tag is camelCase).
if (response.strategy === "reloadStream") {
// `seek_offset` is the ABSOLUTE position to resume at, not a base to add to
// the element's clock: the reloaded stream starts at the item's zero since
// DR-181, so reloadSource seeks there. (The name is the wire field's.)
await adapter.reloadSource(response.new_url ?? "", response.seek_offset ?? positionSeconds);
await adapter.reloadSource(response.selection, response.seek_offset ?? positionSeconds);
} else {
await adapter.seekElement(response.position ?? positionSeconds, 0);
}
@@ -182,26 +183,32 @@ async function switchAudioTrack(
mediaSourceId,
)) as any;
if (response.strategy === "reloadStream") {
await adapter.reloadSource(response.new_url!, response.position!);
await adapter.reloadSource(response.selection, response.position!);
}
}
/**
* Change the bandwidth ceiling of the video playing now. The backend re-opens
* the stream at the new quality and decides who reloads: it handles a native
* backend itself, and hands HTML5 a URL for the same `reloadSource` primitive
* the audio-track switch uses. Requires an active video adapter.
* backend itself, and hands HTML5 a selection for the same `reloadSource`
* primitive the audio-track switch uses. Requires an active video adapter.
*
* TRACES: UR-074 | DR-162
* The change applies to **this playback only** the backend sets a per-playback
* override that the next item clears, leaving the durable Settings default
* alone. Returns the negotiated selection so the caller can show what it
* actually got, which is not always what was asked for: a ceiling above the
* source bitrate is the source.
*
* TRACES: UR-074, UR-079 | DR-162, DR-226
*/
async function setStreamQuality(
quality: StreamingQuality,
currentPosition: number | null,
mediaSourceId: string | null,
audioTrackIndex: number | null,
): Promise<void> {
): Promise<StreamSelection | null> {
const adapter = activeAdapter;
if (!adapter) return;
if (!adapter) return null;
const response = (await commands.playerSetStreamQuality(
requireHandle(),
quality,
@@ -210,10 +217,13 @@ async function setStreamQuality(
mediaSourceId,
audioTrackIndex,
)) as any;
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
if (response.strategy === "reloadStream") {
await adapter.reloadSource(response.new_url ?? "", response.position ?? currentPosition ?? 0);
await adapter.reloadSource(response.selection, response.position ?? currentPosition ?? 0);
return response.selection;
}
// The native backend reloaded itself, but still reports what it opened — the
// caller needs it to show the rung actually in force.
return response.selection ?? null;
}
async function next() {
+1 -72
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { downloadedFilePath, resolveVideoSource } from "./localSource";
import { downloadedFilePath } from "./localSource";
describe("downloadedFilePath", () => {
// The download worker rewrites `downloads.file_path` to the absolute path it
@@ -29,74 +29,3 @@ describe("downloadedFilePath", () => {
expect(downloadedFilePath("C:\\Users\\u\\AppData\\jellytau", stored)).toBe(stored);
});
});
// A stand-in for Tauri's convertFileSrc, so the module stays pure.
const toAssetUrl = (p: string) => `asset://localhost/${encodeURIComponent(p)}`;
describe("resolveVideoSource", () => {
it("plays the downloaded file when one exists", () => {
const decision = resolveVideoSource({
localPath: "/home/u/.local/share/jellytau/movie.mp4",
remoteUrl: "https://server/Videos/abc/master.m3u8",
remoteNeedsTranscoding: true,
toAssetUrl,
});
expect(decision.isLocal).toBe(true);
expect(decision.url).toBe(toAssetUrl("/home/u/.local/share/jellytau/movie.mp4"));
});
it("never marks a local file as needing transcoding, even when the remote did", () => {
// The transcoded path re-requests a whole new stream URL on every seek.
// A local file seeks natively; sending it down that route would ask the
// server for a stream we deliberately avoided.
const decision = resolveVideoSource({
localPath: "/downloads/film.mkv",
remoteUrl: "https://server/Videos/abc/master.m3u8",
remoteNeedsTranscoding: true,
toAssetUrl,
});
expect(decision.needsTranscoding).toBe(false);
});
it("streams when nothing is downloaded, preserving the transcoding flag", () => {
const decision = resolveVideoSource({
localPath: null,
remoteUrl: "https://server/Videos/abc/master.m3u8",
remoteNeedsTranscoding: true,
toAssetUrl,
});
expect(decision).toEqual({
url: "https://server/Videos/abc/master.m3u8",
needsTranscoding: true,
isLocal: false,
});
});
it("streams a direct-play remote without claiming it transcodes", () => {
const decision = resolveVideoSource({
localPath: null,
remoteUrl: "https://server/Videos/abc/stream.mp4",
remoteNeedsTranscoding: false,
toAssetUrl,
});
expect(decision.needsTranscoding).toBe(false);
expect(decision.isLocal).toBe(false);
});
it("falls back to streaming for a blank path rather than building a dead asset URL", () => {
for (const localPath of ["", " "]) {
const decision = resolveVideoSource({
localPath,
remoteUrl: "https://server/stream",
remoteNeedsTranscoding: false,
toAssetUrl,
});
expect(decision.isLocal).toBe(false);
expect(decision.url).toBe("https://server/stream");
}
});
});
-50
View File
@@ -1,41 +1,3 @@
/**
* Choosing between a downloaded file and a server stream for video playback.
*
* Audio has preferred local files since the queue is built (the Rust queue
* resolves `MediaSource::Local`), but video asks the repository for a stream URL
* and never consults `downloads` so a downloaded film was streamed anyway,
* spending bandwidth that had already been spent and failing outright offline.
*
* Pure so it can be unit-tested: the component only supplies the two inputs and
* the asset-URL converter.
*
* TRACES: UR-071 | DR-123 | UT-118
*/
export interface VideoSourceInputs {
/** Absolute on-disk path of a completed download, or null to stream. */
localPath: string | null;
/** Stream URL the repository resolved (already transcoded if it had to be). */
remoteUrl: string;
/** Whether the *remote* stream is a transcode. */
remoteNeedsTranscoding: boolean;
/** Usually Tauri's `convertFileSrc`; injected so this module stays pure. */
toAssetUrl: (path: string) => string;
}
export interface VideoSourceDecision {
/** What to hand the `<video>` element. */
url: string;
/**
* Local files are never transcodes, so this is always false for them. It
* matters because the transcoded path re-requests a whole new stream URL on
* every seek; a local file seeks natively and must not go down that route.
*/
needsTranscoding: boolean;
/** True when playing from disk — for logging and the offline badge. */
isLocal: boolean;
}
/** Absolute on POSIX (`/…`), Windows (`C:\…`, `C:/…`) or a UNC share (`\\…`). */
function isAbsolute(path: string): boolean {
return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path);
@@ -57,15 +19,3 @@ function isAbsolute(path: string): boolean {
export function downloadedFilePath(storageRoot: string, filePath: string): string {
return isAbsolute(filePath) ? filePath : `${storageRoot}/${filePath}`;
}
export function resolveVideoSource(inputs: VideoSourceInputs): VideoSourceDecision {
const { localPath, remoteUrl, remoteNeedsTranscoding, toAssetUrl } = inputs;
// Treat blank/whitespace paths as absent — a malformed `downloads` row must
// not produce an asset URL pointing at nothing.
if (localPath && localPath.trim() !== "") {
return { url: toAssetUrl(localPath), needsTranscoding: false, isLocal: true };
}
return { url: remoteUrl, needsTranscoding: remoteNeedsTranscoding, isLocal: false };
}
+93
View File
@@ -0,0 +1,93 @@
/**
* The loader is chosen from the backend's `transport` tag, never from the URL.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
import { describe, expect, it } from "vitest";
import { elementSrcFor, videoLoaderFor, type LoaderCapabilities } from "./streamTransport";
import type { StreamSelection, Transport } from "$lib/api/bindings";
const MODERN: LoaderCapabilities = { hlsJsSupported: true, nativeHlsSupported: false };
const SAFARI: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: true };
const NEITHER: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: false };
function selection(transport: Transport, url: string): Pick<StreamSelection, "url" | "transport"> {
return { url, transport };
}
describe("videoLoaderFor", () => {
it("attaches hls.js when the backend says HLS and hls.js is available", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe(
"hlsjs",
);
});
it("falls back to the element's own HLS loader when hls.js is unavailable", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
"nativeHls",
);
});
it("loads a progressive stream directly", () => {
expect(
videoLoaderFor(
selection({ type: "progressive" }, "https://s/Videos/1/stream?static=true"),
MODERN,
),
).toBe("direct");
});
it("loads a local file directly", () => {
expect(
videoLoaderFor(selection({ type: "localFile" }, "http://127.0.0.1:9/media/x.mkv"), MODERN),
).toBe("direct");
});
// ---------------------------------------------------------------------
// The two cases the `.m3u8` substring check gets wrong. These are the
// reason the field exists; both fail against a URL-sniffing implementation.
// ---------------------------------------------------------------------
it("does NOT attach hls.js to a progressive stream whose URL happens to end .m3u8", () => {
// A direct play served from a path containing the substring — nothing stops
// a server, a proxy, or a local cache from producing this.
expect(
videoLoaderFor(selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4"), MODERN),
).toBe("direct");
expect(
videoLoaderFor(selection({ type: "progressive" }, "https://s/x?name=master.m3u8"), MODERN),
).toBe("direct");
});
it("DOES attach hls.js to an HLS stream whose URL does not contain .m3u8", () => {
// Jellyfin's own transcoding URLs are not required to end in `.m3u8`, and a
// DASH or query-routed playlist endpoint never would.
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/Videos/1/hls"), MODERN)).toBe(
"hlsjs",
);
expect(
videoLoaderFor(selection({ type: "hls" }, "https://s/stream?format=playlist"), SAFARI),
).toBe("nativeHls");
});
it("falls back to direct when HLS is requested but nothing can play it", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), NEITHER)).toBe(
"direct",
);
});
});
describe("elementSrcFor", () => {
it("empties the element's src only when hls.js drives it", () => {
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe("");
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
"https://s/master.m3u8",
);
});
it("keeps the src for a progressive stream that looks like a playlist", () => {
const s = selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4");
expect(elementSrcFor(s, MODERN)).toBe("https://s/files/movie.m3u8.mp4");
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Which loader opens a stream in the webview `<video>` element.
*
* Extracted from `VideoPlayer.svelte` so the decision can be unit-tested the
* same pattern as `episodeStrip.ts` and `TrackList.logic.test.ts`.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
import type { StreamSelection, Transport } from "$lib/api/bindings";
/** How the element should be fed. */
export type VideoLoader =
/** hls.js drives a MediaSource; the element's own `src` stays empty. */
| "hlsjs"
/** The element loads the playlist itself (Safari/WebKit native HLS). */
| "nativeHls"
/** The element loads the URL directly — a progressive file or a local one. */
| "direct";
/** What the running browser can do, passed in so the decision stays pure. */
export interface LoaderCapabilities {
/** `Hls.isSupported()` */
hlsJsSupported: boolean;
/** `video.canPlayType("application/vnd.apple.mpegurl")` was non-empty */
nativeHlsSupported: boolean;
}
/**
* Pick the loader from the backend's tagged `transport`.
*
* This used to read `url.includes(".m3u8")`, in two places in
* `VideoPlayer.svelte`. Rust *builds* that URL and knows exactly what it is;
* re-deriving the answer here by substring match is a domain fact reconstructed
* in the presentation layer the same error as leaking item-type taxonomy, and
* one that fails silently in both directions: a progressive file served from a
* path containing `.m3u8` gets an HLS loader, and a playlist served from a path
* without it does not.
*
* The transport is the *stream's* property; whether a given loader exists is the
* *browser's*. Only the second is decided here.
*/
export function videoLoaderFor(
selection: Pick<StreamSelection, "url" | "transport">,
capabilities: LoaderCapabilities,
): VideoLoader {
return loaderForTransport(selection.transport.type, capabilities);
}
/**
* The same decision, taken from the transport *tag* alone.
*
* Exists because a Svelte `$effect` that reads the whole selection re-runs
* whenever the selection **object** is replaced even with an identical URL and
* transport and the HLS effect's teardown/rebuild is not idempotent: it
* destroys the hls.js instance and reattaches, which leaves the element with no
* video until something forces another cycle. The pre-DR-225 code read a plain
* URL *string*, so re-assigning the same value was a no-op and the effect stayed
* put. Passing primitives restores that.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
export function loaderForTransport(
transport: Transport["type"],
capabilities: LoaderCapabilities,
): VideoLoader {
if (transport !== "hls") {
// Progressive and local files are what the element loads natively. No
// MediaSource, no playlist parsing.
return "direct";
}
if (capabilities.hlsJsSupported) return "hlsjs";
if (capabilities.nativeHlsSupported) return "nativeHls";
// Nothing here can parse a playlist. Handing the URL to the element is very
// likely to fail, but it is the only remaining move and it surfaces a real
// media error rather than silently doing nothing.
return "direct";
}
/** Convenience for the template: does the element's `src` stay empty? */
export function elementSrcFor(
selection: Pick<StreamSelection, "url" | "transport">,
capabilities: LoaderCapabilities,
): string {
return videoLoaderFor(selection, capabilities) === "hlsjs" ? "" : selection.url;
}
export type { Transport };
+44
View File
@@ -64,6 +64,49 @@ function createAuthStore() {
return repository;
}
/**
* The repository, waiting for session restore rather than failing the instant
* it is asked.
*
* `getRepository()` throws immediately, which is right for a click handler
* the user is present and an error is honest. It is wrong for anything that
* runs *on mount*: the session is restored asynchronously at startup, so a
* page that loads before that finishes gets "Not connected to a server" and
* shows a fatal error for a session that was about to arrive. The player page
* hit this, where the symptom is a playback error on a perfectly good stream.
*
* Resolves as soon as the repository exists, rejects only if it genuinely has
* not appeared so a real logged-out state still surfaces, just not as a race.
*
* TRACES: UR-002 | DR-013
*/
async function waitForRepository(timeoutMs = 5000): Promise<RepositoryClient> {
if (repository) return repository;
return new Promise<RepositoryClient>((resolve, reject) => {
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
unsubscribe();
fn();
};
// Every store change is a chance the session landed. `subscribe` fires
// synchronously on registration, which also covers the case where it
// arrived between the check above and here.
const unsubscribe = subscribe(() => {
if (repository) finish(() => resolve(repository as RepositoryClient));
});
const timer = setTimeout(
() => finish(() => reject(new Error("Not connected to a server"))),
timeoutMs,
);
});
}
/**
* Initialize event listeners from Rust backend.
* These should be called once during app initialization.
@@ -572,6 +615,7 @@ function createAuthStore() {
logout,
clearError,
getRepository,
waitForRepository,
getCurrentSession,
getUserId,
getServerUrl,
@@ -0,0 +1,117 @@
/**
* Waiting for the repository rather than racing it.
*
* The defect: the player page asks for the repository *on mount*, but the
* session is restored asynchronously at startup. Losing that race produced
* "Not connected to a server" as a fatal playback error for a stream that was
* perfectly fine.
*
* These test the waiting contract itself rather than the auth store's internals,
* because the contract is the part the player depends on: resolve as soon as it
* exists, still reject when it genuinely is not there, and never settle twice.
*
* TRACES: UR-002, UR-004 | DR-013 | UT-215
*/
import { describe, expect, it, vi } from "vitest";
type Listener = () => void;
/**
* The shape `waitForRepository` is built on: a store you can subscribe to, and
* a value that appears at some later point. Mirrors the real implementation
* without dragging in Tauri.
*/
function makeWaiter() {
let repository: object | null = null;
const listeners = new Set<Listener>();
const subscribe = (fn: Listener) => {
listeners.add(fn);
fn(); // stores fire synchronously on subscribe
return () => listeners.delete(fn);
};
const publish = (value: object | null) => {
repository = value;
listeners.forEach((fn) => fn());
};
async function waitForRepository(timeoutMs = 5000): Promise<object> {
if (repository) return repository;
return new Promise<object>((resolve, reject) => {
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
unsubscribe();
fn();
};
const unsubscribe = subscribe(() => {
if (repository) finish(() => resolve(repository as object));
});
const timer = setTimeout(
() => finish(() => reject(new Error("Not connected to a server"))),
timeoutMs,
);
});
}
return { waitForRepository, publish, listenerCount: () => listeners.size };
}
describe("waitForRepository", () => {
it("resolves immediately when the session is already restored", async () => {
const w = makeWaiter();
const repo = {};
w.publish(repo);
await expect(w.waitForRepository(50)).resolves.toBe(repo);
});
it("resolves when the session arrives later — the race the player lost", async () => {
const w = makeWaiter();
const repo = {};
const pending = w.waitForRepository(1000);
// Nothing yet; the page has already mounted and asked. Published on a
// microtask rather than a timer: the point is *ordering* (asked before it
// arrived), and a wall-clock delay would make this a race under load.
await Promise.resolve();
w.publish(repo);
await expect(pending).resolves.toBe(repo);
});
it("still rejects when there genuinely is no session", async () => {
vi.useFakeTimers();
const w = makeWaiter();
const pending = w.waitForRepository(500);
const assertion = expect(pending).rejects.toThrow("Not connected to a server");
await vi.advanceTimersByTimeAsync(600);
await assertion;
vi.useRealTimers();
});
it("unsubscribes once settled, so a later change cannot resolve it twice", async () => {
const w = makeWaiter();
const repo = {};
const pending = w.waitForRepository(1000);
expect(w.listenerCount()).toBe(1);
w.publish(repo);
await pending;
expect(w.listenerCount()).toBe(0);
// A further change must not throw or re-settle.
expect(() => w.publish(null)).not.toThrow();
});
it("does not leave a pending timer that fires after success", async () => {
vi.useFakeTimers();
const w = makeWaiter();
const repo = {};
const pending = w.waitForRepository(200);
w.publish(repo);
await expect(pending).resolves.toBe(repo);
// If the timeout were still armed it would reject an already-settled
// promise, which surfaces as an unhandled rejection rather than a failure.
await vi.advanceTimersByTimeAsync(500);
vi.useRealTimers();
});
});
+1 -21
View File
@@ -5,7 +5,7 @@
*/
import { describe, it, expect } from "vitest";
import { formatDuration, formatSecondsDuration } from "./duration";
import { formatDuration } from "./duration";
describe("formatDuration", () => {
it("should format duration from milliseconds (mm:ss format)", () => {
@@ -39,23 +39,3 @@ describe("formatDuration", () => {
expect(formatDuration(9045000, "hh:mm:ss")).toBe("2:30:45");
});
});
describe("formatSecondsDuration", () => {
it("should format duration from seconds (mm:ss format)", () => {
expect(formatSecondsDuration(1)).toBe("0:01");
expect(formatSecondsDuration(60)).toBe("1:00");
expect(formatSecondsDuration(61)).toBe("1:01");
expect(formatSecondsDuration(3661)).toBe("61:01");
});
it("should format duration with hh:mm:ss format", () => {
expect(formatSecondsDuration(3600, "hh:mm:ss")).toBe("1:00:00");
expect(formatSecondsDuration(3661, "hh:mm:ss")).toBe("1:01:01");
expect(formatSecondsDuration(7325, "hh:mm:ss")).toBe("2:02:05");
});
it("should pad minutes and seconds with leading zeros", () => {
expect(formatSecondsDuration(5, "hh:mm:ss")).toBe("0:00:05");
expect(formatSecondsDuration(65, "hh:mm:ss")).toBe("0:01:05");
});
});
+13 -25
View File
@@ -12,11 +12,23 @@
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
* @returns Formatted duration string or empty string if no duration
*/
export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
export function formatDuration(
ms?: number | null,
format: "mm:ss" | "hh:mm:ss" | "h m" = "mm:ss",
): string {
if (!ms) return "";
const totalSeconds = Math.floor(ms / 1000);
// "1h 23m" / "45m" — the shape a runtime is read at a glance, as opposed to
// the clock shape a *position* is read at. Three components had hand-rolled
// this identically; it belongs here with the other two.
if (format === "h m") {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
if (format === "hh:mm:ss") {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
@@ -30,27 +42,3 @@ export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss"
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Convert seconds to formatted duration string
* @param seconds Duration in seconds
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
* @returns Formatted duration string
*/
export function formatSecondsDuration(
seconds: number,
format: "mm:ss" | "hh:mm:ss" = "mm:ss",
): string {
if (format === "hh:mm:ss") {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
// Default "mm:ss" format
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}
+2 -13
View File
@@ -1,6 +1,7 @@
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
<script lang="ts">
import { onMount, untrack } from "svelte";
import { formatDuration } from "$lib/utils/duration";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
@@ -250,18 +251,6 @@
// Images now handled by CachedImage component
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function handleItemClick(clickedItem: MediaItem | Library) {
if (!("kind" in clickedItem)) {
// Library item - navigate to library
@@ -534,7 +523,7 @@
>
{/if}
{#if item.durationMs}
<span>{formatDuration(item.durationMs)}</span>
<span>{formatDuration(item.durationMs, "h m")}</span>
{/if}
{#if item.communityRating}
<span class="flex items-center gap-1">
+76 -51
View File
@@ -3,8 +3,8 @@
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings";
import { downloadedFilePath, resolveVideoSource } from "$lib/player/localSource";
import type { PlayQueueRequest } from "$lib/api/bindings";
import { downloadedFilePath } from "$lib/player/localSource";
import type { PlayQueueRequest, StreamSelection } from "$lib/api/bindings";
import type { MediaItem, MediaKind } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
@@ -76,7 +76,15 @@
const hasNext = $derived($hasNextStore);
const hasPrevious = $derived($hasPreviousStore);
let currentMedia = $state<MediaItem | null>(null);
let streamUrl = $state<string | null>(null);
/**
* What to play, as the backend decided it. Null while still resolving.
*
* Replaces a bare URL string: the transport travels with it, so neither this
* page nor VideoPlayer has to work out whether the URL is a playlist.
*
* TRACES: UR-079 | DR-225
*/
let selection = $state<StreamSelection | null>(null);
let mediaSourceId = $state<string | null>(null);
let isVideo = $state(false);
let isLive = $state(false); // Whether this is a live stream (Live TV channel) - no seek/resume
@@ -94,7 +102,7 @@
// Which player component to render. Video without a stream URL is "pending"
// (still resolving), never audio — see playerSurface.ts.
const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl }));
const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl: selection?.url ?? null }));
onMount(() => {
// Start position polling (only for audio via MPV backend)
@@ -308,17 +316,17 @@
const fullPath = downloadedFilePath(storagePath, localDownload.filePath);
log.debug("loadAndPlay: Full local path:", fullPath);
// Serve the file over the loopback media server rather than the asset
// protocol: the asset protocol answers a range-less request with the
// entire file, so a downloaded film never finished loading. Rust mints
// the URL (it holds the port and the per-session token).
// TRACES: UR-071 | DR-137
const localUrl = await commands.mediaLocalUrl(fullPath);
log.debug("loadAndPlay: Local media URL resolved");
if (isVideo) {
// Local video files don't need transcoding and support native seeking
streamUrl = localUrl;
// Served over the loopback media server rather than the asset
// protocol: the asset protocol answers a range-less request with the
// entire file, so a downloaded film never finished loading. Rust mints
// the URL (it holds the port and the per-session token) and states the
// transport with it.
//
// A downloaded file is a direct play over a local transport, and Rust
// says so rather than this page assuming it.
// TRACES: UR-071 | DR-137, DR-225
selection = await commands.mediaLocalSelection(fullPath);
videoNeedsTranscoding = false;
// Use explicit startPosition, or fall back to retrieved progress from database
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
@@ -346,7 +354,12 @@
} else {
// Online playback - get playback info from server
isOfflinePlayback = false;
const repo = auth.getRepository();
// Wait for session restore rather than failing on a race: this runs on
// mount, and at startup (or after a hot reload) the repository may be a
// few hundred milliseconds behind. Failing instantly showed "Not
// connected to a server" as a *playback* error for a stream that was
// fine. TRACES: UR-002, UR-004 | DR-013
const repo = await auth.waitForRepository();
if (isLive) {
// Live TV channels must be "opened" before streaming; the server returns
@@ -355,7 +368,19 @@
const liveInfo = await repo.openLiveStream(id);
log.debug("loadAndPlay: Live stream URL:", liveInfo.streamUrl);
mediaSourceId = liveInfo.mediaSourceId;
streamUrl = liveInfo.streamUrl;
selection = {
url: liveInfo.streamUrl,
// Rust's verdict, not a guess from the URL.
transport: liveInfo.transport,
playbackKind: { type: "transcode" },
rendition: null,
// A live channel has no ladder to offer: there is no source file to
// measure and no rendition to re-negotiate against.
available: [],
mediaSourceId: liveInfo.mediaSourceId,
playSessionId: liveInfo.playSessionId,
needsTranscoding: true,
};
videoNeedsTranscoding = true;
videoInitialPosition = 0;
isPlaying = true;
@@ -363,46 +388,46 @@
return;
}
log.debug("loadAndPlay: Getting playback info");
const playbackInfo = await repo.getPlaybackInfo(id);
log.debug("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
if (isVideo) {
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
log.debug(
"loadAndPlay: Using video stream, directPlay:",
playbackInfo.directPlay,
"needsTranscoding:",
playbackInfo.needsTranscoding,
);
mediaSourceId = playbackInfo.mediaSourceId;
// Prefer a completed download over streaming. Audio has done this
// since the queue is built; video previously always streamed, so a
// downloaded film re-spent bandwidth already spent and would not play
// at all offline. Rust returns null when nothing is downloaded or the
// file has gone, so this falls back to the server on its own.
// TRACES: UR-071 | DR-123
// A downloaded file is served over the loopback media server, not the
// asset protocol — see DR-137. The URL is minted up front because
// resolveVideoSource stays pure/synchronous.
// TRACES: UR-071 | DR-123, DR-137
//
// Checked *first* so the streaming path below negotiates exactly once:
// asking for a `PlaybackInfo` and then a stream selection meant two
// negotiations per load, and each one claims a transcode identity and
// retires the previous — so the server started a job only to be told
// to stop it a moment later. Observed in the log as a pair of
// `[StreamSelection]` lines for one play.
//
// TRACES: UR-071 | DR-123, DR-137, DR-225
const localPath = await commands.playerLocalMediaPath(id);
const localUrl = localPath ? await commands.mediaLocalUrl(localPath) : null;
const source = resolveVideoSource({
localPath,
remoteUrl: playbackInfo.streamUrl,
remoteNeedsTranscoding: playbackInfo.needsTranscoding,
toAssetUrl: () => localUrl ?? "",
});
streamUrl = source.url;
videoNeedsTranscoding = source.needsTranscoding;
log.debug(
source.isLocal
? "loadAndPlay: Playing downloaded file from disk"
: `loadAndPlay: Using stream URL: ${streamUrl}`,
);
if (localPath) {
// A downloaded file is a direct play over a local transport, served
// by the loopback media server rather than the asset protocol
// (DR-137). Its media-source id still comes from the server, since
// that is what subtitle URLs are keyed by.
selection = await commands.mediaLocalSelection(localPath);
videoNeedsTranscoding = false;
mediaSourceId = (await repo.getPlaybackInfo(id)).mediaSourceId;
log.debug("loadAndPlay: Playing downloaded file from disk");
} else {
// Rust negotiates direct play vs direct stream vs transcode against
// the device profile and the ceiling in force, and returns the
// transport and the media-source id with it. This page no longer
// decides — or separately asks for — any of that.
// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228
selection = await repo.getStreamSelection(id, null, null);
mediaSourceId = selection.mediaSourceId;
// Rust's own verdict — "which kinds count as transcoding" is a
// domain rule, and a direct *stream* is a remux that does not.
videoNeedsTranscoding = selection.needsTranscoding;
log.debug(
`loadAndPlay: ${selection.playbackKind.type} over ${selection.transport.type}`,
);
}
// Set initial position for the video player to seek to after load.
// Use explicit startPosition, or fall back to retrieved progress.
@@ -847,10 +872,10 @@
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if surface === "video" && streamUrl}
{:else if surface === "video" && selection}
<VideoPlayer
media={currentMedia}
{streamUrl}
{selection}
mediaSourceId={mediaSourceId ?? undefined}
initialPosition={videoInitialPosition}
needsTranscoding={videoNeedsTranscoding}