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
2 changes: 2 additions & 0 deletions docs/docs/podcasts.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ The playlists are shown in the episode grid, represented by their icons.

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

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.

You can delete playlists by pressing the trash bin icon next to the playlist name.
The icon will change to a checkmark, which should be pressed within a short duration to confirm the deletion.

Expand Down
7 changes: 7 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export const LOCAL_FILES_SETTINGS: PlaylistSettings = {
shouldRepeat: false,
};

export const PLAYED_SETTINGS: PlaylistSettings = {
icon: "check-square",
name: "Played",
shouldEpisodeRemoveAfterPlay: false,
shouldRepeat: false,
};

export const DEFAULT_SETTINGS: IPodNotesSettings = {
savedFeeds: {},
podNotes: {},
Expand Down
68 changes: 68 additions & 0 deletions src/store/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { get } from "svelte/store";
import { beforeEach, describe, expect, test } from "vitest";

import type { Episode } from "src/types/Episode";
import type { PlayedEpisode } from "src/types/PlayedEpisode";
import { playedEpisodes } from "./index";

const episode: Episode = {
title: "Shared title",
streamUrl: "https://example.com/audio.mp3",
url: "https://example.com/episode",
description: "",
content: "",
podcastName: "Design Podcast",
};

function playedEpisode(podcastName: string): PlayedEpisode {
return {
title: episode.title,
podcastName,
time: 120,
duration: 120,
finished: true,
};
}

describe("playedEpisodes store", () => {
beforeEach(() => {
playedEpisodes.set({});
});

test("markAsUnplayed clears composite and legacy aliases", () => {
playedEpisodes.set({
[episode.title]: playedEpisode("Design Podcast"),
"Design Podcast::Shared title": playedEpisode("Design Podcast"),
"Other Podcast::Shared title": playedEpisode("Other Podcast"),
});

playedEpisodes.markAsUnplayed(episode);

const stored = get(playedEpisodes);
expect(stored[episode.title]).toMatchObject({
finished: false,
time: 0,
});
expect(stored["Design Podcast::Shared title"]).toMatchObject({
finished: false,
time: 0,
});
expect(stored["Other Podcast::Shared title"]).toMatchObject({
finished: true,
time: 120,
});
});

test("markKeyAsUnplayed clears aliases for the keyed played episode", () => {
playedEpisodes.set({
[episode.title]: playedEpisode("Design Podcast"),
"Design Podcast::Shared title": playedEpisode("Design Podcast"),
});

playedEpisodes.markKeyAsUnplayed("Design Podcast::Shared title");

const stored = get(playedEpisodes);
expect(stored[episode.title]?.finished).toBe(false);
expect(stored["Design Podcast::Shared title"]?.finished).toBe(false);
});
});
82 changes: 50 additions & 32 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import type DownloadedEpisode from "src/types/DownloadedEpisode";
import { TFile } from "obsidian";
import type { LocalEpisode } from "src/types/LocalEpisode";
import { getEpisodeKey } from "src/utility/episodeKey";
import {
getPlayedEpisode,
getPlayedEpisodeAliasKeys,
} from "src/utility/episodeStatus";

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

/**
* Gets played episode data, checking both composite key and legacy title-only key
* for backwards compatibility.
*/
function getPlayedEpisode(
playedEps: { [key: string]: PlayedEpisode },
episode: Episode | null | undefined,
): PlayedEpisode | undefined {
if (!episode) return undefined;

const key = getEpisodeKey(episode);
// First try composite key
if (key && playedEps[key]) {
return playedEps[key];
}
// Fall back to title-only for backwards compatibility
if (episode.title && playedEps[episode.title]) {
return playedEps[episode.title];
}
return undefined;
}

return {
subscribe,
set,
Expand Down Expand Up @@ -135,24 +117,60 @@ export const playedEpisodes = (() => {
const key = getEpisodeKey(episode);
if (!key) return playedEpisodes;

const playedEpisode = getPlayedEpisode(playedEpisodes, episode) || {
title: episode.title,
podcastName: episode.podcastName,
time: 0,
duration: 0,
finished: false,
};
markPlayedEpisodeAliasesAsUnplayed(
playedEpisodes,
{
title: episode.title,
podcastName: episode.podcastName,
},
key,
);
return playedEpisodes;
});
},
markKeyAsUnplayed: (key: string) => {
if (!key) return;

playedEpisode.time = 0;
playedEpisode.finished = false;
update((playedEpisodes) => {
const playedEpisode = playedEpisodes[key];
if (!playedEpisode) return playedEpisodes;

playedEpisodes[key] = playedEpisode;
markPlayedEpisodeAliasesAsUnplayed(playedEpisodes, playedEpisode, key);
return playedEpisodes;
});
},
};
})();

