aboutsummaryrefslogtreecommitdiff
path: root/ui/client-scripts/src/player/player.ts
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2026-01-18 23:43:12 +0100
committermetamuffin <metamuffin@disroot.org>2026-01-18 23:43:12 +0100
commited19a428cb5eef84c8cf3fed5fda3afd5fc96305 (patch)
tree39e3167a4f8b7423a15b3a5f56e973554bdb3195 /ui/client-scripts/src/player/player.ts
parent901dff07ed357694eb35284a58c3cc6c003c53ce (diff)
downloadjellything-ed19a428cb5eef84c8cf3fed5fda3afd5fc96305.tar
jellything-ed19a428cb5eef84c8cf3fed5fda3afd5fc96305.tar.bz2
jellything-ed19a428cb5eef84c8cf3fed5fda3afd5fc96305.tar.zst
Move client scripts to build-crate
Diffstat (limited to 'ui/client-scripts/src/player/player.ts')
-rw-r--r--ui/client-scripts/src/player/player.ts173
1 files changed, 173 insertions, 0 deletions
diff --git a/ui/client-scripts/src/player/player.ts b/ui/client-scripts/src/player/player.ts
new file mode 100644
index 0000000..4f59f8a
--- /dev/null
+++ b/ui/client-scripts/src/player/player.ts
@@ -0,0 +1,173 @@
+/*
+ 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) 2026 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+import { OVar, e } from "../jshelper/mod.ts";
+import { SegmentDownloader } from "./download.ts";
+import { PlayerTrack } from "./track/mod.ts";
+import { Logger } from "../jshelper/src/log.ts";
+import { create_track } from "./track/create.ts";
+import { StreamInfo, TimeRange, TrackInfo } from "./types_stream.ts";
+
+export interface BufferRange extends TimeRange { status: "buffered" | "loading" | "queued" }
+export class Player {
+ public video = e("video")
+ public media_source = new MediaSource();
+ public streaminfo?: StreamInfo;
+ public tracks?: TrackInfo[];
+ public active_tracks = new OVar<PlayerTrack[]>([]);
+ 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<string | undefined>(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 base_url: string, poster: string, private start_time: number, public logger?: Logger<string>) {
+ this.video.poster = 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 crash -_-");
+ }
+ this.video.onabort = e => {
+ console.error("video element abort:", e);
+ this.set_pers("Aborted");
+ }
+ this.fetch_meta()
+ }
+
+ async fetch_meta() {
+ this.set_pers("Loading stream metadata...")
+ const res = await fetch(`${this.base_url}?info`, { headers: { "Accept": "application/json" } })
+ if (!res.ok) return this.error.value = "Cannot download stream info."
+
+ let streaminfo!: StreamInfo & { error: string }
+ try { streaminfo = await res.json() }
+ catch (_) { this.set_pers("Error: Node data invalid") }
+ if (streaminfo.error) return this.set_pers("server error: " + streaminfo.error)
+
+ this.set_pers()
+ //! bad code: assignment order is important because chapter callbacks use duration
+ this.duration.value = streaminfo.duration
+ this.streaminfo = streaminfo
+ this.tracks = streaminfo!.tracks;
+ console.log("aaa", this.tracks);
+ this.video.src = URL.createObjectURL(this.media_source)
+ this.media_source.addEventListener("sourceopen", async () => {
+ let video = false, audio = false, subtitle = false;
+ for (let i = 0; i < this.tracks!.length; i++) {
+ const t = this.tracks![i];
+ if (t.kind == "video" && !video)
+ video = true, await this.set_track_enabled(i, true, false)
+ if (t.kind == "audio" && !audio)
+ audio = true, await this.set_track_enabled(i, true, false)
+ if (t.kind == "subtitle" && !subtitle)
+ subtitle = true, await this.set_track_enabled(i, true, false)
+ }
+
+ this.set_pers("Buffering initial stream fragments...")
+
+ this.update(this.start_time)
+ this.video.currentTime = this.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.base_url, 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 display_track(t: TrackInfo): string {
+ return `${t.name}`
+}