diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/script/backbutton.ts | 14 | ||||
| -rw-r--r-- | web/script/dangerbutton.ts | 14 | ||||
| -rw-r--r-- | web/script/import_live.ts | 64 | ||||
| m--------- | web/script/jshelper | 0 | ||||
| -rw-r--r-- | web/script/log_live.ts | 22 | ||||
| -rw-r--r-- | web/script/main.ts | 12 | ||||
| -rw-r--r-- | web/script/player/download.ts | 49 | ||||
| -rw-r--r-- | web/script/player/mediacaps.ts | 79 | ||||
| -rw-r--r-- | web/script/player/mod.ts | 390 | ||||
| -rw-r--r-- | web/script/player/player.ts | 173 | ||||
| -rw-r--r-- | web/script/player/popup.ts | 72 | ||||
| -rw-r--r-- | web/script/player/sync.ts | 163 | ||||
| -rw-r--r-- | web/script/player/track/create.ts | 15 | ||||
| -rw-r--r-- | web/script/player/track/mod.ts | 25 | ||||
| -rw-r--r-- | web/script/player/track/mse.ts | 208 | ||||
| -rw-r--r-- | web/script/player/track/vtt.ts | 96 | ||||
| -rw-r--r-- | web/script/player/types_node.ts | 76 | ||||
| -rw-r--r-- | web/script/player/types_stream.ts | 35 | ||||
| -rw-r--r-- | web/script/transition.ts | 108 |
19 files changed, 0 insertions, 1615 deletions
diff --git a/web/script/backbutton.ts b/web/script/backbutton.ts deleted file mode 100644 index 28a889a..0000000 --- a/web/script/backbutton.ts +++ /dev/null @@ -1,14 +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 { e } from "./jshelper/mod.ts"; - -globalThis.addEventListener("DOMContentLoaded", () => { - document.getElementsByTagName("nav").item(0)?.prepend( - e("a", e("p", "Back"), { class: ["back", "hybrid_button"], onclick() { history.back() } }) - ) -}) - diff --git a/web/script/dangerbutton.ts b/web/script/dangerbutton.ts deleted file mode 100644 index b33b3dc..0000000 --- a/web/script/dangerbutton.ts +++ /dev/null @@ -1,14 +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> -*/ -globalThis.addEventListener("DOMContentLoaded", () => { - document.querySelectorAll("input.danger").forEach(el => { - el.addEventListener("click", ev => { - if (!confirm(`Really ${(el as HTMLInputElement).value}?`)) { - ev.preventDefault() - } - }) - }) -}) diff --git a/web/script/import_live.ts b/web/script/import_live.ts deleted file mode 100644 index 7e5209c..0000000 --- a/web/script/import_live.ts +++ /dev/null @@ -1,64 +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 } from "./jshelper/mod.ts"; -import { e } from "./jshelper/src/element.ts"; - -interface ImportProgress { - total_items: number - finished_items: number - tasks: string[] -} - -function progress_bar(progress: OVar<number>, text: OVar<string>): HTMLElement { - const bar_inner = e("div") - bar_inner.style.height = "100%" - bar_inner.style.backgroundColor = "#444" - bar_inner.style.position = "absolute" - bar_inner.style.top = "0px" - bar_inner.style.left = "0px" - bar_inner.style.zIndex = "2" - const bar_text = e("div") - bar_text.style.position = "absolute" - bar_text.style.top = "0px" - bar_text.style.left = "0px" - bar_text.style.color = "white" - bar_text.style.zIndex = "3" - const bar_outer = e("div", bar_inner, bar_text) - bar_outer.style.position = "relative" - bar_outer.style.width = "100%" - bar_outer.style.height = "2em" - bar_outer.style.backgroundColor = "black" - bar_outer.style.borderRadius = "5px" - progress.onchangeinit(v => bar_inner.style.width = `${v * 100}%`) - text.onchangeinit(v => bar_text.textContent = v) - return bar_outer -} - -globalThis.addEventListener("DOMContentLoaded", () => { - if (!document.getElementById("admin_import")) return - const el = document.getElementById("admin_import")! - - const ws = new WebSocket(`/admin/import`) - ws.onopen = () => console.log("live progress connected"); - ws.onclose = () => console.log("live progress disconnected"); - ws.onerror = e => console.log("live progress ws error", e); - - - const progress = new OVar(0) - const text = new OVar("") - const pre = e("pre") - el.append(progress_bar(progress, text), pre) - - ws.onmessage = msg => { - if (msg.data == "done") return location.reload() - const p: ImportProgress = JSON.parse(msg.data) - text.value = `${p.finished_items} / ${p.total_items}` - progress.value = p.finished_items / p.total_items - pre.textContent = p.tasks.map((e, i) => `thread ${("#" + i).padStart(3)}: ${e}`).join("\n") - } -}) diff --git a/web/script/jshelper b/web/script/jshelper deleted file mode 160000 -Subproject 26b8e17daac3ef21d97c25e55c0812bc9e59286 diff --git a/web/script/log_live.ts b/web/script/log_live.ts deleted file mode 100644 index b8af11e..0000000 --- a/web/script/log_live.ts +++ /dev/null @@ -1,22 +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" /> -globalThis.addEventListener("DOMContentLoaded", () => { - if (!document.body.classList.contains("admin_log")) return - const log = document.getElementById("log")! - - const warnonly = new URL(globalThis.location.href).searchParams.get("warnonly") == "true" - const ws = new WebSocket(`/admin/log?stream&warnonly=${warnonly}&html=true`) - ws.onopen = () => console.log("live log connected"); - ws.onclose = () => console.log("live log disconnected"); - ws.onerror = e => console.log(`live log ws error: ${e}`); - - ws.onmessage = msg => { - log.children[0].children[0].innerHTML += msg.data - while (log.children[0].children[0].children.length > 1024) - log.children[0].children[0].children[0].remove() - } -}) diff --git a/web/script/main.ts b/web/script/main.ts deleted file mode 100644 index 303ac71..0000000 --- a/web/script/main.ts +++ /dev/null @@ -1,12 +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 "./player/mod.ts" -import "./transition.ts" -import "./backbutton.ts" -import "./dangerbutton.ts" -import "./log_live.ts" -import "./import_live.ts" diff --git a/web/script/player/download.ts b/web/script/player/download.ts deleted file mode 100644 index 1c42bad..0000000 --- a/web/script/player/download.ts +++ /dev/null @@ -1,49 +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 } from "../jshelper/mod.ts"; - -interface Measurement { time: number, duration: number, size: number } -export class SegmentDownloader { - private measurements: Measurement[] = [] - - public bandwidth_avail = new OVar(Infinity) - public bandwidth_used = new OVar(Infinity) - public total_downloaded = new OVar(0) - - constructor() { } - - async download(url: string): Promise<ArrayBuffer> { - const dl_start = performance.now(); - const res = await fetch(url) - const dl_header = performance.now(); - if (!res.ok) throw new Error("aaaaaa"); - const buf = await res.arrayBuffer() - const dl_body = performance.now(); - - this.total_downloaded.value += buf.byteLength - if (buf.byteLength > 100 * 1000) { - const m = { - time: dl_start, - duration: (dl_body - dl_header) / 1000, - size: buf.byteLength - } - this.measurements.push(m) - this.update_bandwidth() - } - return buf; - } - - update_bandwidth() { - while (this.measurements.length > 32) - this.measurements.splice(0, 1) - const total_elapsed = (performance.now() - this.measurements.reduce((a, v) => Math.min(a, v.time), 0)) / 1000; - const total_size = this.measurements.reduce((a, v) => v.size + a, 0) - const total_duration = this.measurements.reduce((a, v) => v.duration + a, 0) - this.bandwidth_avail.value = total_size / total_duration - this.bandwidth_used.value = total_size / total_elapsed - } -} diff --git a/web/script/player/mediacaps.ts b/web/script/player/mediacaps.ts deleted file mode 100644 index 9b0e934..0000000 --- a/web/script/player/mediacaps.ts +++ /dev/null @@ -1,79 +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 { FormatInfo, StreamContainer } from "./types_stream.ts"; - -const cache = new Map<string, boolean>() - -// TODO this testing method makes the assumption, that if the codec is supported on its own, it can be -// TODO arbitrarly combined with others that are supported. in reality this is true but the spec does not gurantee it. - -export async function test_media_capability(format: FormatInfo, container: StreamContainer): Promise<boolean> { - const cache_key = JSON.stringify(format) + container - const cached = cache.get(cache_key); - if (cached !== undefined) return cached - const r = await test_media_capability_inner(format, container) - console.log(`${r ? "positive" : "negative"} media capability test finished for codec=${format.codec}`); - cache.set(cache_key, r) - return r -} -async function test_media_capability_inner(format: FormatInfo, container: StreamContainer) { - if (format.codec.startsWith("S_") || format.codec.startsWith("D_")) { - // TODO do we need to check this? - return format.codec == "S_TEXT/WEBVTT" || format.codec == "S_TEXT/UTF8" || format.codec == "D_WEBVTT/SUBTITLES" - } - let res; - if (format.codec.startsWith("A_")) { - res = await navigator.mediaCapabilities.decodingInfo({ - type: "media-source", - audio: { - contentType: track_to_content_type(format, container), - samplerate: format.samplerate, - channels: "" + format.channels, - bitrate: format.bitrate, - } - }) - } - if (format.codec.startsWith("V_")) { - res = await navigator.mediaCapabilities.decodingInfo({ - type: "media-source", - video: { - contentType: track_to_content_type(format, container), - framerate: 30, // TODO get average framerate from server - width: format.width ?? 1920, - height: format.height ?? 1080, - bitrate: format.bitrate - } - }) - } - return res?.supported ?? false -} - -export function track_to_content_type(format: FormatInfo, container: StreamContainer): string { - let c = CONTAINER_TO_MIME_TYPE[container]; - if (format.codec.startsWith("A_")) c = c.replace("video/", "audio/") - return `${c}; codecs="${MASTROSKA_CODEC_MAP[format.codec]}"` -} - -const MASTROSKA_CODEC_MAP: { [key: string]: string } = { - "V_VP9": "vp9", - "V_VP8": "vp8", - "V_AV1": "av1", - "V_MPEG4/ISO/AVC": "avc1.42C01F", - "V_MPEGH/ISO/HEVC": "hev1.1.6.L93.90", - "A_OPUS": "opus", - "A_VORBIS": "vorbis", - "S_TEXT/WEBVTT": "webvtt", - "D_WEBVTT/SUBTITLES": "webvtt", -} -const CONTAINER_TO_MIME_TYPE: { [key in StreamContainer]: string } = { - webvtt: "text/webvtt", - webm: "video/webm", - matroska: "video/x-matroska", - mpeg4: "video/mp4", - jvtt: "application/jellything-vtt+json" -} 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 } -} diff --git a/web/script/player/player.ts b/web/script/player/player.ts deleted file mode 100644 index 4f59f8a..0000000 --- a/web/script/player/player.ts +++ /dev/null @@ -1,173 +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, 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}` -} diff --git a/web/script/player/popup.ts b/web/script/player/popup.ts deleted file mode 100644 index 2c406ba..0000000 --- a/web/script/player/popup.ts +++ /dev/null @@ -1,72 +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" /> - -export class Popup { - trigger_hov = false - content_hov = false - content?: HTMLElement - shown = false - - constructor( - public trigger: HTMLElement, - public container: HTMLElement, - public new_content: () => HTMLElement - ) { - trigger.onmouseenter = () => { - this.trigger_hov = true; - this.update_hov() - } - trigger.onmouseleave = () => { - this.trigger_hov = false; - this.update_hov() - } - } - - set_shown(s: boolean) { - if (this.shown == s) return - if (s) { - this.content = this.new_content() - this.content.addEventListener("mouseenter", () => { - this.content_hov = true; - this.update_hov() - }) - this.content.addEventListener("mouseleave", () => { - this.content_hov = false; - this.update_hov() - }) - this.content.classList.add("jsp-popup") - this.container.append(this.content) - } else { - const content = this.content! - content.classList.add("jsp-popup-out") - setTimeout(() => { - //@ts-ignore i know - const child_undo: undefined | (() => void) = content["jsh_undo"] - if (child_undo) child_undo() - this.container.removeChild(content) - }, 100) - this.content = undefined - } - this.shown = s - } - - hide_timeout?: number - update_hov() { - if (this.content_hov || this.trigger_hov) { - this.set_shown(true) - if (this.hide_timeout !== undefined) { - clearTimeout(this.hide_timeout) - this.hide_timeout = undefined - } - } else { - if (this.hide_timeout === undefined) this.hide_timeout = setTimeout(() => { - this.set_shown(false) - this.hide_timeout = undefined - }, 100) - } - } -} diff --git a/web/script/player/sync.ts b/web/script/player/sync.ts deleted file mode 100644 index 5f33a8e..0000000 --- a/web/script/player/sync.ts +++ /dev/null @@ -1,163 +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, e } from "../jshelper/mod.ts"; -import { Logger } from "../jshelper/src/log.ts"; -import { Player } from "./player.ts" - -export function playersync_controls(sync_state: OVar<undefined | Playersync>, player: Player) { - let channel_name: HTMLInputElement; - let channel_name_copy: HTMLInputElement; - return e("div", { class: ["jsp-controlgroup", "jsp-playersync-controls"] }, - e("h3", "Playersync"), - sync_state.map(sync => sync - ? e("div", - e("span", "Sync enabled."), - e("button", "Disable", { - onclick: () => { sync_state.value?.destroy(); sync_state.value = undefined } - }), - e("p", "Session ID: ", - channel_name_copy = e("input", { type: "text", disabled: true, value: sync.name }), - e("button", "content_paste_go", { - class: "icon", - onclick: () => { - player.logger?.log("Session ID copied to clipboard.") - navigator.clipboard.writeText(channel_name_copy.value) - } - }) - ), - e("h4", "Users"), - sync.users.map(users => - e("ul", ...[...users.keys()].map(u => e("li", u))) - ) - ) - : e("div", - channel_name = e("input", { type: "text", placeholder: "someroom:example.org" }), - e("button", "Join", { - onclick: () => { - if (!channel_name.value.length) return - sync_state.value?.destroy() - sync_state.value = new Playersync(player, player.logger!, channel_name.value) - } - }), e("br"), - e("button", "Create new session", { - onclick: () => { - sync_state.value?.destroy() - sync_state.value = new Playersync(player, player.logger!) - } - })) - ) - ) -} - -function get_username() { - return document.querySelector("nav .account .username")?.textContent ?? "Unknown User" -} - -interface Packet { - time?: number, - playing?: boolean, - join?: string, - leave?: string, -} - -export class Playersync { - private ws: WebSocket - private on_destroy: (() => void)[] = [] - - public name: string - public users = new OVar(new Map<string, null>()) - - private cancel_pers: undefined | (() => void) - set_pers(s?: string) { - if (this.cancel_pers) this.cancel_pers(), this.cancel_pers = undefined - if (s) this.cancel_pers = this.logger?.log_persistent(s) - } - - constructor(private player: Player, private logger: Logger<string>, private channel_name?: string) { - this.set_pers("Playersync enabling...") - - channel_name ??= Math.random().toString(16).padEnd(5, "0").substring(2).substring(0, 6) - let [localpart, remotepart, port] = channel_name.split(":") - if (!remotepart?.length) remotepart = globalThis.location.host - if (port) remotepart += ":" + port - this.name = localpart + ":" + remotepart - - this.ws = new WebSocket(`${globalThis.location.protocol.endsWith("s:") ? "wss" : "ws"}://${remotepart}/playersync/${encodeURIComponent(localpart)}`) - this.on_destroy.push(() => this.ws.close()) - - this.ws.onopen = () => { - this.set_pers() - this.logger.log(`Playersync connected.`) - this.send({ join: get_username() }) - } - this.ws.onerror = () => { - this.set_pers(`Playersync websocket error.`) - } - this.ws.onclose = () => { - this.set_pers(`Playersync websocket closed.`) - } - - let last_time = 0; - this.ws.onmessage = ev => { - const packet: Packet = JSON.parse(ev.data) - console.log("playersync recv", packet); - if (packet.time !== undefined) { - this.player.seek(packet.time) - last_time = packet.time - } - if (packet.playing === true) this.player.play() - if (packet.playing === false) this.player.pause() - if (packet.join) { - this.logger.log(`${packet.join} joined.`) - this.users.value.set(packet.join, null) - this.users.change() - } - if (packet.leave) { - this.logger.log(`${packet.leave} left.`) - this.users.value.delete(packet.leave) - this.users.change() - } - } - - let cb: () => void - - const send_time = () => { - const time = this.player.video.currentTime - if (Math.abs(last_time - time) < 0.01) return - this.send({ time: this.player.video.currentTime }) - } - - player.video.addEventListener("play", cb = () => { - send_time() - this.send({ playing: true }) - }) - this.on_destroy.push(() => player.video.removeEventListener("play", cb)) - - player.video.addEventListener("pause", cb = () => { - this.send({ playing: false }) - send_time() - }) - this.on_destroy.push(() => player.video.removeEventListener("pause", cb)) - - player.video.addEventListener("seeking", cb = () => { - send_time() - }) - this.on_destroy.push(() => player.video.removeEventListener("seeking", cb)) - } - - destroy() { - this.set_pers() - this.logger.log("Playersync disabled.") - this.on_destroy.forEach(f => f()) - } - - send(p: Packet) { - console.log("playersync send", p); - this.ws.send(JSON.stringify(p)) - } -} - diff --git a/web/script/player/track/create.ts b/web/script/player/track/create.ts deleted file mode 100644 index a83a26a..0000000 --- a/web/script/player/track/create.ts +++ /dev/null @@ -1,15 +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> -*/ -import { VttPlayerTrack } from "./vtt.ts"; -import { MSEPlayerTrack } from "./mse.ts"; -import { Player } from "../player.ts"; -import { PlayerTrack } from "./mod.ts"; -import { TrackInfo } from "../types_stream.ts"; - -export function create_track(player: Player, base_url: string, track_index: number, track_info: TrackInfo): PlayerTrack | undefined { - if (track_info.kind == "subtitle") return new VttPlayerTrack(player, base_url, track_index, track_info) - else return new MSEPlayerTrack(player, base_url, track_index, track_info) -} diff --git a/web/script/player/track/mod.ts b/web/script/player/track/mod.ts deleted file mode 100644 index 57f6820..0000000 --- a/web/script/player/track/mod.ts +++ /dev/null @@ -1,25 +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 { TimeRange } from "../types_stream.ts"; -import { OVar } from "../../jshelper/mod.ts"; -import { BufferRange } from "../player.ts"; - -export const TARGET_BUFFER_DURATION = 20 -export const MIN_BUFFER_DURATION = 1 - -export interface AppendRange extends TimeRange { buf: ArrayBuffer, index: number, cb: () => void } - -export abstract class PlayerTrack { - constructor( - public track_index: number, - ) { } - public buffered = new OVar<BufferRange[]>([]); - public abort = new AbortController() - async update(_target: number) { } - public abstract debug(): HTMLElement | OVar<HTMLElement> -} - diff --git a/web/script/player/track/mse.ts b/web/script/player/track/mse.ts deleted file mode 100644 index efcd0d5..0000000 --- a/web/script/player/track/mse.ts +++ /dev/null @@ -1,208 +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> -*/ -import { OVar, show } from "../../jshelper/mod.ts"; -import { test_media_capability, track_to_content_type } from "../mediacaps.ts"; -import { BufferRange, Player } from "../player.ts"; -import { PlayerTrack, AppendRange, TARGET_BUFFER_DURATION, MIN_BUFFER_DURATION } from "./mod.ts"; -import { e } from "../../jshelper/src/element.ts"; -import { FormatInfo, FragmentIndex, StreamContainer, TrackInfo } from "../types_stream.ts"; -import { show_format } from "../mod.ts"; - -interface UsableFormat { format_index: number, usable_index: number, format: FormatInfo, container: StreamContainer } - -export class MSEPlayerTrack extends PlayerTrack { - public source_buffer!: SourceBuffer; - private current_load?: AppendRange; - private loading = new Set<number>(); - private append_queue: AppendRange[] = []; - public index?: FragmentIndex - public active_format = new OVar<UsableFormat | undefined>(undefined); - public usable_formats: UsableFormat[] = [] - - constructor( - private player: Player, - private base_url: string, - track_index: number, - public trackinfo: TrackInfo, - ) { - super(track_index); - this.init() - } - - async init() { - this.buffered.value = [{ start: 0, end: this.player.duration.value, status: "loading" }] - try { - const res = await fetch(`${this.base_url}?fragmentindex&t=${this.track_index}`, { headers: { "Accept": "application/json" } }); - if (!res.ok) return this.player.error.value = "Cannot download index.", undefined; - let index!: FragmentIndex & { error: string; }; - try { index = await res.json(); } - catch (_) { this.player.set_pers("Error: Failed to fetch node"); } - if (index.error) return this.player.set_pers("server error: " + index.error), undefined; - this.index = index - } catch (e) { - if (e instanceof TypeError) { - this.player.set_pers("Cannot download index: Network Error"); - } else throw e; - } - this.buffered.value = [] - - console.log(this.trackinfo); - - for (let i = 0; i < this.trackinfo.formats.length; i++) { - const format = this.trackinfo.formats[i]; - for (const container of format.containers) { - if (container != "webm" && container != "mpeg4") continue; - if (await test_media_capability(format, container)) - this.usable_formats.push({ container, format, format_index: i, usable_index: this.usable_formats.length }) - } - } - - // TODO prefer newer codecs - const sort_key = (f: FormatInfo) => f.remux ? Infinity : f.bitrate - this.usable_formats.sort((a, b) => sort_key(b.format) - sort_key(a.format)) - if (!this.usable_formats.length) - return this.player.logger?.log("No availble format is supported by this device. The track can't be played back.") - this.active_format.value = this.usable_formats[0] - - const ct = track_to_content_type(this.active_format.value!.format, this.active_format.value!.container); - this.source_buffer = this.player.media_source.addSourceBuffer(ct); - this.abort.signal.addEventListener("abort", () => { - console.log(`destroy source buffer for track ${this.track_index}`); - this.player.media_source.removeSourceBuffer(this.source_buffer); - }); - this.source_buffer.mode = "segments"; - this.source_buffer.addEventListener("updateend", () => { - if (this.abort.signal.aborted) return; - if (this.current_load) { - this.loading.delete(this.current_load.index); - const cb = this.current_load.cb; - this.current_load = undefined; - cb() - } else { - console.warn("updateend but nothing is loading") - } - this.update_buf_ranges(); - this.tick_append(); - }); - this.source_buffer.addEventListener("error", e => { - console.error("sourcebuffer error", e); - }); - this.source_buffer.addEventListener("abort", e => { - console.error("sourcebuffer abort", e); - }); - - this.update(this.player.video.currentTime) - } - - choose_format() { - if (!this.active_format.value) return - const ai = this.active_format.value.usable_index - const current_br = this.active_format.value.format.bitrate - const avail_br = this.player.downloader.bandwidth_avail.value * 8 - const prev_format = this.active_format.value - if (ai < this.usable_formats.length - 1 && current_br > avail_br) { - this.active_format.value = this.usable_formats[ai + 1] - } - if (ai > 0 && avail_br > this.usable_formats[ai - 1].format.bitrate * 1.2) { - this.active_format.value = this.usable_formats[ai - 1] - } - if (prev_format != this.active_format.value) { - console.log(`abr ${show.metric(prev_format.format.bitrate)} -> ${show.metric(this.active_format.value.format.bitrate)}`); - this.choose_format() - } - } - - update_buf_ranges() { - if (!this.index) return; - const ranges: BufferRange[] = []; - for (let i = 0; i < this.source_buffer.buffered.length; i++) { - ranges.push({ - start: this.source_buffer.buffered.start(i), - end: this.source_buffer.buffered.end(i), - status: "buffered" - }); - } - for (const r of this.loading) { - ranges.push({ ...this.index[r], status: "loading" }); - } - this.buffered.value = ranges; - } - - override async update(target: number) { - if (!this.index) return; - this.update_buf_ranges(); // TODO required? - - const buffer_to = target + (target < 20 ? Math.max(1, target) : TARGET_BUFFER_DURATION) - - const blocking = []; - for (let i = 0; i < this.index.length; i++) { - const frag = this.index[i]; - if (frag.end < target) continue; - if (frag.start >= buffer_to) break; - if (!this.check_buf_collision(frag.start, frag.end)) continue; - if (frag.start <= target + MIN_BUFFER_DURATION) - blocking.push(this.load(i)); - else - this.load(i); - } - await Promise.all(blocking); - } - check_buf_collision(start: number, end: number) { - const EPSILON = 0.01; - for (const r of this.buffered.value) - if (r.end - EPSILON > start && r.start < end - EPSILON) - return false; - return true; - } - - async load(index: number) { - this.choose_format() - this.loading.add(index); - this.update_buf_ranges() - // TODO update format selection - const url = `${this.base_url}?fragment&t=${this.track_index}&f=${this.active_format.value!.format_index}&i=${index}&c=${this.active_format.value!.container}`; - const buf = await this.player.downloader.download(url); - await new Promise<void>(cb => { - if (!this.index) return; - if (this.abort.signal.aborted) return; - this.append_queue.push({ buf, ...this.index[index], index, cb }); - this.tick_append(); - }); - } - tick_append() { - if (this.source_buffer.updating || this.current_load) return; - if (this.append_queue.length) { - const frag = this.append_queue[0]; - this.append_queue.splice(0, 1); - this.current_load = frag; - // TODO why is appending so unreliable?! sometimes it does not add it - this.source_buffer.changeType(track_to_content_type(this.active_format.value!.format, this.active_format.value!.container)); - this.source_buffer.timestampOffset = this.active_format.value?.container == "mpeg4" ? frag.start : 0 - // this.source_buffer.timestampOffset = this.trackinfo.kind == "video" && !this.active_format.value!.format.remux ? frag.start : 0 - // this.source_buffer.timestampOffset = this.active_format.value?.format.remux ? 0 : frag.start - // this.source_buffer.timestampOffset = 0 - this.source_buffer.appendBuffer(frag.buf); - } - } - - public debug(): OVar<HTMLElement> { - const rtype = (t: string, b: BufferRange[]) => { - const c = b.filter(r => r.status == t); - // ${c.length} range${c.length != 1 ? "s" : ""} - return `${c.reduce((a, v) => a + v.end - v.start, 0).toFixed(2)}s` - } - return this.active_format.liftA2(this.buffered, (p, b) => - e("pre", - p ? - `mse track ${this.track_index}: format ${p.format_index} (${p.format.remux ? "remux" : "transcode"})` - + `\n\tformat: ${show_format(p.format)}` - + `\n\tbuffer type: ${track_to_content_type(p.format, p.container)}` - + `\n\tbuffered: ${rtype("buffered", b)} / queued: ${rtype("queued", b)} / loading: ${rtype("loading", b)}` - : "" - ) as HTMLElement - ) - } -} diff --git a/web/script/player/track/vtt.ts b/web/script/player/track/vtt.ts deleted file mode 100644 index 2152b97..0000000 --- a/web/script/player/track/vtt.ts +++ /dev/null @@ -1,96 +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> -*/ -import { e } from "../../jshelper/src/element.ts"; -import { Player } from "../player.ts"; -import { SubtitleCue, TrackInfo } from "../types_stream.ts"; -import { PlayerTrack } from "./mod.ts"; - -export class VttPlayerTrack extends PlayerTrack { - private track: TextTrack; - public cues?: SubtitleCue[] - - constructor( - private player: Player, - private node_id: string, - track_index: number, - private track_info: TrackInfo, - ) { - super(track_index); - this.track = this.player.video.addTextTrack("subtitles", this.track_info.name, this.track_info.language); - this.buffered.value = [{ start: 0, end: this.player.duration.value, status: "loading" }] - this.init() - } - - private on_ready() { - if (!this.cues) return - this.buffered.value = [{ start: 0, end: this.player.duration.value, status: "buffered" }] - for (const cue of this.cues) { - this.track.addCue(create_cue(cue)); - } - this.track.mode = "showing"; - this.abort.signal.addEventListener("abort", () => { - // TODO disable subtitles properly - this.track.mode = "hidden"; - }); - } - - async init() { - try { - const res = await fetch(`${this.player.base_url}?format=remux&segment=0&container=jvtt&track=${this.track_index}`, { headers: { "Accept": "application/json" } }); - if (!res.ok) return this.player.error.value = "Cannot download index.", undefined; - let ai!: SubtitleCue[] & { error: string; }; - try { ai = await res.json(); } - catch (_) { this.player.set_pers("Error: Failed to fetch node"); } - if (ai.error) return this.player.set_pers("server error: " + ai.error), undefined; - this.cues = ai; - } catch (e) { - if (e instanceof TypeError) { - this.player.set_pers("Cannot download subtitles: Network Error"); - return undefined - } else throw e; - } - this.on_ready() - } - - public debug(): HTMLElement { - return e("pre", `vtt track ${this.track_index}\n\t${this.cues?.length} cues loaded`) - } -} - -function create_cue(cue: SubtitleCue): VTTCue { - const c = new VTTCue(cue.start, cue.end, cue.content); - const props = parse_layout_properties(cue.content.split("\n")[0]) - if (props) { - c.text = cue.content.split("\n").slice(1).join("\n") - // TODO re-enable when it works - // // TODO this does not work at all... - // const region = new VTTRegion() - // if ("position" in props && props.position.endsWith("%")) - // region.regionAnchorX = parseFloat(props.position.replace("%", "")) - // if ("line" in props && props.line.endsWith("%")) - // region.regionAnchorY = parseFloat(props.line.replace("%", "")) - // if ("align" in props) - // c.align = props.align as AlignSetting - // c.region = region - } else { - c.line = -2; - } - return c -} - -function parse_layout_properties(s: string): undefined | Record<string, string> { - const o: Record<string, string> = {} - for (const tok of s.split(" ")) { - const [k, v, ...rest] = tok.split(":") - if (!v || rest.length) return undefined - o[k] = v - } - // some common keys to prevent false positives - if ("position" in o) return o - if ("align" in o) return o - if ("line" in o) return o - return undefined -} diff --git a/web/script/player/types_node.ts b/web/script/player/types_node.ts deleted file mode 100644 index 64f01e5..0000000 --- a/web/script/player/types_node.ts +++ /dev/null @@ -1,76 +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> -*/ - -export interface NodePublic { - kind: NodeKind, - title?: string, - tagline?: string, - description?: string, - id?: string, - path: string[], - children: string[], - release_date?: string, - index?: number, - media?: MediaInfo, - ratings: { [key in Rating]: number }, - // might be incomplete -} - -export type NodeKind = "movie" - | "video" - | "collection" - | "channel" - | "show" - | "series" - | "season" - | "episode" - -export type Rating = "imdb" - | "tmdb" - | "rotten_tomatoes" - | "metacritic" - | "youtube_views" - | "youtube_likes" - | "youtube_followers" - -export interface MediaInfo { - duration: number, - tracks: SourceTrack[], - chapters: Chapter[], -} - -export interface Chapter { - time_start?: number, - time_end?: number, - labels: { [key: string]: string } -} - -export interface SourceTrack { - kind: SourceTrackKind, - name: string, - codec: string, - language: string, -} -export type SourceTrackKind = { - video: { - width: number, - height: number, - fps: number, - } -} - | { - audio: { - channels: number, - sample_rate: number, - bit_depth: number, - } - } | "subtitle"; - -export interface NodeUserData { - watched: WatchedState -} -export type WatchedState = "none" | "watched" | "pending" | { progress: number } - diff --git a/web/script/player/types_stream.ts b/web/script/player/types_stream.ts deleted file mode 100644 index 272f98b..0000000 --- a/web/script/player/types_stream.ts +++ /dev/null @@ -1,35 +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> -*/ -export type FragmentIndex = TimeRange[] -export interface TimeRange { start: number, end: number } -export interface SubtitleCue extends TimeRange { - content: string -} -export interface StreamInfo { - name?: string, - duration: number, - tracks: TrackInfo[], -} -export type TrackKind = "video" | "audio" | "subtitle" -export interface TrackInfo { - name?: string, - language?: string, - kind: TrackKind, - formats: FormatInfo[] -} -export type StreamContainer = "webm" | "matroska" | "mpeg4" | "jvtt" | "webvtt" -export interface FormatInfo { - codec: string, - bitrate: number, - remux: boolean, - containers: StreamContainer[] - - width?: number, - height?: number, - channels?: number, - samplerate?: number, - bit_depth?: number, -} diff --git a/web/script/transition.ts b/web/script/transition.ts deleted file mode 100644 index dadb266..0000000 --- a/web/script/transition.ts +++ /dev/null @@ -1,108 +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 { e } from "./jshelper/src/element.ts"; - -interface HistoryState { - top: number -} - -const DURATION = 200 -globalThis.addEventListener("DOMContentLoaded", () => { - patch_page() -}) - -globalThis.addEventListener("popstate", async e => { - await transition_to(globalThis.location.href, e.state) -}) - -let disable_transition = false -globalThis.addEventListener("navigationrequiresreload", () => { - disable_transition = true -}) - -function patch_page() { - document.querySelectorAll("a").forEach(el => { - if (!el.href.startsWith("http")) return - if (el.target && el.target != "_self") return - el.addEventListener("click", async ev => { - ev.preventDefault() - await transition_to(el.href) - }) - }) -} - -async function transition_to(href: string, state?: HistoryState) { - stop() // nice! - if (disable_transition) return globalThis.location.href = href - const trigger_load = prepare_load(href, state) - await fade(false) - trigger_load() - disable_transition = false; -} - -function show_message(mesg: string, mode: "error" | "success" = "error") { - clear_spinner() - disable_transition = true - document.body.append(e("span", { class: ["jst-message", mode] }, mesg)) -} - -let i = 0; -function prepare_load(href: string, state?: HistoryState) { - const r_promise = fetch(href, { headers: { accept: "text/html" }, redirect: "manual" }) - return async () => { - let rt = "" - try { - const r = await r_promise - if (r.type == "opaqueredirect") { - globalThis.location.href = href - show_message("Native Player Started.", "success") - setTimeout(() => globalThis.location.reload(), 500) - return - } - if (!r.ok) return show_message("Error response. Try again.") - rt = await r.text() - } catch (e) { - if (e instanceof TypeError) return show_message("Navigation failed. Check your connection.") - return show_message("unknown error when fetching page") - } - const [head, body] = rt.split("<head>")[1].split("</head>") - globalThis.history.replaceState({ top: globalThis.scrollY, index: i++ } as HistoryState, "") - if (!state) globalThis.history.pushState({}, "", href) - clear_spinner() - document.head.innerHTML = head - document.body.outerHTML = body - globalThis.dispatchEvent(new Event("DOMContentLoaded")) - globalThis.scrollTo({ top: state?.top ?? 0 }); - fade(true) - } -} - -let spinner_timeout: number | undefined, spinner_element: HTMLElement | undefined; -function clear_spinner() { - if (spinner_timeout) clearTimeout(spinner_timeout) - if (spinner_element) spinner_element.remove() - spinner_element = spinner_timeout = undefined; -} -function fade(dir: boolean) { - const overlay = document.createElement("div") - overlay.classList.add("jst-fade") - overlay.style.backgroundColor = dir ? "black" : "transparent" - overlay.style.animationName = dir ? "jst-fadeout" : "jst-fadein" - overlay.style.animationFillMode = "forwards" - overlay.style.animationDuration = `${DURATION}ms` - document.body.appendChild(overlay) - return new Promise<void>(res => { - setTimeout(() => { - if (dir) document.body.removeChild(overlay) - spinner_timeout = setTimeout(() => { - overlay.append(spinner_element = e("div", { class: "jst-spinner" }, "This is a spinner.")) - }, 500) - res() - }, DURATION) - }) -} |