aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
m---------web/script/jshelper0
-rw-r--r--web/script/player/download.ts2
-rw-r--r--web/script/player/mediacaps.ts101
-rw-r--r--web/script/player/mod.ts99
-rw-r--r--web/script/player/player.ts62
-rw-r--r--web/script/player/profiles.ts_ (renamed from web/script/player/profiles.ts)5
-rw-r--r--web/script/player/track/create.ts10
-rw-r--r--web/script/player/track/mod.ts4
-rw-r--r--web/script/player/track/mse.ts83
-rw-r--r--web/script/player/track/vtt.ts6
-rw-r--r--web/script/player/types_node.ts (renamed from web/script/player/jhls.d.ts)27
-rw-r--r--web/script/player/types_stream.ts39
-rw-r--r--web/style/js-player.css2
13 files changed, 212 insertions, 228 deletions
diff --git a/web/script/jshelper b/web/script/jshelper
-Subproject b2bcdcc99e42015085b4d0d63e7c94b2d4f84e2
+Subproject ef36d50d7858a56cbc08bfb4f272bab9476bb97
diff --git a/web/script/player/download.ts b/web/script/player/download.ts
index 18f1e8d..8294d2a 100644
--- a/web/script/player/download.ts
+++ b/web/script/player/download.ts
@@ -20,7 +20,7 @@ export class SegmentDownloader {
const dl_start = performance.now();
const res = await fetch(url)
const dl_header = performance.now();
- if (!res.ok) throw new Error("aaaaa");
+ if (!res.ok) throw new Error("aaaaaa");
const buf = await res.arrayBuffer()
const dl_body = performance.now();
diff --git a/web/script/player/mediacaps.ts b/web/script/player/mediacaps.ts
index e44b92b..3c55aa9 100644
--- a/web/script/player/mediacaps.ts
+++ b/web/script/player/mediacaps.ts
@@ -4,113 +4,76 @@
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
/// <reference lib="dom" />
-import { EncodingProfile, SourceTrack, SourceTrackKind } from "./jhls.d.ts";
+
+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(track: SourceTrack): Promise<boolean> {
- const cache_key = `${get_track_kind(track.kind)};${track.codec}`
+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(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("D_")) {
// TODO do we need to check this?
- return track.codec == "V_TEXT/WEBVTT" || track.codec == "D_WEBVTT/SUBTITLES"
+ return format.codec == "S_TEXT/WEBVTT" || format.codec == "S_TEXT/UTF8" || 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
}
})
}
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 {
+ 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": "h264",
- "V_MPEGH/ISO/HEVC": "h265",
+ "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 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 53f13bd..e8cde94 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,10 +36,24 @@ 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(node_id: string): HTMLElement {
const logger = new Logger<string>(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<Playersync | undefined>(undefined)
@@ -50,11 +64,11 @@ function initialize_player(node_id: string): HTMLElement {
let mute_saved_volume = 1;
const toggle_mute = () => {
if (player.volume.value == 0) {
- logger.log("Unmuted.");
+ logger.log("Unmuted.", "volume");
player.volume.value = mute_saved_volume
}
else {
- logger.log("Muted.");
+ logger.log("Muted.", "volume");
mute_saved_volume = player.volume.value
player.volume.value = 0.
}
@@ -70,13 +84,13 @@ function initialize_player(node_id: string): HTMLElement {
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)
}
@@ -90,7 +104,7 @@ function initialize_player(node_id: string): HTMLElement {
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",
@@ -103,7 +117,7 @@ function initialize_player(node_id: string): HTMLElement {
} 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)
}
@@ -136,7 +150,7 @@ function initialize_player(node_id: string): HTMLElement {
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 => {
@@ -182,15 +196,16 @@ function initialize_player(node_id: string): HTMLElement {
),
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(
@@ -263,8 +278,8 @@ function initialize_player(node_id: string): HTMLElement {
else if (k.code == "KeyS") screenshot_video(player.video)
else if (k.code == "KeyJ") step_track_kind("subtitles")
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)}`)
- else if (k.code == "Digit0") (player.volume.value *= 1.2), logger.log(`Volume increased to ${show_volume(player.volume.value)}`)
+ 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
@@ -272,8 +287,8 @@ function initialize_player(node_id: string): HTMLElement {
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()
})
@@ -338,25 +353,29 @@ function mouse_idle(e: HTMLElement, timeout: number): OVar<boolean> {
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 {
+ 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(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 @@
*/
/// <reference lib="dom" />
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<Chapter[]>([]);
+ public streaminfo?: StreamInfo;
+ public tracks?: TrackInfo[];
public active_tracks = new OVar<PlayerTrack[]>([]);
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<string>) {
- this.video.poster = `/n/${encodeURIComponent(node_id)}/poster`
+ 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 => {
@@ -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_
index 5ebdeb4..943639c 100644
--- a/web/script/player/profiles.ts
+++ b/web/script/player/profiles.ts_
@@ -5,9 +5,6 @@
*/
/// <reference lib="dom" />
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";
@@ -29,7 +26,7 @@ export class ProfileSelector {
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 (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 })
}
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 <metamuffin.org>
*/
-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..99b348c 100644
--- a/web/script/player/track/mod.ts
+++ b/web/script/player/track/mod.ts
@@ -4,11 +4,11 @@
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
/// <reference lib="dom" />
-import { TimeRange } from "../jhls.d.ts";
+import { TimeRange } from "../types_stream.ts";
import { OVar } from "../../jshelper/mod.ts";
import { BufferRange } from "../player.ts";
-export const TARGET_BUFFER_DURATION = 10
+export const TARGET_BUFFER_DURATION = 15
export const MIN_BUFFER_DURATION = 1
export interface AppendRange extends TimeRange { buf: ArrayBuffer, index: number, cb: () => void }
diff --git a/web/script/player/track/mse.ts b/web/script/player/track/mse.ts
index d1a8c12..237b6f6 100644
--- a/web/script/player/track/mse.ts
+++ b/web/script/player/track/mse.ts
@@ -3,41 +3,42 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-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 { test_media_capability, 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";
+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 profile_selector: ProfileSelector;
- public profile = new OVar<EncodingProfileExt | undefined>(undefined);
- public index?: JhlsTrackIndex
+ public index?: FragmentIndex
+ public active_format = new OVar<UsableFormat | undefined>(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 +50,21 @@ 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}`);
+ 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 })
+ }
+ }
+ 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}`);
@@ -81,10 +93,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 +105,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 +115,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 +137,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<void>(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,9 +154,10 @@ 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
- console.log(`append track ${this.track_index}`);
+ 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.active_format.value?.format.remux ? 0 : frag.start
+ // this.source_buffer.timestampOffset = 0
this.source_buffer.appendBuffer(frag.buf);
}
}
@@ -156,15 +165,17 @@ export class MSEPlayerTrack extends PlayerTrack {
public debug(): OVar<HTMLElement> {
const rtype = (t: string, b: BufferRange[]) => {
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`
+ // ${c.length} range${c.length != 1 ? "s" : ""}
+ return `${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\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
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 <metamuffin.org>
*/
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/jhls.d.ts b/web/script/player/types_node.ts
index c7325e4..6946313 100644
--- a/web/script/player/jhls.d.ts
+++ b/web/script/player/types_node.ts
@@ -4,13 +4,6 @@
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-export interface JhlsTrackIndex {
- fragments: TimeRange[],
- extra_profiles: EncodingProfile[],
-}
-
-export interface TimeRange { start: number, end: number }
-
export interface NodePublic {
kind: NodeKind,
title?: string,
@@ -76,28 +69,8 @@ export type SourceTrackKind = {
}
} | "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/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 <metamuffin.org>
+*/
+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,
+}
diff --git a/web/style/js-player.css b/web/style/js-player.css
index c9a48e9..33669a5 100644
--- a/web/style/js-player.css
+++ b/web/style/js-player.css
@@ -112,6 +112,8 @@
padding: 0.15em;
margin: 0px;
font-size: large;
+}
+.jsp .jsh-log-line-appear {
animation-name: appear;
animation-timing-function: linear;
animation-duration: 0.5s;