function markPlayedEpisodeAliasesAsUnplayed(
playedEpisodeMap: { [key: string]: PlayedEpisode },
episode: Pick<PlayedEpisode, "title" | "podcastName">,
preferredKey: string,
) {
const aliasKeys = getPlayedEpisodeAliasKeys(
playedEpisodeMap,
episode,
preferredKey,
);
const keysToUpdate = aliasKeys.length > 0 ? aliasKeys : [preferredKey];

for (const key of keysToUpdate) {
const playedEpisode = playedEpisodeMap[key] || {
title: episode.title,
podcastName: episode.podcastName,
time: 0,
duration: 0,
finished: false,
};

playedEpisodeMap[key] = {
...playedEpisode,
time: 0,
finished: false,
};
}
}

export const podcastsUpdated = writable(0);

export const savedFeeds = writable<{ [podcastName: string]: PodcastFeed }>({});
Expand Down
1 change: 1 addition & 0 deletions src/types/Playlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export type Playlist = {

shouldEpisodeRemoveAfterPlay: boolean;
shouldRepeat: boolean;
isVirtual?: boolean;
}
72 changes: 44 additions & 28 deletions src/ui/PodcastView/EpisodeList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,47 @@
import Text from "../obsidian/Text.svelte";
import Loading from "./Loading.svelte";
import { getEpisodeKey } from "src/utility/episodeKey";
import { isEpisodeFinished } from "src/utility/episodeStatus";
import {
createEpisodeListEntries,
type EpisodeListEntry,
} from "src/utility/episodeListEntry";

export let episodes: Episode[] = [];
export let episodeEntries: EpisodeListEntry[] | null = null;
export let showThumbnails: boolean = false;
export let showListMenu: boolean = true;
export let showPlayedToggle: boolean = true;
export let alwaysShowPlayedEpisodes: boolean = false;
export let isLoading: boolean = false;
let searchInputQuery: string = "";

function isEpisodeFinished(episode: Episode | null | undefined, playedEps: typeof $playedEpisodes): boolean {
if (!episode) return false;
const key = getEpisodeKey(episode);
// Check composite key first, then fall back to title-only for backwards compat
return (key && playedEps[key]?.finished) || playedEps[episode.title]?.finished || false;
}
$: listEntries = episodeEntries ?? createEpisodeListEntries(episodes);
$: shouldHidePlayedEpisodes = $hidePlayedEpisodes && !alwaysShowPlayedEpisodes;
$: visibleEntries = listEntries.filter(
(entry) =>
!shouldHidePlayedEpisodes ||
!isEpisodeFinished(entry.episode, $playedEpisodes),
);

const dispatch = createEventDispatcher();

