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
195 changes: 194 additions & 1 deletion src/store/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { get, writable } from "svelte/store";
import { get, readable, writable } from "svelte/store";
import type PodNotes from "src/main";
import type { Episode } from "src/types/Episode";
import type { PlayedEpisode } from "src/types/PlayedEpisode";
Expand Down Expand Up @@ -102,6 +102,199 @@ export const savedFeeds = writable<{ [podcastName: string]: PodcastFeed }>({});

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

const LATEST_EPISODES_PER_FEED = 10;

type LatestEpisodesByFeed = Map<string, Episode[]>;
type FeedEpisodeSources = Map<string, Episode[]>;
type LatestEpisodePointer = {
feedTitle: string;
index: number;
episode: Episode;
};

function getEpisodeTimestamp(episode?: Episode): number {
if (!episode?.episodeDate) return 0;

return Number(episode.episodeDate);
}

function getLatestEpisodesForFeed(episodes: Episode[]): Episode[] {
if (!episodes?.length) return [];

return episodes
.slice(0, LATEST_EPISODES_PER_FEED)
.sort((a, b) => getEpisodeTimestamp(b) - getEpisodeTimestamp(a));
}

function shallowEqualEpisodes(a?: Episode[], b?: Episode[]): boolean {
if (!a || !b || a.length !== b.length) return false;

for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}

return true;
}

function pushEpisodePointer(
heap: LatestEpisodePointer[],
pointer: LatestEpisodePointer,
): void {
heap.push(pointer);
let idx = heap.length - 1;

while (idx > 0) {
const parent = Math.floor((idx - 1) / 2);
if (
getEpisodeTimestamp(heap[parent].episode) >=
getEpisodeTimestamp(heap[idx].episode)
) {
break;
}

heap[idx] = heap[parent];
heap[parent] = pointer;
idx = parent;
}
}

function popEpisodePointer(
heap: LatestEpisodePointer[],
): LatestEpisodePointer | undefined {
if (heap.length === 0) return undefined;

const top = heap[0];
const last = heap.pop();

if (last && heap.length > 0) {
heap[0] = last;
let idx = 0;

while (true) {
const left = idx * 2 + 1;
const right = idx * 2 + 2;
let largest = idx;

if (
left < heap.length &&
getEpisodeTimestamp(heap[left].episode) >
getEpisodeTimestamp(heap[largest].episode)
) {
largest = left;
}

if (
right < heap.length &&
getEpisodeTimestamp(heap[right].episode) >
getEpisodeTimestamp(heap[largest].episode)
) {
largest = right;
}

if (largest === idx) break;

const temp = heap[idx];
heap[idx] = heap[largest];
heap[largest] = temp;
idx = largest;
}
}

return top;
}

// Use a max-heap to merge the latest episodes from each feed without
// resorting the entire cache every time a single feed updates.
function mergeLatestEpisodes(latestByFeed: LatestEpisodesByFeed): Episode[] {
const heap: LatestEpisodePointer[] = [];

for (const [feedTitle, episodes] of latestByFeed.entries()) {
if (!episodes.length) continue;

pushEpisodePointer(heap, {
feedTitle,
index: 0,
episode: episodes[0],
});
}

const merged: Episode[] = [];
while (heap.length > 0) {
const pointer = popEpisodePointer(heap);
if (!pointer) break;

merged.push(pointer.episode);

const feedEpisodes = latestByFeed.get(pointer.feedTitle);
const nextIndex = pointer.index + 1;
if (feedEpisodes && nextIndex < feedEpisodes.length) {
pushEpisodePointer(heap, {
feedTitle: pointer.feedTitle,
index: nextIndex,
episode: feedEpisodes[nextIndex],
});
}
}

return merged;
}

export const latestEpisodes = readable<Episode[]>([], (set) => {
let latestByFeed: LatestEpisodesByFeed = new Map();
let feedSources: FeedEpisodeSources = new Map();

const unsubscribe = episodeCache.subscribe((cache) => {
let changed = false;
const nextSources: FeedEpisodeSources = new Map();
const nextLatestByFeed: LatestEpisodesByFeed = new Map();

for (const [feedTitle, episodes] of Object.entries(cache)) {
nextSources.set(feedTitle, episodes);
const previousSource = feedSources.get(feedTitle);
const previousLatest = latestByFeed.get(feedTitle);

if (previousSource === episodes && previousLatest) {
nextLatestByFeed.set(feedTitle, previousLatest);
continue;
}

const latestForFeed = getLatestEpisodesForFeed(episodes);
nextLatestByFeed.set(feedTitle, latestForFeed);

if (!changed) {
changed =
!previousLatest ||
!shallowEqualEpisodes(previousLatest, latestForFeed);
}
}

if (!changed) {
for (const feedTitle of feedSources.keys()) {
if (!nextSources.has(feedTitle)) {
changed = true;
break;
}
}
}

feedSources = nextSources;

if (!changed && nextLatestByFeed.size === latestByFeed.size) {
latestByFeed = nextLatestByFeed;
return;
}

latestByFeed = nextLatestByFeed;
set(mergeLatestEpisodes(latestByFeed));
});

return () => {
latestByFeed.clear();
feedSources.clear();
unsubscribe();
};
});

