/* 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) 2023 metamuffin */ import { OVar, e } from "../jshelper/mod.ts"; export interface Range { start: number, end: number } export interface JhlsMetadata { tracks: JhlsTrack[], duration: number, } export interface JhlsTrack { info: SourceTrack, segments: Range[], } export interface SourceTrack { kind: SourceTrackKind, name: string, codec: string, language: string, } export interface SourceTrackKind { video?: { width: number, height: number, fps: number, }, audio?: { channels: number, sample_rate: number, bit_depth: number, }, subtitles?: boolean, } const TARGET_BUFFER_DURATION = 15 const MIN_BUFFER_DURATION = 1 document.addEventListener("DOMContentLoaded", () => { if (document.body.classList.contains("player")) { if (!globalThis.MediaSource) return alert("Media Source Extension API required") const node_id = globalThis.location.pathname.split("/")[2]; const main = document.getElementById("main")!; document.getElementsByTagName("footer")[0].remove() initialize_player(main, node_id) } }) function initialize_player(el: HTMLElement, node_id: string) { el.innerHTML = "" // clear the body const player = new Player(node_id) 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 controls = e("div", { class: "jsp-controls" }, player.playing.map(playing => e("button", playing ? "||" : "|>", { onclick: toggle_playing })), e("p", { class: "jsp-status" }, player.position.map(v => e("span", display_time(v))), e("br"), player.position.map(v => e("span", display_time(v - player.duration.value))) ), pri = e("div", { class: "jsp-pri" }, pri_current = e("div", { class: "jsp-pri-current" }), player.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), top: `calc(var(--pribufsize)*${i})`, left: pri_map(r.start) } }) )) ))) ) ), e("button", "X", { onclick() { if (document.fullscreenElement) document.exitFullscreen() else document.documentElement.requestFullscreen() } }) ) player.position.onchangeinit(p => pri_current.style.width = pri_map(p)) const pel = e("div", { class: "jsp" }, player.video, player.buffering_status.map(b => e("div", { class: "jsp-overlay" }, b ? e("p", { class: "jsp-buffering" }, b) : undefined )), controls, ) el.append(pel) mouse_idle(pel, 1000, 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.code == "Period") player.pause(), player.frame_forward() if (k.code == "Space") toggle_playing() 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 return; k.preventDefault() }) } function mouse_idle(e: HTMLElement, timeout: number, cb: (b: boolean) => unknown) { let ct: number; let idle = false e.onmouseleave = () => { clearTimeout(ct) } e.onmousemove = () => { clearTimeout(ct) if (idle) { idle = false cb(idle) } ct = setTimeout(() => { idle = true cb(idle) }, timeout) } } function display_time(t: number): string { if (t < 0) return "-" + display_time(-t) let h = 0, m = 0, s = 0; while (t > 3600) t -= 3600, h++; while (t > 60) t -= 60, m++; while (t > 1) t -= 1, s++; return (h ? h + "h" : "") + (m ? m + "m" : "") + (s ? s + "s" : "") } interface BufferRange extends Range { status: "buffered" | "loading" | "queued" } class Player { public video = e("video") private media_source = new MediaSource(); public tracks = new OVar([]); public position = new OVar(0) public duration = new OVar(1) public playing = new OVar(false) public canplay = new OVar(false) public buffering_status = new OVar(undefined) public error = new OVar(undefined) constructor(private node_id: string) { 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.buffering_status.value = "Resuming playback..."; } this.video.onwaiting = () => { console.log("waiting"); this.buffering_status.value = "Buffering..."; this.canplay.value = false; } this.video.onplaying = () => { console.log("playing"); this.playing.value = true; this.buffering_status.value = undefined; } this.video.onpause = () => { console.log("pause"); this.playing.value = false } this.video.oncanplay = () => { console.log("canplay"); this.buffering_status.value = undefined this.canplay.value = true } this.video.onseeking = () => { console.log("seeking"); this.buffering_status.value = "Seeking..." } this.video.onseeked = () => { console.log("seeked"); this.buffering_status.value = undefined } this.fetch_meta() } async fetch_meta() { this.buffering_status.value = "Loading JHLS metadata..." const res = await fetch(`/n/${encodeURIComponent(this.node_id)}/stream?format=jhls`) if (!res.ok) return this.error.value = "Cannot download JHLS metadata" const metadata = await res.json() as JhlsMetadata this.buffering_status.value = undefined this.duration.value = metadata.duration this.video.src = URL.createObjectURL(this.media_source) this.media_source.addEventListener("sourceopen", async () => { this.tracks.value.push(new PlayerTrack(this.media_source, this.node_id, 0, metadata.tracks[0])) this.tracks.value.push(new PlayerTrack(this.media_source, this.node_id, 1, metadata.tracks[1])) this.tracks.change() this.buffering_status.value = "Fetching initial segments..." this.update() await this.canplay.wait_for(true) this.buffering_status.value = undefined }) } async update(newt?: number) { await Promise.all(this.tracks.value.map(t => t.update(newt ?? this.video.currentTime))) } play() { this.video.play() } pause() { this.video.pause() } frame_forward() { //@ts-ignore trust me bro this.video["seekToNextFrame"]() } async seek(p: number) { this.buffering_status.value = "Buffering at target..." await this.update(p) this.video.currentTime = p } } interface AppendRange extends Range { buf: ArrayBuffer, index: number, cb: () => void } class PlayerTrack { private source_buffer: SourceBuffer private current_load?: AppendRange private loading = new Set() public buffered = new OVar([]) private append_queue: AppendRange[] = [] constructor(media_source: MediaSource, private node_id: string, private track_index: number, private metadata: JhlsTrack) { this.source_buffer = media_source.addSourceBuffer("video/webm") this.source_buffer.mode = "segments" this.source_buffer.addEventListener("updateend", () => { 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); }) } 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.metadata.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.metadata.segments.length; i++) { const seg = this.metadata.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) const res = await fetch(`/n/${encodeURIComponent(this.node_id)}/stream?format=hlsseg&tracks=${this.track_index}&index=${index}`) if (!res.ok) throw new Error(`segment fail i=${index} t=${this.track_index}`); const buf = await res.arrayBuffer() await new Promise(cb => { this.append_queue.push({ buf, ...this.metadata.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.source_buffer.timestampOffset = seg.start this.source_buffer.appendBuffer(seg.buf); this.append_queue.splice(0, 1) this.current_load = seg } } }