diff options
Diffstat (limited to 'web/script/player/track')
-rw-r--r-- | web/script/player/track/create.ts | 12 | ||||
-rw-r--r-- | web/script/player/track/mod.ts | 24 | ||||
-rw-r--r-- | web/script/player/track/mse.ts | 140 | ||||
-rw-r--r-- | web/script/player/track/vtt.ts | 80 |
4 files changed, 256 insertions, 0 deletions
diff --git a/web/script/player/track/create.ts b/web/script/player/track/create.ts new file mode 100644 index 0000000..f674c3a --- /dev/null +++ b/web/script/player/track/create.ts @@ -0,0 +1,12 @@ +import { get_track_kind } from "../mediacaps.ts"; +import { create_vtt_track } from "./vtt.ts"; +import { create_mse_track } from "./mse.ts"; +import { Player } from "../player.ts"; +import { SourceTrack } from "../jhls.d.ts"; +import { PlayerTrack } from "./mod.ts"; + +export async function create_track(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise<PlayerTrack | undefined> { + const kind = get_track_kind(metadata.kind) + if (kind == "subtitles") return await create_vtt_track(player, node_id, track_index, metadata) + else return await create_mse_track(player, node_id, track_index, metadata) +} diff --git a/web/script/player/track/mod.ts b/web/script/player/track/mod.ts new file mode 100644 index 0000000..cdb07cc --- /dev/null +++ b/web/script/player/track/mod.ts @@ -0,0 +1,24 @@ +/* + 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 <metamuffin.org> +*/ +/// <reference lib="dom" /> +import { TimeRange } from "../jhls.d.ts"; +import { OVar } from "../../jshelper/mod.ts"; +import { BufferRange } from "../player.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 { + constructor( + public track_index: number, + ) { } + public buffered = new OVar<BufferRange[]>([]); + public abort = new AbortController() + async update(_target: number) { } +} + diff --git a/web/script/player/track/mse.ts b/web/script/player/track/mse.ts new file mode 100644 index 0000000..623e149 --- /dev/null +++ b/web/script/player/track/mse.ts @@ -0,0 +1,140 @@ +import { JhlsTrackIndex, SourceTrack } 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 { PlayerTrack, AppendRange, TARGET_BUFFER_DURATION, MIN_BUFFER_DURATION } from "./mod.ts"; + +export async function create_mse_track(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise<MSEPlayerTrack | undefined> { + 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; + } +} + +export class MSEPlayerTrack extends PlayerTrack { + public source_buffer!: SourceBuffer; + private current_load?: AppendRange; + private loading = new Set<number>(); + private append_queue: AppendRange[] = []; + public profile_selector: ProfileSelector; + public profile = new OVar<EncodingProfileExt | undefined>(undefined); + + 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<void>(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); + } + } +} diff --git a/web/script/player/track/vtt.ts b/web/script/player/track/vtt.ts new file mode 100644 index 0000000..c426fa1 --- /dev/null +++ b/web/script/player/track/vtt.ts @@ -0,0 +1,80 @@ +import { SourceTrack, JvttCue } from "../jhls.d.ts"; +import { Player } from "../player.ts"; +import { PlayerTrack } from "./mod.ts"; + +export async function create_vtt_track(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise<VttPlayerTrack | undefined> { + let index: JvttCue[]; + 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 ai!: JvttCue[] & { error: string; }; + try { ai = await res.json(); } + catch (_) { player.set_pers("Error: Failed to fetch node"); } + if (ai.error) return player.set_pers("server error: " + ai.error), undefined; + index = ai; + } catch (e) { + if (e instanceof TypeError) { + player.set_pers("Cannot download subtitles: Network Error"); + return undefined + } else throw e; + } + const t = new VttPlayerTrack(player, node_id, track_index, metadata, index); + return t; +} + +export class VttPlayerTrack extends PlayerTrack { + private track: TextTrack; + + 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(create_cue(cue)); + } + this.track.mode = "showing"; + this.abort.signal.addEventListener("abort", () => { + // TODO disable subtitles properly + this.track.mode = "hidden"; + }); + } +} + +function create_cue(cue: JvttCue): VTTCue { + const c = new VTTCue(cue.start, cue.end, cue.content); + const props = parse_layout_properties(cue.content.split("\n")[0]) + if (props) { + c.text = cue.content.split("\n").slice(1).join("\n") + // TODO this does not work at all... + const region = new VTTRegion() + if ("position" in props && props.position.endsWith("%")) + region.regionAnchorX = parseFloat(props.position.replace("%", "")) + if ("line" in props && props.line.endsWith("%")) + region.regionAnchorY = parseFloat(props.line.replace("%", "")) + if ("align" in props) + c.align = props.align as AlignSetting + c.region = region + } else { + c.line = -2; + } + return c +} + +function parse_layout_properties(s: string): undefined | Record<string, string> { + const o: Record<string, string> = {} + for (const tok of s.split(" ")) { + const [k, v, ...rest] = tok.split(":") + if (!v || rest.length) return undefined + o[k] = v + } + // some common keys to prevent false positives + if ("position" in o) return o + if ("align" in o) return o + if ("line" in o) return o + return undefined +} |