Skip to content

Commit 05ff7db

Browse files
authored
Improve podcast loading feedback (#151)
1 parent fe05ccd commit 05ff7db

File tree

3 files changed

+108
-3
lines changed

3 files changed

+108
-3
lines changed

src/ui/PodcastView/EpisodeList.svelte

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
import { hidePlayedEpisodes, playedEpisodes } from "src/store";
66
import Icon from "../obsidian/Icon.svelte";
77
import Text from "../obsidian/Text.svelte";
8+
import Loading from "./Loading.svelte";
89
910
export let episodes: Episode[] = [];
1011
export let showThumbnails: boolean = false;
1112
export let showListMenu: boolean = true;
13+
export let isLoading: boolean = false;
1214
let searchInputQuery: string = "";
1315
1416
const dispatch = createEventDispatcher();
@@ -63,7 +65,13 @@
6365
{/if}
6466

6567
<div class="podcast-episode-list">
66-
{#if episodes.length === 0}
68+
{#if isLoading}
69+
<div class="episode-list-loading" role="status" aria-live="polite">
70+
<Loading />
71+
<span>Fetching episodes...</span>
72+
</div>
73+
{/if}
74+
{#if episodes.length === 0 && !isLoading}
6775
<p>No episodes found.</p>
6876
{/if}
6977
{#each episodes as episode (episode.url || episode.streamUrl || `${episode.title}-${episode.episodeDate ?? ""}`)}
@@ -115,4 +123,13 @@
115123
width: 100%;
116124
margin-bottom: 0.5rem;
117125
}
126+
127+
.episode-list-loading {
128+
display: flex;
129+
align-items: center;
130+
justify-content: center;
131+
gap: 0.75rem;
132+
padding: 1rem 0;
133+
color: var(--text-muted);
134+
}
118135
</style>

src/ui/PodcastView/PodcastView.integration.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, render, screen } from "@testing-library/svelte";
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
22
import { get } from "svelte/store";
33
import {
44
afterEach,
@@ -144,4 +144,89 @@ describe("PodcastView integration flow", () => {
144144
expect.objectContaining({ path: expectedPath }),
145145
);
146146
});
147+
148+
test("shows loading state while fetching and streams episodes per feed", async () => {
149+
const secondFeed: PodcastFeed = {
150+
title: "Second Podcast",
151+
url: "https://pod.example.com/feed-two.xml",
152+
artworkUrl: "https://pod.example.com/art-two.jpg",
153+
};
154+
155+
const firstEpisode: Episode = {
156+
title: "Episode A",
157+
streamUrl: "https://pod.example.com/a.mp3",
158+
url: "https://pod.example.com/a",
159+
description: "Episode A description",
160+
content: "<p>Episode A content</p>",
161+
podcastName: testFeed.title,
162+
artworkUrl: testFeed.artworkUrl,
163+
episodeDate: new Date("2024-02-01T00:00:00.000Z"),
164+
};
165+
166+
const secondEpisode: Episode = {
167+
title: "Episode B",
168+
streamUrl: "https://pod.example.com/b.mp3",
169+
url: "https://pod.example.com/b",
170+
description: "Episode B description",
171+
content: "<p>Episode B content</p>",
172+
podcastName: secondFeed.title,
173+
artworkUrl: secondFeed.artworkUrl,
174+
episodeDate: new Date("2024-01-15T00:00:00.000Z"),
175+
};
176+
177+
let resolveFirstFeed!: (value: Episode[]) => void;
178+
let resolveSecondFeed!: (value: Episode[]) => void;
179+
180+
mockGetEpisodes
181+
.mockImplementationOnce(
182+
() =>
183+
new Promise<Episode[]>((resolve) => {
184+
resolveFirstFeed = resolve;
185+
}),
186+
)
187+
.mockImplementationOnce(
188+
() =>
189+
new Promise<Episode[]>((resolve) => {
190+
resolveSecondFeed = resolve;
191+
}),
192+
);
193+
194+
plugin.set({
195+
settings: {
196+
feedCache: {
197+
enabled: false,
198+
ttlHours: 6,
199+
},
200+
},
201+
} as never);
202+
203+
savedFeeds.set({
204+
[testFeed.title]: testFeed,
205+
[secondFeed.title]: secondFeed,
206+
});
207+
viewState.set(ViewState.EpisodeList);
208+
209+
render(PodcastView);
210+
211+
await screen.findByText("Fetching episodes...");
212+
213+
resolveFirstFeed([firstEpisode]);
214+
215+
expect(
216+
await screen.findByText(firstEpisode.title),
217+
).toBeInTheDocument();
218+
expect(screen.getByText("Fetching episodes...")).toBeInTheDocument();
219+
expect(screen.queryByText(secondEpisode.title)).toBeNull();
220+
221+
resolveSecondFeed([secondEpisode]);
222+
223+
expect(
224+
await screen.findByText(secondEpisode.title),
225+
).toBeInTheDocument();
226+
await waitFor(() =>
227+
expect(
228+
screen.queryByText("Fetching episodes..."),
229+
).not.toBeInTheDocument(),
230+
);
231+
});
147232
});

src/ui/PodcastView/PodcastView.svelte

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
let displayedEpisodes: Episode[] = [];
4141
let displayedPlaylists: Playlist[] = [];
4242
let latestEpisodes: Episode[] = [];
43+
let isFetchingEpisodes: boolean = false;
4344
let loadingFeeds: Set<string> = new Set();
4445
let currentSearchQuery: string = "";
4546
let loadingFeedNames: string[] = [];
@@ -50,6 +51,7 @@
5051
loadingFeedNames.length > 3
5152
? `${loadingFeedNames.slice(0, 3).join(", ")} +${loadingFeedNames.length - 3} more`
5253
: loadingFeedNames.join(", ");
54+
$: isFetchingEpisodes = loadingFeedNames.length > 0;
5355
5456
onMount(() => {
5557
const unsubscribePlaylists = playlists.subscribe((pl) => {
@@ -177,9 +179,9 @@
177179
event: CustomEvent<{ feed: PodcastFeed }>
178180
) {
179181
const { feed } = event.detail;
180-
displayedEpisodes = [];
181182
182183
selectedFeed = feed;
184+
displayedEpisodes = [];
183185
viewState.set(ViewState.EpisodeList);
184186
setFeedLoading(feed.title, true);
185187
@@ -284,6 +286,7 @@
284286
<EpisodeList
285287
episodes={displayedEpisodes}
286288
showThumbnails={!selectedFeed || !selectedPlaylist}
289+
isLoading={isFetchingEpisodes}
287290
on:clickEpisode={handleClickEpisode}
288291
on:contextMenuEpisode={handleContextMenuEpisode}
289292
on:clickRefresh={handleClickRefresh}

0 commit comments

Comments
 (0)