Skip to content

Commit 9534d70

Browse files
committed
Merge origin/master into topbar-nav-state-cues
2 parents 8af60f9 + 232c34a commit 9534d70

File tree

15 files changed

+608
-199
lines changed

15 files changed

+608
-199
lines changed

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
3434
podNotes: {},
3535
defaultPlaybackRate: 1,
3636
defaultVolume: 1,
37+
hidePlayedEpisodes: false,
3738
playedEpisodes: {},
3839
favorites: {
3940
...FAVORITES_SETTINGS,

src/main.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
playlists,
88
queue,
99
savedFeeds,
10+
hidePlayedEpisodes,
1011
volume,
1112
} from "src/store";
1213
import { Plugin, type WorkspaceLeaf } from "obsidian";
@@ -29,6 +30,7 @@ import { QueueController } from "./store_controllers/QueueController";
2930
import { FavoritesController } from "./store_controllers/FavoritesController";
3031
import type { Episode } from "./types/Episode";
3132
import CurrentEpisodeController from "./store_controllers/CurrentEpisodeController";
33+
import { HidePlayedEpisodesController } from "./store_controllers/HidePlayedEpisodesController";
3234
import { TimestampTemplateEngine } from "./TemplateEngine";
3335
import createPodcastNote from "./createPodcastNote";
3436
import downloadEpisodeWithNotice from "./downloadEpisode";
@@ -66,6 +68,7 @@ export default class PodNotes extends Plugin implements IPodNotes {
6668
private downloadedEpisodesController?: StoreController<{
6769
[podcastName: string]: DownloadedEpisode[];
6870
}>;
71+
private hidePlayedEpisodesController?: StoreController<boolean>;
6972
private transcriptionService?: TranscriptionService;
7073
private volumeUnsubscribe?: Unsubscriber;
7174

@@ -87,6 +90,7 @@ export default class PodNotes extends Plugin implements IPodNotes {
8790
if (this.settings.currentEpisode) {
8891
currentEpisode.set(this.settings.currentEpisode);
8992
}
93+
hidePlayedEpisodes.set(this.settings.hidePlayedEpisodes);
9094
volume.set(
9195
Math.min(1, Math.max(0, this.settings.defaultVolume ?? 1)),
9296
);
@@ -108,6 +112,10 @@ export default class PodNotes extends Plugin implements IPodNotes {
108112
currentEpisode,
109113
this,
110114
).on();
115+
this.hidePlayedEpisodesController = new HidePlayedEpisodesController(
116+
hidePlayedEpisodes,
117+
this,
118+
).on();
111119

112120
this.api = new API();
113121
this.volumeUnsubscribe = volume.subscribe((value) => {
@@ -358,6 +366,7 @@ export default class PodNotes extends Plugin implements IPodNotes {
358366
this.localFilesController?.off();
359367
this.downloadedEpisodesController?.off();
360368
this.currentEpisodeController?.off();
369+
this.hidePlayedEpisodesController?.off();
361370
this.volumeUnsubscribe?.();
362371
}
363372

src/store/index.ts

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { get, writable } from "svelte/store";
1+
import { get, readable, writable } from "svelte/store";
22
import type PodNotes from "src/main";
33
import type { Episode } from "src/types/Episode";
44
import type { PlayedEpisode } from "src/types/PlayedEpisode";
@@ -13,6 +13,7 @@ export const plugin = writable<PodNotes>();
1313
export const currentTime = writable<number>(0);
1414
export const duration = writable<number>(0);
1515
export const volume = writable<number>(1);
16+
export const hidePlayedEpisodes = writable<boolean>(false);
1617

1718
export const currentEpisode = (() => {
1819
const store = writable<Episode>();
@@ -102,6 +103,170 @@ export const savedFeeds = writable<{ [podcastName: string]: PodcastFeed }>({});
102103

103104
export const episodeCache = writable<{ [podcastName: string]: Episode[] }>({});
104105

106+
const LATEST_EPISODES_PER_FEED = 10;
107+
108+
type LatestEpisodesByFeed = Map<string, Episode[]>;
109+
type FeedEpisodeSources = Map<string, Episode[]>;
110+
111+
function getEpisodeTimestamp(episode?: Episode): number {
112+
if (!episode?.episodeDate) return 0;
113+
114+
return Number(episode.episodeDate);
115+
}
116+
117+
function getLatestEpisodesForFeed(episodes: Episode[]): Episode[] {
118+
if (!episodes?.length) return [];
119+
120+
return episodes
121+
.slice(0, LATEST_EPISODES_PER_FEED)
122+
.sort((a, b) => getEpisodeTimestamp(b) - getEpisodeTimestamp(a));
123+
}
124+
125+
function shallowEqualEpisodes(a?: Episode[], b?: Episode[]): boolean {
126+
if (!a || !b || a.length !== b.length) return false;
127+
128+
for (let i = 0; i < a.length; i += 1) {
129+
if (a[i] !== b[i]) return false;
130+
}
131+
132+
return true;
133+
}
134+
135+
const latestEpisodeIdentifier = (episode: Episode): string =>
136+
`${episode.podcastName}::${episode.title}`;
137+
138+
function insertEpisodeSorted(
139+
episodes: Episode[],
140+
episodeToInsert: Episode,
141+
limit: number,
142+
): Episode[] {
143+
const nextEpisodes = [...episodes];
144+
const value = getEpisodeTimestamp(episodeToInsert);
145+
let low = 0;
146+
let high = nextEpisodes.length;
147+
148+
while (low < high) {
149+
const mid = (low + high) >> 1;
150+
const midValue = getEpisodeTimestamp(nextEpisodes[mid]);
151+
152+
if (value > midValue) {
153+
high = mid;
154+
} else {
155+
low = mid + 1;
156+
}
157+
}
158+
159+
nextEpisodes.splice(low, 0, episodeToInsert);
160+
161+
if (nextEpisodes.length > limit) {
162+
nextEpisodes.length = limit;
163+
}
164+
165+
return nextEpisodes;
166+
}
167+
168+
function removeFeedEntries(
169+
currentLatest: Episode[],
170+
feedEpisodes: Episode[] | undefined = [],
171+
): Episode[] {
172+
if (!feedEpisodes?.length) {
173+
return currentLatest;
174+
}
175+
176+
const feedKeys = new Set(feedEpisodes.map(latestEpisodeIdentifier));
177+
178+
return currentLatest.filter(
179+
(episode) => !feedKeys.has(latestEpisodeIdentifier(episode)),
180+
);
181+
}
182+
183+
function updateLatestEpisodesForFeed(
184+
currentLatest: Episode[],
185+
previousFeedEpisodes: Episode[] | undefined,
186+
nextFeedEpisodes: Episode[] | undefined,
187+
limit: number,
188+
): Episode[] {
189+
let nextLatest = removeFeedEntries(currentLatest, previousFeedEpisodes);
190+
191+
if (!nextFeedEpisodes?.length) {
192+
return nextLatest;
193+
}
194+
195+
for (const episode of nextFeedEpisodes) {
196+
nextLatest = insertEpisodeSorted(nextLatest, episode, limit);
197+
}
198+
199+
return nextLatest;
200+
}
201+
202+
export const latestEpisodes = readable<Episode[]>([], (set) => {
203+
let latestByFeed: LatestEpisodesByFeed = new Map();
204+
let feedSources: FeedEpisodeSources = new Map();
205+
let mergedLatest: Episode[] = [];
206+
207+
const unsubscribe = episodeCache.subscribe((cache) => {
208+
const cacheEntries = Object.entries(cache);
209+
const feedCount = cacheEntries.length;
210+
const latestLimit = Math.max(
211+
1,
212+
LATEST_EPISODES_PER_FEED * Math.max(feedCount, 1),
213+
);
214+
215+
let changed = false;
216+
let nextMerged = mergedLatest;
217+
const nextSources: FeedEpisodeSources = new Map();
218+
const nextLatestByFeed: LatestEpisodesByFeed = new Map();
219+
220+
for (const [feedTitle, episodes] of cacheEntries) {
221+
nextSources.set(feedTitle, episodes);
222+
const previousSource = feedSources.get(feedTitle);
223+
const previousLatest = latestByFeed.get(feedTitle) || [];
224+
225+
const nextLatestForFeed =
226+
previousSource === episodes && previousLatest
227+
? previousLatest
228+
: getLatestEpisodesForFeed(episodes);
229+
230+
nextLatestByFeed.set(feedTitle, nextLatestForFeed);
231+
232+
if (!shallowEqualEpisodes(previousLatest, nextLatestForFeed)) {
233+
changed = true;
234+
nextMerged = updateLatestEpisodesForFeed(
235+
nextMerged,
236+
previousLatest,
237+
nextLatestForFeed,
238+
latestLimit,
239+
);
240+
}
241+
}
242+
243+
for (const feedTitle of latestByFeed.keys()) {
244+
if (!nextSources.has(feedTitle)) {
245+
changed = true;
246+
nextMerged = removeFeedEntries(
247+
nextMerged,
248+
latestByFeed.get(feedTitle),
249+
);
250+
}
251+
}
252+
253+
feedSources = nextSources;
254+
latestByFeed = nextLatestByFeed;
255+
256+
if (changed) {
257+
mergedLatest = nextMerged;
258+
set(mergedLatest);
259+
}
260+
});
261+
262+
return () => {
263+
latestByFeed.clear();
264+
feedSources.clear();
265+
mergedLatest = [];
266+
unsubscribe();
267+
};
268+
});
269+
105270
export const downloadedEpisodes = (() => {
106271
const store = writable<{ [podcastName: string]: DownloadedEpisode[] }>({});
107272
const { subscribe, update, set } = store;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Writable } from "svelte/store";
2+
import type { IPodNotes } from "../types/IPodNotes";
3+
import { StoreController } from "../types/StoreController";
4+
5+
export class HidePlayedEpisodesController extends StoreController<boolean> {
6+
private plugin: IPodNotes;
7+
8+
constructor(store: Writable<boolean>, plugin: IPodNotes) {
9+
super(store);
10+
this.plugin = plugin;
11+
}
12+
13+
protected override onChange(value: boolean) {
14+
if (this.plugin.settings.hidePlayedEpisodes === value) return;
15+
16+
this.plugin.settings.hidePlayedEpisodes = value;
17+
this.plugin.saveSettings();
18+
}
19+
}

src/types/IPodNotesSettings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface IPodNotesSettings {
1010
podNotes: { [episodeName: string]: PodNote };
1111
defaultPlaybackRate: number;
1212
defaultVolume: number;
13+
hidePlayedEpisodes: boolean;
1314
playedEpisodes: { [episodeName: string]: PlayedEpisode };
1415
skipBackwardLength: number;
1516
skipForwardLength: number;

src/ui/PodcastView/EpisodeList.svelte

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
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";
5-
import { playedEpisodes } from "src/store";
5+
import { hidePlayedEpisodes, playedEpisodes } from "src/store";
66
import Icon from "../obsidian/Icon.svelte";
77
import Text from "../obsidian/Text.svelte";
88
99
export let episodes: Episode[] = [];
1010
export let showThumbnails: boolean = false;
1111
export let showListMenu: boolean = true;
12-
let hidePlayedEpisodes: boolean = false;
1312
let searchInputQuery: string = "";
1413
1514
const dispatch = createEventDispatcher();
@@ -40,19 +39,19 @@
4039
<div class="episode-list-search">
4140
<Text
4241
bind:value={searchInputQuery}
43-
on:change={forwardSearchInput}
42+
on:input={forwardSearchInput}
4443
placeholder="Search episodes"
4544
style={{
4645
width: "100%",
4746
}}
4847
/>
4948
</div>
5049
<Icon
51-
icon={hidePlayedEpisodes ? "eye-off" : "eye"}
50+
icon={$hidePlayedEpisodes ? "eye-off" : "eye"}
5251
size={25}
53-
label={hidePlayedEpisodes ? "Show played episodes" : "Hide played episodes"}
54-
pressed={hidePlayedEpisodes}
55-
on:click={() => (hidePlayedEpisodes = !hidePlayedEpisodes)}
52+
label={$hidePlayedEpisodes ? "Show played episodes" : "Hide played episodes"}
53+
pressed={$hidePlayedEpisodes}
54+
on:click={() => hidePlayedEpisodes.update((value) => !value)}
5655
/>
5756
<Icon
5857
icon="refresh-cw"
@@ -67,9 +66,9 @@
6766
{#if episodes.length === 0}
6867
<p>No episodes found.</p>
6968
{/if}
70-
{#each episodes as episode}
69+
{#each episodes as episode (episode.url || episode.streamUrl || `${episode.title}-${episode.episodeDate ?? ""}`)}
7170
{@const episodePlayed = $playedEpisodes[episode.title]?.finished}
72-
{#if !hidePlayedEpisodes || !episodePlayed}
71+
{#if !$hidePlayedEpisodes || !episodePlayed}
7372
<EpisodeListItem
7473
{episode}
7574
episodeFinished={episodePlayed}
@@ -86,17 +85,19 @@
8685
.episode-list-view-container {
8786
display: flex;
8887
flex-direction: column;
89-
align-items: center;
90-
justify-content: center;
88+
align-items: stretch;
89+
justify-content: flex-start;
90+
width: 100%;
9191
}
9292
9393
.podcast-episode-list {
9494
display: flex;
9595
flex-direction: column;
96-
align-items: center;
97-
justify-content: center;
96+
align-items: stretch;
97+
justify-content: flex-start;
9898
width: 100%;
9999
height: 100%;
100+
gap: 0.25rem;
100101
}
101102
102103
.episode-list-menu {

0 commit comments

Comments
 (0)