Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions src/ui/PodcastView/EpisodePlayer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@

let isHoveringArtwork: boolean = false;
let isLoading: boolean = true;
let loadError: string | null = null;
// Distinct from isLoading (a display flag): gates persistence so it stays
// closed when media fails to load, since a failed load clears the spinner
// without restoring the saved position. Only a real metadata load opens it.
let playbackRestored: boolean = false;
let playerVolume: number = 1;
let mediaElement: HTMLMediaElement | null = null;
// The currentEpisode subscription fires synchronously on subscribe with the
Expand Down Expand Up @@ -177,10 +182,20 @@

function onMetadataLoaded() {
isLoading = false;
loadError = null;
playbackRestored = true;

restorePlaybackTime();
}

// A blocked, dead, or unreachable media URL never fires loadedmetadata, so
// without this the loading overlay spins forever (found while gating all
// outbound requests: a refused stream URL looked identical to a slow one).
function onMediaError() {
isLoading = false;
Comment thread
chhoumann marked this conversation as resolved.
loadError = "Could not load this episode's media.";
}

function restorePlaybackTime() {
const playedEps = $playedEpisodes;
const currentEp = $currentEpisode;
Expand Down Expand Up @@ -263,9 +278,10 @@

function persistPlaybackPosition() {
// Never persist before metadata has loaded and the saved position has been
// restored (onMetadataLoaded) — writing the pre-restore 0 would clobber the
// stored position we are about to resume from.
if (isLoading || !$currentEpisode) return;
// restored (onMetadataLoaded), including the failed-load path where the
// spinner clears but no restore ran — writing the pre-restore 0 would
// clobber the stored position we resume from.
if (!playbackRestored || !$currentEpisode) return;
if (shouldSuppressSegmentProgressPersistence()) return;

playedEpisodes.setEpisodeTime(
Expand Down Expand Up @@ -339,6 +355,8 @@
// the src swap persist the new episode under its key with the old/zero
// time and clobber its saved resume position (issue #33).
isLoading = true;
loadError = null;
playbackRestored = false;
lastPositionSaveMs = Number.NEGATIVE_INFINITY;
segmentStopTimeWithoutProgressSave = null;

Expand All @@ -352,7 +370,7 @@
// progress bar stays pinned at 100% and the timestamps show the
// previous episode's end for the whole (network-bound) metadata fetch.
// onMetadataLoaded → restorePlaybackTime sets the real position, and
// the isLoading guard above keeps this reset from being persisted.
// the playbackRestored guard keeps this reset from being persisted.
if (hasSeenFirstEpisodeFire) {
currentTime.set(0);
duration.set(0);
Expand Down Expand Up @@ -504,6 +522,7 @@
bind:volume={playerVolume}
on:ended={onEpisodeEnded}
on:loadedmetadata={onMetadataLoaded}
on:error={onMediaError}
on:timeupdate={onTimeUpdate}
on:pause={onPause}
on:play|preventDefault
Expand All @@ -516,6 +535,11 @@
<div class="podcast-artwork-isloading-overlay">
<Loading />
</div>
{:else if loadError}
<div class="podcast-artwork-load-error" role="alert">
<Icon icon="alert-triangle" clickable={false} />
<span>{loadError}</span>
</div>
{:else}
<button
type="button"
Expand Down Expand Up @@ -569,6 +593,11 @@
<div class="podcast-artwork-isloading-overlay">
<Loading />
</div>
{:else if loadError}
<div class="podcast-artwork-load-error" role="alert">
<Icon icon="alert-triangle" clickable={false} />
<span>{loadError}</span>
</div>
{:else}
<div
class="podcast-artwork-overlay"
Expand All @@ -595,6 +624,7 @@
bind:volume={playerVolume}
on:ended={onEpisodeEnded}
on:loadedmetadata={onMetadataLoaded}
on:error={onMediaError}
on:timeupdate={onTimeUpdate}
on:pause={onPause}
on:play|preventDefault
Expand Down Expand Up @@ -820,6 +850,24 @@
outline-offset: 2px;
}

.podcast-artwork-load-error {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-align: center;
padding: 0.5rem;
background-color: rgba(0, 0, 0, 0.6);
color: var(--text-error);
font-size: 0.85rem;
}

.podcast-artwork-isloading-overlay {
position: absolute;
inset: 0;
Expand Down
50 changes: 50 additions & 0 deletions src/ui/PodcastView/EpisodePlayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,56 @@ describe("EpisodePlayer", () => {
expect(get(requestedPlaybackTime)).toBeNull();
});

test("shows an error state instead of a permanent spinner when media fails to load", async () => {
const { container } = render(EpisodePlayer);
await waitFor(() => {
expect(container.querySelector("audio")).not.toBeNull();
});
const audio = container.querySelector("audio") as HTMLAudioElement;

// A blocked/dead stream URL fires error, never loadedmetadata.
await fireEvent.error(audio);

expect(container.querySelector(".podcast-artwork-isloading-overlay")).toBeNull();
const error = container.querySelector(".podcast-artwork-load-error");
expect(error).not.toBeNull();
expect(error?.textContent).toContain("Could not load");
});

test("does not persist a pre-restore position when media fails to load", async () => {
// Seed a saved resume point; a failed load must not overwrite it with 0.
playedEpisodes.setEpisodeTime(testEpisode, 1234, 3600, false);
const { container, unmount } = render(EpisodePlayer);
await waitFor(() => {
expect(container.querySelector("audio")).not.toBeNull();
});
const audio = container.querySelector("audio") as HTMLAudioElement;

await fireEvent.error(audio);
// Destroy fires persistPlaybackPosition; the guard must keep it closed.
unmount();

const saved = get(playedEpisodes)[`${testEpisode.podcastName}::${testEpisode.title}`];
expect(saved?.time).toBe(1234);
});

test("clears the error state when the next episode loads", async () => {
const { container } = render(EpisodePlayer);
await waitFor(() => {
expect(container.querySelector("audio")).not.toBeNull();
});
const audio = container.querySelector("audio") as HTMLAudioElement;
await fireEvent.error(audio);
expect(container.querySelector(".podcast-artwork-load-error")).not.toBeNull();

currentEpisode.set({ ...testEpisode, title: "Next Episode" });
await waitFor(() => {
expect(container.querySelector(".podcast-artwork-isloading-overlay")).not.toBeNull();
});
await fireEvent.loadedMetadata(container.querySelector("audio") as HTMLAudioElement);
expect(container.querySelector(".podcast-artwork-load-error")).toBeNull();
});

test("arms a requested segment after metadata loads", async () => {
requestedPlaybackTime.set({
episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`,
Expand Down