Skip to content
Snippets Groups Projects
Commit dd19b618 authored by Christian Bager Bach Houmann's avatar Christian Bager Bach Houmann
Browse files

feat: view latest episodes

parent 1aefdcfc
No related branches found
No related tags found
No related merge requests found
<script lang="ts">
import { Episode } from "src/types/Episode";
import { PodcastFeed } from "src/types/PodcastFeed";
import { createEventDispatcher, onMount } from "svelte";
import EpisodeListItem from "./EpisodeListItem.svelte";
import { playedEpisodes } from "src/store";
import Icon from "../obsidian/Icon.svelte";
import { debounce } from "obsidian";
import Fuse from "fuse.js";
import Text from "../obsidian/Text.svelte";
export let episodes: Episode[] = [];
export let feed: PodcastFeed | null = null;
export let showThumbnails: boolean = false;
let hidePlayedEpisodes: boolean = false;
let displayedEpisodes: Episode[] = [];
let searchInputQuery: string = "";
const dispatch = createEventDispatcher();
......@@ -21,44 +17,20 @@
dispatch("clickEpisode", { episode: event.detail.episode });
}
function searchEpisodes(query: string) {
if (query.length === 0) {
displayedEpisodes = episodes;
return;
}
const fuse = new Fuse(episodes, {
shouldSort: true,
findAllMatches: true,
threshold: 0.4,
isCaseSensitive: false,
keys: ['title'],
});
const searchResults = fuse.search(query);
displayedEpisodes = searchResults.map(resItem => resItem.item);
function forwardSearchInput(event: CustomEvent<{ query: string }>) {
dispatch("search", { query: event.detail.query });
}
const onSearchInput = debounce((event: CustomEvent<{value: string}>) => {
searchEpisodes(event.detail.value);
}, 250);
onMount(() => {
displayedEpisodes = episodes;
});
</script>
<div class="episode-list-view-container">
<div class="podcast-header">
<img id="podcast-artwork" src={feed?.artworkUrl} alt={feed?.title} />
<h2 class="podcast-heading">{feed?.title}</h2>
</div>
<slot name="header">Fallback</slot>
<div class="episode-list-menu">
<div class="episode-list-search">
<Text
bind:value={searchInputQuery}
on:change={forwardSearchInput}
placeholder="Search episodes"
on:change={onSearchInput}
style={{
width: "100%",
}}
......@@ -77,12 +49,13 @@
</div>
<div class="podcast-episode-list">
{#each displayedEpisodes as episode}
{#each episodes as episode}
{@const episodePlayed = $playedEpisodes[episode.title]?.finished}
{#if !hidePlayedEpisodes || !episodePlayed}
<EpisodeListItem
episode={episode}
episodeFinished={episodePlayed}
showEpisodeImage={showThumbnails}
on:clickEpisode={forwardClickEpisode}
/>
{/if}
......@@ -98,18 +71,6 @@
justify-content: center;
}
.podcast-header {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
padding: 0.5rem;
}
.podcast-heading {
text-align: center;
}
.podcast-episode-list {
display: flex;
flex-direction: column;
......
<script lang="ts">
export let text: string = "";
export let artworkUrl: string = "";
</script>
<div class="podcast-header">
{#if artworkUrl}
<img id="podcast-artwork" src={artworkUrl} alt={text} />
{/if}
<h2 class="podcast-heading">{text}</h2>
</div>
<style>
.podcast-header {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
padding: 0.5rem;
}
.podcast-heading {
text-align: center;
}
</style>
\ No newline at end of file
......@@ -4,6 +4,7 @@
export let episode: Episode;
export let episodeFinished: boolean = false;
export let showEpisodeImage: boolean = false;
const dispatch = createEventDispatcher();
......@@ -19,7 +20,15 @@
class="podcast-episode-item"
on:click={onClickEpisode}
>
<div class="podcast-episode-information">
{#if showEpisodeImage && episode?.artworkUrl}
<div class="podcast-episode-thumbnail-container">
<img class="podcast-episode-thumbnail" src={episode?.artworkUrl} alt={episode.title} />
</div>
{/if}
<div
class="podcast-episode-information"
style:flex-basis={showEpisodeImage ? "80%" : ""}
>
<span class="episode-item-date">{date.toUpperCase()}</span>
<span class={`episode-item-title ${episodeFinished && "strikeout"}`}>{episode.title}</span>
</div>
......@@ -34,6 +43,7 @@
padding: 0.5rem;
width: 100%;
border: solid 1px var(--background-divider);
gap: 0.25rem;
}
.podcast-episode-item:hover {
......@@ -59,4 +69,18 @@
.episode-item-date {
color: gray;
}
.podcast-episode-thumbnail-container {
flex-basis: 20%;
display: flex;
align-items: center;
justify-content: center;
}
.podcast-episode-thumbnail {
border-radius: 15%;
max-width: 5rem;
max-height: 5rem;
cursor: pointer;
}
</style>
<script lang="ts">
import { PodcastFeed } from "src/types/PodcastFeed";
import FeedGrid from "./PodcastGrid.svelte";
import { currentEpisode, savedFeeds, episodeCache } from "src/store";
import {
currentEpisode,
savedFeeds,
episodeCache,
} from "src/store";
import EpisodePlayer from "./EpisodePlayer.svelte";
import EpisodeList from "./EpisodeList.svelte";
import { Episode } from "src/types/Episode";
import FeedParser from "src/parser/feedParser";
import TopBar from "./TopBar.svelte";
import { ViewState } from "src/types/ViewState";
import { onDestroy } from "svelte";
import { onDestroy, onMount } from "svelte";
import EpisodeListHeader from "./EpisodeListHeader.svelte";
import Icon from "../obsidian/Icon.svelte";
import { debounce } from "obsidian";
import searchEpisodes from "src/utility/searchEpisodes";
let feeds: PodcastFeed[] = [];
let selectedFeed: PodcastFeed | null = null;
let episodeList: Episode[] = [];
let displayedEpisodes: Episode[] = [];
let latestEpisodes: Episode[] = [];
let viewState: ViewState;
const unsubscribe = savedFeeds.subscribe(storeValue => {
onMount(async () => {
await fetchEpisodesInAllFeeds(feeds);
const unsubscribe = episodeCache.subscribe((cache) => {
latestEpisodes = Object.entries(cache)
.map(([_, episodes]) => episodes.splice(0, 10))
.flat()
.sort((a, b) => {
if (a.episodeDate && b.episodeDate)
return Number(b.episodeDate) - Number(a.episodeDate)
return 0;
});
});
return () => {
unsubscribe();
};
});
const unsubscribe = savedFeeds.subscribe((storeValue) => {
feeds = Object.values(storeValue);
});
async function fetchEpisodes(feed: PodcastFeed): Promise<Episode[]> {
return await (new FeedParser(feed).parse(feed.url));
}
async function fetchEpisodes(feed: PodcastFeed, useCache: boolean = true): Promise<Episode[]> {
const cachedEpisodesInFeed = $episodeCache[feed.title];
async function handleClickPodcast(event: CustomEvent<{ feed: PodcastFeed }>) {
episodeList = [];
if (useCache && cachedEpisodesInFeed && cachedEpisodesInFeed.length > 0) {
return cachedEpisodesInFeed;
}
const episodes = await new FeedParser(feed).parse(feed.url);
const { feed } = event.detail;
selectedFeed = feed;
episodeCache.update((cache) => ({
...cache,
[feed.title]: episodes,
}));
const cachedEpisodesInFeed = $episodeCache[feed.title];
return episodes;
}
if (cachedEpisodesInFeed && cachedEpisodesInFeed.length > 0) {
episodeList = cachedEpisodesInFeed;
} else {
const episodes = await fetchEpisodes(feed);
episodeList = episodes;
episodeCache.update(cache => ({ ...cache, [feed.title]: episodes }));
}
function fetchEpisodesInAllFeeds(feedsToSearch: PodcastFeed[]): Promise<Episode[]> {
return Promise.all(feedsToSearch.map((feed) => fetchEpisodes(feed))).then((episodes) => {
return episodes.flat();
});
}
async function handleClickPodcast(
event: CustomEvent<{ feed: PodcastFeed }>
) {
const { feed } = event.detail;
displayedEpisodes = [];
selectedFeed = feed;
displayedEpisodes = await fetchEpisodes(feed);
viewState = ViewState.EpisodeList;
}
......@@ -53,20 +91,22 @@
async function handleClickRefresh() {
if (!selectedFeed) return;
const { title } = selectedFeed;
const episodes = await fetchEpisodes(selectedFeed)
episodeList = episodes;
episodeCache.update(cache => ({ ...cache, [title]: episodes }));
displayedEpisodes = await fetchEpisodes(selectedFeed, false);
}
const handleSearch = debounce((event: CustomEvent<{value: string}>) => {
console.log("searching for", event.detail.value);
searchEpisodes(event.detail.value, displayedEpisodes);
}, 250);
onDestroy(unsubscribe);
</script>
<div class="podcast-view">
<TopBar
bind:viewState
canShowEpisodeList={!!selectedFeed}
canShowEpisodeList={true}
canShowPlayer={!!$currentEpisode}
/>
......@@ -74,16 +114,42 @@
<EpisodePlayer />
{:else if viewState === ViewState.EpisodeList}
<EpisodeList
feed={selectedFeed}
episodes={episodeList}
episodes={selectedFeed ? displayedEpisodes : latestEpisodes}
showThumbnails={!selectedFeed}
on:clickEpisode={handleClickEpisode}
on:clickRefresh={handleClickRefresh}
/>
on:search={handleSearch}
>
<svelte:fragment slot="header">
{#if selectedFeed}
<span
class="go-back"
on:click={() => {
selectedFeed = null;
viewState = ViewState.EpisodeList;
}}
>
<Icon
icon={"arrow-left"}
style={{
display: "flex",
"align-items": "center",
}}
size={20}
/> Latest Episodes
</span>
<EpisodeListHeader
text={selectedFeed.title}
artworkUrl={selectedFeed.artworkUrl}
/>
{:else}
<EpisodeListHeader text="Latest Episodes" />
{/if}
</svelte:fragment>
</EpisodeList>
{:else if viewState === ViewState.PodcastGrid}
<FeedGrid
feeds={feeds}
on:clickPodcast={handleClickPodcast}
/>
<FeedGrid {feeds} on:clickPodcast={handleClickPodcast} />
{/if}
</div>
......@@ -93,4 +159,19 @@
flex-direction: column;
height: 100%;
}
.go-back {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
gap: 0.5rem;
cursor: pointer;
margin-right: auto;
opacity: 0.75;
}
.go-back:hover {
opacity: 1;
}
</style>
import FeedParser from "src/parser/feedParser";
import { Episode } from "src/types/Episode";
import { PlayedEpisode } from "src/types/PlayedEpisode"
import { PodcastFeed } from "src/types/PodcastFeed";
export default async function findPlayedEpisodesInFeeds(
playedEpisodes: PlayedEpisode[],
feeds: PodcastFeed[],
): Promise<Episode[]>
{
const episodesByPodcast = playedEpisodes.reduce((acc: {[podcastName: string]: PlayedEpisode[]}, episode) => {
const podcastName = episode.podcastName;
const episodes = acc[podcastName] || [];
episodes.push(episode);
acc[podcastName] = episodes;
return acc;
}, {});
const playedEpisodesInFeeds: Episode[] = [];
for (const [podcastName, episodes] of Object.entries(episodesByPodcast)) {
const feed = feeds.find(feed => feed.title === podcastName);
if (!feed) continue;
const parser = new FeedParser(feed);
const episodesInFeed = await parser.parse(feed.url);
for (const episode of episodes) {
const episodeInFeed = episodesInFeed.find(e => e.title === episode.title);
if (episodeInFeed) {
playedEpisodesInFeeds.push(episodeInFeed);
}
}
}
return playedEpisodesInFeeds;
}
\ No newline at end of file
import Fuse from "fuse.js";
import { Episode } from "src/types/Episode";
export default function searchEpisodes(query: string, episodes: Episode[]): Episode[] {
if (query.length === 0 || episodes.length === 0) {
return [];
}
const fuse = new Fuse(episodes, {
shouldSort: true,
findAllMatches: true,
threshold: 0.4,
isCaseSensitive: false,
keys: ['title'],
});
const searchResults = fuse.search(query);
return searchResults.map(resItem => resItem.item);
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment