Skip to content

Commit cb0ed31

Browse files
committed
Improve podcast loading feedback
1 parent f2d6e80 commit cb0ed31

3 files changed

Lines changed: 159 additions & 12 deletions

File tree

src/ui/PodcastView/EpisodeList.svelte

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
<script lang="ts">
22
import type { Episode } from "src/types/Episode";
3-
import { createEventDispatcher, onMount } from "svelte";
3+
import { createEventDispatcher } from "svelte";
44
import EpisodeListItem from "./EpisodeListItem.svelte";
55
import { 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 hidePlayedEpisodes: boolean = false;
1315
let searchInputQuery: string = "";
1416
@@ -64,7 +66,13 @@
6466
{/if}
6567

6668
<div class="podcast-episode-list">
67-
{#if episodes.length === 0}
69+
{#if isLoading}
70+
<div class="episode-list-loading" role="status" aria-live="polite">
71+
<Loading />
72+
<span>Fetching episodes...</span>
73+
</div>
74+
{/if}
75+
{#if episodes.length === 0 && !isLoading}
6876
<p>No episodes found.</p>
6977
{/if}
7078
{#each episodes as episode}
@@ -114,4 +122,13 @@
114122
width: 100%;
115123
margin-bottom: 0.5rem;
116124
}
125+
126+
.episode-list-loading {
127+
display: flex;
128+
align-items: center;
129+
justify-content: center;
130+
gap: 0.75rem;
131+
padding: 1rem 0;
132+
color: var(--text-muted);
133+
}
117134
</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: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ import { get } from "svelte/store";
3939
let displayedEpisodes: Episode[] = [];
4040
let displayedPlaylists: Playlist[] = [];
4141
let latestEpisodes: Episode[] = [];
42+
let isFetchingEpisodes: boolean = false;
43+
let pendingEpisodeFetches: number = 0;
44+
45+
function startEpisodeFetch() {
46+
pendingEpisodeFetches += 1;
47+
isFetchingEpisodes = true;
48+
}
49+
50+
function finishEpisodeFetch() {
51+
pendingEpisodeFetches = Math.max(0, pendingEpisodeFetches - 1);
52+
isFetchingEpisodes = pendingEpisodeFetches > 0;
53+
}
4254
4355
onMount(() => {
4456
const unsubscribePlaylists = playlists.subscribe((pl) => {
@@ -129,25 +141,51 @@ onMount(() => {
129141
}
130142
}
131143
132-
function fetchEpisodesInAllFeeds(
144+
async function fetchEpisodesInAllFeeds(
133145
feedsToSearch: PodcastFeed[]
134146
): Promise<Episode[]> {
135-
return Promise.all(
136-
feedsToSearch.map((feed) => fetchEpisodes(feed))
137-
).then((episodes) => {
138-
return episodes.flat();
139-
});
147+
if (!feedsToSearch.length) {
148+
return [];
149+
}
150+
151+
startEpisodeFetch();
152+
153+
try {
154+
const episodes = await Promise.allSettled(
155+
feedsToSearch.map(async (feed) => {
156+
const feedEpisodes = await fetchEpisodes(feed);
157+
158+
if (!selectedFeed && !selectedPlaylist) {
159+
displayedEpisodes = latestEpisodes;
160+
}
161+
162+
return feedEpisodes;
163+
}),
164+
);
165+
166+
return episodes
167+
.map((result) => (result.status === "fulfilled" ? result.value : []))
168+
.flat();
169+
} finally {
170+
finishEpisodeFetch();
171+
}
140172
}
141173
142174
async function handleClickPodcast(
143175
event: CustomEvent<{ feed: PodcastFeed }>
144176
) {
145177
const { feed } = event.detail;
146-
displayedEpisodes = [];
147178
148179
selectedFeed = feed;
149-
displayedEpisodes = await fetchEpisodes(feed);
180+
displayedEpisodes = [];
181+
startEpisodeFetch();
150182
viewState.set(ViewState.EpisodeList);
183+
184+
try {
185+
displayedEpisodes = await fetchEpisodes(feed);
186+
} finally {
187+
finishEpisodeFetch();
188+
}
151189
}
152190
153191
function handleClickEpisode(event: CustomEvent<{ episode: Episode }>) {
@@ -166,7 +204,13 @@ onMount(() => {
166204
async function handleClickRefresh() {
167205
if (!selectedFeed) return;
168206
169-
displayedEpisodes = await fetchEpisodes(selectedFeed, false);
207+
startEpisodeFetch();
208+
209+
try {
210+
displayedEpisodes = await fetchEpisodes(selectedFeed, false);
211+
} finally {
212+
finishEpisodeFetch();
213+
}
170214
}
171215
172216
const handleSearch = debounce((event: CustomEvent<{ query: string }>) => {
@@ -216,6 +260,7 @@ onMount(() => {
216260
<EpisodeList
217261
episodes={displayedEpisodes}
218262
showThumbnails={!selectedFeed || !selectedPlaylist}
263+
isLoading={isFetchingEpisodes}
219264
on:clickEpisode={handleClickEpisode}
220265
on:contextMenuEpisode={handleContextMenuEpisode}
221266
on:clickRefresh={handleClickRefresh}

0 commit comments

Comments
 (0)