/* 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) 2024 metamuffin */ /// import { JhlsTrackIndex, SourceTrack, TimeRange } from "./jhls.d.ts"; import { OVar } from "../jshelper/mod.ts"; import { profile_to_partial_track, track_to_content_type } from "./mediacaps.ts"; import { BufferRange, Player } from "./player.ts"; import { EncodingProfileExt, ProfileSelector } from "./profiles.ts"; import { JvttCue } from "./jhls.d.ts"; import { get_track_kind } from "./mediacaps.ts"; export const TARGET_BUFFER_DURATION = 10 export const MIN_BUFFER_DURATION = 1 export interface AppendRange extends TimeRange { buf: ArrayBuffer, index: number, cb: () => void } export abstract class PlayerTrack { public static async new(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise { const kind = get_track_kind(metadata.kind) if (kind == "subtitles") return await VttPlayerTrack.new(player, node_id, track_index, metadata) else return await MSEPlayerTrack.new(player, node_id, track_index, metadata) } constructor( public track_index: number, ) { } public buffered = new OVar([]); public abort = new AbortController() async update(_target: number) { } } export class VttPlayerTrack extends PlayerTrack { private track: TextTrack public static async new(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise { try { const res = await fetch(`/n/${encodeURIComponent(player.node_id)}/stream?format=jvtt&tracks=${track_index}`, { headers: { "Accept": "application/json" } }) if (!res.ok) return player.error.value = "Cannot download index.", undefined let index!: JvttCue[] & { error: string } try { index = await res.json() } catch (_) { player.set_pers("Error: Failed to fetch node") } if (index.error) return player.set_pers("server error: " + index.error), undefined const t = new VttPlayerTrack(player, node_id, track_index, metadata, index) return t } catch (e) { if (e instanceof TypeError) { player.set_pers("Cannot download subtitles: Network Error") } else throw e } } constructor( private player: Player, private node_id: string, track_index: number, private metadata: SourceTrack, public cues: JvttCue[], ) { super(track_index) this.track = this.player.video.addTextTrack("subtitles", metadata.name, metadata.language) for (const cue of cues) { this.track.addCue(new VTTCue(cue.start, cue.end, cue.content)) } this.track.mode = "showing" this.abort.signal.addEventListener("abort", () => { // TODO disable subtitles properly this.track.mode = "hidden" }) } } export class MSEPlayerTrack extends PlayerTrack { public source_buffer!: SourceBuffer; private current_load?: AppendRange; private loading = new Set(); private append_queue: AppendRange[] = []; public profile_selector: ProfileSelector public profile = new OVar(undefined); public static async new(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise { try { const res = await fetch(`/n/${encodeURIComponent(player.node_id)}/stream?format=jhlsi&tracks=${track_index}`, { headers: { "Accept": "application/json" } }) if (!res.ok) return player.error.value = "Cannot download index.", undefined let index!: JhlsTrackIndex & { error: string } try { index = await res.json() } catch (_) { player.set_pers("Error: Failed to fetch node") } if (index.error) return player.set_pers("server error: " + index.error), undefined const t = new MSEPlayerTrack(player, node_id, track_index, metadata, index) await t.init() return t } catch (e) { if (e instanceof TypeError) { player.set_pers("Cannot download index: Network Error") } else throw e } } constructor( private player: Player, private node_id: string, track_index: number, private metadata: SourceTrack, public index: JhlsTrackIndex, ) { super(track_index) this.profile_selector = new ProfileSelector(player, this, player.downloader.bandwidth) } async init() { await this.profile_selector.select_optimal_profile(this.track_index, this.profile); const ct = track_to_content_type(this.track_from_profile())! console.log(`track ${this.track_index} source buffer content-type: ${ct}`); 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.current_load.cb(); this.loading.delete(this.current_load.index); this.current_load = undefined; } 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); }); } track_from_profile(): SourceTrack { if (this.profile.value) return profile_to_partial_track(this.profile.value) else return this.metadata } update_buf_ranges() { 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.segments[r], status: "loading" }); } this.buffered.value = ranges; } async update(target: number) { this.update_buf_ranges(); // TODO required? const blocking = []; for (let i = 0; i < this.index.segments.length; i++) { const seg = this.index.segments[i]; if (seg.end < target) continue; if (seg.start >= target + TARGET_BUFFER_DURATION) break; if (!this.check_buf_collision(seg.start, seg.end)) continue; if (seg.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.loading.add(index); await this.profile_selector.select_optimal_profile(this.track_index, this.profile); const url = `/n/${encodeURIComponent(this.node_id)}/stream?format=snippet&webm=true&tracks=${this.track_index}&index=${index}${this.profile.value ? `&profile=${this.profile.value.id}` : ""}`; const buf = await this.player.downloader.download(url); await new Promise(cb => { if (this.abort.signal.aborted) return this.append_queue.push({ buf, ...this.index.segments[index], index, cb }); this.tick_append(); }); } tick_append() { if (this.source_buffer.updating) return; if (this.append_queue.length) { const seg = this.append_queue[0]; this.append_queue.splice(0, 1); this.current_load = seg; // TODO why is appending so unreliable?! sometimes it does not add it this.source_buffer.changeType(track_to_content_type(this.track_from_profile())!); this.source_buffer.timestampOffset = seg.start; this.source_buffer.appendBuffer(seg.buf); } } }