From ed19a428cb5eef84c8cf3fed5fda3afd5fc96305 Mon Sep 17 00:00:00 2001 From: metamuffin Date: Sun, 18 Jan 2026 23:43:12 +0100 Subject: Move client scripts to build-crate --- ui/client-scripts/src/player/track/create.ts | 15 ++ ui/client-scripts/src/player/track/mod.ts | 25 ++++ ui/client-scripts/src/player/track/mse.ts | 208 +++++++++++++++++++++++++++ ui/client-scripts/src/player/track/vtt.ts | 96 +++++++++++++ 4 files changed, 344 insertions(+) create mode 100644 ui/client-scripts/src/player/track/create.ts create mode 100644 ui/client-scripts/src/player/track/mod.ts create mode 100644 ui/client-scripts/src/player/track/mse.ts create mode 100644 ui/client-scripts/src/player/track/vtt.ts (limited to 'ui/client-scripts/src/player/track') 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 +*/ +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 +*/ +/// +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([]); + public abort = new AbortController() + async update(_target: number) { } + public abstract debug(): HTMLElement | OVar +} + 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 +*/ +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(); + private append_queue: AppendRange[] = []; + public index?: FragmentIndex + public active_format = new OVar(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(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 { + 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 +*/ +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 { + const o: Record = {} + 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 +} -- cgit v1.3