diff options
| author | metamuffin <metamuffin@disroot.org> | 2026-01-18 23:43:12 +0100 |
|---|---|---|
| committer | metamuffin <metamuffin@disroot.org> | 2026-01-18 23:43:12 +0100 |
| commit | ed19a428cb5eef84c8cf3fed5fda3afd5fc96305 (patch) | |
| tree | 39e3167a4f8b7423a15b3a5f56e973554bdb3195 /ui/client-scripts | |
| parent | 901dff07ed357694eb35284a58c3cc6c003c53ce (diff) | |
| download | jellything-ed19a428cb5eef84c8cf3fed5fda3afd5fc96305.tar jellything-ed19a428cb5eef84c8cf3fed5fda3afd5fc96305.tar.bz2 jellything-ed19a428cb5eef84c8cf3fed5fda3afd5fc96305.tar.zst | |
Move client scripts to build-crate
Diffstat (limited to 'ui/client-scripts')
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) + }) +} |