diff options
| author | metamuffin <metamuffin@disroot.org> | 2026-01-18 23:43:12 +0100 |
|---|---|---|
| committer | metamuffin <metamuffin@disroot.org> | 2026-01-18 23:43:12 +0100 |
| commit | ed19a428cb5eef84c8cf3fed5fda3afd5fc96305 (patch) | |
| tree | 39e3167a4f8b7423a15b3a5f56e973554bdb3195 /ui/client-scripts/src/player/mod.ts | |
| parent | 901dff07ed357694eb35284a58c3cc6c003c53ce (diff) | |
| download | jellything-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.ts | 390 |
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 } +} |