add video playback + transcoding

AuthorKonata <konata@posteo.jp>
Date
Commit949ed73c128344b70d274b023e86b1f02c4d5b11
Parentbcac5c6
17 files changed, 932 insertions(+), 323 deletions(-)
▾Mserver/package.json
@@ -19,10 +19,12 @@
"concurrently": "^9.1.2"
},
"dependencies": {
/*elysiajs bug from 1.3.0: headers get appended instead of overwritten*/
"elysia": "1.2.25",
"@elysiajs/static": "1.2.0",
"command-exists": "^1.2.9",
"elysia": "1.2.25",
"file-type": "^20.0.1",
"mediainfo.js": "^0.3.5",
"music-metadata": "^11.2.0",
"music-server-shared": "file:../shared",
"range-parser": "^1.2.1",
▾Aserver/src/ffmpeg.ts
@@ -0,0 +1,216 @@
import { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
import { createRFC6381String } from "music-server-shared/utils";
import { probeFile, readStream } from "./utils";
function secondsToFfmpeg(seconds: number): string {
// Ensure seconds is non-negative
if (seconds < 0) {
throw new Error("Seconds must be a non-negative number");
}
// Extract hours, minutes, seconds, and milliseconds
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
// Calculate the fractional part for milliseconds
const milliseconds = Math.floor((seconds - Math.floor(seconds)) * 1000);
// Pad numbers with leading zeros
const hoursStr = hours.toString().padStart(2, "0");
const minutesStr = minutes.toString().padStart(2, "0");
const secondsStr = secs.toString().padStart(2, "0");
const millisecondsStr = milliseconds.toString().padStart(3, "0");
return `${hoursStr}:${minutesStr}:${secondsStr}.${millisecondsStr}`;
}
export function convertWithFFmpeg(
filePath: string,
audioBitrate: number,
videoBitrate: number,
container: MediaContainer,
requestedAudioCodec: AudioCodec,
requestedVideoCodec: VideoCodec,
requestedVideoEncodingSetting: VideoEncodingSetting,
seekTo: number | undefined,
) {
const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec);
let audioCodec = undefined;
switch (requestedAudioCodec) {
case AudioCodec.aac:
audioCodec = "aac";
break;
case AudioCodec.mp3: {
audioCodec = "libmp3lame";
break;
}
case AudioCodec.opus: {
audioCodec = "libopus";
break;
}
case AudioCodec.vorbis: {
audioCodec = "libvorbis";
break;
}
default: {
throw new Error(`Unsupported audio codec: ${requestedAudioCodec}`);
}
}
let maxWidth = undefined;
if (videoBitrate <= 210000) {
maxWidth = 860;
} else if (videoBitrate < 420000) {
maxWidth = 1000;
} else if (videoBitrate < 1500000) {
maxWidth = 1280;
} else if (videoBitrate < 2000000) {
maxWidth = 1920;
} else if (videoBitrate < 10000000) {
maxWidth = 3840;
}
let containerSettings = undefined;
if (container === MediaContainer.mp4) {
containerSettings = [
"-avoid_negative_ts",
"make_zero",
"-movflags",
"+frag_keyframe+empty_moov+default_base_moof+faststart",
// "-g",
// "60",
];
}
const videoEncodingSettings: string[] = [];
let videoCodec = undefined;
let extraVideoSettings = undefined;
//only convert video if transcoding is requesting so
if (videoCodec !== VideoCodec.none) {
extraVideoSettings = [
//make sure pixel format is browser compatible
"-pix_fmt",
"yuv420p",
//automatically deinterlace video to double fps if video is interlaced
//scale video down based on bitrate
"-vf",
`yadif=deint=1:mode=1${maxWidth ? `, scale='if(gt(iw,${maxWidth}),${maxWidth},iw)':-2` : ""}`,
"-g",
"600",
];
switch (requestedVideoCodec) {
case VideoCodec.av1:
videoCodec = "libsvtav1"; //TODO: support hardware encoding
switch (requestedVideoEncodingSetting) {
case VideoEncodingSetting.veryfast:
videoEncodingSettings.push("-preset", "8");
break;
case VideoEncodingSetting.fast:
videoEncodingSettings.concat("-preset", "7");
break;
case VideoEncodingSetting.balanced:
videoEncodingSettings.concat("-preset", "6");
break;
case VideoEncodingSetting.quality:
videoEncodingSettings.concat("-preset", "5");
break;
default: {
throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
}
}
break;
case VideoCodec.h264:
videoCodec = "libx264"; //TODO: support hardware encoding
videoEncodingSettings.concat(["-profile", "high"]);
switch (requestedVideoEncodingSetting) {
case VideoEncodingSetting.veryfast:
videoEncodingSettings.push("-preset", "veryfast");
break;
case VideoEncodingSetting.fast:
videoEncodingSettings.concat("-preset", "fast");
break;
case VideoEncodingSetting.balanced:
videoEncodingSettings.concat("-preset", "medium");
break;
case VideoEncodingSetting.quality:
videoEncodingSettings.concat("-preset", "slow");
break;
default: {
throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
}
}
break;
case VideoCodec.h265:
videoCodec = "libx265"; //TODO: support hardware encoding
videoEncodingSettings.concat(["-profile", "main"]);
switch (requestedVideoEncodingSetting) {
case VideoEncodingSetting.veryfast:
videoEncodingSettings.push("-preset", "veryfast");
break;
case VideoEncodingSetting.fast:
videoEncodingSettings.concat("-preset", "fast");
break;
case VideoEncodingSetting.balanced:
videoEncodingSettings.concat("-preset", "medium");
break;
case VideoEncodingSetting.quality:
videoEncodingSettings.concat("-preset", "slow");
break;
default: {
throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
}
}
break;
case VideoCodec.vp9:
videoCodec = "libvpx-vp9";
switch (requestedVideoEncodingSetting) {
case VideoEncodingSetting.veryfast:
videoEncodingSettings.concat("-deadline", "realtime", "-cpu-used", "5");
break;
case VideoEncodingSetting.fast:
videoEncodingSettings.concat("-deadline", "realtime", "-cpu-used", "4");
break;
case VideoEncodingSetting.balanced:
videoEncodingSettings.concat("-deadline", "good", "-cpu-used", "2");
break;
case VideoEncodingSetting.quality:
videoEncodingSettings.concat("-deadline", "good", "-cpu-used", "2");
break;
default: {
throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
}
}
break;
}
}
const seekParams = seekTo === undefined ? [] : ["-ss", secondsToFfmpeg(seekTo)];
const args = ["ffmpeg"]
.concat(seekParams) //put seek first to do a fast keyframe seek
.concat(["-i", filePath])
.concat(containerSettings || [])
.concat(
videoCodec
? ["-c:v", videoCodec, "-b:v", videoBitrate.toString()] //TODO: better bitrate selection
.concat(extraVideoSettings || [])
.concat(videoEncodingSettings)
: ["-vn"],
)
.concat(videoEncodingSettings)
.concat(["-c:a", audioCodec, "-b:a", audioBitrate.toString()])
.concat(["-f", container, "-"]);
const cmd = Bun.spawn(args, {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
if (exitCode !== 0 && exitCode !== null) {
console.error("ffmpeg failed with status code", exitCode);
console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
}
},
});
return { cmd, mimeType };
}
▾Mserver/src/index.ts
@@ -14,23 +14,22 @@ import {
password,
authTokens,
} from "./shared";
import {
getPathInfo,
listFiles,
findCover,
matchesType,
convertWithFFmpeg,
packWithTar,
probeFile,
toAvif,
} from "./utils";
import { getPathInfo, listFiles, findCover, matchesType, packWithTar, probeFile, toAvif } from "./utils";
import { stat } from "node:fs/promises";
import parseRange from "range-parser";
import { MediaFormat } from "music-server-shared/types";
import { decodePath } from "music-server-shared/utils";
import { stringifyMap } from "music-server-shared/mapconversion";
import { randomUUIDv7 } from "bun";
import {
AudioCodec,
MediaContainer,
type Metadata,
VideoCodec,
VideoEncodingSetting,
type IsVideoResponse,
} from "music-server-shared/types";
import { convertWithFFmpeg } from "./ffmpeg";
//TODO: transcoding cache?
//TODO: add reasonable timeouts for caches
@@ -90,7 +89,7 @@ const downloadHandler: DownloadHandler = async ({ headers, params, set, request
if (!headers.range) {
set.status = "OK";
return Bun.file(filePath);
return new Response(Bun.file(filePath));
}
const rangeData = parseRange(fileSize, headers.range, { combine: true });
@@ -163,9 +162,15 @@ const app = setup
return "Forbidden file type";
}
const probe = await probeFile(filePath);
//don't use higher bitrate than what the file has, use requested bitrate if unknown, default to 96kbit if not provided
const bitrate =
query.bitrate && probe.bitrate ? Math.min(query.bitrate, probe.bitrate) : query.bitrate || 96000;
//don't use higher bitrate than what the file has, use requested bitrate if unknown
const audioBitrate = probe.audioBitrate
? Math.min(query.audioBitrate, probe.audioBitrate)
: query.audioBitrate;
const videoBitrate =
probe.videoBitrate && query.videoBitrate
? Math.min(query.videoBitrate, probe.videoBitrate)
: query.videoBitrate;
if (!matchesType(fileScan.mimeType, mediaTypes)) {
set.status = "Temporary Redirect";
set.headers.Location = `/download/${params["*"]}`;
@@ -173,8 +178,12 @@ const app = setup
}
const { cmd, mimeType } = convertWithFFmpeg(
filePath,
bitrate,
query.format ?? MediaFormat["opus/ogg"],
audioBitrate,
videoBitrate || 0,
query.container,
query.audioCodec,
query.videoCodec || VideoCodec.none,
query.videoEncodingSetting || VideoEncodingSetting.balanced,
query.seekTo,
);
request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
@@ -196,10 +205,14 @@ const app = setup
},
{
query: t.Object({
bitrate: t.Number(),
format: t.Enum(MediaFormat),
disableChunkedTranscoding: t.Optional(t.Boolean()),
seekTo: t.Optional(t.Number()),
disableChunkedTranscoding: t.Optional(t.Boolean()),
container: t.Enum(MediaContainer),
videoCodec: t.Optional(t.Enum(VideoCodec)),
videoBitrate: t.Optional(t.Number()),
videoEncodingSetting: t.Optional(t.Enum(VideoEncodingSetting)),
audioCodec: t.Enum(AudioCodec),
audioBitrate: t.Number(),
}),
},
)
@@ -218,6 +231,11 @@ const app = setup
},
{ query: t.Optional(t.Object({ recursive: t.Boolean() })) },
)
.get("/isVideo/*", async ({ params }) => {
const dirPath = path.join(musicRoot, decodePath(params["*"]));
const probeData = await probeFile(dirPath).catch(() => ({}) as Metadata);
return { isVideo: probeData.videoCodec !== undefined } as IsVideoResponse;
})
.get(
"/cover/*",
async ({ params, set, query }) => {
▾Mserver/src/shared.ts
@@ -1,9 +1,10 @@
import path from "node:path";
import type { ProbeData } from "./utils";
import commandExists from "command-exists";
import type { Metadata } from "music-server-shared/types";
export const coverRegex = process.env.COVER_REGEX === undefined ? /(cover)|(folder).(png|jpg)$/i : "";
export const musicRoot = process.env.MUSIC_ROOT || "";
export const mediaInfoDisabled = !!process.env.DISABLE_MEDIAINFO || false;
if (musicRoot === "") {
console.error("Please define the environment variable MUSIC_ROOT");
process.exit(1);
@@ -52,7 +53,7 @@ export const mediaTypes = ["audio", "video"];
export const allowedTypes = [...mediaTypes, "image"];
export const fileTypeCache = new Map<string, string>();
export const probeCache = new Map<string, ProbeData>();
export const probeCache = new Map<string, Metadata>();
export const generatedPlaylistIds = new Map<string, string[]>();
export class PathInfo {
▾Mserver/src/utils.ts
@@ -1,12 +1,13 @@
import { StatusMap } from "elysia";
import { fileTypeFromFile } from "file-type";
import { type FileListingWithStatus, MediaFormat, type Metadata } from "music-server-shared/types";
import type { FileListingWithStatus, Metadata } from "music-server-shared/types";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import {
coverRegex,
excludeExtension,
fileTypeCache,
mediaInfoDisabled,
mediaTypes,
musicRoot,
PathInfo,
@@ -16,6 +17,7 @@ import {
import { parseFile } from "music-metadata";
import tmp from "tmp";
import { readFileSync, writeFileSync } from "node:fs";
import MediaInfo from "mediainfo.js";
export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
const promises = arr.map(asyncCallback);
@@ -26,6 +28,8 @@ export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => P
type CoverResult = { info: PathInfo; path: string } | { info: PathInfo; content: Uint8Array } | { info: ServerError };
const mediaInfo = await MediaInfo();
export async function findCover(targetPath: string): Promise<CoverResult> {
try {
if ((await stat(targetPath)).isFile()) {
@@ -66,35 +70,40 @@ export async function findCover(targetPath: string): Promise<CoverResult> {
return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
}
export interface ProbeData extends Metadata {
bitrate?: number;
}
export async function probeFile(filePath: string): Promise<ProbeData> {
export async function probeFile(filePath: string): Promise<Metadata> {
const cached = probeCache.get(filePath);
if (cached) return cached;
const parsed = await parseFile(filePath, { duration: true, skipCovers: false });
const probeData = {
let probeData: Metadata = {
duration: parsed.format.duration || 0,
bitrate: parsed.format.bitrate,
title: parsed.common.title,
artist: parsed.common.artist,
album: parsed.common.album,
genre: parsed.common.genre,
trackNumber: parsed.common.track.no || undefined,
// embeddedCover: parsed.common.picture
// ? `data:${parsed.common.picture[0].format};base64,${Buffer.from(parsed.common.picture[0].data).toString("base64")}`
// : undefined,
};
if (!mediaInfoDisabled) {
const file = Bun.file(filePath);
const fileSize = (await file.stat()).size;
const chunkReader = async (size: number, offset: number) => {
return await file.slice(offset, offset + size).bytes();
};
const info = await mediaInfo.analyzeData(fileSize, chunkReader);
probeData = {
...probeData,
audioCodec: info.media?.track.find((track) => track["@type"] === "Audio")?.Format,
audioBitrate: info.media?.track.find((track) => track["@type"] === "Audio")?.BitRate,
videoCodec: info.media?.track.find((track) => track["@type"] === "Video")?.Format,
videoBitrate: info.media?.track.find((track) => track["@type"] === "Video")?.BitRate,
container: info.media?.track.find((track) => track["@type"] === "General")?.Format,
};
}
probeCache.set(filePath, probeData);
return probeData;
}
function probeDataToMetadata(probeData: ProbeData): Metadata {
const { bitrate, ...metadata } = { ...probeData };
return metadata;
}
export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
try {
if (excludeExtension.includes(path.extname(filePath).slice(1))) return new ServerError(404, "Excluded extension");
@@ -156,7 +165,7 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Fi
} else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
try {
const probe = await probeFile(itemPath);
files.set(fileName, { metadata: probeDataToMetadata(probe) });
files.set(fileName, { metadata: probe });
} catch (probeError) {
console.error("probing error:", probeError);
}
@@ -173,7 +182,7 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Fi
}
}
async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
export async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
@@ -197,90 +206,6 @@ async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Arra
}
}
function secondsToFfmpeg(seconds: number): string {
// Ensure seconds is non-negative
if (seconds < 0) {
throw new Error("Seconds must be a non-negative number");
}
// Extract hours, minutes, seconds, and milliseconds
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
// Calculate the fractional part for milliseconds
const milliseconds = Math.floor((seconds - Math.floor(seconds)) * 1000);
// Pad numbers with leading zeros
const hoursStr = hours.toString().padStart(2, "0");
const minutesStr = minutes.toString().padStart(2, "0");
const secondsStr = secs.toString().padStart(2, "0");
const millisecondsStr = milliseconds.toString().padStart(3, "0");
return `${hoursStr}:${minutesStr}:${secondsStr}.${millisecondsStr}`;
}
export function convertWithFFmpeg(filePath: string, bitrate: number, format: MediaFormat, seekTo: number | undefined) {
let codec = undefined;
let container = undefined;
let mimeType = undefined;
switch (format) {
case MediaFormat["opus/ogg"]: {
codec = "libopus";
container = "ogg";
mimeType = 'audio/ogg; codecs="opus"';
break;
}
case MediaFormat["opus/webm"]: {
codec = "libopus";
container = "webm";
mimeType = 'audio/webm; codecs="opus"';
break;
}
case MediaFormat.mp3: {
codec = "libmp3lame";
container = "mp3";
mimeType = "audio/mpeg";
break;
}
case MediaFormat["vorbis/ogg"]: {
codec = "libvorbis";
container = "ogg";
mimeType = 'audio/ogg; codecs="vorbis"';
break;
}
case MediaFormat["vorbis/webm"]: {
codec = "libvorbis";
container = "webm";
mimeType = 'audio/webm; codecs="vorbis"';
break;
}
default: {
throw new Error(`Unsupported format: ${format}`);
}
}
const seekParams = seekTo === undefined ? [] : ["-ss", secondsToFfmpeg(seekTo)];
const cmd = Bun.spawn(
["ffmpeg", "-i", filePath]
.concat(seekParams)
.concat(["-vn", "-c:a", codec, "-f", container, "-b:a", bitrate.toString(), "-"]),
{
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
if (exitCode !== 0 && exitCode !== null) {
console.error("ffmpeg failed with status code", exitCode);
console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
}
},
},
);
return { cmd, mimeType };
}
export function packWithTar(files: string[]) {
// Ensure all files are within musicRoot
const absoluteFiles = files.map((f) => path.join(musicRoot, f));
▾Mshared/types.ts
@@ -4,7 +4,16 @@ export interface Metadata {
title?: string;
artist?: string;
album?: string;
genre?: string[]
genre?: string[];
audioCodec?: string;
audioBitrate?: number;
videoCodec?: string;
videoBitrate?: number;
container?: string;
}
export interface IsVideoResponse {
isVideo: boolean;
}
export interface MediaFile {
@@ -27,10 +36,31 @@ export interface DirectoryWithStatus {
export type FileListing = Map<string, Directory | MediaFile>;
export type FileListingWithStatus = Map<string, DirectoryWithStatus | MediaFile>;
export enum MediaFormat {
"opus/ogg" = "opus/ogg",
"opus/webm" = "opus/webm",
export enum MediaContainer {
mp4 = "mp4",
webm = "webm",
mp3 = "mp3",
ogg = "ogg",
}
export enum VideoCodec {
none = "none",
av1 = "av1",
h264 = "h264",
h265 = "h265",
vp9 = "vp9",
}
export enum AudioCodec {
opus = "opus",
aac = "aac",
vorbis = "vorbis",
mp3 = "mp3",
"vorbis/ogg" = "vorbis/ogg",
"vorbis/webm" = "vorbis/webm",
}
}
export enum VideoEncodingSetting {
veryfast = "veryfast",
fast = "fast",
balanced = "balanced",
quality = "quality",
}
▾Mshared/utils.ts
@@ -1,3 +1,5 @@
import { AudioCodec, MediaContainer, VideoCodec } from "./types";
export function decodePath(path: string) {
return path
.split("/")
@@ -5,10 +7,70 @@ export function decodePath(path: string) {
.join("/");
}
export function encodePath(path: string) {
return path
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
}
}
//IMPORTANT: needs to be kept in sync with the backend ffmpeg parameters
export function createRFC6381String(container: MediaContainer, videoCodec: VideoCodec, audioCodec: AudioCodec): string {
let mimeType = "";
switch (container) {
case MediaContainer.mp4:
mimeType = "video/mp4";
break;
case MediaContainer.webm:
mimeType = "video/webm";
break;
case MediaContainer.mp3:
mimeType = "audio/mpeg";
break;
case MediaContainer.ogg:
mimeType = "audio/ogg";
break;
default:
throw new Error(`Unsupported container: ${container}`);
}
mimeType += '; codecs="';
//TODO: use correct level values
switch (videoCodec) {
case VideoCodec.none:
break;
case VideoCodec.av1:
mimeType += "av01.0.17M.08, "; // Main profile, level 6.1, Main tier, 8bit
break;
case VideoCodec.h264:
mimeType += "avc1.64003d, "; // High Profile (profile number + constraint flags), level 6.1
break;
case VideoCodec.h265:
mimeType += "hev1.1.1.L183, "; // Main profile, Main tier, level 6.1
break;
case VideoCodec.vp9:
mimeType += "vp09.00.61.08, "; //4:2:0, level 6.1, 8bit
break;
default:
throw new Error(`Unsupported video codec: ${videoCodec}`);
}
switch (audioCodec) {
case AudioCodec.opus:
mimeType += "opus";
break;
case AudioCodec.aac:
mimeType += "aac";
break;
case AudioCodec.vorbis:
mimeType += "vorbis";
break;
case AudioCodec.mp3:
mimeType += "mp3";
break;
default:
throw new Error(`Unsupported audio codec: ${audioCodec}`);
}
mimeType += '"';
return mimeType;
}
▾Mwebclient/src/App.tsx
@@ -16,15 +16,21 @@ import {
import { formatFilename, LocalStorageValues, toast } from "./utils";
import Playlist from "./components/Playlist";
import { getCover, getSrc, listFiles } from "./offline";
import { type Directory, type MediaFile, MediaFormat } from "music-server-shared/types";
import {
AudioCodec,
MediaContainer,
type Directory,
type MediaFile,
VideoCodec,
VideoEncodingSetting,
} from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
import { BUILD_HASH } from "./build-info";
import { createStore, reconcile } from "solid-js/store";
import { decodePath, encodePath } from "music-server-shared/utils";
import { createRFC6381String, decodePath, encodePath } from "music-server-shared/utils";
import DownloadManager from "./components/DownloadManager";
import { SignIn } from "./components/SignIn";
//TODO: add bitrate+format to metadata
//TODO: more file display options
//TODO: keep synced-aborted in downloadmanager
//TODO: bypass music-metadata if it fails
@@ -57,8 +63,6 @@ const App: Component = () => {
//default settings
enableTranscoding: true,
disableChunkedTranscoding: false,
bitrate: 128000,
format: MediaFormat["opus/webm"],
maxTranscodes: 4,
maxDownloads: 2,
transcodeCovers: false,
@@ -75,6 +79,21 @@ const App: Component = () => {
useMSE: "MediaSource" in window,
rememberDir: false,
duplicateWarning: true,
videoSettings: {
container: MediaContainer.mp4,
videoCodec: VideoCodec.av1,
videoBitrate: 8000000, //8mbit
videoEncodingSetting: VideoEncodingSetting.fast,
audioCodec: AudioCodec.opus,
audioBitrate: 128000, //128kbit
},
audioSettings: {
container: MediaContainer.webm,
audioBitrate: 128000, //128kbit
audioCodec: AudioCodec.opus,
},
});
const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
const [isShuffled, setShuffled] = createSignal(false);
@@ -87,6 +106,7 @@ const App: Component = () => {
},
{ initialValue: [] },
);
const [videoShown, setVideoShown] = createSignal(false);
createEffect(() => {
const parts = currentDir().split("/").filter(Boolean);
@@ -110,15 +130,20 @@ const App: Component = () => {
document.fonts.add(new_font);
});
createEffect(() => {
videoElement.style.display = videoShown() ? "block" : "none";
});
function stopPlaying() {
batch(() => {
setPlayerState("isPlaying", false);
setPlayerState("currentTime", 0);
setPlayerState("currentTrack", null);
});
audioElement.pause();
if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
audioElement.srcObject = null;
videoElement.pause();
if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
videoElement.srcObject = null;
}
createEffect(
@@ -174,11 +199,12 @@ const App: Component = () => {
});
}
let audioElement!: HTMLAudioElement;
let videoElement!: HTMLVideoElement;
function handlePlayerError(e: unknown) {
if (e instanceof Error && e.name === "AbortError") return;
if (e instanceof Error && e.name === "NotSupportedError") {
console.error(e);
toast(
"Playing track failed: Format not supported, check if the server is up when playing a remote file, or change your transcoding settings",
"error",
@@ -189,8 +215,23 @@ const App: Component = () => {
}
let mediaSource: MediaSource | null = null;
let mediaSourceAbortController: AbortController | null = null;
async function getMSEUrl(srcUrl: string, offsetSeconds: number, isVideo: boolean) {
let mimeType = undefined;
if (isVideo) {
mimeType = createRFC6381String(
options.videoSettings.container,
options.videoSettings.videoCodec,
options.videoSettings.audioCodec,
);
} else {
mimeType = createRFC6381String(
options.audioSettings.container,
VideoCodec.none,
options.audioSettings.audioCodec,
);
}
function getMSEUrl(srcUrl: string, mimeType: string, offsetSeconds: number) {
if (!MediaSource.isTypeSupported(mimeType)) return null;
mediaSource = new MediaSource();
const thisMediaSource = mediaSource;
@@ -198,45 +239,51 @@ const App: Component = () => {
if (!thisMediaSource) return;
const sourceBuffer = thisMediaSource.addSourceBuffer(mimeType);
thisMediaSource.duration = playerState.currentTrack?.metadata.duration || 0;
sourceBuffer.mode = "sequence";
sourceBuffer.timestampOffset = offsetSeconds;
const pendingBuffer: Uint8Array[] = [];
let isBuffering = false;
const processQueue = () => {
if (isBuffering || pendingBuffer.length === 0) return;
const buffer = pendingBuffer.shift() as Uint8Array;
if (thisMediaSource.readyState !== "open") return;
isBuffering = true;
sourceBuffer.appendBuffer(buffer);
};
sourceBuffer.addEventListener("updateend", () => {
isBuffering = false;
processQueue();
});
try {
const response = await fetch(srcUrl, { credentials: "same-origin" });
mediaSourceAbortController = new AbortController();
const response = await fetch(srcUrl, { credentials: "same-origin", signal: mediaSourceAbortController.signal });
if (!response.ok || !response.body) throw new Error("Failed to fetch stream");
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const push = () => {
// If buffer updating, wait
if (sourceBuffer.updating) {
sourceBuffer.addEventListener("updateend", push, { once: true });
return;
}
if (thisMediaSource.readyState !== "open") return;
// thisMediaSource.duration = 100;
sourceBuffer.appendBuffer(value.slice(0).buffer);
};
push();
if (value) {
// Copy value to prevent buffer overwriting
pendingBuffer.push(Uint8Array.from(value));
processQueue();
}
}
if (sourceBuffer.updating) {
sourceBuffer.addEventListener(
"updateend",
() => {
try {
thisMediaSource.endOfStream();
} catch {}
},
{ once: true },
);
} else {
try {
thisMediaSource.endOfStream();
} catch {}
// Wait for queue to drain
while (pendingBuffer.length > 0 || isBuffering) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
} catch (error) {
console.error(error);
if (!(error instanceof Error && error.name === "AbortError")) {
console.error(error);
}
} finally {
try {
thisMediaSource.endOfStream();
} catch {}
@@ -245,14 +292,18 @@ const App: Component = () => {
return URL.createObjectURL(thisMediaSource);
}
//without this, seeking via mse will cause an infinite loop
let ignoreSeek = false;
// Play a track from the playlist
async function playTrack(track: PlaylistItem, seekTo?: number) {
try {
const src = await getSrc(track.path, options, isOffline(), seekTo || 0);
const { src, isVideo } = await getSrc(track.path, options, isOffline(), seekTo || 0);
if (!src) return;
//Cleanup
if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
if (mediaSource) {
//without this abort, the old fetch keeps running and the old ffmpeg instance keeps transcoding
mediaSourceAbortController?.abort();
try {
mediaSource.endOfStream();
mediaSource = null;
@@ -262,25 +313,28 @@ const App: Component = () => {
//need both of these lines to work without logging an error
//just removeAttribute doesn't work at all
//just srcObject = null works, but logs a weird csp error
audioElement.removeAttribute("src");
audioElement.srcObject = null;
//TODO: get correct mimetype
videoElement.removeAttribute("src");
videoElement.srcObject = null;
const mseURL =
options.enableTranscoding && options.useMSE && !src.startsWith("blob:")
? getMSEUrl(src, 'audio/webm; codecs="opus"', seekTo || 0)
? await getMSEUrl(src, seekTo || 0, isVideo === true)
: null;
audioElement.src = mseURL || src;
audioElement.currentTime = (mseURL && seekTo) || 0;
setVideoShown(false);
videoElement.src = mseURL || src;
if (mseURL && seekTo) {
ignoreSeek = true;
videoElement.currentTime = seekTo;
}
batch(() => {
setPlayerState("isPlaying", true);
setPlayerState("currentTime", audioElement.currentTime);
setPlayerState("currentTime", videoElement.currentTime);
setPlayerState("buffered", []);
//clone, otherwise it shares the reference
setPlayerState("currentTrack", { ...track });
setPlayerState("waitingForStart", true);
});
await audioElement.play();
await videoElement.play();
} catch (e) {
setPlayerState("isPlaying", false);
handlePlayerError(e);
@@ -331,7 +385,7 @@ const App: Component = () => {
if (newIsPlaying) {
setPlayerState("waitingForStart", true);
audioElement
videoElement
.play()
.catch((e) => {
setPlayerState("isPlaying", false);
@@ -342,7 +396,7 @@ const App: Component = () => {
setPlayerState("waitingForStart", false);
});
} else {
audioElement.pause();
videoElement.pause();
}
}
@@ -358,12 +412,30 @@ const App: Component = () => {
}
function updateTime() {
setPlayerState("currentTime", audioElement.currentTime);
setPlayerState("currentTime", videoElement.currentTime);
//also call updateProgress here, because some browsers send the "progress" events too slow
updateProgress()
updateProgress();
}
function updateProgress() {
setPlayerState("buffered", reconcile(timeRangesToArray(audioElement.buffered)));
setPlayerState("buffered", reconcile(timeRangesToArray(videoElement.buffered)));
}
function needMSESeek(seekTo: number) {
function isInTimeRanges(timeRanges: TimeRanges, targetTime: number): boolean {
for (let i = 0; i < timeRanges.length; i++) {
if (targetTime >= timeRanges.start(i) && targetTime <= timeRanges.end(i)) {
return true;
}
}
// Special case: if seeking to exactly 0 and the first buffered range starts very close to 0
// (accounting for common MSE container overhead), treat it as within range
if (targetTime === 0 && timeRanges.length > 0 && timeRanges.start(0) < 0.1) {
return true;
}
return false;
}
return mediaSource !== null && !isInTimeRanges(videoElement.buffered, seekTo);
}
// Initialize app
@@ -406,17 +478,35 @@ const App: Component = () => {
createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
// Set initial audio element volume
audioElement.volume = playerState.volume;
// Set up audio element event handlers
audioElement.addEventListener("ended", playNext);
audioElement.addEventListener("timeupdate", updateTime);
audioElement.addEventListener("progress", updateProgress);
audioElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
audioElement.addEventListener("play", () => setPlayerState("isPlaying", true));
audioElement.addEventListener("volumechange", () => setPlayerState("volume", audioElement.volume));
audioElement.addEventListener("canplay", () => setPlayerState("waitingForStart", false));
// Set initial video element volume
videoElement.volume = playerState.volume;
// Set up video element event handlers
videoElement.addEventListener("ended", playNext);
videoElement.addEventListener("timeupdate", updateTime);
videoElement.addEventListener("progress", updateProgress);
videoElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
videoElement.addEventListener("play", () => setPlayerState("isPlaying", true));
videoElement.addEventListener("volumechange", () => setPlayerState("volume", videoElement.volume));
videoElement.addEventListener("canplay", () => setPlayerState("waitingForStart", false));
videoElement.addEventListener("seeking", () => {
if (!ignoreSeek && playerState.currentTrack && needMSESeek(videoElement.currentTime)) {
playTrack(playerState.currentTrack, videoElement.currentTime);
}
ignoreSeek = false;
});
videoElement.addEventListener("loadedmetadata", () => {
setVideoShown(videoElement.videoWidth !== 0 || videoElement.videoHeight !== 0);
});
videoElement.addEventListener("click", (e) => {
e.preventDefault();
togglePlay();
});
videoElement.addEventListener("fullscreenchange", () => {
if (document.fullscreenElement !== videoElement) {
videoElement.controls = false;
}
});
window.addEventListener("hashchange", () => {
setCurrentDir(decodePath(window.location.hash.slice(1)));
@@ -430,13 +520,25 @@ const App: Component = () => {
if (e.key === " ") {
togglePlay();
e.preventDefault();
} else if (e.key === "ArrowLeft") {
videoElement.currentTime -= 10;
e.preventDefault();
} else if (e.key === "ArrowRight") {
videoElement.currentTime += 10;
e.preventDefault();
} else if (e.key === "ArrowUp") {
videoElement.volume += 0.1;
e.preventDefault();
} else if (e.key === "ArrowDown") {
videoElement.volume -= 0.1;
e.preventDefault();
}
});
});
createEffect(() => {
audioElement.volume = playerState.volume;
audioElement.playbackRate = playerState.rate;
videoElement.volume = playerState.volume;
videoElement.playbackRate = playerState.rate;
});
return (
@@ -521,15 +623,16 @@ const App: Component = () => {
setPlayerState={setPlayerState}
playTrack={playTrack}
playNext={playNext}
audioElement={audioElement}
videoElement={(el) => {
videoElement = el;
}}
videoShown={videoShown}
options={options}
fetchFiles={fetchFiles}
isOffline={isOffline}
/>
</div>
<audio ref={audioElement} preload="auto" />
<PlayerControls
playerState={playerState}
setPlayerState={setPlayerState}
@@ -540,8 +643,8 @@ const App: Component = () => {
setShuffled={setShuffled}
isRepeated={isRepeated}
setRepeated={setRepeated}
audioElement={audioElement}
isMSE={() => mediaSource !== null}
videoElement={videoElement}
needMSESeek={(seekTo: number) => needMSESeek(seekTo)}
seekTo={(seekTo: number) => playerState.currentTrack && playTrack(playerState.currentTrack, seekTo)}
options={options}
/>
▾Mwebclient/src/components/FileBrowser.css
@@ -10,7 +10,7 @@
.file-browser {
flex: 1;
min-width: 0;
height: 100%;
min-height: 100%;
background-color: var(--color-a);
display: flex;
flex-direction: column;
@@ -123,20 +123,6 @@
}
}
.cover {
position: relative;
height: 25%;
border-top: 1px solid var(--color-c);
display: flex;
justify-content: center;
& > img {
padding: 0.5rem;
object-fit: contain;
max-height: 100%;
max-width: 100%;
}
}
@media (max-width: 750px) {
.file-browser {
z-index: 1;
▾Mwebclient/src/components/FileBrowser.tsx
@@ -214,7 +214,7 @@ export default function FileBrowser(props: FileBrowserProps) {
createEffect(() => localStorage.setItem(LocalStorageValues.fileBrowserButtons, showActionButtons().toString()));
filelistElement.addEventListener("scrollend", () => {
scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
if (scrollPositions.length > 0) scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
});
});
▾Mwebclient/src/components/OptionsModal.tsx
@@ -1,11 +1,11 @@
import { type Accessor, createEffect, type Setter, on, createSignal, Switch, Match } from "solid-js";
import { type Accessor, createEffect, type Setter, on, createSignal, Switch, Match, For } from "solid-js";
import { FileDisplay, type AppOptions } from "../types";
import "./OptionsModal.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { toast } from "../utils";
import { MediaFormat } from "music-server-shared/types";
import { createStore } from "solid-js/store";
import { MediaContainer, VideoCodec, AudioCodec, VideoEncodingSetting } from "music-server-shared/types";
interface OptionsModalProps {
showOptions: Accessor<boolean>;
@@ -14,6 +14,46 @@ interface OptionsModalProps {
setOptions: (options: AppOptions) => void;
}
function supportedVideoCodecs(container: MediaContainer) {
let codecs: VideoCodec[] = [];
switch (container) {
case MediaContainer.mp3:
case MediaContainer.webm:
case MediaContainer.ogg:
break;
case MediaContainer.mp4:
codecs = [VideoCodec.av1, VideoCodec.h264, VideoCodec.h265, VideoCodec.vp9];
break;
default: {
throw new Error(`Unsupported container: ${container}`);
}
}
return codecs.concat([VideoCodec.none]);
}
function supportedAudioCodecs(container: MediaContainer, forVideo?: boolean) {
let codecs: AudioCodec[] = [];
switch (container) {
case MediaContainer.mp3:
codecs = [AudioCodec.mp3];
break;
case MediaContainer.webm:
codecs = [AudioCodec.opus, AudioCodec.vorbis];
break;
case MediaContainer.ogg:
codecs = [AudioCodec.opus, AudioCodec.vorbis];
break;
case MediaContainer.mp4:
codecs = [AudioCodec.mp3];
if (forVideo) codecs.push(AudioCodec.opus, AudioCodec.aac); //ffmpeg generates garbage if opus or aac is used in mp4 without video
break;
default: {
throw new Error(`Unsupported container: ${container}`);
}
}
return codecs;
}
export default function OptionsModal(props: OptionsModalProps) {
//clone, otherwise it shares the reference
const [newOptions, setNewOptions] = createStore<AppOptions>({ ...props.options });
@@ -21,6 +61,30 @@ export default function OptionsModal(props: OptionsModalProps) {
let dialogElement!: HTMLDialogElement;
let formElement!: HTMLFormElement;
//update to valid codecs on video container change
createEffect(() => {
const suportedVideo = supportedVideoCodecs(newOptions.videoSettings.container);
if (!suportedVideo.includes(newOptions.videoSettings.videoCodec)) {
setNewOptions("videoSettings", "videoCodec", suportedVideo[0]);
}
const suportedAudio = supportedAudioCodecs(
newOptions.videoSettings.container,
newOptions.videoSettings.videoCodec !== VideoCodec.none,
);
if (!suportedAudio.includes(newOptions.videoSettings.audioCodec)) {
setNewOptions("videoSettings", "audioCodec", suportedAudio[0]);
}
});
//update to valid codecs on audio container change
createEffect(() => {
const suportedAudio = supportedAudioCodecs(newOptions.audioSettings.container, false);
if (!suportedAudio.includes(newOptions.audioSettings.audioCodec)) {
setNewOptions("audioSettings", "audioCodec", suportedAudio[0]);
}
});
createEffect(
on(props.showOptions, () => {
if (props.showOptions()) {
@@ -86,6 +150,28 @@ export default function OptionsModal(props: OptionsModalProps) {
>
3
</button>
{newOptions.enableTranscoding && (
<button
onClick={(e) => {
e.preventDefault();
return setOptionsPage(4);
}}
classList={{ "page-button-active": optionsPage() === 4 }}
>
4
</button>
)}
{newOptions.enableTranscoding && (
<button
onClick={(e) => {
e.preventDefault();
return setOptionsPage(5);
}}
classList={{ "page-button-active": optionsPage() === 5 }}
>
5
</button>
)}
</div>
<Switch>
<Match when={optionsPage() === 1}>
@@ -108,38 +194,6 @@ export default function OptionsModal(props: OptionsModalProps) {
disabled={!newOptions.enableTranscoding}
/>
</div>
<div>
<label for="bitrate-select">Bitrate:</label>
<select
id="bitrate-select"
value={newOptions.bitrate}
onChange={(e) => setNewOptions("bitrate", Number.parseInt(e.target.value))}
disabled={!newOptions.enableTranscoding}
>
<option value="32000">32 kbps</option>
<option value="48000">48 kbps</option>
<option value="64000">64 kbps</option>
<option value="96000">96 kbps</option>
<option value="128000">128 kbps</option>
<option value="192000">192 kbps</option>
<option value="320000">320 kbps</option>
</select>
</div>
<div>
<label for="codec-select">Format:</label>
<select
id="codec-select"
value={newOptions.format}
onChange={(e) => setNewOptions("format", e.target.value as MediaFormat)}
disabled={!newOptions.enableTranscoding}
>
<option>{MediaFormat["opus/ogg"]}</option>
<option>{MediaFormat["opus/webm"]}</option>
<option>{MediaFormat.mp3}</option>
<option>{MediaFormat["vorbis/ogg"]}</option>
<option>{MediaFormat["vorbis/webm"]}</option>
</select>
</div>
<div>
<label for="max-transcodes-input">Max concurrent transcodes:</label>
<input
@@ -163,15 +217,6 @@ export default function OptionsModal(props: OptionsModalProps) {
onChange={(e) => setNewOptions("maxDownloads", e.target.valueAsNumber)}
/>
</div>
<div>
<label for="transcode-covers-checkbox">Transcode covers to avif:</label>
<input
id="transcode-covers-checkbox"
type="checkbox"
checked={newOptions.transcodeCovers}
onChange={(e) => setNewOptions("transcodeCovers", e.target.checked)}
/>
</div>
</Match>
<Match when={optionsPage() === 2}>
<div>
@@ -290,6 +335,171 @@ export default function OptionsModal(props: OptionsModalProps) {
onChange={(e) => setNewOptions("duplicateWarning", e.target.checked)}
/>
</div>
<div>
<label for="transcode-covers-checkbox">Transcode covers to avif:</label>
<input
id="transcode-covers-checkbox"
type="checkbox"
checked={newOptions.transcodeCovers}
onChange={(e) => setNewOptions("transcodeCovers", e.target.checked)}
/>
</div>
</Match>
<Match when={optionsPage() === 4}>
<div style={{ "font-size": "1.2rem" }} class="title">
Transcoding settings for videos
</div>
<div>
<label for="container-select">Container:</label>
<select
id="container-select"
value={newOptions.videoSettings.container}
onChange={(e) => setNewOptions("videoSettings", "container", e.target.value as MediaContainer)}
>
<For each={Object.values(MediaContainer)}>
{(format) => {
return <option value={format}>{format}</option>;
}}
</For>
</select>
</div>
<div>
<label for="video-codec-select">Video codec:</label>
<select
id="video-codec-select"
value={newOptions.videoSettings.videoCodec}
onChange={(e) => setNewOptions("videoSettings", "videoCodec", e.target.value as VideoCodec)}
>
<For each={supportedVideoCodecs(newOptions.videoSettings.container)}>
{(codec) => {
return <option value={codec}>{codec}</option>;
}}
</For>
</select>
</div>
<div>
<label for="video-settings-select">Video encoding settings:</label>
<select
id="video-settings-select"
value={newOptions.videoSettings.videoEncodingSetting}
onChange={(e) =>
setNewOptions("videoSettings", "videoEncodingSetting", e.target.value as VideoEncodingSetting)
}
>
<For each={Object.values(VideoEncodingSetting)}>
{(setting) => {
return <option value={setting}>{setting}</option>;
}}
</For>
</select>
</div>
<div>
<label for="video-bitrate-select">Video bitrate:</label>
<select
id="video-bitrate-select"
value={newOptions.videoSettings.videoBitrate}
onChange={(e) => setNewOptions("videoSettings", "videoBitrate", Number.parseInt(e.target.value))}
>
<option value="210000">210 kbps</option>
<option value="420000">420 kbps</option>
<option value="720000">720 kbps</option>
<option value="1500000">1.5 Mbps</option>
<option value="3000000">3 Mbps</option>
<option value="4000000">4 Mbps</option>
<option value="6000000">6 Mbps</option>
<option value="8000000">8 Mbps</option>
<option value="10000000">10 Mbps</option>
<option value="15000000">15 Mbps</option>
<option value="20000000">20 Mbps</option>
<option value="30000000">30 Mbps</option>
<option value="40000000">40 Mbps</option>
</select>
</div>
<div>
<label for="audio-codec-select">Audio codec:</label>
<select
id="audio-codec-select"
value={newOptions.videoSettings.audioCodec}
onChange={(e) => setNewOptions("videoSettings", "audioCodec", e.target.value as AudioCodec)}
>
<For
each={supportedAudioCodecs(
newOptions.videoSettings.container,
newOptions.videoSettings.videoCodec !== VideoCodec.none,
)}
>
{(codec) => {
return <option value={codec}>{codec}</option>;
}}
</For>
</select>
</div>
<div>
<label for="audio-bitrate-select">Audio bitrate:</label>
<select
id="audio-bitrate-select"
value={newOptions.videoSettings.audioBitrate}
onChange={(e) => setNewOptions("videoSettings", "audioBitrate", Number.parseInt(e.target.value))}
>
<option value="32000">32 kbps</option>
<option value="48000">48 kbps</option>
<option value="64000">64 kbps</option>
<option value="96000">96 kbps</option>
<option value="128000">128 kbps</option>
<option value="192000">192 kbps</option>
<option value="320000">320 kbps</option>
</select>
</div>
</Match>
<Match when={optionsPage() === 5}>
<div style={{ "font-size": "1.2rem" }} class="title">
Transcoding settings for music
</div>
<div>
<label for="container-select">Container:</label>
<select
id="container-select"
value={newOptions.audioSettings.container}
onChange={(e) => setNewOptions("audioSettings", "container", e.target.value as MediaContainer)}
>
<For each={Object.values(MediaContainer)}>
{(format) => {
return <option value={format}>{format}</option>;
}}
</For>
</select>
</div>
<div>
<label for="audio-codec-select">Audio codec:</label>
<select
id="audio-codec-select"
value={newOptions.audioSettings.audioCodec}
onChange={(e) => setNewOptions("audioSettings", "audioCodec", e.target.value as AudioCodec)}
>
<For each={supportedAudioCodecs(newOptions.audioSettings.container)}>
{(codec) => {
return <option value={codec}>{codec}</option>;
}}
</For>
</select>
</div>
<div>
<label for="audio-bitrate-select">Audio bitrate:</label>
<select
id="audio-bitrate-select"
value={newOptions.audioSettings.audioBitrate}
onChange={(e) => setNewOptions("audioSettings", "audioBitrate", Number.parseInt(e.target.value))}
>
<option value="32000">32 kbps</option>
<option value="48000">48 kbps</option>
<option value="64000">64 kbps</option>
<option value="96000">96 kbps</option>
<option value="128000">128 kbps</option>
<option value="192000">192 kbps</option>
<option value="320000">320 kbps</option>
</select>
</div>
</Match>
</Switch>
<div class="option-buttons">
▾Mwebclient/src/components/PlayerControls.tsx
@@ -4,6 +4,7 @@ import { type Accessor, createEffect, createSignal, For, onCleanup, onMount, typ
import {
ClearSVG,
DownSVG,
FullscreenSVG,
InfoSVG,
NextTrackSVG,
PauseSVG,
@@ -28,10 +29,10 @@ interface PlayerControlsProps {
setShuffled: Setter<boolean>;
isRepeated: Accessor<boolean>;
setRepeated: Setter<boolean>;
isMSE: Accessor<boolean>;
needMSESeek: (seekTo: number) => boolean;
seekTo: (seekTo: number) => void;
options: AppOptions;
audioElement: HTMLAudioElement;
videoElement: HTMLVideoElement;
}
export default function PlayerControls(props: PlayerControlsProps) {
@@ -45,26 +46,11 @@ export default function PlayerControls(props: PlayerControlsProps) {
const volume = Number.parseFloat(input.value);
props.setPlayerState("volume", volume);
if (props.audioElement) {
props.audioElement.volume = volume;
if (props.videoElement) {
props.videoElement.volume = volume;
}
}
function isInTimeRanges(timeRanges: TimeRanges, targetTime: number): boolean {
for (let i = 0; i < timeRanges.length; i++) {
if (targetTime >= timeRanges.start(i) && targetTime <= timeRanges.end(i)) {
return true;
}
}
// Special case: if seeking to exactly 0 and the first buffered range starts very close to 0
// (accounting for common MSE container overhead), treat it as within range
if (targetTime === 0 && timeRanges.length > 0 && timeRanges.start(0) < 0.1) {
return true;
}
return true;
}
function progressClick(e: MouseEvent) {
const progressBar = e.currentTarget as HTMLDivElement;
const rect = progressBar.getBoundingClientRect();
@@ -74,14 +60,14 @@ export default function PlayerControls(props: PlayerControlsProps) {
// Seek to position
function seekTo(posPercent: number) {
const playerState = props.playerState;
if (!props.audioElement || !playerState.currentTrack) return;
if (!props.videoElement || !playerState.currentTrack) return;
const duration = playerState.currentTrack.metadata.duration;
const seekTime = Math.max(0, posPercent * duration);
if (props.isMSE() && !isInTimeRanges(props.audioElement.buffered, seekTime)) {
if (props.needMSESeek(seekTime)) {
props.seekTo(seekTime);
} else {
props.setPlayerState("waitingForStart", true);
props.audioElement.currentTime = seekTime;
props.videoElement.currentTime = seekTime;
}
}
@@ -160,6 +146,11 @@ export default function PlayerControls(props: PlayerControlsProps) {
);
}
function bitrateDisplay(bitrate: number | undefined, divisor: number) {
if (!bitrate) return undefined;
return bitrate / divisor;
}
return (
<>
<dialog ref={metadataDialogRef} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
@@ -180,6 +171,19 @@ export default function PlayerControls(props: PlayerControlsProps) {
{MetadataEntry("Artist", props.playerState.currentTrack?.metadata.artist)}
{MetadataEntry("Genre", props.playerState.currentTrack?.metadata.genre?.join(", "))}
{MetadataEntry("Duration", props.playerState.currentTrack?.metadata.duration.toFixed(1), " seconds")}
{MetadataEntry("Container format", props.playerState.currentTrack?.metadata.container)}
{MetadataEntry("Audio codec", props.playerState.currentTrack?.metadata.audioCodec)}
{MetadataEntry(
"Audio bitrate",
bitrateDisplay(props.playerState.currentTrack?.metadata.audioBitrate, 1000),
" kbps",
)}
{MetadataEntry("Video codec", props.playerState.currentTrack?.metadata.videoCodec)}
{MetadataEntry(
"Video bitrate",
bitrateDisplay(props.playerState.currentTrack?.metadata.videoBitrate, 1000 * 1000),
" mbps",
)}
</div>
</div>
</dialog>
@@ -307,6 +311,15 @@ export default function PlayerControls(props: PlayerControlsProps) {
<option value={3}>3x</option>
</select>
</div>
<button
title="Toggle Fullscreen"
onClick={() => {
props.videoElement.controls = true;
props.videoElement.requestFullscreen();
}}
>
<FullscreenSVG />
</button>
</div>
</Show>
</div>
▾Mwebclient/src/components/Playlist.css
@@ -111,17 +111,5 @@
min-height: 2rem;
}
.cover {
position: relative;
height: 25%;
border-top: 1px solid var(--color-c);
display: flex;
justify-content: center;
& > img {
padding: 0.5rem;
object-fit: contain;
max-height: 100%;
max-width: 100%;
}
}
▾Mwebclient/src/components/Playlist.tsx
@@ -27,7 +27,8 @@ interface PlaylistProps {
setPlayerState: Setter<PlayerState>;
playTrack: (item: PlaylistItem) => void;
playNext: () => void;
audioElement: HTMLAudioElement;
videoElement: (el: HTMLVideoElement) => void;
videoShown: Accessor<boolean>;
options: AppOptions;
fetchFiles: ResourceActions<FlatFileListing>;
isOffline: Accessor<boolean>;
@@ -136,6 +137,7 @@ export default function Playlist(props: PlaylistProps) {
setSyncedCount(playlist.filter((item) => storedFiles.includes(item.path)).length);
}),
);
let videoElement!: HTMLVideoElement;
onMount(() => {
const savedPlaylistButtons = localStorage.getItem(LocalStorageValues.playlistButtons);
if (savedPlaylistButtons !== null) setShowActionButtons(savedPlaylistButtons === "true");
@@ -230,14 +232,20 @@ export default function Playlist(props: PlaylistProps) {
);
}}
</DraggableList>
<Show when={!props.options.hidePlaylistCover && (cover.loading || cover())}>
<div class="cover">
<Show when={cover.loading}>
<LoadingScreen />
</Show>
<img src={cover()} alt="" />
</div>
</Show>
<div
class="cover"
style={{
display:
props.videoShown() || (!props.options.hidePlaylistCover && (cover.loading || cover())) ? undefined : "none",
}}
>
<Show when={cover.loading}>
<LoadingScreen />
</Show>
<img src={cover()} alt="" />
<video ref={props.videoElement} preload="auto" />
</div>
</div>
);
}
▾Mwebclient/src/offline.ts
@@ -5,6 +5,7 @@ import type {
FileListingWithStatus,
DirectoryWithStatus,
Metadata,
IsVideoResponse,
} from "music-server-shared/types";
import { parseMap } from "music-server-shared/mapconversion";
import type { AppOptions, Playlist } from "./types";
@@ -324,7 +325,6 @@ export async function getCover(
}
if (offline) return undefined;
const response = await fetch(`/cover/${encodePath(dir)}${options.transcodeCovers ? "?transcode=true" : ""}`, {
method: "GET",
credentials: "same-origin",
@@ -354,11 +354,11 @@ export async function getSrc(
offline: boolean,
seekTo: number,
forceUrl?: true,
): Promise<string>;
): Promise<{ src: string; isVideo?: boolean }>;
export async function getSrc(path: string, options: AppOptions, offline: boolean, seekTo: number, forceUrl?: boolean) {
if (!forceUrl) {
const dbResult = await db.files.get(path);
if (dbResult) return URL.createObjectURL(dbResult.data);
if (dbResult) return { src: URL.createObjectURL(dbResult.data), isVideo: undefined };
if (offline) {
toast("This file is not cached, leave offline mode to play it", "error");
@@ -366,15 +366,30 @@ export async function getSrc(path: string, options: AppOptions, offline: boolean
}
}
if (options.enableTranscoding) {
return (
`${joinPath("/transcode", encodePath(path))}` +
`?bitrate=${options.bitrate}` +
`&format=${options.format}` +
`&disableChunkedTranscoding=${options.disableChunkedTranscoding}` +
`${seekTo === 0 ? "" : `&seekTo=${seekTo}`}`
);
const isVideo = (
(await fetch(joinPath("isVideo", encodePath(path)))
.then((res) => res.json())
.catch((err) => {
console.error(err);
return { isVideo: false };
})) as IsVideoResponse
).isVideo;
return {
src:
`${joinPath("transcode", encodePath(path))}` +
`?disableChunkedTranscoding=${options.disableChunkedTranscoding}` +
`&container=${isVideo ? options.videoSettings.container : options.audioSettings.container}` +
`${isVideo ? `&videoCodec=${options.videoSettings.videoCodec}` : ""}` +
`${isVideo ? `&videoBitrate=${options.videoSettings.videoBitrate}` : ""}` +
`${isVideo ? `&videoEncodingSetting=${options.videoSettings.videoEncodingSetting}` : ""}` +
`&audioCodec=${isVideo ? options.videoSettings.audioCodec : options.audioSettings.audioCodec}` +
`&audioBitrate=${isVideo ? options.videoSettings.audioBitrate : options.audioSettings.audioBitrate}` +
`${seekTo === 0 ? "" : `&seekTo=${seekTo}`}`,
isVideo: isVideo,
};
}
return joinPath("/download", encodePath(path));
return { src: joinPath("download", encodePath(path)), isVideo: undefined };
}
export async function loadPlaylists() {
@@ -489,7 +504,7 @@ async function downloadFile(
const semaphorePromise = downloadSemaphore.acquire();
await Promise.race([abortPromise, semaphorePromise]);
try {
const res = await fetch(await getSrc(path, options, false, 0, true), {
const res = await fetch((await getSrc(path, options, false, 0, true)).src, {
signal: controller.signal,
credentials: "same-origin",
});
@@ -536,7 +551,7 @@ async function downloadFile(
downloadSemaphore.release();
}
} catch (e) {
if (e && typeof e === "object" && "name" in e && e.name === "AbortError") return;
if (e instanceof Error && e.name === "AbortError") return;
stopProgress(
path,
// if it was already synced, restore it
▾Mwebclient/src/style.css
@@ -144,3 +144,17 @@ input {
border: 1px solid var(--color-c);
padding: 0.25rem;
}
.cover {
position: relative;
height: 25%;
border-top: 1px solid var(--color-c);
display: flex;
justify-content: center;
& > img {
padding: 0.5rem;
object-fit: contain;
max-height: 100%;
max-width: 100%;
}
}
▾Mwebclient/src/types.ts
@@ -1,4 +1,10 @@
import type { MediaFile, MediaFormat } from "music-server-shared/types";
import type {
AudioCodec,
MediaContainer,
MediaFile,
VideoCodec,
VideoEncodingSetting,
} from "music-server-shared/types";
export interface PlaylistItem extends MediaFile {
id: string; //used for determining currently playing item
@@ -29,8 +35,6 @@ export enum FileDisplay {
export interface AppOptions {
enableTranscoding: boolean;
disableChunkedTranscoding: boolean;
bitrate: number;
format: MediaFormat;
maxTranscodes: number;
maxDownloads: number;
transcodeCovers: boolean;
@@ -47,4 +51,18 @@ export interface AppOptions {
useMSE: boolean;
rememberDir: boolean;
duplicateWarning: boolean;
videoSettings: {
container: MediaContainer;
videoCodec: VideoCodec;
videoEncodingSetting: VideoEncodingSetting;
videoBitrate: number;
audioCodec: AudioCodec;
audioBitrate: number;
};
audioSettings: {
container: MediaContainer;
audioCodec: AudioCodec;
audioBitrate: number;
};
}