aboutsummaryrefslogtreecommitdiff
path: root/ui/client-scripts
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2026-01-18 23:43:12 +0100
committermetamuffin <metamuffin@disroot.org>2026-01-18 23:43:12 +0100
commited19a428cb5eef84c8cf3fed5fda3afd5fc96305 (patch)
tree39e3167a4f8b7423a15b3a5f56e973554bdb3195 /ui/client-scripts
parent901dff07ed357694eb35284a58c3cc6c003c53ce (diff)
downloadjellything-ed19a428cb5eef84c8cf3fed5fda3afd5fc96305.tar
jellything-ed19a428cb5eef84c8cf3fed5fda3afd5fc96305.tar.bz2
jellything-ed19a428cb5eef84c8cf3fed5fda3afd5fc96305.tar.zst
Move client scripts to build-crate
Diffstat (limited to 'ui/client-scripts')
-rw-r--r--ui/client-scripts/Cargo.toml9
-rw-r--r--ui/client-scripts/build.rs30
-rw-r--r--ui/client-scripts/src/backbutton.ts14
-rw-r--r--ui/client-scripts/src/dangerbutton.ts14
-rw-r--r--ui/client-scripts/src/import_live.ts64
-rw-r--r--ui/client-scripts/src/lib.rs13
-rw-r--r--ui/client-scripts/src/log_live.ts22
-rw-r--r--ui/client-scripts/src/main.ts12
-rw-r--r--ui/client-scripts/src/player/download.ts49
-rw-r--r--ui/client-scripts/src/player/mediacaps.ts79
-rw-r--r--ui/client-scripts/src/player/mod.ts390
-rw-r--r--ui/client-scripts/src/player/player.ts173
-rw-r--r--ui/client-scripts/src/player/popup.ts72
-rw-r--r--ui/client-scripts/src/player/sync.ts163
-rw-r--r--ui/client-scripts/src/player/track/create.ts15
-rw-r--r--ui/client-scripts/src/player/track/mod.ts25
-rw-r--r--ui/client-scripts/src/player/track/mse.ts208
-rw-r--r--ui/client-scripts/src/player/track/vtt.ts96
-rw-r--r--ui/client-scripts/src/player/types_node.ts76
-rw-r--r--ui/client-scripts/src/player/types_stream.ts35
-rw-r--r--ui/client-scripts/src/transition.ts108
21 files changed, 1667 insertions, 0 deletions
diff --git a/ui/client-scripts/Cargo.toml b/ui/client-scripts/Cargo.toml
new file mode 100644
index 0000000..1332388
--- /dev/null
+++ b/ui/client-scripts/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "jellyui-client-scripts"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+
+[build-dependencies]
+glob = "0.3.3"
diff --git a/ui/client-scripts/build.rs b/ui/client-scripts/build.rs
new file mode 100644
index 0000000..256fa21
--- /dev/null
+++ b/ui/client-scripts/build.rs
@@ -0,0 +1,30 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+#![feature(exit_status_error)]
+use std::process::{Command, Stdio};
+
+fn main() {
+ println!("cargo:rerun-if-changed=build.rs");
+ for file in glob::glob("src/**/*.ts")
+ .unwrap()
+ .map(Result::unwrap)
+ {
+ println!("cargo:rerun-if-changed={}", file.to_str().unwrap());
+ }
+ let outpath = std::env::var("OUT_DIR").unwrap();
+ // this is great :)))
+ let mut proc = Command::new("esbuild")
+ .arg("src/main.ts")
+ .arg("--bundle")
+ .arg(format!("--outfile={outpath}/bundle.js"))
+ .arg("--target=esnext")
+ .arg("--sourcemap")
+ .arg("--format=esm")
+ .stderr(Stdio::piped())
+ .spawn()
+ .unwrap();
+ proc.wait().unwrap().exit_ok().unwrap();
+}
diff --git a/ui/client-scripts/src/backbutton.ts b/ui/client-scripts/src/backbutton.ts
new file mode 100644
index 0000000..28a889a
--- /dev/null
+++ b/ui/client-scripts/src/backbutton.ts
@@ -0,0 +1,14 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+import { e } from "./jshelper/mod.ts";
+
+globalThis.addEventListener("DOMContentLoaded", () => {
+ document.getElementsByTagName("nav").item(0)?.prepend(
+ e("a", e("p", "Back"), { class: ["back", "hybrid_button"], onclick() { history.back() } })
+ )
+})
+
diff --git a/ui/client-scripts/src/dangerbutton.ts b/ui/client-scripts/src/dangerbutton.ts
new file mode 100644
index 0000000..b33b3dc
--- /dev/null
+++ b/ui/client-scripts/src/dangerbutton.ts
@@ -0,0 +1,14 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+globalThis.addEventListener("DOMContentLoaded", () => {
+ document.querySelectorAll("input.danger").forEach(el => {
+ el.addEventListener("click", ev => {
+ if (!confirm(`Really ${(el as HTMLInputElement).value}?`)) {
+ ev.preventDefault()
+ }
+ })
+ })
+})
diff --git a/ui/client-scripts/src/import_live.ts b/ui/client-scripts/src/import_live.ts
new file mode 100644
index 0000000..7e5209c
--- /dev/null
+++ b/ui/client-scripts/src/import_live.ts
@@ -0,0 +1,64 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+
+import { OVar } from "./jshelper/mod.ts";
+import { e } from "./jshelper/src/element.ts";
+
+interface ImportProgress {
+ total_items: number
+ finished_items: number
+ tasks: string[]
+}
+
+function progress_bar(progress: OVar<number>, text: OVar<string>): HTMLElement {
+ const bar_inner = e("div")
+ bar_inner.style.height = "100%"
+ bar_inner.style.backgroundColor = "#444"
+ bar_inner.style.position = "absolute"
+ bar_inner.style.top = "0px"
+ bar_inner.style.left = "0px"
+ bar_inner.style.zIndex = "2"
+ const bar_text = e("div")
+ bar_text.style.position = "absolute"
+ bar_text.style.top = "0px"
+ bar_text.style.left = "0px"
+ bar_text.style.color = "white"
+ bar_text.style.zIndex = "3"
+ const bar_outer = e("div", bar_inner, bar_text)
+ bar_outer.style.position = "relative"
+ bar_outer.style.width = "100%"
+ bar_outer.style.height = "2em"
+ bar_outer.style.backgroundColor = "black"
+ bar_outer.style.borderRadius = "5px"
+ progress.onchangeinit(v => bar_inner.style.width = `${v * 100}%`)
+ text.onchangeinit(v => bar_text.textContent = v)
+ return bar_outer
+}
+
+globalThis.addEventListener("DOMContentLoaded", () => {
+ if (!document.getElementById("admin_import")) return
+ const el = document.getElementById("admin_import")!
+
+ const ws = new WebSocket(`/admin/import`)
+ ws.onopen = () => console.log("live progress connected");
+ ws.onclose = () => console.log("live progress disconnected");
+ ws.onerror = e => console.log("live progress ws error", e);
+
+
+ const progress = new OVar(0)
+ const text = new OVar("")
+ const pre = e("pre")
+ el.append(progress_bar(progress, text), pre)
+
+ ws.onmessage = msg => {
+ if (msg.data == "done") return location.reload()
+ const p: ImportProgress = JSON.parse(msg.data)
+ text.value = `${p.finished_items} / ${p.total_items}`
+ progress.value = p.finished_items / p.total_items
+ pre.textContent = p.tasks.map((e, i) => `thread ${("#" + i).padStart(3)}: ${e}`).join("\n")
+ }
+})
diff --git a/ui/client-scripts/src/lib.rs b/ui/client-scripts/src/lib.rs
new file mode 100644
index 0000000..408799a
--- /dev/null
+++ b/ui/client-scripts/src/lib.rs
@@ -0,0 +1,13 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+use std::borrow::Cow;
+
+pub fn js_bundle() -> Cow<'static, str> {
+ include_str!(concat!(env!("OUT_DIR"), "/bundle.js")).into()
+}
+pub fn js_bundle_map() -> Cow<'static, str> {
+ include_str!(concat!(env!("OUT_DIR"), "/bundle.js.map")).into()
+}
diff --git a/ui/client-scripts/src/log_live.ts b/ui/client-scripts/src/log_live.ts
new file mode 100644
index 0000000..b8af11e
--- /dev/null
+++ b/ui/client-scripts/src/log_live.ts
@@ -0,0 +1,22 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+globalThis.addEventListener("DOMContentLoaded", () => {
+ if (!document.body.classList.contains("admin_log")) return
+ const log = document.getElementById("log")!
+
+ const warnonly = new URL(globalThis.location.href).searchParams.get("warnonly") == "true"
+ const ws = new WebSocket(`/admin/log?stream&warnonly=${warnonly}&html=true`)
+ ws.onopen = () => console.log("live log connected");
+ ws.onclose = () => console.log("live log disconnected");
+ ws.onerror = e => console.log(`live log ws error: ${e}`);
+
+ ws.onmessage = msg => {
+ log.children[0].children[0].innerHTML += msg.data
+ while (log.children[0].children[0].children.length > 1024)
+ log.children[0].children[0].children[0].remove()
+ }
+})
diff --git a/ui/client-scripts/src/main.ts b/ui/client-scripts/src/main.ts
new file mode 100644
index 0000000..303ac71
--- /dev/null
+++ b/ui/client-scripts/src/main.ts
@@ -0,0 +1,12 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+import "./player/mod.ts"
+import "./transition.ts"
+import "./backbutton.ts"
+import "./dangerbutton.ts"
+import "./log_live.ts"
+import "./import_live.ts"
diff --git a/ui/client-scripts/src/player/download.ts b/ui/client-scripts/src/player/download.ts
new file mode 100644
index 0000000..1c42bad
--- /dev/null
+++ b/ui/client-scripts/src/player/download.ts
@@ -0,0 +1,49 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+import { OVar } from "../jshelper/mod.ts";
+
+interface Measurement { time: number, duration: number, size: number }
+export class SegmentDownloader {
+ private measurements: Measurement[] = []
+
+ public bandwidth_avail = new OVar(Infinity)
+ public bandwidth_used = new OVar(Infinity)
+ public total_downloaded = new OVar(0)
+
+ constructor() { }
+
+ async download(url: string): Promise<ArrayBuffer> {
+ const dl_start = performance.now();
+ const res = await fetch(url)
+ const dl_header = performance.now();
+ if (!res.ok) throw new Error("aaaaaa");
+ const buf = await res.arrayBuffer()
+ const dl_body = performance.now();
+
+ this.total_downloaded.value += buf.byteLength
+ if (buf.byteLength > 100 * 1000) {
+ const m = {
+ time: dl_start,
+ duration: (dl_body - dl_header) / 1000,
+ size: buf.byteLength
+ }
+ this.measurements.push(m)
+ this.update_bandwidth()
+ }
+ return buf;
+ }
+
+ update_bandwidth() {
+ while (this.measurements.length > 32)
+ this.measurements.splice(0, 1)
+ const total_elapsed = (performance.now() - this.measurements.reduce((a, v) => Math.min(a, v.time), 0)) / 1000;
+ const total_size = this.measurements.reduce((a, v) => v.size + a, 0)
+ const total_duration = this.measurements.reduce((a, v) => v.duration + a, 0)
+ this.bandwidth_avail.value = total_size / total_duration
+ this.bandwidth_used.value = total_size / total_elapsed
+ }
+}
diff --git a/ui/client-scripts/src/player/mediacaps.ts b/ui/client-scripts/src/player/mediacaps.ts
new file mode 100644
index 0000000..9b0e934
--- /dev/null
+++ b/ui/client-scripts/src/player/mediacaps.ts
@@ -0,0 +1,79 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+
+import { FormatInfo, StreamContainer } from "./types_stream.ts";
+
+const cache = new Map<string, boolean>()
+
+// TODO this testing method makes the assumption, that if the codec is supported on its own, it can be
+// TODO arbitrarly combined with others that are supported. in reality this is true but the spec does not gurantee it.
+
+export async function test_media_capability(format: FormatInfo, container: StreamContainer): Promise<boolean> {
+ const cache_key = JSON.stringify(format) + container
+ const cached = cache.get(cache_key);
+ if (cached !== undefined) return cached
+ const r = await test_media_capability_inner(format, container)
+ console.log(`${r ? "positive" : "negative"} media capability test finished for codec=${format.codec}`);
+ cache.set(cache_key, r)
+ return r
+}
+async function test_media_capability_inner(format: FormatInfo, container: StreamContainer) {
+ if (format.codec.startsWith("S_") || format.codec.startsWith("D_")) {
+ // TODO do we need to check this?
+ return format.codec == "S_TEXT/WEBVTT" || format.codec == "S_TEXT/UTF8" || format.codec == "D_WEBVTT/SUBTITLES"
+ }
+ let res;
+ if (format.codec.startsWith("A_")) {
+ res = await navigator.mediaCapabilities.decodingInfo({
+ type: "media-source",
+ audio: {
+ contentType: track_to_content_type(format, container),
+ samplerate: format.samplerate,
+ channels: "" + format.channels,
+ bitrate: format.bitrate,
+ }
+ })
+ }
+ if (format.codec.startsWith("V_")) {
+ res = await navigator.mediaCapabilities.decodingInfo({
+ type: "media-source",
+ video: {
+ contentType: track_to_content_type(format, container),
+ framerate: 30, // TODO get average framerate from server
+ width: format.width ?? 1920,
+ height: format.height ?? 1080,
+ bitrate: format.bitrate
+ }
+ })
+ }
+ return res?.supported ?? false
+}
+
+export function track_to_content_type(format: FormatInfo, container: StreamContainer): string {
+ let c = CONTAINER_TO_MIME_TYPE[container];
+ if (format.codec.startsWith("A_")) c = c.replace("video/", "audio/")
+ return `${c}; codecs="${MASTROSKA_CODEC_MAP[format.codec]}"`
+}
+
+const MASTROSKA_CODEC_MAP: { [key: string]: string } = {
+ "V_VP9": "vp9",
+ "V_VP8": "vp8",
+ "V_AV1": "av1",
+ "V_MPEG4/ISO/AVC": "avc1.42C01F",
+ "V_MPEGH/ISO/HEVC": "hev1.1.6.L93.90",
+ "A_OPUS": "opus",
+ "A_VORBIS": "vorbis",
+ "S_TEXT/WEBVTT": "webvtt",
+ "D_WEBVTT/SUBTITLES": "webvtt",
+}
+const CONTAINER_TO_MIME_TYPE: { [key in StreamContainer]: string } = {
+ webvtt: "text/webvtt",
+ webm: "video/webm",
+ matroska: "video/x-matroska",
+ mpeg4: "video/mp4",
+ jvtt: "application/jellything-vtt+json"
+}
diff --git a/ui/client-scripts/src/player/mod.ts b/ui/client-scripts/src/player/mod.ts
new file mode 100644
index 0000000..dc9e51d
--- /dev/null
+++ b/ui/client-scripts/src/player/mod.ts
@@ -0,0 +1,390 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+import { OVar, show } from "../jshelper/mod.ts";
+import { e } from "../jshelper/mod.ts";
+import { Logger } from "../jshelper/src/log.ts";
+import { Player } from "./player.ts";
+import { Popup } from "./popup.ts";
+import { Playersync, playersync_controls } from "./sync.ts"
+import { Chapter, NodePublic, NodeUserData } from "./types_node.ts";
+import { FormatInfo, TrackKind } from "./types_stream.ts";
+
+globalThis.addEventListener("DOMContentLoaded", () => {
+ if (document.body.classList.contains("player")) {
+ if (globalThis.location.search.search("nojsp") != -1) return
+ if (!globalThis.MediaSource) return alert("Media Source Extension API required")
+ const node_id = globalThis.location.pathname.split("/")[2];
+ document.getElementById("player")?.remove();
+ document.getElementsByClassName("playerconf").item(0)?.remove()
+ globalThis.dispatchEvent(new Event("navigationrequiresreload"))
+ document.getElementById("main")!.prepend(initialize_player(node_id))
+ }
+})
+
+const MEDIA_KIND_ICONS: { [key in TrackKind]: [string, string] } = {
+ video: ["tv_off", "tv"],
+ audio: ["volume_off", "volume_up"],
+ subtitle: ["subtitles_off", "subtitles"],
+}
+
+function toggle_fullscreen() {
+ if (document.fullscreenElement) document.exitFullscreen()
+ else document.documentElement.requestFullscreen()
+}
+
+// function get_continue_time(w: WatchedState): number {
+// if (typeof w == "string") return 0
+// else return w.progress
+// }
+
+function get_query_start_time() {
+ const u = new URL(globalThis.location.href)
+ const p = u.searchParams.get("t")
+ if (!p) return
+ const x = parseFloat(p)
+ if (Number.isNaN(x)) return
+ return x
+}
+
+function initialize_player(node_id: string): HTMLElement {
+ const logger = new Logger<string>(s => e("p", s))
+ const start_time = get_query_start_time() ?? 0 // TODO get_continue_time(ndata.userdata.watched);
+ const player = new Player(`/n/${encodeURIComponent(node_id)}/stream`, `/n/${encodeURIComponent(node_id)}/poster`, start_time, logger)
+ const show_stats = new OVar(false);
+ const idle_inhibit = new OVar(false)
+ const sync_state = new OVar<Playersync | undefined>(undefined)
+ const chapters = new OVar<Chapter[]>([])
+
+ fetch(`/n/${node_id}`, { headers: { Accept: "application/json" } })
+ .then(res => {
+ if (!res.ok) throw "a"
+ return res.json()
+ })
+ .catch(() => logger.log_persistent("Node data failed to download"))
+ .then(ndata_ => {
+ const ndata = ndata_ as { node: NodePublic, userdata: NodeUserData }
+ console.log(ndata.node.media!.chapters);
+ chapters.value = ndata.node.media!.chapters
+ })
+
+ //@ts-ignore for debugging
+ globalThis.player = player;
+
+ let mute_saved_volume = 1;
+ const toggle_mute = () => {
+ if (player.volume.value == 0) {
+ logger.log("Unmuted.", "volume");
+ player.volume.value = mute_saved_volume
+ }
+ else {
+ logger.log("Muted.", "volume");
+ mute_saved_volume = player.volume.value
+ player.volume.value = 0.
+ }
+ }
+ const toggle_playing = () => player.playing.value ? player.pause() : player.play()
+ const pri_map = (v: number) => (v / player.duration.value * 100) + "%"
+
+ let pri_current: HTMLElement;
+ let pri: HTMLElement;
+
+ const popups = e("div")
+
+ const step_track_kind = (kind: TrackKind) => {
+ // TODO cycle through all of them
+ const active = player.active_tracks.value.filter(
+ ts => player.tracks![ts.track_index].kind == kind)
+ if (active.length > 0) {
+ for (const t of active) player.set_track_enabled(t.track_index, false)
+ } else {
+ const all_kind = (player.tracks ?? [])
+ .map((track, index) => ({ index, track }))
+ .filter(({ track }) => track.kind == kind)
+ if (all_kind.length < 1) return logger.log(`No ${kind} tracks available`)
+ player.set_track_enabled(all_kind[0].index, true)
+ }
+ }
+
+ const quit = () => {
+ globalThis.history.back()
+ setTimeout(() => globalThis.close(), 10)
+ }
+
+ const track_select = (kind: TrackKind) => {
+ const button = e("div", player.active_tracks.map(_ => {
+ const active = player.active_tracks.value.filter(
+ ts => player.tracks![ts.track_index].kind == kind)
+ const enabled = active.length > 0
+ return e("button", MEDIA_KIND_ICONS[kind][+enabled], {
+ class: "icon",
+ aria_label: `configure ${kind}`,
+ onclick: () => {
+ if (enabled) {
+ for (const t of active) {
+ player.set_track_enabled(t.track_index, false)
+ }
+ } else {
+ const all_kind = (player.tracks ?? [])
+ .map((track, index) => ({ index, track }))
+ .filter(({ track }) => track.kind == kind)
+ if (all_kind.length < 1) return
+ player.set_track_enabled(all_kind[0].index, true)
+ }
+ }
+ })
+ }))
+ const volume = () => {
+ const slider = e("input")
+ slider.type = "range"
+ slider.min = "0"
+ slider.max = "1"
+ slider.step = "any"
+ slider.valueAsNumber = Math.cbrt(player.video.volume)
+ const slider_mapping = (x: number) => x * x * x
+ slider.onchange = () => player.video.volume = slider_mapping(slider.valueAsNumber)
+ slider.onmousemove = () => player.video.volume = slider_mapping(slider.valueAsNumber)
+ return [e("div", { class: ["jsp-controlgroup", "jsp-volumecontrol"] },
+ e("label", `Volume`),
+ e("span", { class: "jsp-volume" }, player.volume.map(v => show_volume(v))),
+ slider
+ )]
+ }
+
+ new Popup(button, popups, () =>
+ e("div", { class: "jsp-track-select-popup" },
+ e("h2", `${kind[0].toUpperCase()}${kind.substring(1)}`),
+
+ ...(kind == "audio" ? volume() : []),
+
+ player.active_tracks.map(_ => {
+ const tracks_avail = (player.tracks ?? [])
+ .map((track, index) => ({ index, track }))
+ .filter(({ track }) => track.kind == kind);
+ if (!tracks_avail.length) return e("p", `No ${kind} tracks available.`) as HTMLElement;
+ return e("ul", { class: "jsp-track-list" }, ...tracks_avail
+ .map(({ track, index }): HTMLElement => {
+ const active = player.active_tracks.value.find(ts => ts.track_index == index) !== undefined
+ const onclick = () => {
+ player.set_track_enabled(index, !active)
+ // TODO show loading indicator
+ }
+ return e("li", { class: active ? ["active"] : [] },
+ e("button", { class: ["jsp-track-state", "icon"], onclick }, active ? "remove" : "add"), " ",
+ e("span", { class: "jsp-track-name" }, `"${track.name}"`), " ",
+ e("span", { class: "jsp-track-lang" }, `(${track.language})`)
+ )
+ })
+ )
+ })
+ )
+ )
+ return button
+ }
+
+ const settings_popup = () => {
+ const button = e("button", "settings", { class: "icon", aria_label: "settings" })
+ new Popup(button, popups, () => e("div", { class: "jsp-settings-popup" },
+ e("h2", "Settings"),
+ playersync_controls(sync_state, player),
+ e("button", "Launch Native Player", {
+ onclick: () => {
+ globalThis.location.href = `?kind=nativefullscreen&t=${player.position.value}`
+ }
+ })
+ ))
+ return button;
+ }
+
+ const controls = e("div", { class: "jsp-controls" },
+ player.playing.map(playing =>
+ e("button", { class: "icon", aria_label: "toggle pause/play" }, playing ? "pause" : "play_arrow", { onclick: toggle_playing })
+ ),
+ e("p", { class: "jsp-status" },
+ player.position.map(v => e("span", show.duration(v))), e("br"),
+ player.position.map(v => e("span", show.duration(v - player.duration.value)))
+ ),
+ pri = e("div", { class: "jsp-pri" },
+ pri_current = e("div", { class: "jsp-pri-current" }),
+ chapters.liftA2(player.duration,
+ (chapters, duration) => duration == 0 ? e("div") : e("div", ...chapters.map(chap => e("div", {
+ class: "jsp-chapter",
+ style: {
+ left: pri_map(chap.time_start ?? 0),
+ width: pri_map((chap.time_end ?? duration) - (chap.time_start ?? 0))
+ }
+ }, e("p", chap.labels[0][1]))))
+ ),
+ player.active_tracks.map(
+ tracks => e("div", ...tracks.map((t, i) => t.buffered.map(
+ ranges => e("div", ...ranges.map(
+ r => e("div", {
+ class: ["jsp-pri-buffer", `jsp-pri-buffer-${r.status}`],
+ style: {
+ width: pri_map(r.end - r.start),
+ height: `calc(var(--csize)/${tracks.length})`,
+ top: `calc(var(--csize)/${tracks.length}*${i})`,
+ left: pri_map(r.start)
+ }
+ })
+ ))
+ )))
+ )
+ ),
+ e("div", { class: "jsp-track-select" },
+ track_select("video"),
+ track_select("audio"),
+ track_select("subtitle")
+ ),
+ settings_popup(),
+ e("button", "fullscreen", { class: "icon", onclick: toggle_fullscreen, aria_label: "fullscreen" })
+ )
+
+ player.position.onchangeinit(p => pri_current.style.width = pri_map(p))
+
+ const pel = e("div", { class: "jsp" },
+ player.video,
+ show_stats.map(do_show => e("div", player.active_tracks.map(tracks =>
+ !do_show ? e("div") : e("div", { class: "jsp-stats" },
+ player.downloader.bandwidth_avail.map(b => e("pre", `estimated available bandwidth: ${show.metric(b, "B/s")} | ${show.metric(b * 8, "b/s")}`)),
+ player.downloader.bandwidth_used.map(b => e("pre", `estimated used bandwidth: ${show.metric(b, "B/s")} | ${show.metric(b * 8, "b/s")}`)),
+ player.downloader.total_downloaded.map(b => e("pre", `downloaded bytes total: ${show.metric(b, "B")}`)),
+ player.position.map(_ => e("pre", `frames dropped: ${player.video.getVideoPlaybackQuality().droppedVideoFrames}`)),
+ OVar.interval(() => e("pre", ""
+ + `video resolution: source: ${player.video.videoWidth}x${player.video.videoHeight}\n`
+ + ` display: ${player.video.clientWidth}x${player.video.clientHeight}`), 100),
+ ...tracks.map(t => t.debug())
+ )
+ ))),
+ logger.element,
+ popups,
+ controls,
+ )
+
+ controls.onmouseenter = () => idle_inhibit.value = true
+ controls.onmouseleave = () => idle_inhibit.value = false
+ mouse_idle(pel, 1000)
+ .liftA2(idle_inhibit, (x, y) => x && !y)
+ .onchangeinit(idle => {
+ controls.style.opacity = idle ? "0" : "1"
+ pel.style.cursor = idle ? "none" : "default"
+ })
+
+ player.video.addEventListener("click", toggle_playing)
+ pri.addEventListener("mousedown", ev => {
+ const r = pri.getBoundingClientRect()
+ const p = (ev.clientX - r.left) / (r.right - r.left)
+ player.seek(p * player.duration.value)
+ })
+
+ document.body.addEventListener("keydown", k => {
+ if (k.ctrlKey || k.altKey || k.metaKey) return
+ if (k.code == "Period") player.pause(), player.frame_forward()
+ else if (k.code == "Space") toggle_playing()
+ else if (k.code == "KeyP") toggle_playing()
+ else if (k.code == "KeyF") toggle_fullscreen()
+ else if (k.code == "KeyQ") quit()
+ else if (k.code == "KeyS") screenshot_video(player.video)
+ else if (k.code == "KeyJ") step_track_kind("subtitle")
+ else if (k.code == "KeyM") toggle_mute()
+ else if (k.code == "Digit9") (player.volume.value /= 1.2), logger.log(`Volume decreased to ${show_volume(player.volume.value)}`, "volume")
+ else if (k.code == "Digit0") (player.volume.value *= 1.2), logger.log(`Volume increased to ${show_volume(player.volume.value)}`, "volume")
+ else if (k.key == "#") step_track_kind("audio")
+ else if (k.key == "_") step_track_kind("video")
+ else if (k.code == "KeyV") show_stats.value = !show_stats.value
+ else if (k.code == "ArrowLeft") player.seek(player.position.value - 5)
+ else if (k.code == "ArrowRight") player.seek(player.position.value + 5)
+ else if (k.code == "ArrowUp") player.seek(player.position.value - 60)
+ else if (k.code == "ArrowDown") player.seek(player.position.value + 60)
+ else if (k.code == "PageUp") player.seek(find_closest_chaps(player.position.value, chapters.value).prev?.time_start ?? 0)
+ else if (k.code == "PageDown") player.seek(find_closest_chaps(player.position.value, chapters.value).next?.time_start ?? player.duration.value)
+ else return;
+ k.preventDefault()
+ })
+ send_player_progress(node_id, player)
+
+ return pel
+}
+
+function screenshot_video(video: HTMLVideoElement) {
+ // TODO bug: video needs to be played to take a screenshot. if you have just seeked somewhere it wont work.
+ const canvas = document.createElement("canvas")
+ canvas.width = video.videoWidth
+ canvas.height = video.videoHeight
+ const context = canvas.getContext("2d")!
+ context.fillStyle = "#ff00ff"
+ context.fillRect(0, 0, video.videoWidth, video.videoHeight)
+ context.drawImage(video, 0, 0)
+ canvas.toBlob(blob => {
+ if (!blob) throw new Error("failed to create blob");
+ const a = document.createElement("a");
+ a.download = "screenshot.webp";
+ a.href = globalThis.URL.createObjectURL(blob)
+ a.click()
+ setTimeout(() => URL.revokeObjectURL(a.href), 0)
+ }, "image/webp", 0.95)
+}
+
+let sent_watched = false;
+function send_player_progress(node_id: string, player: Player) {
+ let t = 0;
+ setInterval(() => {
+ const nt = player.video.currentTime
+ if (t != nt) {
+ t = nt
+ const start = nt < 1 * 60
+ const end = nt > player.duration.value - 5 * 60
+
+ if (!start) fetch(`/n/${encodeURIComponent(node_id)}/progress?t=${nt}`, { method: "POST" })
+ if (end && !sent_watched) {
+ fetch(`/n/${encodeURIComponent(node_id)}/watched?state=watched`, { method: "POST" })
+ sent_watched = true;
+ }
+ }
+ }, 10000)
+}
+
+function mouse_idle(e: HTMLElement, timeout: number): OVar<boolean> {
+ let ct: number;
+ const idle = new OVar(false)
+ e.onmouseleave = () => {
+ clearTimeout(ct)
+ }
+ e.onmousemove = () => {
+ clearTimeout(ct)
+ if (idle) {
+ idle.value = false
+ }
+ ct = setTimeout(() => {
+ idle.value = true
+ }, timeout)
+ }
+ return idle
+}
+
+export function show_format(format: FormatInfo): string {
+ let o = `${format.codec} br=${show.metric(format.bitrate, "b/s")} ac=${format.containers.join(",")}`
+ if (format.width) o += ` w=${format.width}`
+ if (format.height) o += ` h=${format.height}`
+ if (format.samplerate) o += ` ar=${show.metric(format.samplerate, "Hz")}`
+ if (format.channels) o += ` ac=${format.channels}`
+ if (format.bit_depth) o += ` bits=${format.bit_depth}`
+ return o
+}
+export function show_volume(v: number): string {
+ return `${v == 0 ? "-∞" : (Math.log10(v) * 10).toFixed(2)}dB | ${(v * 100).toFixed(2)}%`
+}
+
+function find_closest_chaps(position: number, chapters: Chapter[]) {
+ let prev, next;
+ for (const c of chapters) {
+ const t_start = (c.time_start ?? 0)
+ next = c;
+ if (t_start > position) break
+ prev = c;
+ }
+ return { next, prev }
+}
diff --git a/ui/client-scripts/src/player/player.ts b/ui/client-scripts/src/player/player.ts
new file mode 100644
index 0000000..4f59f8a
--- /dev/null
+++ b/ui/client-scripts/src/player/player.ts
@@ -0,0 +1,173 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+import { OVar, e } from "../jshelper/mod.ts";
+import { SegmentDownloader } from "./download.ts";
+import { PlayerTrack } from "./track/mod.ts";
+import { Logger } from "../jshelper/src/log.ts";
+import { create_track } from "./track/create.ts";
+import { StreamInfo, TimeRange, TrackInfo } from "./types_stream.ts";
+
+export interface BufferRange extends TimeRange { status: "buffered" | "loading" | "queued" }
+export class Player {
+ public video = e("video")
+ public media_source = new MediaSource();
+ public streaminfo?: StreamInfo;
+ public tracks?: TrackInfo[];
+ public active_tracks = new OVar<PlayerTrack[]>([]);
+ public downloader: SegmentDownloader = new SegmentDownloader();
+
+ public position = new OVar(0)
+ public duration = new OVar(1)
+ public volume = new OVar(0)
+ public playing = new OVar(false)
+ public canplay = new OVar(false)
+ public error = new OVar<string | undefined>(undefined)
+
+ private cancel_buffering_pers: undefined | (() => void)
+ set_pers(s?: string) {
+ if (this.cancel_buffering_pers) this.cancel_buffering_pers(), this.cancel_buffering_pers = undefined
+ if (s) this.cancel_buffering_pers = this.logger?.log_persistent(s)
+ }
+
+ constructor(public base_url: string, poster: string, private start_time: number, public logger?: Logger<string>) {
+ this.video.poster = poster
+ this.volume.value = this.video.volume
+ let skip_change = false;
+ this.volume.onchange(v => {
+ if (v > 1.) return this.volume.value = 1;
+ if (v < 0.) return this.volume.value = 0;
+ if (!skip_change) this.video.volume = v
+ skip_change = false
+ })
+ this.video.onvolumechange = () => {
+ skip_change = true;
+ this.volume.value = this.video.volume
+ }
+
+ this.video.onloadedmetadata = () => { }
+ this.video.ondurationchange = () => { }
+ this.video.ontimeupdate = () => {
+ this.position.value = this.video.currentTime
+ this.update() // TODO maybe not here
+ }
+ this.video.onplay = () => {
+ console.log("play");
+ this.set_pers("Resuming playback...")
+ }
+ this.video.onwaiting = () => {
+ console.log("waiting");
+ if (this.video.currentTime > this.duration.value - 0.2) return this.set_pers("Playback finished")
+ this.set_pers("Buffering...")
+ this.canplay.value = false;
+ }
+ this.video.onplaying = () => {
+ console.log("playing");
+ this.playing.value = true;
+ this.set_pers()
+ }
+ this.video.onpause = () => {
+ console.log("pause");
+ this.playing.value = false
+ }
+ this.video.oncanplay = () => {
+ console.log("canplay");
+ this.set_pers()
+ this.canplay.value = true
+ }
+ this.video.onseeking = () => {
+ console.log("seeking");
+ this.set_pers("Seeking...")
+ }
+ this.video.onseeked = () => {
+ console.log("seeked");
+ this.set_pers()
+ }
+ this.video.onerror = e => {
+ console.error("video element error:", e);
+ this.set_pers("MSE crash -_-");
+ }
+ this.video.onabort = e => {
+ console.error("video element abort:", e);
+ this.set_pers("Aborted");
+ }
+ this.fetch_meta()
+ }
+
+ async fetch_meta() {
+ this.set_pers("Loading stream metadata...")
+ const res = await fetch(`${this.base_url}?info`, { headers: { "Accept": "application/json" } })
+ if (!res.ok) return this.error.value = "Cannot download stream info."
+
+ let streaminfo!: StreamInfo & { error: string }
+ try { streaminfo = await res.json() }
+ catch (_) { this.set_pers("Error: Node data invalid") }
+ if (streaminfo.error) return this.set_pers("server error: " + streaminfo.error)
+
+ this.set_pers()
+ //! bad code: assignment order is important because chapter callbacks use duration
+ this.duration.value = streaminfo.duration
+ this.streaminfo = streaminfo
+ this.tracks = streaminfo!.tracks;
+ console.log("aaa", this.tracks);
+ this.video.src = URL.createObjectURL(this.media_source)
+ this.media_source.addEventListener("sourceopen", async () => {
+ let video = false, audio = false, subtitle = false;
+ for (let i = 0; i < this.tracks!.length; i++) {
+ const t = this.tracks![i];
+ if (t.kind == "video" && !video)
+ video = true, await this.set_track_enabled(i, true, false)
+ if (t.kind == "audio" && !audio)
+ audio = true, await this.set_track_enabled(i, true, false)
+ if (t.kind == "subtitle" && !subtitle)
+ subtitle = true, await this.set_track_enabled(i, true, false)
+ }
+
+ this.set_pers("Buffering initial stream fragments...")
+
+ this.update(this.start_time)
+ this.video.currentTime = this.start_time
+
+ await this.canplay.wait_for(true)
+ this.set_pers()
+ })
+ }
+
+ async update(newt?: number) {
+ await Promise.all(this.active_tracks.value.map(t => t.update(newt ?? this.video.currentTime)))
+ }
+
+ async set_track_enabled(index: number, state: boolean, update = true) {
+ console.log(`(${index}) set enabled ${state}`);
+ const active_index = this.active_tracks.value.findIndex(t => t.track_index == index)
+ if (!state && active_index != -1) {
+ this.logger?.log(`Disabled track ${index}: ${display_track(this.tracks![index])}`)
+ const [track] = this.active_tracks.value.splice(active_index, 1)
+ track.abort.abort()
+ } else if (state && active_index == -1) {
+ this.logger?.log(`Enabled track ${index}: ${display_track(this.tracks![index])}`)
+ this.active_tracks.value.push(create_track(this, this.base_url, index, this.tracks![index])!)
+ if (update) await this.update()
+ }
+ this.active_tracks.change()
+ }
+
+ play() { this.video.play() }
+ pause() { this.video.pause() }
+ frame_forward() {
+ //@ts-ignore trust me bro
+ this.video["seekToNextFrame"]()
+ }
+ async seek(p: number) {
+ this.set_pers("Buffering at target...")
+ await this.update(p)
+ this.video.currentTime = p
+ }
+}
+
+function display_track(t: TrackInfo): string {
+ return `${t.name}`
+}
diff --git a/ui/client-scripts/src/player/popup.ts b/ui/client-scripts/src/player/popup.ts
new file mode 100644
index 0000000..2c406ba
--- /dev/null
+++ b/ui/client-scripts/src/player/popup.ts
@@ -0,0 +1,72 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+
+export class Popup {
+ trigger_hov = false
+ content_hov = false
+ content?: HTMLElement
+ shown = false
+
+ constructor(
+ public trigger: HTMLElement,
+ public container: HTMLElement,
+ public new_content: () => HTMLElement
+ ) {
+ trigger.onmouseenter = () => {
+ this.trigger_hov = true;
+ this.update_hov()
+ }
+ trigger.onmouseleave = () => {
+ this.trigger_hov = false;
+ this.update_hov()
+ }
+ }
+
+ set_shown(s: boolean) {
+ if (this.shown == s) return
+ if (s) {
+ this.content = this.new_content()
+ this.content.addEventListener("mouseenter", () => {
+ this.content_hov = true;
+ this.update_hov()
+ })
+ this.content.addEventListener("mouseleave", () => {
+ this.content_hov = false;
+ this.update_hov()
+ })
+ this.content.classList.add("jsp-popup")
+ this.container.append(this.content)
+ } else {
+ const content = this.content!
+ content.classList.add("jsp-popup-out")
+ setTimeout(() => {
+ //@ts-ignore i know
+ const child_undo: undefined | (() => void) = content["jsh_undo"]
+ if (child_undo) child_undo()
+ this.container.removeChild(content)
+ }, 100)
+ this.content = undefined
+ }
+ this.shown = s
+ }
+
+ hide_timeout?: number
+ update_hov() {
+ if (this.content_hov || this.trigger_hov) {
+ this.set_shown(true)
+ if (this.hide_timeout !== undefined) {
+ clearTimeout(this.hide_timeout)
+ this.hide_timeout = undefined
+ }
+ } else {
+ if (this.hide_timeout === undefined) this.hide_timeout = setTimeout(() => {
+ this.set_shown(false)
+ this.hide_timeout = undefined
+ }, 100)
+ }
+ }
+}
diff --git a/ui/client-scripts/src/player/sync.ts b/ui/client-scripts/src/player/sync.ts
new file mode 100644
index 0000000..5f33a8e
--- /dev/null
+++ b/ui/client-scripts/src/player/sync.ts
@@ -0,0 +1,163 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+import { OVar, e } from "../jshelper/mod.ts";
+import { Logger } from "../jshelper/src/log.ts";
+import { Player } from "./player.ts"
+
+export function playersync_controls(sync_state: OVar<undefined | Playersync>, player: Player) {
+ let channel_name: HTMLInputElement;
+ let channel_name_copy: HTMLInputElement;
+ return e("div", { class: ["jsp-controlgroup", "jsp-playersync-controls"] },
+ e("h3", "Playersync"),
+ sync_state.map(sync => sync
+ ? e("div",
+ e("span", "Sync enabled."),
+ e("button", "Disable", {
+ onclick: () => { sync_state.value?.destroy(); sync_state.value = undefined }
+ }),
+ e("p", "Session ID: ",
+ channel_name_copy = e("input", { type: "text", disabled: true, value: sync.name }),
+ e("button", "content_paste_go", {
+ class: "icon",
+ onclick: () => {
+ player.logger?.log("Session ID copied to clipboard.")
+ navigator.clipboard.writeText(channel_name_copy.value)
+ }
+ })
+ ),
+ e("h4", "Users"),
+ sync.users.map(users =>
+ e("ul", ...[...users.keys()].map(u => e("li", u)))
+ )
+ )
+ : e("div",
+ channel_name = e("input", { type: "text", placeholder: "someroom:example.org" }),
+ e("button", "Join", {
+ onclick: () => {
+ if (!channel_name.value.length) return
+ sync_state.value?.destroy()
+ sync_state.value = new Playersync(player, player.logger!, channel_name.value)
+ }
+ }), e("br"),
+ e("button", "Create new session", {
+ onclick: () => {
+ sync_state.value?.destroy()
+ sync_state.value = new Playersync(player, player.logger!)
+ }
+ }))
+ )
+ )
+}
+
+function get_username() {
+ return document.querySelector("nav .account .username")?.textContent ?? "Unknown User"
+}
+
+interface Packet {
+ time?: number,
+ playing?: boolean,
+ join?: string,
+ leave?: string,
+}
+
+export class Playersync {
+ private ws: WebSocket
+ private on_destroy: (() => void)[] = []
+
+ public name: string
+ public users = new OVar(new Map<string, null>())
+
+ private cancel_pers: undefined | (() => void)
+ set_pers(s?: string) {
+ if (this.cancel_pers) this.cancel_pers(), this.cancel_pers = undefined
+ if (s) this.cancel_pers = this.logger?.log_persistent(s)
+ }
+
+ constructor(private player: Player, private logger: Logger<string>, private channel_name?: string) {
+ this.set_pers("Playersync enabling...")
+
+ channel_name ??= Math.random().toString(16).padEnd(5, "0").substring(2).substring(0, 6)
+ let [localpart, remotepart, port] = channel_name.split(":")
+ if (!remotepart?.length) remotepart = globalThis.location.host
+ if (port) remotepart += ":" + port
+ this.name = localpart + ":" + remotepart
+
+ this.ws = new WebSocket(`${globalThis.location.protocol.endsWith("s:") ? "wss" : "ws"}://${remotepart}/playersync/${encodeURIComponent(localpart)}`)
+ this.on_destroy.push(() => this.ws.close())
+
+ this.ws.onopen = () => {
+ this.set_pers()
+ this.logger.log(`Playersync connected.`)
+ this.send({ join: get_username() })
+ }
+ this.ws.onerror = () => {
+ this.set_pers(`Playersync websocket error.`)
+ }
+ this.ws.onclose = () => {
+ this.set_pers(`Playersync websocket closed.`)
+ }
+
+ let last_time = 0;
+ this.ws.onmessage = ev => {
+ const packet: Packet = JSON.parse(ev.data)
+ console.log("playersync recv", packet);
+ if (packet.time !== undefined) {
+ this.player.seek(packet.time)
+ last_time = packet.time
+ }
+ if (packet.playing === true) this.player.play()
+ if (packet.playing === false) this.player.pause()
+ if (packet.join) {
+ this.logger.log(`${packet.join} joined.`)
+ this.users.value.set(packet.join, null)
+ this.users.change()
+ }
+ if (packet.leave) {
+ this.logger.log(`${packet.leave} left.`)
+ this.users.value.delete(packet.leave)
+ this.users.change()
+ }
+ }
+
+ let cb: () => void
+
+ const send_time = () => {
+ const time = this.player.video.currentTime
+ if (Math.abs(last_time - time) < 0.01) return
+ this.send({ time: this.player.video.currentTime })
+ }
+
+ player.video.addEventListener("play", cb = () => {
+ send_time()
+ this.send({ playing: true })
+ })
+ this.on_destroy.push(() => player.video.removeEventListener("play", cb))
+
+ player.video.addEventListener("pause", cb = () => {
+ this.send({ playing: false })
+ send_time()
+ })
+ this.on_destroy.push(() => player.video.removeEventListener("pause", cb))
+
+ player.video.addEventListener("seeking", cb = () => {
+ send_time()
+ })
+ this.on_destroy.push(() => player.video.removeEventListener("seeking", cb))
+ }
+
+ destroy() {
+ this.set_pers()
+ this.logger.log("Playersync disabled.")
+ this.on_destroy.forEach(f => f())
+ }
+
+ send(p: Packet) {
+ console.log("playersync send", p);
+ this.ws.send(JSON.stringify(p))
+ }
+}
+
diff --git a/ui/client-scripts/src/player/track/create.ts b/ui/client-scripts/src/player/track/create.ts
new file mode 100644
index 0000000..a83a26a
--- /dev/null
+++ b/ui/client-scripts/src/player/track/create.ts
@@ -0,0 +1,15 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+import { VttPlayerTrack } from "./vtt.ts";
+import { MSEPlayerTrack } from "./mse.ts";
+import { Player } from "../player.ts";
+import { PlayerTrack } from "./mod.ts";
+import { TrackInfo } from "../types_stream.ts";
+
+export function create_track(player: Player, base_url: string, track_index: number, track_info: TrackInfo): PlayerTrack | undefined {
+ if (track_info.kind == "subtitle") return new VttPlayerTrack(player, base_url, track_index, track_info)
+ else return new MSEPlayerTrack(player, base_url, track_index, track_info)
+}
diff --git a/ui/client-scripts/src/player/track/mod.ts b/ui/client-scripts/src/player/track/mod.ts
new file mode 100644
index 0000000..57f6820
--- /dev/null
+++ b/ui/client-scripts/src/player/track/mod.ts
@@ -0,0 +1,25 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+import { TimeRange } from "../types_stream.ts";
+import { OVar } from "../../jshelper/mod.ts";
+import { BufferRange } from "../player.ts";
+
+export const TARGET_BUFFER_DURATION = 20
+export const MIN_BUFFER_DURATION = 1
+
+export interface AppendRange extends TimeRange { buf: ArrayBuffer, index: number, cb: () => void }
+
+export abstract class PlayerTrack {
+ constructor(
+ public track_index: number,
+ ) { }
+ public buffered = new OVar<BufferRange[]>([]);
+ public abort = new AbortController()
+ async update(_target: number) { }
+ public abstract debug(): HTMLElement | OVar<HTMLElement>
+}
+
diff --git a/ui/client-scripts/src/player/track/mse.ts b/ui/client-scripts/src/player/track/mse.ts
new file mode 100644
index 0000000..efcd0d5
--- /dev/null
+++ b/ui/client-scripts/src/player/track/mse.ts
@@ -0,0 +1,208 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+import { OVar, show } from "../../jshelper/mod.ts";
+import { test_media_capability, track_to_content_type } from "../mediacaps.ts";
+import { BufferRange, Player } from "../player.ts";
+import { PlayerTrack, AppendRange, TARGET_BUFFER_DURATION, MIN_BUFFER_DURATION } from "./mod.ts";
+import { e } from "../../jshelper/src/element.ts";
+import { FormatInfo, FragmentIndex, StreamContainer, TrackInfo } from "../types_stream.ts";
+import { show_format } from "../mod.ts";
+
+interface UsableFormat { format_index: number, usable_index: number, format: FormatInfo, container: StreamContainer }
+
+export class MSEPlayerTrack extends PlayerTrack {
+ public source_buffer!: SourceBuffer;
+ private current_load?: AppendRange;
+ private loading = new Set<number>();
+ private append_queue: AppendRange[] = [];
+ public index?: FragmentIndex
+ public active_format = new OVar<UsableFormat | undefined>(undefined);
+ public usable_formats: UsableFormat[] = []
+
+ constructor(
+ private player: Player,
+ private base_url: string,
+ track_index: number,
+ public trackinfo: TrackInfo,
+ ) {
+ super(track_index);
+ this.init()
+ }
+
+ async init() {
+ this.buffered.value = [{ start: 0, end: this.player.duration.value, status: "loading" }]
+ try {
+ const res = await fetch(`${this.base_url}?fragmentindex&t=${this.track_index}`, { headers: { "Accept": "application/json" } });
+ if (!res.ok) return this.player.error.value = "Cannot download index.", undefined;
+ let index!: FragmentIndex & { error: string; };
+ try { index = await res.json(); }
+ catch (_) { this.player.set_pers("Error: Failed to fetch node"); }
+ if (index.error) return this.player.set_pers("server error: " + index.error), undefined;
+ this.index = index
+ } catch (e) {
+ if (e instanceof TypeError) {
+ this.player.set_pers("Cannot download index: Network Error");
+ } else throw e;
+ }
+ this.buffered.value = []
+
+ console.log(this.trackinfo);
+
+ for (let i = 0; i < this.trackinfo.formats.length; i++) {
+ const format = this.trackinfo.formats[i];
+ for (const container of format.containers) {
+ if (container != "webm" && container != "mpeg4") continue;
+ if (await test_media_capability(format, container))
+ this.usable_formats.push({ container, format, format_index: i, usable_index: this.usable_formats.length })
+ }
+ }
+
+ // TODO prefer newer codecs
+ const sort_key = (f: FormatInfo) => f.remux ? Infinity : f.bitrate
+ this.usable_formats.sort((a, b) => sort_key(b.format) - sort_key(a.format))
+ if (!this.usable_formats.length)
+ return this.player.logger?.log("No availble format is supported by this device. The track can't be played back.")
+ this.active_format.value = this.usable_formats[0]
+
+ const ct = track_to_content_type(this.active_format.value!.format, this.active_format.value!.container);
+ this.source_buffer = this.player.media_source.addSourceBuffer(ct);
+ this.abort.signal.addEventListener("abort", () => {
+ console.log(`destroy source buffer for track ${this.track_index}`);
+ this.player.media_source.removeSourceBuffer(this.source_buffer);
+ });
+ this.source_buffer.mode = "segments";
+ this.source_buffer.addEventListener("updateend", () => {
+ if (this.abort.signal.aborted) return;
+ if (this.current_load) {
+ this.loading.delete(this.current_load.index);
+ const cb = this.current_load.cb;
+ this.current_load = undefined;
+ cb()
+ } else {
+ console.warn("updateend but nothing is loading")
+ }
+ this.update_buf_ranges();
+ this.tick_append();
+ });
+ this.source_buffer.addEventListener("error", e => {
+ console.error("sourcebuffer error", e);
+ });
+ this.source_buffer.addEventListener("abort", e => {
+ console.error("sourcebuffer abort", e);
+ });
+
+ this.update(this.player.video.currentTime)
+ }
+
+ choose_format() {
+ if (!this.active_format.value) return
+ const ai = this.active_format.value.usable_index
+ const current_br = this.active_format.value.format.bitrate
+ const avail_br = this.player.downloader.bandwidth_avail.value * 8
+ const prev_format = this.active_format.value
+ if (ai < this.usable_formats.length - 1 && current_br > avail_br) {
+ this.active_format.value = this.usable_formats[ai + 1]
+ }
+ if (ai > 0 && avail_br > this.usable_formats[ai - 1].format.bitrate * 1.2) {
+ this.active_format.value = this.usable_formats[ai - 1]
+ }
+ if (prev_format != this.active_format.value) {
+ console.log(`abr ${show.metric(prev_format.format.bitrate)} -> ${show.metric(this.active_format.value.format.bitrate)}`);
+ this.choose_format()
+ }
+ }
+
+ update_buf_ranges() {
+ if (!this.index) return;
+ const ranges: BufferRange[] = [];
+ for (let i = 0; i < this.source_buffer.buffered.length; i++) {
+ ranges.push({
+ start: this.source_buffer.buffered.start(i),
+ end: this.source_buffer.buffered.end(i),
+ status: "buffered"
+ });
+ }
+ for (const r of this.loading) {
+ ranges.push({ ...this.index[r], status: "loading" });
+ }
+ this.buffered.value = ranges;
+ }
+
+ override async update(target: number) {
+ if (!this.index) return;
+ this.update_buf_ranges(); // TODO required?
+
+ const buffer_to = target + (target < 20 ? Math.max(1, target) : TARGET_BUFFER_DURATION)
+
+ const blocking = [];
+ for (let i = 0; i < this.index.length; i++) {
+ const frag = this.index[i];
+ if (frag.end < target) continue;
+ if (frag.start >= buffer_to) break;
+ if (!this.check_buf_collision(frag.start, frag.end)) continue;
+ if (frag.start <= target + MIN_BUFFER_DURATION)
+ blocking.push(this.load(i));
+ else
+ this.load(i);
+ }
+ await Promise.all(blocking);
+ }
+ check_buf_collision(start: number, end: number) {
+ const EPSILON = 0.01;
+ for (const r of this.buffered.value)
+ if (r.end - EPSILON > start && r.start < end - EPSILON)
+ return false;
+ return true;
+ }
+
+ async load(index: number) {
+ this.choose_format()
+ this.loading.add(index);
+ this.update_buf_ranges()
+ // TODO update format selection
+ const url = `${this.base_url}?fragment&t=${this.track_index}&f=${this.active_format.value!.format_index}&i=${index}&c=${this.active_format.value!.container}`;
+ const buf = await this.player.downloader.download(url);
+ await new Promise<void>(cb => {
+ if (!this.index) return;
+ if (this.abort.signal.aborted) return;
+ this.append_queue.push({ buf, ...this.index[index], index, cb });
+ this.tick_append();
+ });
+ }
+ tick_append() {
+ if (this.source_buffer.updating || this.current_load) return;
+ if (this.append_queue.length) {
+ const frag = this.append_queue[0];
+ this.append_queue.splice(0, 1);
+ this.current_load = frag;
+ // TODO why is appending so unreliable?! sometimes it does not add it
+ this.source_buffer.changeType(track_to_content_type(this.active_format.value!.format, this.active_format.value!.container));
+ this.source_buffer.timestampOffset = this.active_format.value?.container == "mpeg4" ? frag.start : 0
+ // this.source_buffer.timestampOffset = this.trackinfo.kind == "video" && !this.active_format.value!.format.remux ? frag.start : 0
+ // this.source_buffer.timestampOffset = this.active_format.value?.format.remux ? 0 : frag.start
+ // this.source_buffer.timestampOffset = 0
+ this.source_buffer.appendBuffer(frag.buf);
+ }
+ }
+
+ public debug(): OVar<HTMLElement> {
+ const rtype = (t: string, b: BufferRange[]) => {
+ const c = b.filter(r => r.status == t);
+ // ${c.length} range${c.length != 1 ? "s" : ""}
+ return `${c.reduce((a, v) => a + v.end - v.start, 0).toFixed(2)}s`
+ }
+ return this.active_format.liftA2(this.buffered, (p, b) =>
+ e("pre",
+ p ?
+ `mse track ${this.track_index}: format ${p.format_index} (${p.format.remux ? "remux" : "transcode"})`
+ + `\n\tformat: ${show_format(p.format)}`
+ + `\n\tbuffer type: ${track_to_content_type(p.format, p.container)}`
+ + `\n\tbuffered: ${rtype("buffered", b)} / queued: ${rtype("queued", b)} / loading: ${rtype("loading", b)}`
+ : ""
+ ) as HTMLElement
+ )
+ }
+}
diff --git a/ui/client-scripts/src/player/track/vtt.ts b/ui/client-scripts/src/player/track/vtt.ts
new file mode 100644
index 0000000..2152b97
--- /dev/null
+++ b/ui/client-scripts/src/player/track/vtt.ts
@@ -0,0 +1,96 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+import { e } from "../../jshelper/src/element.ts";
+import { Player } from "../player.ts";
+import { SubtitleCue, TrackInfo } from "../types_stream.ts";
+import { PlayerTrack } from "./mod.ts";
+
+export class VttPlayerTrack extends PlayerTrack {
+ private track: TextTrack;
+ public cues?: SubtitleCue[]
+
+ constructor(
+ private player: Player,
+ private node_id: string,
+ track_index: number,
+ private track_info: TrackInfo,
+ ) {
+ super(track_index);
+ this.track = this.player.video.addTextTrack("subtitles", this.track_info.name, this.track_info.language);
+ this.buffered.value = [{ start: 0, end: this.player.duration.value, status: "loading" }]
+ this.init()
+ }
+
+ private on_ready() {
+ if (!this.cues) return
+ this.buffered.value = [{ start: 0, end: this.player.duration.value, status: "buffered" }]
+ for (const cue of this.cues) {
+ this.track.addCue(create_cue(cue));
+ }
+ this.track.mode = "showing";
+ this.abort.signal.addEventListener("abort", () => {
+ // TODO disable subtitles properly
+ this.track.mode = "hidden";
+ });
+ }
+
+ async init() {
+ try {
+ const res = await fetch(`${this.player.base_url}?format=remux&segment=0&container=jvtt&track=${this.track_index}`, { headers: { "Accept": "application/json" } });
+ if (!res.ok) return this.player.error.value = "Cannot download index.", undefined;
+ let ai!: SubtitleCue[] & { error: string; };
+ try { ai = await res.json(); }
+ catch (_) { this.player.set_pers("Error: Failed to fetch node"); }
+ if (ai.error) return this.player.set_pers("server error: " + ai.error), undefined;
+ this.cues = ai;
+ } catch (e) {
+ if (e instanceof TypeError) {
+ this.player.set_pers("Cannot download subtitles: Network Error");
+ return undefined
+ } else throw e;
+ }
+ this.on_ready()
+ }
+
+ public debug(): HTMLElement {
+ return e("pre", `vtt track ${this.track_index}\n\t${this.cues?.length} cues loaded`)
+ }
+}
+
+function create_cue(cue: SubtitleCue): VTTCue {
+ const c = new VTTCue(cue.start, cue.end, cue.content);
+ const props = parse_layout_properties(cue.content.split("\n")[0])
+ if (props) {
+ c.text = cue.content.split("\n").slice(1).join("\n")
+ // TODO re-enable when it works
+ // // TODO this does not work at all...
+ // const region = new VTTRegion()
+ // if ("position" in props && props.position.endsWith("%"))
+ // region.regionAnchorX = parseFloat(props.position.replace("%", ""))
+ // if ("line" in props && props.line.endsWith("%"))
+ // region.regionAnchorY = parseFloat(props.line.replace("%", ""))
+ // if ("align" in props)
+ // c.align = props.align as AlignSetting
+ // c.region = region
+ } else {
+ c.line = -2;
+ }
+ return c
+}
+
+function parse_layout_properties(s: string): undefined | Record<string, string> {
+ const o: Record<string, string> = {}
+ for (const tok of s.split(" ")) {
+ const [k, v, ...rest] = tok.split(":")
+ if (!v || rest.length) return undefined
+ o[k] = v
+ }
+ // some common keys to prevent false positives
+ if ("position" in o) return o
+ if ("align" in o) return o
+ if ("line" in o) return o
+ return undefined
+}
diff --git a/ui/client-scripts/src/player/types_node.ts b/ui/client-scripts/src/player/types_node.ts
new file mode 100644
index 0000000..64f01e5
--- /dev/null
+++ b/ui/client-scripts/src/player/types_node.ts
@@ -0,0 +1,76 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+
+export interface NodePublic {
+ kind: NodeKind,
+ title?: string,
+ tagline?: string,
+ description?: string,
+ id?: string,
+ path: string[],
+ children: string[],
+ release_date?: string,
+ index?: number,
+ media?: MediaInfo,
+ ratings: { [key in Rating]: number },
+ // might be incomplete
+}
+
+export type NodeKind = "movie"
+ | "video"
+ | "collection"
+ | "channel"
+ | "show"
+ | "series"
+ | "season"
+ | "episode"
+
+export type Rating = "imdb"
+ | "tmdb"
+ | "rotten_tomatoes"
+ | "metacritic"
+ | "youtube_views"
+ | "youtube_likes"
+ | "youtube_followers"
+
+export interface MediaInfo {
+ duration: number,
+ tracks: SourceTrack[],
+ chapters: Chapter[],
+}
+
+export interface Chapter {
+ time_start?: number,
+ time_end?: number,
+ labels: { [key: string]: string }
+}
+
+export interface SourceTrack {
+ kind: SourceTrackKind,
+ name: string,
+ codec: string,
+ language: string,
+}
+export type SourceTrackKind = {
+ video: {
+ width: number,
+ height: number,
+ fps: number,
+ }
+}
+ | {
+ audio: {
+ channels: number,
+ sample_rate: number,
+ bit_depth: number,
+ }
+ } | "subtitle";
+
+export interface NodeUserData {
+ watched: WatchedState
+}
+export type WatchedState = "none" | "watched" | "pending" | { progress: number }
+
diff --git a/ui/client-scripts/src/player/types_stream.ts b/ui/client-scripts/src/player/types_stream.ts
new file mode 100644
index 0000000..272f98b
--- /dev/null
+++ b/ui/client-scripts/src/player/types_stream.ts
@@ -0,0 +1,35 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+export type FragmentIndex = TimeRange[]
+export interface TimeRange { start: number, end: number }
+export interface SubtitleCue extends TimeRange {
+ content: string
+}
+export interface StreamInfo {
+ name?: string,
+ duration: number,
+ tracks: TrackInfo[],
+}
+export type TrackKind = "video" | "audio" | "subtitle"
+export interface TrackInfo {
+ name?: string,
+ language?: string,
+ kind: TrackKind,
+ formats: FormatInfo[]
+}
+export type StreamContainer = "webm" | "matroska" | "mpeg4" | "jvtt" | "webvtt"
+export interface FormatInfo {
+ codec: string,
+ bitrate: number,
+ remux: boolean,
+ containers: StreamContainer[]
+
+ width?: number,
+ height?: number,
+ channels?: number,
+ samplerate?: number,
+ bit_depth?: number,
+}
diff --git a/ui/client-scripts/src/transition.ts b/ui/client-scripts/src/transition.ts
new file mode 100644
index 0000000..dadb266
--- /dev/null
+++ b/ui/client-scripts/src/transition.ts
@@ -0,0 +1,108 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+
+import { e } from "./jshelper/src/element.ts";
+
+interface HistoryState {
+ top: number
+}
+
+const DURATION = 200
+globalThis.addEventListener("DOMContentLoaded", () => {
+ patch_page()
+})
+
+globalThis.addEventListener("popstate", async e => {
+ await transition_to(globalThis.location.href, e.state)
+})
+
+let disable_transition = false
+globalThis.addEventListener("navigationrequiresreload", () => {
+ disable_transition = true
+})
+
+function patch_page() {
+ document.querySelectorAll("a").forEach(el => {
+ if (!el.href.startsWith("http")) return
+ if (el.target && el.target != "_self") return
+ el.addEventListener("click", async ev => {
+ ev.preventDefault()
+ await transition_to(el.href)
+ })
+ })
+}
+
+async function transition_to(href: string, state?: HistoryState) {
+ stop() // nice!
+ if (disable_transition) return globalThis.location.href = href
+ const trigger_load = prepare_load(href, state)
+ await fade(false)
+ trigger_load()
+ disable_transition = false;
+}
+
+function show_message(mesg: string, mode: "error" | "success" = "error") {
+ clear_spinner()
+ disable_transition = true
+ document.body.append(e("span", { class: ["jst-message", mode] }, mesg))
+}
+
+let i = 0;
+function prepare_load(href: string, state?: HistoryState) {
+ const r_promise = fetch(href, { headers: { accept: "text/html" }, redirect: "manual" })
+ return async () => {
+ let rt = ""
+ try {
+ const r = await r_promise
+ if (r.type == "opaqueredirect") {
+ globalThis.location.href = href
+ show_message("Native Player Started.", "success")
+ setTimeout(() => globalThis.location.reload(), 500)
+ return
+ }
+ if (!r.ok) return show_message("Error response. Try again.")
+ rt = await r.text()
+ } catch (e) {
+ if (e instanceof TypeError) return show_message("Navigation failed. Check your connection.")
+ return show_message("unknown error when fetching page")
+ }
+ const [head, body] = rt.split("<head>")[1].split("</head>")
+ globalThis.history.replaceState({ top: globalThis.scrollY, index: i++ } as HistoryState, "")
+ if (!state) globalThis.history.pushState({}, "", href)
+ clear_spinner()
+ document.head.innerHTML = head
+ document.body.outerHTML = body
+ globalThis.dispatchEvent(new Event("DOMContentLoaded"))
+ globalThis.scrollTo({ top: state?.top ?? 0 });
+ fade(true)
+ }
+}
+
+let spinner_timeout: number | undefined, spinner_element: HTMLElement | undefined;
+function clear_spinner() {
+ if (spinner_timeout) clearTimeout(spinner_timeout)
+ if (spinner_element) spinner_element.remove()
+ spinner_element = spinner_timeout = undefined;
+}
+function fade(dir: boolean) {
+ const overlay = document.createElement("div")
+ overlay.classList.add("jst-fade")
+ overlay.style.backgroundColor = dir ? "black" : "transparent"
+ overlay.style.animationName = dir ? "jst-fadeout" : "jst-fadein"
+ overlay.style.animationFillMode = "forwards"
+ overlay.style.animationDuration = `${DURATION}ms`
+ document.body.appendChild(overlay)
+ return new Promise<void>(res => {
+ setTimeout(() => {
+ if (dir) document.body.removeChild(overlay)
+ spinner_timeout = setTimeout(() => {
+ overlay.append(spinner_element = e("div", { class: "jst-spinner" }, "This is a spinner."))
+ }, 500)
+ res()
+ }, DURATION)
+ })
+}