/* 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 = 30 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() { } })), 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.buffered.map( ranges => e("div", ...ranges.map( r => e("div", { class: "jsp-pri-buffered", style: { width: pri_map(r.end - r.start), 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") vel["seekToNextFrame" as "play"]() 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" | "downloading" | "queued" } class Player { public video = e("video") private media_source = new MediaSource(); public tracks: PlayerTrack[] = []; public position = new OVar(0) public duration = new OVar(1) public playing = new OVar(false) public buffered = new OVar([]) 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.update_buffered_ranges() } this.video.onplay = () => { this.buffering_status.value = undefined; } this.video.onwaiting = () => { this.buffering_status.value = "Buffering..."; } this.video.onplaying = () => { this.playing.value = true; } this.video.onpause = () => { this.playing.value = false } this.fetch_meta() } update_buffered_ranges() { const o: BufferRange[] = [] for (let i = 0; i < this.video.buffered.length; i++) { o.push({ start: this.video.buffered.start(i), end: this.video.buffered.end(i), status: "buffered" }) } this.buffered.value = o; } 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", () => { this.tracks.push(new PlayerTrack(this.media_source, this.node_id, 0, metadata.tracks[0])) this.tracks.push(new PlayerTrack(this.media_source, this.node_id, 1, metadata.tracks[1])) this.buffering_status.value = "Fetching initial segments..." this.update() }) } update() { this.tracks.forEach(t => t.update(this.video.currentTime)) } play() { console.log("play"); this.video.play() } pause() { console.log("pause"); this.video.pause() } seek(p: number) { this.video.currentTime = p this.update() } } class PlayerTrack { private source_buffer: SourceBuffer private loaded = new Set() private loading = new Set() private append_queue: (Range & { buf: ArrayBuffer, cb: () => void })[] = [] 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", () => { this.tick_append() }) this.source_buffer.addEventListener("error", e => { console.error("sourcebuffer error", e); }) } async update(target: number) { console.log(`update track ${this.track_index}`, target, this.loaded, this.loading); for (let i = 0; i < this.metadata.segments.length; i++) { const segment = this.metadata.segments[i]; if (segment.start >= target - 1 && segment.end < target + TARGET_BUFFER_DURATION) { if (!this.loaded.has(i) && !this.loading.has(i)) { this.loading.add(i) this.load(i) } } } } async load(index: number) { 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() this.loading.delete(index) this.loaded.add(index) await new Promise(cb => { this.append_queue.push({ buf, ...this.metadata.segments[index], cb }) this.tick_append() }) } tick_append() { console.log("tick", this.append_queue); if (this.source_buffer.updating) return if (this.append_queue.length) { const seg = this.append_queue[0]; console.log("append", this.track_index, seg); this.source_buffer.timestampOffset = seg.start this.source_buffer.appendBuffer(seg.buf); this.append_queue.splice(0, 1) } } }