diff options
Diffstat (limited to 'web/script/player/mod.ts')
| -rw-r--r-- | web/script/player/mod.ts | 390 |
1 files changed, 0 insertions, 390 deletions
diff --git a/web/script/player/mod.ts b/web/script/player/mod.ts deleted file mode 100644 index dc9e51d..0000000 --- a/web/script/player/mod.ts +++ /dev/null @@ -1,390 +0,0 @@ -/* - 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 } -} |