small fixes

AuthorKonata <konata@posteo.jp>
Date
Commit1da831f615ceeb735246f19f92ca94ace674d317
Parent3fb7dda
4 files changed, 77 insertions(+), 22 deletions(-)
▾M.gitignore
@@ -1,5 +1,6 @@
.idea
node_modules
bun.lock
dist
.env
build-info.ts
build-info.ts
▾Mwebclient/src/App.tsx
@@ -143,7 +143,20 @@ const App: Component = () => {
});
videoElement.pause();
//tear down any in-flight MSE stream, otherwise the fetch + server-side ffmpeg transcode keeps running
mediaSourceAbortController?.abort();
mediaSourceAbortController = null;
if (mediaSource) {
try {
mediaSource.endOfStream();
} catch {}
mediaSource = null;
}
if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
//remove the src before clearing srcObject, otherwise the media load algorithm
//re-fetches the just-revoked blob url and parks the element in an error state
videoElement.removeAttribute("src");
videoElement.srcObject = null;
}
@@ -236,7 +249,7 @@ const App: Component = () => {
if (!MediaSource.isTypeSupported(mimeType)) return null;
mediaSource = new MediaSource();
const thisMediaSource = mediaSource;
mediaSource.addEventListener("sourceopen", async () => {
const onSourceOpen = async () => {
if (!thisMediaSource) return;
const sourceBuffer = thisMediaSource.addSourceBuffer(mimeType);
@@ -252,8 +265,8 @@ const App: Component = () => {
function waitUntilUpdateDone() {
return new Promise((resolve) => {
if (!sourceBuffer.updating) resolve(undefined);
return sourceBuffer.addEventListener("updateend", resolve, { once: true });
if (!sourceBuffer.updating) return resolve(undefined);
sourceBuffer.addEventListener("updateend", resolve, { once: true });
});
}
@@ -286,20 +299,30 @@ const App: Component = () => {
async function saveUpdate(action: () => void) {
while (true) {
// bail if the stream is gone or the media element entered a fatal error state:
// when videoElement.error is set the MediaSource stays "open" but every
// appendBuffer throws InvalidStateError, which would otherwise spin forever
if (thisMediaSource.readyState !== "open" || videoElement.error) return;
try {
if (thisMediaSource.readyState !== "open") return;
await waitUntilUpdateDone();
action();
return;
} catch (e) {
if (!(e instanceof Error && e.name === "InvalidStateError")) throw e;
// an InvalidStateError while we are not updating is unrecoverable
// (e.g. element error / detached buffer) - don't retry into an infinite loop
if (!sourceBuffer.updating) return;
}
}
}
while (true) {
const { done, value } = await reader.read();
if (done || thisMediaSource.readyState !== "open") break;
if (done || thisMediaSource.readyState !== "open") {
//release the connection (and the server-side ffmpeg transcode) when we stop early
if (!done) reader.cancel().catch(() => {});
break;
}
const CHUNK_SIZE = 1000000; // 1MB chunks
for (let offset = 0; offset < value.length; offset += CHUNK_SIZE) {
@@ -326,25 +349,35 @@ const App: Component = () => {
thisMediaSource.endOfStream();
} catch {}
}
});
};
//once:true - a single MediaSource must only run one sourceopen handler
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
return URL.createObjectURL(thisMediaSource);
}
//without this, seeking via mse will cause an infinite loop
let ignoreSeek = false;
//incremented on every playTrack call so a slow/older invocation can detect it has been superseded
//and bail before clobbering the newer track's state
let playGeneration = 0;
// Play a track from the playlist
async function playTrack(track: PlaylistItem, seekTo?: number) {
const generation = ++playGeneration;
try {
const { src, isVideo } = await getSrc(track.path, options, isOffline(), seekTo || 0);
if (!src) return;
if (!src || generation !== playGeneration) return;
//Cleanup
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();
mediaSourceAbortController = null;
//null out before endOfStream: it throws when readyState isn't "open", which would
//otherwise leave a stale non-null mediaSource and make needMSESeek misfire on later tracks
const oldMediaSource = mediaSource;
mediaSource = null;
try {
mediaSource.endOfStream();
mediaSource = null;
oldMediaSource.endOfStream();
} catch {}
}
@@ -357,6 +390,17 @@ const App: Component = () => {
options.enableTranscoding && options.useMSE && !src.startsWith("blob:")
? await getMSEUrl(src, seekTo || 0, isVideo === true)
: null;
if (generation !== playGeneration) {
//superseded while getMSEUrl ran: it created a detached MediaSource + object URL, but its
//sourceopen handler never fired (we never set videoElement.src), so no fetch/transcode was
//started and there's nothing to abort. Just revoke the URL and drop the now-stale module
//reference so a later needMSESeek can't misfire on it.
if (mseURL) {
URL.revokeObjectURL(mseURL);
mediaSource = null;
}
return;
}
setVideoShown(false);
videoElement.src = mseURL || src;
if (mseURL && seekTo) {
@@ -510,7 +554,15 @@ const App: Component = () => {
// Sync to localStorage
createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
createEffect(() => {
//a large playlist can exceed the localStorage quota; swallow the error so it doesn't
//propagate out of the reactive flush and break every subsequent playlist update
try {
localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist()));
} catch (e) {
console.error("Persisting playlist to localStorage failed", e);
}
});
createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
▾Mwebclient/src/components/FileBrowser.tsx
@@ -156,10 +156,8 @@ export default function FileBrowser(props: FileBrowserProps) {
if (queue) scrollPlaylistToBottom();
else props.setShowFileBrowser(false);
};
if (
props.options.duplicateWarning &&
oldPlaylist.some((oldItem) => newPlaylist.some((newItem) => oldItem.path === newItem.path))
) {
const newPaths = new Set(newPlaylist.map((item) => item.path));
if (props.options.duplicateWarning && oldPlaylist.some((oldItem) => newPaths.has(oldItem.path))) {
addAnywayButton.onclick = () => {
addAction();
dialogElement.close();
▾Mwebclient/src/components/Playlist.tsx
@@ -49,8 +49,8 @@ export default function Playlist(props: PlaylistProps) {
);
function removeFromPlaylist(id: string) {
if (id === props.playerState.currentTrack?.id) props.playNext();
const newPlaylist = props.playlist().filter((i) => i.id !== id);
if (id === props.playerState.currentTrack?.id && newPlaylist.length > 0) props.playNext();
props.setPlaylist(newPlaylist);
}
@@ -90,10 +90,13 @@ export default function Playlist(props: PlaylistProps) {
try {
const itemsToSync: PlaylistItem[] = [];
const playlist = props.playlist();
const unique = playlist.filter((item, pos) => playlist.indexOf(item) === pos);
const storedFiles = storedFilesLive();
for (const item of unique) {
if (!storedFiles.includes(item.path)) itemsToSync.push(item);
const stored = new Set(storedFilesLive().map(String));
//dedup by path (playlist items are always fresh objects, so reference dedup was a no-op)
const seen = new Set<string>();
for (const item of playlist) {
if (seen.has(item.path) || stored.has(item.path)) continue;
seen.add(item.path);
itemsToSync.push(item);
}
let downloadPromises!: Promise<void>[];
batch(() => {
@@ -133,8 +136,9 @@ export default function Playlist(props: PlaylistProps) {
const [syncedCount, setSyncedCount] = createSignal(0);
createEffect(
on([props.playlist, storedFilesLive], async ([playlist, storedFiles]) => {
setSyncedCount(playlist.filter((item) => storedFiles.includes(item.path)).length);
on([props.playlist, storedFilesLive], ([playlist, storedFiles]) => {
const stored = new Set(storedFiles.map(String));
setSyncedCount(playlist.filter((item) => stored.has(item.path)).length);
}),
);
let videoElement!: HTMLVideoElement;