function forwardClickEpisode(event: CustomEvent<{ episode: Episode }>) {
dispatch("clickEpisode", { episode: event.detail.episode });
function forwardClickEpisode(
entry: EpisodeListEntry,
event: CustomEvent<{ episode: Episode }>,
) {
dispatch("clickEpisode", {
episode: event.detail.episode,
entry,
});
}

function forwardContextMenuEpisode(
entry: EpisodeListEntry,
event: CustomEvent<{ episode: Episode; event: MouseEvent }>
) {
dispatch("contextMenuEpisode", {
episode: event.detail.episode,
entry,
event: event.detail.event,
});
}
Expand All @@ -56,13 +72,15 @@
}}
/>
</div>
<Icon
icon={$hidePlayedEpisodes ? "eye-off" : "eye"}
size={25}
label={$hidePlayedEpisodes ? "Show played episodes" : "Hide played episodes"}
pressed={$hidePlayedEpisodes}
on:click={() => hidePlayedEpisodes.update((value) => !value)}
/>
{#if showPlayedToggle}
<Icon
icon={$hidePlayedEpisodes ? "eye-off" : "eye"}
size={25}
label={$hidePlayedEpisodes ? "Show played episodes" : "Hide played episodes"}
pressed={$hidePlayedEpisodes}
on:click={() => hidePlayedEpisodes.update((value) => !value)}
/>
{/if}
<Icon
icon="refresh-cw"
size={25}
Expand All @@ -79,20 +97,18 @@
<span>Fetching episodes...</span>
</div>
{/if}
{#if episodes.length === 0 && !isLoading}
{#if visibleEntries.length === 0 && !isLoading}
<p>No episodes found.</p>
{/if}
{#each episodes as episode, index (getEpisodeKey(episode) ?? `${episode.title}-${episode.episodeDate ?? ""}-${index}`)}
{@const episodePlayed = isEpisodeFinished(episode, $playedEpisodes)}
{#if !$hidePlayedEpisodes || !episodePlayed}
<EpisodeListItem
{episode}
episodeFinished={episodePlayed}
showEpisodeImage={showThumbnails}
on:clickEpisode={forwardClickEpisode}
on:contextMenu={forwardContextMenuEpisode}
/>
{/if}
{#each visibleEntries as entry, index (getEpisodeKey(entry.episode) ?? `${entry.episode.title}-${entry.episode.episodeDate ?? ""}-${index}`)}
<EpisodeListItem
episode={entry.episode}
episodeFinished={isEpisodeFinished(entry.episode, $playedEpisodes)}
showEpisodeImage={showThumbnails}
unavailableReason={entry.unavailableReason}
on:clickEpisode={forwardClickEpisode.bind(null, entry)}
on:contextMenu={forwardContextMenuEpisode.bind(null, entry)}
/>
{/each}
</div>
</div>
Expand Down
14 changes: 14 additions & 0 deletions src/ui/PodcastView/EpisodeListItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
export let episode: Episode;
export let episodeFinished: boolean = false;
export let showEpisodeImage: boolean = false;
export let unavailableReason: string | undefined = undefined;

const dispatch = createEventDispatcher();
const dateFormatter = new Intl.DateTimeFormat("en-GB", {
Expand Down Expand Up @@ -57,6 +58,7 @@
class="podcast-episode-item"
on:click={onClickEpisode}
on:contextmenu={onContextMenu}
title={unavailableReason ?? episode.title}
>
{#if showEpisodeImage && episode?.artworkUrl}
<div class="podcast-episode-thumbnail-container">
Expand All @@ -75,6 +77,9 @@
<div class="podcast-episode-information">
<span class="episode-item-date">{date}</span>
<span class={`episode-item-title ${episodeFinished && "strikeout"}`}>{episode.title}</span>
{#if unavailableReason}
<span class="episode-item-status">{unavailableReason}</span>
{/if}
</div>
</button>

Expand Down Expand Up @@ -119,6 +124,10 @@
opacity: 0.6;
}

.podcast-episode-item:has(.episode-item-status) {
opacity: 0.75;
}

.podcast-episode-information {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -174,4 +183,9 @@
object-fit: cover;
border-radius: 0.375rem;
}

.episode-item-status {
font-size: 0.75rem;
color: var(--text-muted);
}
</style>
Loading
Loading