Skip to content

Commit da44a7b

Browse files
committed
Merge master into cache-first-feed-loading
2 parents 485fb7c + 732f094 commit da44a7b

14 files changed

Lines changed: 425 additions & 138 deletions

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: 195 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,199 @@ 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+
type LatestEpisodePointer = {
111+
feedTitle: string;
112+
index: number;
113+
episode: Episode;
114+
};
115+
116+
function getEpisodeTimestamp(episode?: Episode): number {
117+
if (!episode?.episodeDate) return 0;
118+
119+
return Number(episode.episodeDate);
120+
}
121+
122+
function getLatestEpisodesForFeed(episodes: Episode[]): Episode[] {
123+
if (!episodes?.length) return [];
124+
125+
return episodes
126+
.slice(0, LATEST_EPISODES_PER_FEED)
127+
.sort((a, b) => getEpisodeTimestamp(b) - getEpisodeTimestamp(a));
128+
}
129+
130+
function shallowEqualEpisodes(a?: Episode[], b?: Episode[]): boolean {
131+
if (!a || !b || a.length !== b.length) return false;
132+
133+
for (let i = 0; i < a.length; i += 1) {
134+
if (a[i] !== b[i]) return false;
135+
}
136+
137+
return true;
138+
}
139+
140+
function pushEpisodePointer(
141+
heap: LatestEpisodePointer[],
142+
pointer: LatestEpisodePointer,
143+
): void {
144+
heap.push(pointer);
145+
let idx = heap.length - 1;
146+
147+
while (idx > 0) {
148+
const parent = Math.floor((idx - 1) / 2);
149+
if (
150+
getEpisodeTimestamp(heap[parent].episode) >=
151+
getEpisodeTimestamp(heap[idx].episode)
152+
) {
153+
break;
154+
}
155+
156+
heap[idx] = heap[parent];
157+
heap[parent] = pointer;
158+
idx = parent;
159+
}
160+
}
161+
162+
function popEpisodePointer(
163+
heap: LatestEpisodePointer[],
164+
): LatestEpisodePointer | undefined {
165+
if (heap.length === 0) return undefined;
166+
167+
const top = heap[0];
168+
const last = heap.pop();
169+
170+
if (last && heap.length > 0) {
171+
heap[0] = last;
172+
let idx = 0;
173+
174+
while (true) {
175+
const left = idx * 2 + 1;
176+
const right = idx * 2 + 2;
177+
let largest = idx;
178+
179+
if (
180+
left < heap.length &&
181+
getEpisodeTimestamp(heap[left].episode) >
182+
getEpisodeTimestamp(heap[largest].episode)
183+
) {
184+
largest = left;
185+
}
186+
187+
if (
188+
right < heap.length &&
189+
getEpisodeTimestamp(heap[right].episode) >
190+
getEpisodeTimestamp(heap[largest].episode)
191+
) {
192+
largest = right;
193+
}
194+
195+
if (largest === idx) break;
196+
197+
const temp = heap[idx];
198+
heap[idx] = heap[largest];
199+
heap[largest] = temp;
200+
idx = largest;
201+
}
202+
}
203+
204+
return top;
205+
}
206+
207+
// Use a max-heap to merge the latest episodes from each feed without
208+
// resorting the entire cache every time a single feed updates.
209+
function mergeLatestEpisodes(latestByFeed: LatestEpisodesByFeed): Episode[] {
210+
const heap: LatestEpisodePointer[] = [];
211+
212+
for (const [feedTitle, episodes] of latestByFeed.entries()) {
213+
if (!episodes.length) continue;
214+
215+
pushEpisodePointer(heap, {
216+
feedTitle,
217+
index: 0,
218+
episode: episodes[0],
219+
});
220+
}
221+
222+
const merged: Episode[] = [];
223+
while (heap.length > 0) {
224+
const pointer = popEpisodePointer(heap);
225+
if (!pointer) break;
226+
227+
merged.push(pointer.episode);
228+
229+
const feedEpisodes = latestByFeed.get(pointer.feedTitle);
230+
const nextIndex = pointer.index + 1;
231+
if (feedEpisodes && nextIndex < feedEpisodes.length) {
232+
pushEpisodePointer(heap, {
233+
feedTitle: pointer.feedTitle,
234+
index: nextIndex,
235+
episode: feedEpisodes[nextIndex],
236+
});
237+
}
238+
}
239+
240+
return merged;
241+
}
242+
243+
export const latestEpisodes = readable<Episode[]>([], (set) => {
244+
let latestByFeed: LatestEpisodesByFeed = new Map();
245+
let feedSources: FeedEpisodeSources = new Map();
246+
247+
const unsubscribe = episodeCache.subscribe((cache) => {
248+
let changed = false;
249+
const nextSources: FeedEpisodeSources = new Map();
250+
const nextLatestByFeed: LatestEpisodesByFeed = new Map();
251+
252+
for (const [feedTitle, episodes] of Object.entries(cache)) {
253+
nextSources.set(feedTitle, episodes);
254+
const previousSource = feedSources.get(feedTitle);
255+
const previousLatest = latestByFeed.get(feedTitle);
256+
257+
if (previousSource === episodes && previousLatest) {
258+
nextLatestByFeed.set(feedTitle, previousLatest);
259+
continue;
260+
}
261+
262+
const latestForFeed = getLatestEpisodesForFeed(episodes);
263+
nextLatestByFeed.set(feedTitle, latestForFeed);
264+
265+
if (!changed) {
266+
changed =
267+
!previousLatest ||
268+
!shallowEqualEpisodes(previousLatest, latestForFeed);
269+
}
270+
}
271+
272+
if (!changed) {
273+
for (const feedTitle of feedSources.keys()) {
274+
if (!nextSources.has(feedTitle)) {
275+
changed = true;
276+
break;
277+
}
278+
}
279+
}
280+
281+
feedSources = nextSources;
282+
283+
if (!changed && nextLatestByFeed.size === latestByFeed.size) {
284+
latestByFeed = nextLatestByFeed;
285+
return;
286+
}
287+
288+
latestByFeed = nextLatestByFeed;
289+
set(mergeLatestEpisodes(latestByFeed));
290+
});
291+
292+
return () => {
293+
latestByFeed.clear();
294+
feedSources.clear();
295+
unsubscribe();
296+
};
297+
});
298+
105299
export const downloadedEpisodes = (() => {
106300
const store = writable<{ [podcastName: string]: DownloadedEpisode[] }>({});
107301
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: 14 additions & 13 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();
@@ -48,11 +47,11 @@
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)