feat(video): render mpv behind the webview, and collapse duplicated helpers

DR-231 with the design the failed reparent forced. mpv's render API draws into
an FBO we own; the texture is composited by `gdk_cairo_draw_from_gl()` in the
default vbox's own `draw` handler. GTK draws a container before its children, so
the webview lands on top for free — no reparenting, no GtkOverlay, and nothing a
Tauri upgrade can invalidate by assuming its own widget layout.

Split so Windows inherits the useful half: `mpv_render` is the portable side
(render context, framebuffer, GL resolution) and `video_surface` is the GTK side
that consumes it. Nothing in the former is GTK-aware.

Three things the spike paid for, carried over rather than rediscovered:

  - libepoxy exports GL entry points as *data* symbols. `dlsym("epoxy_glFoo")`
    returns the address *of a function pointer*, not of code — returning it
    makes mpv jump into non-executable data and take SIGSEGV on the first GL
    call. The value is read out of that location instead.
  - Frame pacing goes through mpv's update callback plus `report_swap`. Its
    absence looks like a GPU or compositing limit (fine in a window, judders at
    fullscreen) and is neither.
  - The render context is created on `realize` and destroyed on `unrealize`,
    with the update callback unregistered *before* the free, so a callback
    cannot land on a freed pointer. That is DR-232 built in from the start
    rather than retrofitted: the spike had no teardown at all, which remains the
    likeliest explanation for the one SIGSEGV it could not reproduce.

Writing it also caught a bug that would have looked like severe stutter: the
update callback flagged a new frame but never asked GTK to repaint, so decoded
frames would only have reached the screen when something else happened to
invalidate the widget.

Still off by default behind JELLYTAU_NATIVE_VIDEO=1. It compiles and is wired;
no frame has been put on screen yet.

Redundant code, continued. `formatSecondsDuration` had no caller. Three
components had hand-rolled `formatDuration`: Queue's was byte-equivalent to the
shared "mm:ss", while EpisodeFocusView and the library page shared an identical
"1h 23m" shape the util did not offer — so that format joins the other two and
all three components now call one function.

A survey for exported symbols referenced only by tests returns 23 more. They are
deliberately left: spot-checking found `setLogForwarder` is the injection seam
for a lazily-initialised forwarder, and `getCachedImageUrl` is the read path of
a thumbnail cache whose management UI exists in Settings. Neither is dead — one
is test infrastructure and the other is an unwired feature, and deleting either
would remove capability while looking like tidying. The list is worth working
through deliberately, not in a playback branch.
This commit is contained in:
2026-08-22 13:45:04 +02:00
parent 7545de6cc7
commit 45144cb6b0
11 changed files with 1471 additions and 1010 deletions
@@ -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 } }>,
) {
+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")}`;
}