export const downloadedEpisodes = (() => {
const store = writable<{ [podcastName: string]: DownloadedEpisode[] }>({});
const { subscribe, update, set } = store;
Expand Down
97 changes: 48 additions & 49 deletions src/ui/PodcastView/PodcastView.svelte
Original file line number Diff line number Diff line change
@@ -1,37 +1,38 @@
<script lang="ts">
import type { PodcastFeed } from "src/types/PodcastFeed";
import PodcastGrid from "./PodcastGrid.svelte";
import {
currentEpisode,
savedFeeds,
episodeCache,
playlists,
queue,
favorites,
localFiles,
podcastView,
viewState,
downloadedEpisodes,
plugin,
} from "src/store";
import {
currentEpisode,
savedFeeds,
episodeCache,
latestEpisodes as latestEpisodesStore,
playlists,
queue,
favorites,
localFiles,
podcastView,
viewState,
downloadedEpisodes,
plugin,
} from "src/store";
import EpisodePlayer from "./EpisodePlayer.svelte";
import EpisodeList from "./EpisodeList.svelte";
import type { Episode } from "src/types/Episode";
import FeedParser from "src/parser/feedParser";
import TopBar from "./TopBar.svelte";
import { ViewState } from "src/types/ViewState";
import { onMount } from "svelte";
import { onMount } from "svelte";
import EpisodeListHeader from "./EpisodeListHeader.svelte";
import Icon from "../obsidian/Icon.svelte";
import { debounce } from "obsidian";
import searchEpisodes from "src/utility/searchEpisodes";
import type { Playlist } from "src/types/Playlist";
import spawnEpisodeContextMenu from "./spawnEpisodeContextMenu";
import {
getCachedEpisodes,
setCachedEpisodes,
} from "src/services/FeedCacheService";
import { get } from "svelte/store";
import {
getCachedEpisodes,
setCachedEpisodes,
} from "src/services/FeedCacheService";
import { get } from "svelte/store";

let feeds: PodcastFeed[] = [];
let selectedFeed: PodcastFeed | null = null;
Expand All @@ -40,41 +41,39 @@ import { get } from "svelte/store";
let displayedPlaylists: Playlist[] = [];
let latestEpisodes: Episode[] = [];

onMount(() => {
const unsubscribePlaylists = playlists.subscribe((pl) => {
displayedPlaylists = [$queue, $favorites, $localFiles, ...Object.values(pl)];
});
onMount(() => {
const unsubscribePlaylists = playlists.subscribe((pl) => {
displayedPlaylists = [$queue, $favorites, $localFiles, ...Object.values(pl)];
});

const unsubscribeSavedFeeds = savedFeeds.subscribe((storeValue) => {
feeds = Object.values(storeValue);
});
const unsubscribeSavedFeeds = savedFeeds.subscribe((storeValue) => {
feeds = Object.values(storeValue);
});

const unsubscribeEpisodeCache = episodeCache.subscribe((cache) => {
latestEpisodes = Object.entries(cache)
.map(([_, episodes]) => episodes.slice(0, 10))
.flat()
.sort((a, b) => {
if (a.episodeDate && b.episodeDate)
return Number(b.episodeDate) - Number(a.episodeDate);
const unsubscribeLatestEpisodes = latestEpisodesStore.subscribe(
(episodes) => {
latestEpisodes = episodes;

return 0;
});
});
if (!selectedFeed && !selectedPlaylist) {
displayedEpisodes = episodes;
}
},
);

(async () => {
await fetchEpisodesInAllFeeds(feeds);
(async () => {
await fetchEpisodesInAllFeeds(feeds);

if (!selectedFeed) {
displayedEpisodes = latestEpisodes;
}
})();

return () => {
unsubscribeEpisodeCache();
unsubscribeSavedFeeds();
unsubscribePlaylists();
};
});
if (!selectedFeed) {
displayedEpisodes = latestEpisodes;
}
})();

return () => {
unsubscribeLatestEpisodes();
unsubscribeSavedFeeds();
unsubscribePlaylists();
};
});

async function fetchEpisodes(
feed: PodcastFeed,
Expand Down