/* 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 */ /// import { OVar, show } from "../jshelper/mod.ts"; import { e } from "../jshelper/mod.ts"; import { Logger } from "../jshelper/src/log.ts"; import { EncodingProfile } from "./jhls.d.ts"; import { TrackKind, get_track_kind } from "./mediacaps.ts"; import { Player } from "./player.ts"; import { Popup } from "./popup.ts"; import { Playersync, playersync_controls } from "./sync.ts" import { MSEPlayerTrack } from "./track/mse.ts"; globalThis.addEventListener("DOMContentLoaded", () => { if (document.body.classList.contains("player")) { if (!globalThis.MediaSource) return alert("Media Source Extension API required") const node_id = globalThis.location.pathname.split("/")[2]; const main = document.getElementById("main")!; document.getElementsByTagName("footer")[0].remove() globalThis.dispatchEvent(new Event("navigationrequiresreload")) initialize_player(main, node_id) } }) const MEDIA_KIND_ICONS: { [key in TrackKind]: [string, string] } = { video: ["tv_off", "tv"], audio: ["volume_off", "volume_up"], subtitles: ["subtitles_off", "subtitles"], } function toggle_fullscreen() { if (document.fullscreenElement) document.exitFullscreen() else document.documentElement.requestFullscreen() } function initialize_player(el: HTMLElement, node_id: string) { el.innerHTML = "" // clear the body const logger = new Logger(s => e("p", s)) const player = new Player(node_id, logger) const show_stats = new OVar(false); const idle_inhibit = new OVar(false) const sync_state = new OVar(undefined) 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 => get_track_kind(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 }) => get_track_kind(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 track_select = (kind: TrackKind) => { const button = e("div", player.active_tracks.map(_ => { const active = player.active_tracks.value.filter( ts => get_track_kind(player.tracks![ts.track_index].kind) == kind) const enabled = active.length > 0 return e("button", MEDIA_KIND_ICONS[kind][+enabled], { class: "icon", 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 }) => get_track_kind(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 = player.video.volume slider.onchange = () => player.video.volume = slider.valueAsNumber slider.onmousemove = () => player.video.volume = slider.valueAsNumber return [e("div", { class: ["jsp-controlgroup", "jsp-volumecontrol"] }, e("label", `Volume`), e("span", { class: "jsp-volume" }, player.volume.map(v => `${(v * 100).toFixed(2)}% | ${v == 0 ? "-∞" : (Math.log2(v) * 10).toFixed(2)}dB` as string )), 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 }) => get_track_kind(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", onclick }, active ? "-" : "+"), " ", 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" }) new Popup(button, popups, () => e("div", { class: "jsp-settings-popup" }, e("h2", "Settings"), playersync_controls(sync_state, player), e("button", "Launch Native Player", { onclick: () => { window.location.href = `?kind=nativefullscreen` } }) )) return button; } const controls = e("div", { class: "jsp-controls" }, player.playing.map(playing => e("button", { class: "icon" }, 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" }), 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("subtitles") ), settings_popup(), e("button", "fullscreen", { class: "icon", onclick: toggle_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.map(b => e("pre", `estimated bandwidth: ${show.metric(b, "B/s")} | ${show.metric(b * 8, "b/s")}`)), ...tracks.map((t, i) => t instanceof MSEPlayerTrack ? t.profile.map(p => e("pre", `mse track ${i}: ` + (p ? `profile ${p.id} (${show_profile(p)})` : `remux`)) ) : e("pre", `vtt track ${i}: native jvtt`)) ) ))), logger.element, popups, controls, ) el.append(pel) 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") window.history.back() else if (k.code == "KeyS") screenshot_video(player.video) else if (k.code == "KeyJ") step_track_kind("subtitles") 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 return; k.preventDefault() }) send_player_progress(node_id, player) } 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 = window.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 { 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 } function show_profile(profile: EncodingProfile): string { if (profile.audio) return `codec=${profile.audio.codec} br=${show.metric(profile.audio.bitrate, "b/s")}${profile.audio.sample_rate ? ` sr=${show.metric(profile.audio.sample_rate, "Hz")}` : ""}` if (profile.video) return `codec=${profile.video.codec} br=${show.metric(profile.video.bitrate, "b/s")} w=${profile.video.width} preset=${profile.video.preset}` if (profile.subtitles) return `codec=${profile.subtitles.codec}` return `???` }