allow using MSE for playback
Mserver/src/index.ts
@@ -171,8 +171,12 @@ const app = setup
set.headers.Location = `/download/${params["*"]}`;
return "Not a media file, redirecting to normal endpoint";
}
const { cmd, mimeType } = convertWithFFmpeg(filePath, bitrate, query.format ?? MediaFormat["opus/ogg"]);
const { cmd, mimeType } = convertWithFFmpeg(
filePath,
bitrate,
query.format ?? MediaFormat["opus/ogg"],
query.seekTo,
);
request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
set.headers["content-type"] = mimeType;
if (query.disableChunkedTranscoding) {
@@ -191,9 +195,12 @@ const app = setup
return new Response(cmd.stdout);
},
{
query: t.Optional(
t.Object({ bitrate: t.Number(), format: t.Enum(MediaFormat), disableChunkedTranscoding: t.Boolean() }),
),
query: t.Object({
bitrate: t.Number(),
format: t.Enum(MediaFormat),
disableChunkedTranscoding: t.Optional(t.Boolean()),
seekTo: t.Optional(t.Number()),
}),
},
)
.get(
Mserver/src/utils.ts
@@ -197,7 +197,30 @@ async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Arra
}
}
export function convertWithFFmpeg(filePath: string, bitrate: number, format: MediaFormat) {
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;
@@ -236,8 +259,13 @@ export function convertWithFFmpeg(filePath: string, bitrate: number, format: Med
throw new Error(`Unsupported format: ${format}`);
}
}
const seekParams = seekTo === undefined ? [] : ["-ss", secondsToFfmpeg(seekTo)];
const cmd = Bun.spawn(
["ffmpeg", "-i", filePath, "-vn", "-c:a", codec, "-f", container, "-b:a", bitrate.toString(), "-"],
["ffmpeg", "-i", filePath]
.concat(seekParams)
.concat(["-vn", "-c:a", codec, "-f", container, "-b:a", bitrate.toString(), "-"]),
{
stdin: "ignore",
stdout: "pipe",
Mwebclient/src/App.tsx
@@ -72,6 +72,7 @@ const App: Component = () => {
controlsDisplay: FileDisplay.TITLE,
progressBarHeight: 10,
useMSE: "MediaSource" in window,
rememberDir: false,
duplicateWarning: true,
});
@@ -187,22 +188,92 @@ const App: Component = () => {
}
}
let mediaSource: MediaSource | null = null;
function getMSEUrl(srcUrl: string, mimeType: string, offsetSeconds: number) {
if (!MediaSource.isTypeSupported(mimeType)) return null;
mediaSource = new MediaSource();
const thisMediaSource = mediaSource;
mediaSource.addEventListener("sourceopen", async () => {
if (!thisMediaSource) return;
const sourceBuffer = thisMediaSource.addSourceBuffer(mimeType);
sourceBuffer.timestampOffset = offsetSeconds;
try {
const response = await fetch(srcUrl, { credentials: "same-origin" });
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 (sourceBuffer.updating) {
sourceBuffer.addEventListener(
"updateend",
() => {
try {
thisMediaSource.endOfStream();
} catch {}
},
{ once: true },
);
} else {
try {
thisMediaSource.endOfStream();
} catch {}
}
} catch (error) {
console.error(error);
try {
thisMediaSource.endOfStream();
} catch {}
}
});
return URL.createObjectURL(thisMediaSource);
}
// Play a track from the playlist
async function playTrack(track: PlaylistItem) {
async function playTrack(track: PlaylistItem, seekTo?: number) {
try {
const src = await getSrc(track.path, options, isOffline());
const src = await getSrc(track.path, options, isOffline(), seekTo || 0);
if (!src) return;
//Cleanup
if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
if (mediaSource) {
try {
mediaSource.endOfStream();
mediaSource = null;
} catch {}
}
//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;
audioElement.src = src;
//TODO: get correct mimetype
const mseURL =
options.enableTranscoding && options.useMSE && !src.startsWith("blob:")
? getMSEUrl(src, 'audio/webm; codecs="opus"', seekTo || 0)
: null;
audioElement.src = mseURL || src;
audioElement.currentTime = (mseURL && seekTo) || 0;
batch(() => {
setPlayerState("isPlaying", true);
setPlayerState("currentTime", 0);
setPlayerState("currentTime", audioElement.currentTime);
setPlayerState("buffered", []);
//clone, otherwise it shares the reference
setPlayerState("currentTrack", { ...track });
@@ -286,9 +357,12 @@ const App: Component = () => {
return result;
}
// Update progress when the time updates
function updateProgress() {
function updateTime() {
setPlayerState("currentTime", audioElement.currentTime);
//also call updateProgress here, because some browsers send the "progress" events too slow
updateProgress()
}
function updateProgress() {
setPlayerState("buffered", reconcile(timeRangesToArray(audioElement.buffered)));
}
@@ -337,8 +411,8 @@ const App: Component = () => {
// Set up audio element event handlers
audioElement.addEventListener("ended", playNext);
audioElement.addEventListener("timeupdate", updateProgress);
// audioElement.addEventListener("durationchange", updateProgress);
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));
@@ -467,6 +541,8 @@ const App: Component = () => {
isRepeated={isRepeated}
setRepeated={setRepeated}
audioElement={audioElement}
isMSE={() => mediaSource !== null}
seekTo={(seekTo: number) => playerState.currentTrack && playTrack(playerState.currentTrack, seekTo)}
options={options}
/>
Mwebclient/src/components/OptionsModal.tsx
@@ -263,6 +263,15 @@ export default function OptionsModal(props: OptionsModalProps) {
</div>
</Match>
<Match when={optionsPage() === 3}>
<div>
<label for="mse-checkbox">Allow Media Source Extensions (MSE):</label>
<input
id="mse-checkbox"
type="checkbox"
checked={newOptions.useMSE}
onChange={(e) => setNewOptions("useMSE", e.target.checked)}
/>
</div>
<div>
<label for="remember-dir-checkbox">Remember last directory:</label>
<input
Mwebclient/src/components/PlayerControls.tsx
@@ -28,6 +28,8 @@ interface PlayerControlsProps {
setShuffled: Setter<boolean>;
isRepeated: Accessor<boolean>;
setRepeated: Setter<boolean>;
isMSE: Accessor<boolean>;
seekTo: (seekTo: number) => void;
options: AppOptions;
audioElement: HTMLAudioElement;
}
@@ -48,19 +50,39 @@ export default function PlayerControls(props: PlayerControlsProps) {
}
}
// Seek to position
function seekTo(e: MouseEvent) {
const playerState = props.playerState;
if (!props.audioElement || !playerState.currentTrack) return;
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();
const pos = (e.clientX - rect.left) / rect.width;
seekTo((e.clientX - rect.left) / rect.width);
}
// Seek to position
function seekTo(posPercent: number) {
const playerState = props.playerState;
if (!props.audioElement || !playerState.currentTrack) return;
const duration = playerState.currentTrack.metadata.duration;
props.setPlayerState("waitingForStart", true);
props.audioElement.currentTime = pos * duration;
props.setPlayerState("currentTime", pos * duration);
const seekTime = Math.max(0, posPercent * duration);
if (props.isMSE() && !isInTimeRanges(props.audioElement.buffered, seekTime)) {
props.seekTo(seekTime);
} else {
props.setPlayerState("waitingForStart", true);
props.audioElement.currentTime = seekTime;
}
}
const [isDragging, setIsDragging] = createSignal(false);
@@ -106,10 +128,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
setIsDragging(false);
if (e.type !== "pointercancel") {
const rect = progressContainerRef.getBoundingClientRect();
const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const duration = props.playerState.currentTrack.metadata.duration;
props.setPlayerState("waitingForStart", true);
props.audioElement.currentTime = pos * duration;
seekTo(Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)));
}
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
@@ -190,7 +209,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
)}
</div>
</div>
<div class="progress-container" onClick={seekTo} ref={progressContainerRef}>
<div class="progress-container" onClick={progressClick} ref={progressContainerRef}>
<div
class="progress-bar"
style={{
Mwebclient/src/offline.ts
@@ -348,8 +348,14 @@ export async function getCover(
}
//returns an object url to the blob if synced, otherwise the server url
export async function getSrc(path: string, options: AppOptions, offline: boolean, forceUrl?: true): Promise<string>;
export async function getSrc(path: string, options: AppOptions, offline: boolean, forceUrl?: boolean) {
export async function getSrc(
path: string,
options: AppOptions,
offline: boolean,
seekTo: number,
forceUrl?: true,
): Promise<string>;
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);
@@ -360,7 +366,13 @@ 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}`;
return (
`${joinPath("/transcode", encodePath(path))}` +
`?bitrate=${options.bitrate}` +
`&format=${options.format}` +
`&disableChunkedTranscoding=${options.disableChunkedTranscoding}` +
`${seekTo === 0 ? "" : `&seekTo=${seekTo}`}`
);
}
return joinPath("/download", encodePath(path));
}
@@ -477,7 +489,7 @@ async function downloadFile(
const semaphorePromise = downloadSemaphore.acquire();
await Promise.race([abortPromise, semaphorePromise]);
try {
const res = await fetch(await getSrc(path, options, false, true), {
const res = await fetch(await getSrc(path, options, false, 0, true), {
signal: controller.signal,
credentials: "same-origin",
});