From c59abb792391e2f7540a80bb8d989021fe0a5b80 Mon Sep 17 00:00:00 2001 From: metamuffin Date: Tue, 15 Apr 2025 13:54:52 +0200 Subject: refactor jsp, part 1 --- web/script/player/jhls.d.ts | 103 -------------------------------------- web/script/player/mediacaps.ts | 96 +++++++++++------------------------ web/script/player/mod.ts | 88 +++++++++++++++++++------------- web/script/player/player.ts | 62 ++++++++--------------- web/script/player/profiles.ts | 83 ------------------------------ web/script/player/profiles.ts_ | 80 +++++++++++++++++++++++++++++ web/script/player/track/create.ts | 10 ++-- web/script/player/track/mod.ts | 2 +- web/script/player/track/mse.ts | 63 +++++++++++------------ web/script/player/track/vtt.ts | 6 +-- web/script/player/types_node.ts | 76 ++++++++++++++++++++++++++++ web/script/player/types_stream.ts | 39 +++++++++++++++ 12 files changed, 335 insertions(+), 373 deletions(-) delete mode 100644 web/script/player/jhls.d.ts delete mode 100644 web/script/player/profiles.ts create mode 100644 web/script/player/profiles.ts_ create mode 100644 web/script/player/types_node.ts create mode 100644 web/script/player/types_stream.ts (limited to 'web/script') diff --git a/web/script/player/jhls.d.ts b/web/script/player/jhls.d.ts deleted file mode 100644 index c7325e4..0000000 --- a/web/script/player/jhls.d.ts +++ /dev/null @@ -1,103 +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) 2025 metamuffin -*/ - -export interface JhlsTrackIndex { - fragments: TimeRange[], - extra_profiles: EncodingProfile[], -} - -export interface TimeRange { start: number, end: number } - -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, - } - } | "subtitles"; - -export interface EncodingProfile { - video?: { - codec: string, - preset: number, - bitrate: number, - width: number, - }, - audio?: { - codec: string, - bitrate: number, - sample_rate?: number, - }, - subtitles?: { - codec: string, - }, -} - -export interface NodeUserData { - watched: WatchedState -} -export type WatchedState = "none" | "watched" | "pending" | { progress: number } - -export interface JvttCue extends TimeRange { - content: string -} \ No newline at end of file diff --git a/web/script/player/mediacaps.ts b/web/script/player/mediacaps.ts index e44b92b..037a84b 100644 --- a/web/script/player/mediacaps.ts +++ b/web/script/player/mediacaps.ts @@ -4,85 +4,58 @@ Copyright (C) 2025 metamuffin */ /// -import { EncodingProfile, SourceTrack, SourceTrackKind } from "./jhls.d.ts"; + +import { FormatInfo, StreamContainer } from "./types_stream.ts"; const cache = new Map() // 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(track: SourceTrack): Promise { - const cache_key = `${get_track_kind(track.kind)};${track.codec}` +export async function test_media_capability(format: FormatInfo, container: StreamContainer): Promise { + 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(track) - console.log(`${r ? "positive" : "negative"} media capability test finished for codec=${track.codec}`); + 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(track: SourceTrack) { - if (track.kind == "subtitles") { +async function test_media_capability_inner(format: FormatInfo, container: StreamContainer) { + if (format.codec.startsWith("S_") || format.codec.startsWith("V_") || format.codec.startsWith("D_")) { // TODO do we need to check this? - return track.codec == "V_TEXT/WEBVTT" || track.codec == "D_WEBVTT/SUBTITLES" + return format.codec == "V_TEXT/WEBVTT" || format.codec == "D_WEBVTT/SUBTITLES" } let res; - const codec = MASTROSKA_CODEC_MAP[track.codec] - if (!codec) return console.warn(`unknown codec: ${track.codec}`), false - if ("audio" in track.kind) { + if (format.codec.startsWith("A_")) { res = await navigator.mediaCapabilities.decodingInfo({ type: "media-source", audio: { - contentType: `audio/webm; codecs=${codec}`, - samplerate: track.kind.audio.sample_rate, - channels: "" + track.kind.audio.channels, - bitrate: 128 * 1000, + contentType: track_to_content_type(format, container), + samplerate: format.samplerate, + channels: "" + format.channels, + bitrate: format.bitrate, } }) } - if ("video" in track.kind) { + if (format.codec.startsWith("V_")) { res = await navigator.mediaCapabilities.decodingInfo({ type: "media-source", video: { - contentType: `video/webm; codecs=${codec}`, - framerate: track.kind.video.fps || 30, - width: track.kind.video.width, - height: track.kind.video.height, - bitrate: 5 * 1000 * 1000 // TODO we dont know this but we should in the future + 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 } }) } + console.log(format, res); return res?.supported ?? false } -export function track_to_content_type(track: SourceTrack): string | undefined { - if (track.kind == "subtitles") return "video/webm" - const codec = MASTROSKA_CODEC_MAP[track.codec] - if (!codec) return - return `${get_track_kind(track.kind)}/webm; codecs="${codec}"` -} -export function profile_to_partial_track(profile: EncodingProfile): SourceTrack { - if (profile.audio) { - return { - codec: FFMPEG_ENCODER_CODEC_MAP[profile.audio.codec], - kind: { audio: { bit_depth: 16, channels: 2, sample_rate: 48000 } }, - name: "test audio", - language: "en" - } - } else if (profile.video) { - return { - codec: FFMPEG_ENCODER_CODEC_MAP[profile.video.codec], - kind: { video: { fps: 30, height: 1080, width: 1090 } }, - language: "en", - name: "test video" - } - } else if (profile.subtitles) { - return { - codec: FFMPEG_ENCODER_CODEC_MAP[profile.subtitles.codec], - kind: "subtitles", - language: "en", - name: "test subtitle" - } - } else throw new Error("unreachable"); +export function track_to_content_type(format: FormatInfo, container: StreamContainer): string { + return `${CONTAINER_TO_MIME_TYPE[container]}; codecs="${MASTROSKA_CODEC_MAP[format.codec]}"` } const MASTROSKA_CODEC_MAP: { [key: string]: string } = { @@ -96,21 +69,10 @@ const MASTROSKA_CODEC_MAP: { [key: string]: string } = { "S_TEXT/WEBVTT": "webvtt", "D_WEBVTT/SUBTITLES": "webvtt", } - -const FFMPEG_ENCODER_CODEC_MAP: { [key: string]: string } = { - "libsvtav1": "V_AV1", - "libvpx": "V_VP8", - "libvpx-vp9": "V_VP9", - "opus": "A_OPUS", - "libopus": "A_OPUS", -} - -export type TrackKind = "audio" | "video" | "subtitles" -export function get_track_kind(track: SourceTrackKind): TrackKind { - // TODO why different encodings for "subtitles"? - if (track == "subtitles") return "subtitles" - if ("subtitles" in track) return "subtitles" - if ("audio" in track) return "audio" - if ("video" in track) return "video" - throw new Error("invalid track"); +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 index 15c37da..82ee287 100644 --- a/web/script/player/mod.ts +++ b/web/script/player/mod.ts @@ -7,11 +7,11 @@ import { OVar, show } from "../jshelper/mod.ts"; import { e } from "../jshelper/mod.ts"; import { Logger } from "../jshelper/src/log.ts"; -import { EncodingProfile } from "./jhls.d.ts"; -import { TrackKind, get_track_kind } from "./mediacaps.ts"; import { Player } from "./player.ts"; import { Popup } from "./popup.ts"; import { Playersync, playersync_controls } from "./sync.ts" +import { WatchedState } from "./types_node.ts"; +import { FormatInfo, TrackKind } from "./types_stream.ts"; globalThis.addEventListener("DOMContentLoaded", () => { if (document.body.classList.contains("player")) { @@ -36,12 +36,26 @@ function toggle_fullscreen() { 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(el: HTMLElement, node_id: string) { el.innerHTML = "" // clear the body const logger = new Logger(s => e("p", s)) - const player = new Player(node_id, logger) + const 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(undefined) @@ -72,13 +86,13 @@ function initialize_player(el: HTMLElement, node_id: string) { const step_track_kind = (kind: TrackKind) => { // TODO cycle through all of them const active = player.active_tracks.value.filter( - ts => get_track_kind(player.tracks![ts.track_index].kind) == kind) + 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 }) => get_track_kind(track.kind) == kind) + .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) } @@ -92,7 +106,7 @@ function initialize_player(el: HTMLElement, node_id: string) { const track_select = (kind: TrackKind) => { const button = e("div", player.active_tracks.map(_ => { const active = player.active_tracks.value.filter( - ts => get_track_kind(player.tracks![ts.track_index].kind) == kind) + ts => player.tracks![ts.track_index].kind == kind) const enabled = active.length > 0 return e("button", MEDIA_KIND_ICONS[kind][+enabled], { class: "icon", @@ -105,7 +119,7 @@ function initialize_player(el: HTMLElement, node_id: string) { } else { const all_kind = (player.tracks ?? []) .map((track, index) => ({ index, track })) - .filter(({ track }) => get_track_kind(track.kind) == kind) + .filter(({ track }) => track.kind == kind) if (all_kind.length < 1) return player.set_track_enabled(all_kind[0].index, true) } @@ -138,7 +152,7 @@ function initialize_player(el: HTMLElement, node_id: string) { player.active_tracks.map(_ => { const tracks_avail = (player.tracks ?? []) .map((track, index) => ({ index, track })) - .filter(({ track }) => get_track_kind(track.kind) == kind); + .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 => { @@ -184,15 +198,16 @@ function initialize_player(el: HTMLElement, node_id: string) { ), pri = e("div", { class: "jsp-pri" }, pri_current = e("div", { class: "jsp-pri-current" }), - player.chapters.map( - chapters => e("div", ...chapters.map(chap => e("div", { - class: "jsp-chapter", - style: { - left: pri_map(chap.time_start ?? 0), - width: pri_map((chap.time_end ?? player.duration.value) - (chap.time_start ?? 0)) - } - }, e("p", chap.labels[0][1])))) - ), + // TODO + // player.chapters.map( + // chapters => e("div", ...chapters.map(chap => e("div", { + // class: "jsp-chapter", + // style: { + // left: pri_map(chap.time_start ?? 0), + // width: pri_map((chap.time_end ?? player.duration.value) - (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( @@ -275,8 +290,8 @@ function initialize_player(el: HTMLElement, node_id: string) { 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).prev?.time_start ?? 0) - else if (k.code == "PageDown") player.seek(find_closest_chaps(player).next?.time_start ?? player.duration.value) + // else if (k.code == "PageUp") player.seek(find_closest_chaps(player).prev?.time_start ?? 0) + // else if (k.code == "PageDown") player.seek(find_closest_chaps(player).next?.time_start ?? player.duration.value) else return; k.preventDefault() }) @@ -339,25 +354,26 @@ function mouse_idle(e: HTMLElement, timeout: number): OVar { return idle } -export function show_profile(profile: EncodingProfile): string { - if (profile.audio) return `codec=${profile.audio.codec} br=${show.metric(profile.audio.bitrate, "b/s")}${profile.audio.sample_rate ? ` sr=${show.metric(profile.audio.sample_rate, "Hz")}` : ""}` - if (profile.video) return `codec=${profile.video.codec} br=${show.metric(profile.video.bitrate, "b/s")} w=${profile.video.width} preset=${profile.video.preset}` - if (profile.subtitles) return `codec=${profile.subtitles.codec}` - return `???` +export function show_format(format: FormatInfo): string { + // if (format.audio) return `codec=${format.audio.codec} br=${show.metric(format.audio.bitrate, "b/s")}${format.audio.sample_rate ? ` sr=${show.metric(format.audio.sample_rate, "Hz")}` : ""}` + // if (format.video) return `codec=${format.video.codec} br=${show.metric(format.video.bitrate, "b/s")} w=${format.video.width} preset=${format.video.preset}` + // if (format.subtitles) return `codec=${format.subtitles.codec}` + return `TODO` } 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(player: Player) { - const now = player.position.value - const chaps = player.chapters.value - let prev, next; - for (const c of chaps) { - const t_start = (c.time_start ?? 0) - next = c; - if (t_start > now) break - prev = c; - } - return { next, prev } -} +// TODO +// function find_closest_chaps(player: Player) { +// const now = player.position.value +// const chaps = player.chapters.value +// let prev, next; +// for (const c of chaps) { +// const t_start = (c.time_start ?? 0) +// next = c; +// if (t_start > now) break +// prev = c; +// } +// return { next, prev } +// } diff --git a/web/script/player/player.ts b/web/script/player/player.ts index e0a6ddf..f44c14f 100644 --- a/web/script/player/player.ts +++ b/web/script/player/player.ts @@ -5,20 +5,18 @@ */ /// import { OVar, e } from "../jshelper/mod.ts"; -import { NodePublic, NodeUserData, SourceTrack, TimeRange } from "./jhls.d.ts"; import { SegmentDownloader } from "./download.ts"; import { PlayerTrack } from "./track/mod.ts"; import { Logger } from "../jshelper/src/log.ts"; -import { WatchedState, Chapter } from "./jhls.d.ts"; -import { get_track_kind } from "./mediacaps.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 tracks?: SourceTrack[]; - public chapters = new OVar([]); + public streaminfo?: StreamInfo; + public tracks?: TrackInfo[]; public active_tracks = new OVar([]); public downloader: SegmentDownloader = new SegmentDownloader(); @@ -35,8 +33,8 @@ export class Player { if (s) this.cancel_buffering_pers = this.logger?.log_persistent(s) } - constructor(public node_id: string, public logger?: Logger) { - this.video.poster = `/n/${encodeURIComponent(node_id)}/poster` + constructor(public base_url: string, poster: string, private start_time: number, public logger?: Logger) { + this.video.poster = poster this.volume.value = this.video.volume let skip_change = false; this.volume.onchange(v => { @@ -100,40 +98,38 @@ export class Player { } async fetch_meta() { - this.set_pers("Loading metadata...") - const res = await fetch(`/n/${encodeURIComponent(this.node_id)}`, { headers: { "Accept": "application/json" } }) - if (!res.ok) return this.error.value = "Cannot download node." + 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 ndata!: { node: NodePublic, userdata: NodeUserData } & { error: string } - try { ndata = await res.json() } + let streaminfo!: StreamInfo & { error: string } + try { streaminfo = await res.json() } catch (_) { this.set_pers("Error: Node data invalid") } - if (ndata.error) return this.set_pers("server error: " + ndata.error) + 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 = ndata.node.media!.duration - this.chapters.value = ndata.node.media!.chapters - this.tracks = ndata.node.media!.tracks + this.duration.value = streaminfo.segments[0].duration + this.streaminfo = streaminfo + this.tracks = streaminfo!.segments[0].tracks; this.video.src = URL.createObjectURL(this.media_source) this.media_source.addEventListener("sourceopen", async () => { let video = false, audio = false, subtitles = false; for (let i = 0; i < this.tracks!.length; i++) { const t = this.tracks![i]; - const kind = get_track_kind(t.kind) - if (kind == "video" && !video) + if (t.kind == "video" && !video) video = true, await this.set_track_enabled(i, true, false) - if (kind == "audio" && !audio) + if (t.kind == "audio" && !audio) audio = true, await this.set_track_enabled(i, true, false) - if (kind == "subtitles" && !subtitles) + if (t.kind == "subtitles" && !subtitles) subtitles = true, await this.set_track_enabled(i, true, false) } this.set_pers("Buffering initial stream fragments...") - const start_time = get_query_start_time() ?? get_continue_time(ndata.userdata.watched); - this.update(start_time) - this.video.currentTime = start_time + this.update(this.start_time) + this.video.currentTime = this.start_time await this.canplay.wait_for(true) this.set_pers() @@ -153,7 +149,7 @@ export class Player { 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.node_id, index, this.tracks![index])!) + this.active_tracks.value.push(create_track(this, this.base_url, 0, index, this.tracks![index])!) if (update) await this.update() } this.active_tracks.change() @@ -172,20 +168,6 @@ export class Player { } } -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 display_track(t: SourceTrack): string { - return `"${t.name}" (${t.language})` +function display_track(t: TrackInfo): string { + return `${t.name}` } diff --git a/web/script/player/profiles.ts b/web/script/player/profiles.ts deleted file mode 100644 index 5ebdeb4..0000000 --- a/web/script/player/profiles.ts +++ /dev/null @@ -1,83 +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) 2025 metamuffin -*/ -/// -import { OVar } from "../jshelper/mod.ts"; -import { EncodingProfile, SourceTrackKind } from "./jhls.d.ts"; -import { get_track_kind } from "./mediacaps.ts"; -import { profile_to_partial_track, test_media_capability } from "./mediacaps.ts"; -import { Player } from "./player.ts"; -import { MSEPlayerTrack } from "./track/mse.ts"; - -const PROFILE_UP_FAC = 0.6 -const PROFILE_DOWN_FAC = 0.8 - -export interface EncodingProfileExt extends EncodingProfile { id: number, order: number } -export class ProfileSelector { - profiles: EncodingProfileExt[] = [] - is_init = false - - constructor( - private player: Player, - private track: MSEPlayerTrack, - private bandwidth: OVar - ) { - } - async init() { - for (let id = 0; id < this.track.index!.extra_profiles.length; id++) { - const p = this.track.index!.extra_profiles[id]; - // TODO hacky type casting solution - if (get_track_kind(this.track.metadata.kind) != get_track_kind(p as unknown as SourceTrackKind)) continue - if (!await test_media_capability(profile_to_partial_track(p))) continue - this.profiles.push({ id, order: 0, ...p }) - } - this.profiles.sort((a, b) => profile_byterate(b) - profile_byterate(a)) - for (let i = 0; i < this.profiles.length; i++) this.profiles[i].order = i - } - async remux_supported(track: number): Promise { - return await test_media_capability(this.player.tracks![track]) - } - async select_optimal_profile(track: number, profile: OVar) { - if (!this.is_init) await this.init(), this.is_init = true; - - const sup_remux = await this.remux_supported(track); - if (!sup_remux && !this.profiles.length) { - this.player.logger?.log("None of the available codecs are supported. This track can't be played back.") - return false - } - const min_prof = sup_remux ? -1 : 0 - const co = profile.value?.order ?? min_prof - // TODO use actual bitrate as a fallback. the server should supply it. - const current_bitrate = profile_byterate(this.profiles[co], 500 * 1000) - const next_bitrate = profile_byterate(this.profiles[co - 1], 500 * 1000) - // console.log({ current_bitrate, next_bitrate, co, bandwidth: this.bandwidth.value * 8 }); - if (!sup_remux && !profile.value) profile.value = this.profiles[co]; - if (current_bitrate > this.bandwidth.value * PROFILE_DOWN_FAC && co + 1 < this.profiles.length) { - console.log("profile up"); - profile.value = this.profiles[co + 1] - this.log_change(track, profile.value) - } - if (next_bitrate < this.bandwidth.value * PROFILE_UP_FAC && co > min_prof) { - console.log("profile down"); - profile.value = this.profiles[co - 1] - this.log_change(track, profile.value) - } - - // profile.value = profs[0] - return true - } - - log_change(track: number, p: EncodingProfileExt | undefined) { - const ps = p ? `transcoding profile ${p.id}` : `remuxed original` - this.player.logger?.log(`Track #${track} switched to ${ps}`) - } -} - -function profile_byterate(p?: EncodingProfile, fallback = 0): number { - if (p?.audio) return p.audio.bitrate / 8 - if (p?.video) return p.video.bitrate / 8 - if (p?.subtitles) return 100 - return fallback -} diff --git a/web/script/player/profiles.ts_ b/web/script/player/profiles.ts_ new file mode 100644 index 0000000..943639c --- /dev/null +++ b/web/script/player/profiles.ts_ @@ -0,0 +1,80 @@ +/* + 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) 2025 metamuffin +*/ +/// +import { OVar } from "../jshelper/mod.ts"; +import { Player } from "./player.ts"; +import { MSEPlayerTrack } from "./track/mse.ts"; + +const PROFILE_UP_FAC = 0.6 +const PROFILE_DOWN_FAC = 0.8 + +export interface EncodingProfileExt extends EncodingProfile { id: number, order: number } +export class ProfileSelector { + profiles: EncodingProfileExt[] = [] + is_init = false + + constructor( + private player: Player, + private track: MSEPlayerTrack, + private bandwidth: OVar + ) { + } + async init() { + for (let id = 0; id < this.track.index!.extra_profiles.length; id++) { + const p = this.track.index!.extra_profiles[id]; + // TODO hacky type casting solution + if (get_track_kind(this.track.trackinfo.kind) != get_track_kind(p as unknown as SourceTrackKind)) continue + if (!await test_media_capability(profile_to_partial_track(p))) continue + this.profiles.push({ id, order: 0, ...p }) + } + this.profiles.sort((a, b) => profile_byterate(b) - profile_byterate(a)) + for (let i = 0; i < this.profiles.length; i++) this.profiles[i].order = i + } + async remux_supported(track: number): Promise { + return await test_media_capability(this.player.tracks![track]) + } + async select_optimal_profile(track: number, profile: OVar) { + if (!this.is_init) await this.init(), this.is_init = true; + + const sup_remux = await this.remux_supported(track); + if (!sup_remux && !this.profiles.length) { + this.player.logger?.log("None of the available codecs are supported. This track can't be played back.") + return false + } + const min_prof = sup_remux ? -1 : 0 + const co = profile.value?.order ?? min_prof + // TODO use actual bitrate as a fallback. the server should supply it. + const current_bitrate = profile_byterate(this.profiles[co], 500 * 1000) + const next_bitrate = profile_byterate(this.profiles[co - 1], 500 * 1000) + // console.log({ current_bitrate, next_bitrate, co, bandwidth: this.bandwidth.value * 8 }); + if (!sup_remux && !profile.value) profile.value = this.profiles[co]; + if (current_bitrate > this.bandwidth.value * PROFILE_DOWN_FAC && co + 1 < this.profiles.length) { + console.log("profile up"); + profile.value = this.profiles[co + 1] + this.log_change(track, profile.value) + } + if (next_bitrate < this.bandwidth.value * PROFILE_UP_FAC && co > min_prof) { + console.log("profile down"); + profile.value = this.profiles[co - 1] + this.log_change(track, profile.value) + } + + // profile.value = profs[0] + return true + } + + log_change(track: number, p: EncodingProfileExt | undefined) { + const ps = p ? `transcoding profile ${p.id}` : `remuxed original` + this.player.logger?.log(`Track #${track} switched to ${ps}`) + } +} + +function profile_byterate(p?: EncodingProfile, fallback = 0): number { + if (p?.audio) return p.audio.bitrate / 8 + if (p?.video) return p.video.bitrate / 8 + if (p?.subtitles) return 100 + return fallback +} diff --git a/web/script/player/track/create.ts b/web/script/player/track/create.ts index 1aaf12c..95bccca 100644 --- a/web/script/player/track/create.ts +++ b/web/script/player/track/create.ts @@ -3,15 +3,13 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ -import { get_track_kind } from "../mediacaps.ts"; import { VttPlayerTrack } from "./vtt.ts"; import { MSEPlayerTrack } from "./mse.ts"; import { Player } from "../player.ts"; -import { SourceTrack } from "../jhls.d.ts"; import { PlayerTrack } from "./mod.ts"; +import { TrackInfo } from "../types_stream.ts"; -export function create_track(player: Player, node_id: string, track_index: number, metadata: SourceTrack): PlayerTrack | undefined { - const kind = get_track_kind(metadata.kind) - if (kind == "subtitles") return new VttPlayerTrack(player, node_id, track_index, metadata) - else return new MSEPlayerTrack(player, node_id, track_index, metadata) +export function create_track(player: Player, base_url: string, segment_index: number, track_index: number, track_info: TrackInfo): PlayerTrack | undefined { + if (track_info.kind == "subtitles") return new VttPlayerTrack(player, base_url, track_index, track_info) + else return new MSEPlayerTrack(player, base_url, segment_index, track_index, track_info) } diff --git a/web/script/player/track/mod.ts b/web/script/player/track/mod.ts index 0c7c1c0..5a91209 100644 --- a/web/script/player/track/mod.ts +++ b/web/script/player/track/mod.ts @@ -4,7 +4,7 @@ Copyright (C) 2025 metamuffin */ /// -import { TimeRange } from "../jhls.d.ts"; +import { TimeRange } from "../types_stream.ts"; import { OVar } from "../../jshelper/mod.ts"; import { BufferRange } from "../player.ts"; diff --git a/web/script/player/track/mse.ts b/web/script/player/track/mse.ts index d1a8c12..066bbfd 100644 --- a/web/script/player/track/mse.ts +++ b/web/script/player/track/mse.ts @@ -3,41 +3,41 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ -import { JhlsTrackIndex, SourceTrack } from "../jhls.d.ts"; import { OVar } from "../../jshelper/mod.ts"; -import { profile_to_partial_track, track_to_content_type } from "../mediacaps.ts"; +import { track_to_content_type } from "../mediacaps.ts"; import { BufferRange, Player } from "../player.ts"; -import { EncodingProfileExt, ProfileSelector } from "../profiles.ts"; import { PlayerTrack, AppendRange, TARGET_BUFFER_DURATION, MIN_BUFFER_DURATION } from "./mod.ts"; -import { show_profile } from "../mod.ts"; import { e } from "../../jshelper/src/element.ts"; +import { FormatInfo, FragmentIndex, StreamContainer, TrackInfo } from "../types_stream.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(); private append_queue: AppendRange[] = []; - public profile_selector: ProfileSelector; - public profile = new OVar(undefined); - public index?: JhlsTrackIndex + public index?: FragmentIndex + public active_format = new OVar(undefined); + public usable_formats: UsableFormat[] = [] constructor( private player: Player, - private node_id: string, + private base_url: string, + private segment_index: number, track_index: number, - public metadata: SourceTrack, + public trackinfo: TrackInfo, ) { super(track_index); - this.profile_selector = new ProfileSelector(player, this, player.downloader.bandwidth_avail); this.init() } async init() { this.buffered.value = [{ start: 0, end: this.player.duration.value, status: "loading" }] try { - const res = await fetch(`/n/${encodeURIComponent(this.node_id)}/stream?format=jhlsi&track=${this.track_index}`, { headers: { "Accept": "application/json" } }); + const res = await fetch(`${this.base_url}?fragmentindex&segment=${this.segment_index}&track=${this.track_index}`, { headers: { "Accept": "application/json" } }); if (!res.ok) return this.player.error.value = "Cannot download index.", undefined; - let index!: JhlsTrackIndex & { error: string; }; + 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; @@ -49,10 +49,9 @@ export class MSEPlayerTrack extends PlayerTrack { } this.buffered.value = [] - const canplay = await this.profile_selector.select_optimal_profile(this.track_index, this.profile); - if (!canplay) return this.player.set_track_enabled(this.track_index, false) - const ct = track_to_content_type(this.track_from_profile())!; - console.log(`track ${this.track_index} source buffer content-type: ${ct}`); + this.active_format.value = { usable_index: 0, format_index: 0, container: "webm", format: this.trackinfo.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}`); @@ -81,10 +80,6 @@ export class MSEPlayerTrack extends PlayerTrack { this.update(this.player.video.currentTime) } - track_from_profile(): SourceTrack { - if (this.profile.value) return profile_to_partial_track(this.profile.value); - else return this.metadata; - } update_buf_ranges() { if (!this.index) return; @@ -97,7 +92,7 @@ export class MSEPlayerTrack extends PlayerTrack { }); } for (const r of this.loading) { - ranges.push({ ...this.index.fragments[r], status: "loading" }); + ranges.push({ ...this.index[r], status: "loading" }); } this.buffered.value = ranges; } @@ -107,8 +102,8 @@ export class MSEPlayerTrack extends PlayerTrack { this.update_buf_ranges(); // TODO required? const blocking = []; - for (let i = 0; i < this.index.fragments.length; i++) { - const frag = this.index.fragments[i]; + for (let i = 0; i < this.index.length; i++) { + const frag = this.index[i]; if (frag.end < target) continue; if (frag.start >= target + TARGET_BUFFER_DURATION) break; if (!this.check_buf_collision(frag.start, frag.end)) continue; @@ -129,13 +124,13 @@ export class MSEPlayerTrack extends PlayerTrack { async load(index: number) { this.loading.add(index); - await this.profile_selector.select_optimal_profile(this.track_index, this.profile); - const url = `/n/${encodeURIComponent(this.node_id)}/stream?format=frag&webm=true&track=${this.track_index}&index=${index}${this.profile.value ? `&profile=${this.profile.value.id}` : ""}`; + // TODO update format selection + const url = `${this.base_url}?fragment&segment=${this.segment_index}&track=${this.track_index}&format=${this.active_format.value!.format_index}&index=${index}&container=${this.active_format.value!.container}`; const buf = await this.player.downloader.download(url); await new Promise(cb => { if (!this.index) return; if (this.abort.signal.aborted) return; - this.append_queue.push({ buf, ...this.index.fragments[index], index, cb }); + this.append_queue.push({ buf, ...this.index[index], index, cb }); this.tick_append(); }); } @@ -146,8 +141,8 @@ export class MSEPlayerTrack extends PlayerTrack { 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.track_from_profile())!); - this.source_buffer.timestampOffset = this.profile.value !== undefined ? frag.start : 0 + 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 !== undefined ? frag.start : 0 console.log(`append track ${this.track_index}`); this.source_buffer.appendBuffer(frag.buf); } @@ -158,13 +153,13 @@ export class MSEPlayerTrack extends PlayerTrack { const c = b.filter(r => r.status == t); return `${c.length} range${c.length != 1 ? "s" : ""}, ${c.reduce((a, v) => a + v.end - v.start, 0).toFixed(2)}s` } - return this.profile.liftA2(this.buffered, (p, b) => + return this.active_format.liftA2(this.buffered, (p, b) => e("pre", - `mse track ${this.track_index}: ${(p ? `profile ${p.id} (${show_profile(p)})` : `remux`)}` - + `\n\ttype: ${track_to_content_type(this.track_from_profile())}` - + `\n\tbuffered: ${rtype("buffered", b)}` - + `\n\tqueued: ${rtype("queued", b)}` - + `\n\tloading: ${rtype("loading", b)}` + p ? + `mse track ${this.track_index}: format ${p.format_index} (${p.format.remux ? "remux" : "transcode"})` + + `\n\ttype: ${track_to_content_type(p.format, p.container)} br=${p.format.bitrate}` + + `\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 index ea4951c..3dd7670 100644 --- a/web/script/player/track/vtt.ts +++ b/web/script/player/track/vtt.ts @@ -4,8 +4,8 @@ Copyright (C) 2025 metamuffin */ import { e } from "../../jshelper/src/element.ts"; -import { SourceTrack, JvttCue } from "../jhls.d.ts"; import { Player } from "../player.ts"; +import { JvttCue, TrackInfo } from "../types_stream.ts"; import { PlayerTrack } from "./mod.ts"; export class VttPlayerTrack extends PlayerTrack { @@ -16,10 +16,10 @@ export class VttPlayerTrack extends PlayerTrack { private player: Player, private node_id: string, track_index: number, - private metadata: SourceTrack, + private track_info: TrackInfo, ) { super(track_index); - this.track = this.player.video.addTextTrack("subtitles", this.metadata.name, this.metadata.language); + 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() } diff --git a/web/script/player/types_node.ts b/web/script/player/types_node.ts new file mode 100644 index 0000000..6946313 --- /dev/null +++ b/web/script/player/types_node.ts @@ -0,0 +1,76 @@ +/* + 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) 2025 metamuffin +*/ + +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, + } + } | "subtitles"; + +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 new file mode 100644 index 0000000..290a778 --- /dev/null +++ b/web/script/player/types_stream.ts @@ -0,0 +1,39 @@ +/* + 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) 2025 metamuffin +*/ +export type FragmentIndex = TimeRange[] +export interface TimeRange { start: number, end: number } +export interface JvttCue extends TimeRange { + content: string +} +export interface StreamInfo { + name?: string, + segments: SegmentInfo[], +} +export interface SegmentInfo { + name?: string, + duration: number, + tracks: TrackInfo[], +} +export type TrackKind = "video" | "audio" | "subtitles" +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, +} -- cgit v1.2.3-70-g09d2