aboutsummaryrefslogtreecommitdiff
path: root/ui/client-scripts/src/player/track
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/src/player/track
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/src/player/track')
-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
4 files changed, 344 insertions, 0 deletions
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
+}