Skip to content

Commit 4e7e7e9

Browse files
authored
feat(podcast-view): add played episodes view (#170)
* feat(podcast-view): add played episodes view * codex: address PR review feedback (#170)
1 parent d094eae commit 4e7e7e9

14 files changed

Lines changed: 948 additions & 93 deletions

docs/docs/podcasts.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ The playlists are shown in the episode grid, represented by their icons.
1414

1515
By default, you will have a queue playlist and a favorites playlist.
1616

17+
PodNotes also shows a Played playlist in the episode grid. It is a virtual playlist that lists episodes marked as played across all podcasts. Episodes still available in your feeds can be played or managed like normal episodes. Older played-history entries that can no longer be found in current feeds remain visible so you can mark them as unplayed.
18+
1719
You can delete playlists by pressing the trash bin icon next to the playlist name.
1820
The icon will change to a checkmark, which should be pressed within a short duration to confirm the deletion.
1921

src/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export const LOCAL_FILES_SETTINGS: PlaylistSettings = {
2929
shouldRepeat: false,
3030
};
3131

32+
export const PLAYED_SETTINGS: PlaylistSettings = {
33+
icon: "check-square",
34+
name: "Played",
35+
shouldEpisodeRemoveAfterPlay: false,
36+
shouldRepeat: false,
37+
};
38+
3239
export const DEFAULT_SETTINGS: IPodNotesSettings = {
3340
savedFeeds: {},
3441
podNotes: {},

src/store/index.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { get } from "svelte/store";
2+
import { beforeEach, describe, expect, test } from "vitest";
3+
4+
import type { Episode } from "src/types/Episode";
5+
import type { PlayedEpisode } from "src/types/PlayedEpisode";
6+
import { playedEpisodes } from "./index";
7+
8+
const episode: Episode = {
9+
title: "Shared title",
10+
streamUrl: "https://example.com/audio.mp3",
11+
url: "https://example.com/episode",
12+
description: "",
13+
content: "",
14+
podcastName: "Design Podcast",
15+
};
16+
17+
function playedEpisode(podcastName: string): PlayedEpisode {
18+
return {
19+
title: episode.title,
20+
podcastName,
21+
time: 120,
22+
duration: 120,
23+
finished: true,
24+
};
25+
}
26+
27+
describe("playedEpisodes store", () => {
28+
beforeEach(() => {
29+
playedEpisodes.set({});
30+
});
31+
32+
test("markAsUnplayed clears composite and legacy aliases", () => {
33+
playedEpisodes.set({
34+
[episode.title]: playedEpisode("Design Podcast"),
35+
"Design Podcast::Shared title": playedEpisode("Design Podcast"),
36+
"Other Podcast::Shared title": playedEpisode("Other Podcast"),
37+
});
38+
39+
playedEpisodes.markAsUnplayed(episode);
40+
41+
const stored = get(playedEpisodes);
42+
expect(stored[episode.title]).toMatchObject({
43+
finished: false,
44+
time: 0,
45+
});
46+
expect(stored["Design Podcast::Shared title"]).toMatchObject({
47+
finished: false,
48+
time: 0,
49+
});
50+
expect(stored["Other Podcast::Shared title"]).toMatchObject({
51+
finished: true,
52+
time: 120,
53+
});
54+
});
55+
56+
test("markKeyAsUnplayed clears aliases for the keyed played episode", () => {
57+
playedEpisodes.set({
58+
[episode.title]: playedEpisode("Design Podcast"),
59+
"Design Podcast::Shared title": playedEpisode("Design Podcast"),
60+
});
61+
62+
playedEpisodes.markKeyAsUnplayed("Design Podcast::Shared title");
63+
64+
const stored = get(playedEpisodes);
65+
expect(stored[episode.title]?.finished).toBe(false);
66+
expect(stored["Design Podcast::Shared title"]?.finished).toBe(false);
67+
});
68+
});

src/store/index.ts

Lines changed: 50 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import type DownloadedEpisode from "src/types/DownloadedEpisode";
99
import { TFile } from "obsidian";
1010
import type { LocalEpisode } from "src/types/LocalEpisode";
1111
import { getEpisodeKey } from "src/utility/episodeKey";
12+
import {
13+
getPlayedEpisode,
14+
getPlayedEpisodeAliasKeys,
15+
} from "src/utility/episodeStatus";
1216

1317
export const plugin = writable<PodNotes>();
1418
export const currentTime = writable<number>(0);
@@ -51,28 +55,6 @@ export const playedEpisodes = (() => {
5155
const store = writable<{ [key: string]: PlayedEpisode }>({});
5256
const { subscribe, update, set } = store;
5357

54-
/**
55-
* Gets played episode data, checking both composite key and legacy title-only key
56-
* for backwards compatibility.
57-
*/
58-
function getPlayedEpisode(
59-
playedEps: { [key: string]: PlayedEpisode },
60-
episode: Episode | null | undefined,
61-
): PlayedEpisode | undefined {
62-
if (!episode) return undefined;
63-
64-
const key = getEpisodeKey(episode);
65-
// First try composite key
66-
if (key && playedEps[key]) {
67-
return playedEps[key];
68-
}
69-
// Fall back to title-only for backwards compatibility
70-
if (episode.title && playedEps[episode.title]) {
71-
return playedEps[episode.title];
72-
}
73-
return undefined;
74-
}
75-
7658
return {
7759
subscribe,
7860
set,
@@ -135,24 +117,60 @@ export const playedEpisodes = (() => {
135117
const key = getEpisodeKey(episode);
136118
if (!key) return playedEpisodes;
137119

138-
const playedEpisode = getPlayedEpisode(playedEpisodes, episode) || {
139-
title: episode.title,
140-
podcastName: episode.podcastName,
141-
time: 0,
142-
duration: 0,
143-
finished: false,
144-
};
120+
markPlayedEpisodeAliasesAsUnplayed(
121+
playedEpisodes,
122+
{
123+
title: episode.title,
124+
podcastName: episode.podcastName,
125+
},
126+
key,
127+
);
128+
return playedEpisodes;
129+
});
130+
},
131+
markKeyAsUnplayed: (key: string) => {
132+
if (!key) return;
145133

146-
playedEpisode.time = 0;
147-
playedEpisode.finished = false;
134+
update((playedEpisodes) => {
135+
const playedEpisode = playedEpisodes[key];
136+
if (!playedEpisode) return playedEpisodes;
148137

149-
playedEpisodes[key] = playedEpisode;
138+
markPlayedEpisodeAliasesAsUnplayed(playedEpisodes, playedEpisode, key);
150139
return playedEpisodes;
151140
});
152141
},
153142
};
154143
})();
155144

145+
function markPlayedEpisodeAliasesAsUnplayed(
146+
playedEpisodeMap: { [key: string]: PlayedEpisode },
147+
episode: Pick<PlayedEpisode, "title" | "podcastName">,
148+
preferredKey: string,
149+
) {
150+
const aliasKeys = getPlayedEpisodeAliasKeys(
151+
playedEpisodeMap,
152+
episode,
153+
preferredKey,
154+
);
155+
const keysToUpdate = aliasKeys.length > 0 ? aliasKeys : [preferredKey];
156+
157+
for (const key of keysToUpdate) {
158+
const playedEpisode = playedEpisodeMap[key] || {
159+
title: episode.title,
160+
podcastName: episode.podcastName,
161+
time: 0,
162+
duration: 0,
163+
finished: false,
164+
};
165+
166+
playedEpisodeMap[key] = {
167+
...playedEpisode,
168+
time: 0,
169+
finished: false,
170+
};
171+
}
172+
}
173+
156174
export const podcastsUpdated = writable(0);
157175

158176
export const savedFeeds = writable<{ [podcastName: string]: PodcastFeed }>({});

src/types/Playlist.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ export type Playlist = {
1010

1111
shouldEpisodeRemoveAfterPlay: boolean;
1212
shouldRepeat: boolean;
13+
isVirtual?: boolean;
1314
}

src/ui/PodcastView/EpisodeList.svelte

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,47 @@
77
import Text from "../obsidian/Text.svelte";
88
import Loading from "./Loading.svelte";
99
import { getEpisodeKey } from "src/utility/episodeKey";
10+
import { isEpisodeFinished } from "src/utility/episodeStatus";
11+
import {
12+
createEpisodeListEntries,
13+
type EpisodeListEntry,
14+
} from "src/utility/episodeListEntry";
1015
1116
export let episodes: Episode[] = [];
17+
export let episodeEntries: EpisodeListEntry[] | null = null;
1218
export let showThumbnails: boolean = false;
1319
export let showListMenu: boolean = true;
20+
export let showPlayedToggle: boolean = true;
21+
export let alwaysShowPlayedEpisodes: boolean = false;
1422
export let isLoading: boolean = false;
1523
let searchInputQuery: string = "";
16-
17-
function isEpisodeFinished(episode: Episode | null | undefined, playedEps: typeof $playedEpisodes): boolean {
18-
if (!episode) return false;
19-
const key = getEpisodeKey(episode);
20-
// Check composite key first, then fall back to title-only for backwards compat
21-
return (key && playedEps[key]?.finished) || playedEps[episode.title]?.finished || false;
22-
}
24+
$: listEntries = episodeEntries ?? createEpisodeListEntries(episodes);
25+
$: shouldHidePlayedEpisodes = $hidePlayedEpisodes && !alwaysShowPlayedEpisodes;
26+
$: visibleEntries = listEntries.filter(
27+
(entry) =>
28+
!shouldHidePlayedEpisodes ||
29+
!isEpisodeFinished(entry.episode, $playedEpisodes),
30+
);
2331
2432
const dispatch = createEventDispatcher();
2533
26-
function forwardClickEpisode(event: CustomEvent<{ episode: Episode }>) {
27-
dispatch("clickEpisode", { episode: event.detail.episode });
34+
function forwardClickEpisode(
35+
entry: EpisodeListEntry,
36+
event: CustomEvent<{ episode: Episode }>,
37+
) {
38+
dispatch("clickEpisode", {
39+
episode: event.detail.episode,
40+
entry,
41+
});
2842
}
2943
3044
function forwardContextMenuEpisode(
45+
entry: EpisodeListEntry,
3146
event: CustomEvent<{ episode: Episode; event: MouseEvent }>
3247
) {
3348
dispatch("contextMenuEpisode", {
3449
episode: event.detail.episode,
50+
entry,
3551
event: event.detail.event,
3652
});
3753
}
@@ -56,13 +72,15 @@
5672
}}
5773
/>
5874
</div>
59-
<Icon
60-
icon={$hidePlayedEpisodes ? "eye-off" : "eye"}
61-
size={25}
62-
label={$hidePlayedEpisodes ? "Show played episodes" : "Hide played episodes"}
63-
pressed={$hidePlayedEpisodes}
64-
on:click={() => hidePlayedEpisodes.update((value) => !value)}
65-
/>
75+
{#if showPlayedToggle}
76+
<Icon
77+
icon={$hidePlayedEpisodes ? "eye-off" : "eye"}
78+
size={25}
79+
label={$hidePlayedEpisodes ? "Show played episodes" : "Hide played episodes"}
80+
pressed={$hidePlayedEpisodes}
81+
on:click={() => hidePlayedEpisodes.update((value) => !value)}
82+
/>
83+
{/if}
6684
<Icon
6785
icon="refresh-cw"
6886
size={25}
@@ -79,20 +97,18 @@
7997
<span>Fetching episodes...</span>
8098
</div>
8199
{/if}
82-
{#if episodes.length === 0 && !isLoading}
100+
{#if visibleEntries.length === 0 && !isLoading}
83101
<p>No episodes found.</p>
84102
{/if}
85-
{#each episodes as episode, index (getEpisodeKey(episode) ?? `${episode.title}-${episode.episodeDate ?? ""}-${index}`)}
86-
{@const episodePlayed = isEpisodeFinished(episode, $playedEpisodes)}
87-
{#if !$hidePlayedEpisodes || !episodePlayed}
88-
<EpisodeListItem
89-
{episode}
90-
episodeFinished={episodePlayed}
91-
showEpisodeImage={showThumbnails}
92-
on:clickEpisode={forwardClickEpisode}
93-
on:contextMenu={forwardContextMenuEpisode}
94-
/>
95-
{/if}
103+
{#each visibleEntries as entry, index (getEpisodeKey(entry.episode) ?? `${entry.episode.title}-${entry.episode.episodeDate ?? ""}-${index}`)}
104+
<EpisodeListItem
105+
episode={entry.episode}
106+
episodeFinished={isEpisodeFinished(entry.episode, $playedEpisodes)}
107+
showEpisodeImage={showThumbnails}
108+
unavailableReason={entry.unavailableReason}
109+
on:clickEpisode={forwardClickEpisode.bind(null, entry)}
110+
on:contextMenu={forwardContextMenuEpisode.bind(null, entry)}
111+
/>
96112
{/each}
97113
</div>
98114
</div>

src/ui/PodcastView/EpisodeListItem.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
export let episode: Episode;
77
export let episodeFinished: boolean = false;
88
export let showEpisodeImage: boolean = false;
9+
export let unavailableReason: string | undefined = undefined;
910
1011
const dispatch = createEventDispatcher();
1112
const dateFormatter = new Intl.DateTimeFormat("en-GB", {
@@ -57,6 +58,7 @@
5758
class="podcast-episode-item"
5859
on:click={onClickEpisode}
5960
on:contextmenu={onContextMenu}
61+
title={unavailableReason ?? episode.title}
6062
>
6163
{#if showEpisodeImage && episode?.artworkUrl}
6264
<div class="podcast-episode-thumbnail-container">
@@ -75,6 +77,9 @@
7577
<div class="podcast-episode-information">
7678
<span class="episode-item-date">{date}</span>
7779
<span class={`episode-item-title ${episodeFinished && "strikeout"}`}>{episode.title}</span>
80+
{#if unavailableReason}
81+
<span class="episode-item-status">{unavailableReason}</span>
82+
{/if}
7883
</div>
7984
</button>
8085

@@ -119,6 +124,10 @@
119124
opacity: 0.6;
120125
}
121126
127+
.podcast-episode-item:has(.episode-item-status) {
128+
opacity: 0.75;
129+
}
130+
122131
.podcast-episode-information {
123132
display: flex;
124133
flex-direction: column;
@@ -174,4 +183,9 @@
174183
object-fit: cover;
175184
border-radius: 0.375rem;
176185
}
186+
187+
.episode-item-status {
188+
font-size: 0.75rem;
189+
color: var(--text-muted);
190+
}
177191
</style>

0 commit comments

Comments
 (0)