aboutsummaryrefslogtreecommitdiff
path: root/ui/client-scripts/src/player/mod.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/mod.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/mod.ts')
-rw-r--r--ui/client-scripts/src/player/mod.ts390
1 files changed, 390 insertions, 0 deletions
diff --git a/ui/client-scripts/src/player/mod.ts b/ui/client-scripts/src/player/mod.ts
new file mode 100644
index 0000000..dc9e51d
--- /dev/null
+++ b/ui/client-scripts/src/player/mod.ts
@@ -0,0 +1,390 @@
+/*
+ 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, show } from "../jshelper/mod.ts";
+import { e } from "../jshelper/mod.ts";
+import { Logger } from "../jshelper/src/log.ts";
+import { Player } from "./player.ts";
+import { Popup } from "./popup.ts";
+import { Playersync, playersync_controls } from "./sync.ts"
+import { Chapter, NodePublic, NodeUserData } from "./types_node.ts";
+import { FormatInfo, TrackKind } from "./types_stream.ts";
+
+globalThis.addEventListener("DOMContentLoaded", () => {
+ if (document.body.classList.contains("player")) {
+ if (globalThis.location.search.search("nojsp") != -1) return
+ if (!globalThis.MediaSource) return alert("Media Source Extension API required")
+ const node_id = globalThis.location.pathname.split("/")[2];
+ document.getElementById("player")?.remove();
+ document.getElementsByClassName("playerconf").item(0)?.remove()
+ globalThis.dispatchEvent(new Event("navigationrequiresreload"))
+ document.getElementById("main")!.prepend(initialize_player(node_id))
+ }
+})
+
+const MEDIA_KIND_ICONS: { [key in TrackKind]: [string, string] } = {
+ video: ["tv_off", "tv"],
+ audio: ["volume_off", "volume_up"],
+ subtitle: ["subtitles_off", "subtitles"],
+}
+
+function toggle_fullscreen() {
+ if (document.fullscreenElement) document.exitFullscreen()
+ else document.documentElement.requestFullscreen()
+}
+
+// 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(globalThis.location.href)
+ const p = u.searchParams.get("t")
+ if (!p) return
+ const x = parseFloat(p)
+ if (Number.isNaN(x)) return
+ return x
+}
+
+function initialize_player(node_id: string): HTMLElement {
+ const logger = new Logger<string>(s => e("p", s))
+ const start_time = get_query_start_time() ?? 0 // TODO get_continue_time(ndata.userdata.watched);
+ const player = new Player(`/n/${encodeURIComponent(node_id)}/stream`, `/n/${encodeURIComponent(node_id)}/poster`, start_time, logger)
+ const show_stats = new OVar(false);
+ const idle_inhibit = new OVar(false)
+ const sync_state = new OVar<Playersync | undefined>(undefined)
+ const chapters = new OVar<Chapter[]>([])
+
+ fetch(`/n/${node_id}`, { headers: { Accept: "application/json" } })
+ .then(res => {
+ if (!res.ok) throw "a"
+ return res.json()
+ })
+ .catch(() => logger.log_persistent("Node data failed to download"))
+ .then(ndata_ => {
+ const ndata = ndata_ as { node: NodePublic, userdata: NodeUserData }
+ console.log(ndata.node.media!.chapters);
+ chapters.value = ndata.node.media!.chapters
+ })
+
+ //@ts-ignore for debugging
+ globalThis.player = player;
+
+ let mute_saved_volume = 1;
+ const toggle_mute = () => {
+ if (player.volume.value == 0) {
+ logger.log("Unmuted.", "volume");
+ player.volume.value = mute_saved_volume
+ }
+ else {
+ logger.log("Muted.", "volume");
+ mute_saved_volume = player.volume.value
+ player.volume.value = 0.
+ }
+ }
+ 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 popups = e("div")
+
+ const step_track_kind = (kind: TrackKind) => {
+ // TODO cycle through all of them
+ const active = player.active_tracks.value.filter(
+ ts => player.tracks![ts.track_index].kind == kind)
+ if (active.length > 0) {
+ for (const t of active) player.set_track_enabled(t.track_index, false)
+ } else {
+ const all_kind = (player.tracks ?? [])
+ .map((track, index) => ({ index, track }))
+ .filter(({ track }) => track.kind == kind)
+ if (all_kind.length < 1) return logger.log(`No ${kind} tracks available`)
+ player.set_track_enabled(all_kind[0].index, true)
+ }
+ }
+
+ const quit = () => {
+ globalThis.history.back()
+ setTimeout(() => globalThis.close(), 10)
+ }
+
+ const track_select = (kind: TrackKind) => {
+ const button = e("div", player.active_tracks.map(_ => {
+ const active = player.active_tracks.value.filter(
+ ts => player.tracks![ts.track_index].kind == kind)
+ const enabled = active.length > 0
+ return e("button", MEDIA_KIND_ICONS[kind][+enabled], {
+ class: "icon",
+ aria_label: `configure ${kind}`,
+ onclick: () => {
+ if (enabled) {
+ for (const t of active) {
+ player.set_track_enabled(t.track_index, false)
+ }
+ } else {
+ const all_kind = (player.tracks ?? [])
+ .map((track, index) => ({ index, track }))
+ .filter(({ track }) => track.kind == kind)
+ if (all_kind.length < 1) return
+ player.set_track_enabled(all_kind[0].index, true)
+ }
+ }
+ })
+ }))
+ const volume = () => {
+ const slider = e("input")
+ slider.type = "range"
+ slider.min = "0"
+ slider.max = "1"
+ slider.step = "any"
+ slider.valueAsNumber = Math.cbrt(player.video.volume)
+ const slider_mapping = (x: number) => x * x * x
+ slider.onchange = () => player.video.volume = slider_mapping(slider.valueAsNumber)
+ slider.onmousemove = () => player.video.volume = slider_mapping(slider.valueAsNumber)
+ return [e("div", { class: ["jsp-controlgroup", "jsp-volumecontrol"] },
+ e("label", `Volume`),
+ e("span", { class: "jsp-volume" }, player.volume.map(v => show_volume(v))),
+ slider
+ )]
+ }
+
+ new Popup(button, popups, () =>
+ e("div", { class: "jsp-track-select-popup" },
+ e("h2", `${kind[0].toUpperCase()}${kind.substring(1)}`),
+
+ ...(kind == "audio" ? volume() : []),
+
+ player.active_tracks.map(_ => {
+ const tracks_avail = (player.tracks ?? [])
+ .map((track, index) => ({ index, track }))
+ .filter(({ track }) => track.kind == kind);
+ if (!tracks_avail.length) return e("p", `No ${kind} tracks available.`) as HTMLElement;
+ return e("ul", { class: "jsp-track-list" }, ...tracks_avail
+ .map(({ track, index }): HTMLElement => {
+ const active = player.active_tracks.value.find(ts => ts.track_index == index) !== undefined
+ const onclick = () => {
+ player.set_track_enabled(index, !active)
+ // TODO show loading indicator
+ }
+ return e("li", { class: active ? ["active"] : [] },
+ e("button", { class: ["jsp-track-state", "icon"], onclick }, active ? "remove" : "add"), " ",
+ e("span", { class: "jsp-track-name" }, `"${track.name}"`), " ",
+ e("span", { class: "jsp-track-lang" }, `(${track.language})`)
+ )
+ })
+ )
+ })
+ )
+ )
+ return button
+ }
+
+ const settings_popup = () => {
+ const button = e("button", "settings", { class: "icon", aria_label: "settings" })
+ new Popup(button, popups, () => e("div", { class: "jsp-settings-popup" },
+ e("h2", "Settings"),
+ playersync_controls(sync_state, player),
+ e("button", "Launch Native Player", {
+ onclick: () => {
+ globalThis.location.href = `?kind=nativefullscreen&t=${player.position.value}`
+ }
+ })
+ ))
+ return button;
+ }
+
+ const controls = e("div", { class: "jsp-controls" },
+ player.playing.map(playing =>
+ e("button", { class: "icon", aria_label: "toggle pause/play" }, playing ? "pause" : "play_arrow", { onclick: toggle_playing })
+ ),
+ e("p", { class: "jsp-status" },
+ player.position.map(v => e("span", show.duration(v))), e("br"),
+ player.position.map(v => e("span", show.duration(v - player.duration.value)))
+ ),
+ pri = e("div", { class: "jsp-pri" },
+ pri_current = e("div", { class: "jsp-pri-current" }),
+ chapters.liftA2(player.duration,
+ (chapters, duration) => duration == 0 ? e("div") : e("div", ...chapters.map(chap => e("div", {
+ class: "jsp-chapter",
+ style: {
+ left: pri_map(chap.time_start ?? 0),
+ width: pri_map((chap.time_end ?? duration) - (chap.time_start ?? 0))
+ }
+ }, e("p", chap.labels[0][1]))))
+ ),
+ player.active_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),
+ height: `calc(var(--csize)/${tracks.length})`,
+ top: `calc(var(--csize)/${tracks.length}*${i})`,
+ left: pri_map(r.start)
+ }
+ })
+ ))
+ )))
+ )
+ ),
+ e("div", { class: "jsp-track-select" },
+ track_select("video"),
+ track_select("audio"),
+ track_select("subtitle")
+ ),
+ settings_popup(),
+ e("button", "fullscreen", { class: "icon", onclick: toggle_fullscreen, aria_label: "fullscreen" })
+ )
+
+ player.position.onchangeinit(p => pri_current.style.width = pri_map(p))
+
+ const pel = e("div", { class: "jsp" },
+ player.video,
+ show_stats.map(do_show => e("div", player.active_tracks.map(tracks =>
+ !do_show ? e("div") : e("div", { class: "jsp-stats" },
+ player.downloader.bandwidth_avail.map(b => e("pre", `estimated available bandwidth: ${show.metric(b, "B/s")} | ${show.metric(b * 8, "b/s")}`)),
+ player.downloader.bandwidth_used.map(b => e("pre", `estimated used bandwidth: ${show.metric(b, "B/s")} | ${show.metric(b * 8, "b/s")}`)),
+ player.downloader.total_downloaded.map(b => e("pre", `downloaded bytes total: ${show.metric(b, "B")}`)),
+ player.position.map(_ => e("pre", `frames dropped: ${player.video.getVideoPlaybackQuality().droppedVideoFrames}`)),
+ OVar.interval(() => e("pre", ""
+ + `video resolution: source: ${player.video.videoWidth}x${player.video.videoHeight}\n`
+ + ` display: ${player.video.clientWidth}x${player.video.clientHeight}`), 100),
+ ...tracks.map(t => t.debug())
+ )
+ ))),
+ logger.element,
+ popups,
+ controls,
+ )
+
+ controls.onmouseenter = () => idle_inhibit.value = true
+ controls.onmouseleave = () => idle_inhibit.value = false
+ mouse_idle(pel, 1000)
+ .liftA2(idle_inhibit, (x, y) => x && !y)
+ .onchangeinit(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.ctrlKey || k.altKey || k.metaKey) return
+ if (k.code == "Period") player.pause(), player.frame_forward()
+ else if (k.code == "Space") toggle_playing()
+ else if (k.code == "KeyP") toggle_playing()
+ else if (k.code == "KeyF") toggle_fullscreen()
+ else if (k.code == "KeyQ") quit()
+ else if (k.code == "KeyS") screenshot_video(player.video)
+ else if (k.code == "KeyJ") step_track_kind("subtitle")
+ else if (k.code == "KeyM") toggle_mute()
+ else if (k.code == "Digit9") (player.volume.value /= 1.2), logger.log(`Volume decreased to ${show_volume(player.volume.value)}`, "volume")
+ else if (k.code == "Digit0") (player.volume.value *= 1.2), logger.log(`Volume increased to ${show_volume(player.volume.value)}`, "volume")
+ else if (k.key == "#") step_track_kind("audio")
+ else if (k.key == "_") step_track_kind("video")
+ else if (k.code == "KeyV") show_stats.value = !show_stats.value
+ 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 if (k.code == "PageUp") player.seek(find_closest_chaps(player.position.value, chapters.value).prev?.time_start ?? 0)
+ else if (k.code == "PageDown") player.seek(find_closest_chaps(player.position.value, chapters.value).next?.time_start ?? player.duration.value)
+ else return;
+ k.preventDefault()
+ })
+ send_player_progress(node_id, player)
+
+ return pel
+}
+
+function screenshot_video(video: HTMLVideoElement) {
+ // TODO bug: video needs to be played to take a screenshot. if you have just seeked somewhere it wont work.
+ const canvas = document.createElement("canvas")
+ canvas.width = video.videoWidth
+ canvas.height = video.videoHeight
+ const context = canvas.getContext("2d")!
+ context.fillStyle = "#ff00ff"
+ context.fillRect(0, 0, video.videoWidth, video.videoHeight)
+ context.drawImage(video, 0, 0)
+ canvas.toBlob(blob => {
+ if (!blob) throw new Error("failed to create blob");
+ const a = document.createElement("a");
+ a.download = "screenshot.webp";
+ a.href = globalThis.URL.createObjectURL(blob)
+ a.click()
+ setTimeout(() => URL.revokeObjectURL(a.href), 0)
+ }, "image/webp", 0.95)
+}
+
+let sent_watched = false;
+function send_player_progress(node_id: string, player: Player) {
+ let t = 0;
+ setInterval(() => {
+ const nt = player.video.currentTime
+ if (t != nt) {
+ t = nt
+ const start = nt < 1 * 60
+ const end = nt > player.duration.value - 5 * 60
+
+ if (!start) fetch(`/n/${encodeURIComponent(node_id)}/progress?t=${nt}`, { method: "POST" })
+ if (end && !sent_watched) {
+ fetch(`/n/${encodeURIComponent(node_id)}/watched?state=watched`, { method: "POST" })
+ sent_watched = true;
+ }
+ }
+ }, 10000)
+}
+
+function mouse_idle(e: HTMLElement, timeout: number): OVar<boolean> {
+ let ct: number;
+ const idle = new OVar(false)
+ e.onmouseleave = () => {
+ clearTimeout(ct)
+ }
+ e.onmousemove = () => {
+ clearTimeout(ct)
+ if (idle) {
+ idle.value = false
+ }
+ ct = setTimeout(() => {
+ idle.value = true
+ }, timeout)
+ }
+ return idle
+}
+
+export function show_format(format: FormatInfo): string {
+ let o = `${format.codec} br=${show.metric(format.bitrate, "b/s")} ac=${format.containers.join(",")}`
+ if (format.width) o += ` w=${format.width}`
+ if (format.height) o += ` h=${format.height}`
+ if (format.samplerate) o += ` ar=${show.metric(format.samplerate, "Hz")}`
+ if (format.channels) o += ` ac=${format.channels}`
+ if (format.bit_depth) o += ` bits=${format.bit_depth}`
+ return o
+}
+export function show_volume(v: number): string {
+ return `${v == 0 ? "-∞" : (Math.log10(v) * 10).toFixed(2)}dB | ${(v * 100).toFixed(2)}%`
+}
+
+function find_closest_chaps(position: number, chapters: Chapter[]) {
+ let prev, next;
+ for (const c of chapters) {
+ const t_start = (c.time_start ?? 0)
+ next = c;
+ if (t_start > position) break
+ prev = c;
+ }
+ return { next, prev }
+}