aboutsummaryrefslogtreecommitdiff
path: root/web/script/player
diff options
context:
space:
mode:
Diffstat (limited to 'web/script/player')
-rw-r--r--web/script/player/download.ts49
-rw-r--r--web/script/player/mediacaps.ts79
-rw-r--r--web/script/player/mod.ts390
-rw-r--r--web/script/player/player.ts173
-rw-r--r--web/script/player/popup.ts72
-rw-r--r--web/script/player/sync.ts163
-rw-r--r--web/script/player/track/create.ts15
-rw-r--r--web/script/player/track/mod.ts25
-rw-r--r--web/script/player/track/mse.ts208
-rw-r--r--web/script/player/track/vtt.ts96
-rw-r--r--web/script/player/types_node.ts76
-rw-r--r--web/script/player/types_stream.ts35
12 files changed, 0 insertions, 1381 deletions
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,
-}