/* 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 { OVar, e } from "../jshelper/mod.ts"; import { NodePublic, NodeUserData, SourceTrack, TimeRange } from "./jhls.d.ts"; import { SegmentDownloader } from "./download.ts"; import { PlayerTrack } from "./track/mod.ts"; import { Logger } from "../jshelper/src/log.ts"; import { WatchedState, Chapter } from "./jhls.d.ts"; import { get_track_kind } from "./mediacaps.ts"; import { create_track } from "./track/create.ts"; export interface BufferRange extends TimeRange { status: "buffered" | "loading" | "queued" } export class Player { public video = e("video") public media_source = new MediaSource(); public tracks?: SourceTrack[]; public chapters = new OVar([]); public active_tracks = new OVar([]); public downloader: SegmentDownloader = new SegmentDownloader(); public position = new OVar(0) public duration = new OVar(1) public volume = new OVar(0) public playing = new OVar(false) public canplay = new OVar(false) public error = new OVar(undefined) private cancel_buffering_pers: undefined | (() => void) set_pers(s?: string) { if (this.cancel_buffering_pers) this.cancel_buffering_pers(), this.cancel_buffering_pers = undefined if (s) this.cancel_buffering_pers = this.logger?.log_persistent(s) } constructor(public node_id: string, public logger?: Logger) { this.video.poster = `/n/${encodeURIComponent(node_id)}/asset?role=poster` this.volume.value = this.video.volume let skip_change = false; this.volume.onchange(v => { if (v > 1.) return this.volume.value = 1; if (v < 0.) return this.volume.value = 0; if (!skip_change) this.video.volume = v skip_change = false }) this.video.onvolumechange = () => { skip_change = true; this.volume.value = this.video.volume } 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.set_pers("Resuming playback...") } this.video.onwaiting = () => { console.log("waiting"); if (this.video.currentTime > this.duration.value - 0.2) return this.set_pers("Playback finished") this.set_pers("Buffering...") this.canplay.value = false; } this.video.onplaying = () => { console.log("playing"); this.playing.value = true; this.set_pers() } this.video.onpause = () => { console.log("pause"); this.playing.value = false } this.video.oncanplay = () => { console.log("canplay"); this.set_pers() this.canplay.value = true } this.video.onseeking = () => { console.log("seeking"); this.set_pers("Seeking...") } this.video.onseeked = () => { console.log("seeked"); this.set_pers() } this.video.onerror = e => { console.error("video element error:", e); this.set_pers("MSE sucks"); } this.video.onabort = e => { console.error("video element abort:", e); this.set_pers("Aborted"); } this.fetch_meta() } async fetch_meta() { this.set_pers("Loading node...") const res = await fetch(`/n/${encodeURIComponent(this.node_id)}`, { headers: { "Accept": "application/json" } }) if (!res.ok) return this.error.value = "Cannot download node." let metadata!: NodePublic & { error: string } try { metadata = await res.json() } catch (_) { this.set_pers("Error: Failed to fetch node") } if (metadata.error) return this.set_pers("server error: " + metadata.error) this.set_pers("Loading node user data...") const udres = await fetch(`/n/${encodeURIComponent(this.node_id)}/userdata`, { headers: { "Accept": "application/json" } }) if (!udres.ok) return this.error.value = "Cannot download node." let userdata!: NodeUserData & { error: string } try { userdata = await udres.json() } catch (_) { this.set_pers("Error: Failed to fetch node user data") } if (userdata.error) return this.set_pers("server error: " + metadata.error) this.set_pers() //! bad code: assignment order is important because chapter callbacks use duration this.duration.value = metadata.media!.duration this.chapters.value = metadata.media!.chapters this.tracks = metadata.media!.tracks this.video.src = URL.createObjectURL(this.media_source) this.media_source.addEventListener("sourceopen", async () => { this.set_pers("Downloading track indecies...") let video = false, audio = false, subtitles = false; for (let i = 0; i < this.tracks!.length; i++) { const t = this.tracks![i]; const kind = get_track_kind(t.kind) if (kind == "video" && !video) video = true, await this.set_track_enabled(i, true, false) if (kind == "audio" && !audio) audio = true, await this.set_track_enabled(i, true, false) if (kind == "subtitles" && !subtitles) subtitles = true, await this.set_track_enabled(i, true, false) } this.set_pers("Downloading initial segments...") const start_time = get_query_start_time() ?? get_continue_time(userdata.watched); this.update(start_time) this.video.currentTime = start_time await this.canplay.wait_for(true) this.set_pers() }) } async update(newt?: number) { await Promise.all(this.active_tracks.value.map(t => t.update(newt ?? this.video.currentTime))) } async set_track_enabled(index: number, state: boolean, update = true) { console.log(`(${index}) set enabled ${state}`); const active_index = this.active_tracks.value.findIndex(t => t.track_index == index) if (!state && active_index != -1) { this.logger?.log(`Disabled track ${index}: ${display_track(this.tracks![index])}`) const [track] = this.active_tracks.value.splice(active_index, 1) track.abort.abort() } else if (state && active_index == -1) { this.logger?.log(`Enabled track ${index}: ${display_track(this.tracks![index])}`) this.active_tracks.value.push(create_track(this, this.node_id, index, this.tracks![index])!) if (update) await this.update() } this.active_tracks.change() } play() { this.video.play() } pause() { this.video.pause() } frame_forward() { //@ts-ignore trust me bro this.video["seekToNextFrame"]() } async seek(p: number) { this.set_pers("Buffering at target...") await this.update(p) this.video.currentTime = p } } function get_continue_time(w: WatchedState): number { if (typeof w == "string") return 0 else return w.progress } function get_query_start_time() { const u = new URL(window.location.href) const p = u.searchParams.get("t") if (!p) return const x = parseFloat(p) if (Number.isNaN(x)) return return x } function display_track(t: SourceTrack): string { return `"${t.name}" (${t.language})` }