/* 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) 2025 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, private segment_index: number, 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&segment=${this.segment_index}&track=${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&segment=${this.segment_index}&track=${this.track_index}&format=${this.active_format.value!.format_index}&index=${index}&container=${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 ) } }