Skip to content

Commit

Permalink
feat: view latest episodes
Browse files Browse the repository at this point in the history
  • Loading branch information
chhoumann committed Jul 15, 2022
1 parent 1aefdcf commit dd19b61
Show file tree
Hide file tree
Showing 6 changed files with 230 additions and 82 deletions.
57 changes: 9 additions & 48 deletions src/ui/PodcastView/EpisodeList.svelte
@@ -1,64 +1,36 @@
<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();
function forwardClickEpisode(event: CustomEvent<{ episode: Episode }>) {
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%",
}}
Expand All @@ -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}
Expand All @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions src/ui/PodcastView/EpisodeListHeader.svelte
@@ -0,0 +1,25 @@
<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>
26 changes: 25 additions & 1 deletion src/ui/PodcastView/EpisodeListItem.svelte
Expand Up @@ -4,6 +4,7 @@
export let episode: Episode;
export let episodeFinished: boolean = false;
export let showEpisodeImage: boolean = false;
const dispatch = createEventDispatcher();
Expand All @@ -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>
Expand All @@ -34,6 +43,7 @@
padding: 0.5rem;
width: 100%;
border: solid 1px var(--background-divider);
gap: 0.25rem;
}
.podcast-episode-item:hover {
Expand All @@ -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>
147 changes: 114 additions & 33 deletions src/ui/PodcastView/PodcastView.svelte
@@ -1,46 +1,84 @@
<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;
}
Expand All @@ -53,37 +91,65 @@
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}
/>

{#if viewState === ViewState.Player}
<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>

Expand All @@ -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>

0 comments on commit dd19b61

Please sign in to comment.