From d8c6aaef12b48a8d1d5e9ec2b3da678caf5143d8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 3 May 2026 18:02:50 +0200 Subject: [PATCH] Fix player button class token handling --- src/parser/feedParser.test.ts | 2 + src/parser/feedParser.ts | 27 ++ src/types/Episode.ts | 1 + src/ui/PodcastView/EpisodeList.svelte | 104 ++++- src/ui/PodcastView/EpisodeListHeader.svelte | 29 +- src/ui/PodcastView/EpisodeListItem.svelte | 368 +++++++++++++++--- src/ui/PodcastView/EpisodePlayer.svelte | 308 ++++++++++----- src/ui/PodcastView/EpisodePlayer.test.ts | 24 ++ .../PodcastView.integration.test.ts | 111 +++++- src/ui/PodcastView/PodcastView.svelte | 328 +++++++++++++++- src/ui/PodcastView/TopBar.svelte | 28 +- src/ui/obsidian/Button.svelte | 32 +- src/ui/obsidian/Button.test.ts | 24 ++ tests/mocks/obsidian.ts | 2 +- 14 files changed, 1204 insertions(+), 184 deletions(-) create mode 100644 src/ui/obsidian/Button.test.ts diff --git a/src/parser/feedParser.test.ts b/src/parser/feedParser.test.ts index 5084f5e..9913dcc 100644 --- a/src/parser/feedParser.test.ts +++ b/src/parser/feedParser.test.ts @@ -25,6 +25,7 @@ const sampleRssFeed = ` First episode description Mon, 01 Jan 2024 00:00:00 GMT Episode 1 iTunes Title + 01:02:03 Episode 2 @@ -202,6 +203,7 @@ describe("FeedParser", () => { expect(episode.description).toBe("First episode description"); expect(episode.episodeDate).toEqual(new Date("Mon, 01 Jan 2024 00:00:00 GMT")); expect(episode.itunesTitle).toBe("Episode 1 iTunes Title"); + expect(episode.duration).toBe(3723); // Feed metadata should now be populated expect(episode.podcastName).toBe("Test Podcast"); expect(episode.feedUrl).toBe("https://example.com/feed.xml"); diff --git a/src/parser/feedParser.ts b/src/parser/feedParser.ts index 56be610..f850eb9 100644 --- a/src/parser/feedParser.ts +++ b/src/parser/feedParser.ts @@ -114,6 +114,7 @@ export default class FeedParser { const pubDateEl = item.querySelector("pubDate"); const itunesImageEl = this.findImageElement(item); const itunesTitleEl = item.getElementsByTagName("itunes:title")[0]; + const durationEl = item.getElementsByTagName("itunes:duration")[0]; const chaptersEl = item.getElementsByTagName("podcast:chapters")[0]; if (!titleEl || !streamUrlEl || !pubDateEl) { @@ -140,6 +141,7 @@ export default class FeedParser { podcastName: this.feed?.title || "", artworkUrl, episodeDate: pubDate, + duration: parseDuration(durationEl?.textContent), feedUrl: this.feed?.url || "", itunesTitle: itunesTitle || "", chaptersUrl, @@ -155,3 +157,28 @@ export default class FeedParser { return body; } } + +function parseDuration(rawDuration: string | null | undefined): number | undefined { + if (!rawDuration) return undefined; + + const trimmedDuration = rawDuration.trim(); + if (!trimmedDuration) return undefined; + + if (/^\d+$/.test(trimmedDuration)) { + return Number.parseInt(trimmedDuration, 10); + } + + const segments = trimmedDuration + .split(":") + .map((segment) => Number.parseInt(segment, 10)); + + if ( + segments.length < 2 || + segments.length > 3 || + segments.some((segment) => Number.isNaN(segment)) + ) { + return undefined; + } + + return segments.reduce((total, segment) => total * 60 + segment, 0); +} diff --git a/src/types/Episode.ts b/src/types/Episode.ts index 2299ace..4f3b142 100644 --- a/src/types/Episode.ts +++ b/src/types/Episode.ts @@ -8,6 +8,7 @@ export interface Episode { feedUrl?: string, artworkUrl?: string; episodeDate?: Date; + duration?: number; itunesTitle?: string; /** URL to the podcast:chapters JSON file */ chaptersUrl?: string; diff --git a/src/ui/PodcastView/EpisodeList.svelte b/src/ui/PodcastView/EpisodeList.svelte index c650c9b..e0e7c27 100644 --- a/src/ui/PodcastView/EpisodeList.svelte +++ b/src/ui/PodcastView/EpisodeList.svelte @@ -2,16 +2,35 @@ import type { Episode } from "src/types/Episode"; import { createEventDispatcher } from "svelte"; import EpisodeListItem from "./EpisodeListItem.svelte"; - import { hidePlayedEpisodes, playedEpisodes } from "src/store"; + import { + downloadedEpisodes, + favorites, + hidePlayedEpisodes, + playedEpisodes, + plugin, + queue, + } from "src/store"; import Icon from "../obsidian/Icon.svelte"; import Text from "../obsidian/Text.svelte"; import Loading from "./Loading.svelte"; import { getEpisodeKey } from "src/utility/episodeKey"; - import { isEpisodeFinished } from "src/utility/episodeStatus"; + import { getPlayedEpisode, isEpisodeFinished } from "src/utility/episodeStatus"; import { createEpisodeListEntries, type EpisodeListEntry, } from "src/utility/episodeListEntry"; + import type DownloadedEpisode from "src/types/DownloadedEpisode"; + import type { Playlist } from "src/types/Playlist"; + import type { PlayedEpisode } from "src/types/PlayedEpisode"; + import { getPodcastNote } from "src/createPodcastNote"; + + type EpisodeQuickAction = + | "play" + | "togglePlayed" + | "download" + | "note" + | "favorite" + | "queue"; export let episodes: Episode[] = []; export let episodeEntries: EpisodeListEntry[] | null = null; @@ -20,6 +39,7 @@ export let showPlayedToggle: boolean = true; export let alwaysShowPlayedEpisodes: boolean = false; export let isLoading: boolean = false; + export let noteRefreshToken: number = 0; let searchInputQuery: string = ""; $: listEntries = episodeEntries ?? createEpisodeListEntries(episodes); $: shouldHidePlayedEpisodes = $hidePlayedEpisodes && !alwaysShowPlayedEpisodes; @@ -52,9 +72,69 @@ }); } + function forwardQuickAction( + entry: EpisodeListEntry, + event: CustomEvent<{ episode: Episode; action: EpisodeQuickAction }>, + ) { + dispatch("quickActionEpisode", { + episode: event.detail.episode, + action: event.detail.action, + entry, + }); + } + function forwardSearchInput(event: CustomEvent<{ value: string }>) { dispatch("search", { query: event.detail.value }); } + + function hasEpisode(episodes: Episode[], episode: Episode): boolean { + const episodeKey = getEpisodeKey(episode); + + return episodes.some((candidate) => { + const candidateKey = getEpisodeKey(candidate); + return candidateKey && episodeKey + ? candidateKey === episodeKey + : candidate.title === episode.title; + }); + } + + function isEpisodeDownloaded( + episode: Episode, + downloaded: Record, + ): boolean { + return Boolean(downloaded[episode.podcastName]?.some( + (candidate) => candidate.title === episode.title, + )); + } + + function isEpisodeQueued(episode: Episode, currentQueue: Playlist): boolean { + return hasEpisode(currentQueue.episodes, episode); + } + + function isEpisodeFavorite( + episode: Episode, + currentFavorites: Playlist, + ): boolean { + return hasEpisode(currentFavorites.episodes, episode); + } + + function findPlayedEpisode(episode: Episode): PlayedEpisode | undefined { + return getPlayedEpisode($playedEpisodes, episode); + } + + function noteExists(episode: Episode): boolean { + noteRefreshToken; + + const pluginInstance = $plugin; + if (!pluginInstance?.settings?.note?.path) return false; + if (!("app" in globalThis)) return false; + + try { + return Boolean(getPodcastNote(episode)); + } catch { + return false; + } + }
@@ -104,10 +184,16 @@ {/each}
@@ -146,11 +232,11 @@ flex-direction: row; justify-content: flex-end; align-items: center; - gap: 0.5rem; + gap: 0.375rem; width: 100%; - padding: 0.5rem 0.75rem; + padding: 0.5rem 0.875rem; border-bottom: 1px solid var(--background-modifier-border); - background: var(--background-secondary); + background: var(--background-primary); } .episode-list-search { @@ -158,6 +244,14 @@ min-width: 0; } + :global(.episode-list-menu .icon-button) { + width: 2rem; + height: 2rem; + min-height: 2rem; + border-radius: 0.375rem; + box-shadow: none !important; + } + .episode-list-loading { display: flex; align-items: center; diff --git a/src/ui/PodcastView/EpisodeListHeader.svelte b/src/ui/PodcastView/EpisodeListHeader.svelte index 5475e68..cfe3f69 100644 --- a/src/ui/PodcastView/EpisodeListHeader.svelte +++ b/src/ui/PodcastView/EpisodeListHeader.svelte @@ -3,7 +3,7 @@ export let artworkUrl: string = ""; -
+
{#if artworkUrl} {text} {/if} @@ -13,26 +13,35 @@ \ No newline at end of file + diff --git a/src/ui/PodcastView/EpisodeListItem.svelte b/src/ui/PodcastView/EpisodeListItem.svelte index 3aa986d..aae88b6 100644 --- a/src/ui/PodcastView/EpisodeListItem.svelte +++ b/src/ui/PodcastView/EpisodeListItem.svelte @@ -1,14 +1,34 @@ - + +
+ + + + + +
- +
diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index eb410ac..ac06e01 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -18,7 +18,6 @@ import { onDestroy, onMount } from "svelte"; import Icon from "../obsidian/Icon.svelte"; import Button from "../obsidian/Button.svelte"; - import Slider from "../obsidian/Slider.svelte"; import Loading from "./Loading.svelte"; import EpisodeList from "./EpisodeList.svelte"; import ChapterList from "./ChapterList.svelte"; @@ -50,9 +49,22 @@ let isHoveringArtwork: boolean = false; let isLoading: boolean = true; + let hasRestoredPlaybackTime: boolean = false; let playerVolume: number = 1; let chapters: Chapter[] = []; let lastChaptersUrl: string | undefined = undefined; + let currentTimeText: string = "00:00:00"; + let remainingTimeText: string = "--:--:--"; + let progressDuration: number = 1; + let progressTime: number = 0; + + $: progressDuration = Number.isFinite($duration) && $duration > 0 ? $duration : 1; + $: progressTime = Number.isFinite($currentTime) && $currentTime > 0 ? $currentTime : 0; + $: currentTimeText = formatSeconds(progressTime, "HH:mm:ss"); + $: remainingTimeText = + Number.isFinite($duration) && $duration > 0 + ? formatSeconds(Math.max(0, $duration - progressTime), "HH:mm:ss") + : "--:--:--"; function togglePlayback() { isPaused.update((value) => !value); @@ -61,6 +73,8 @@ function onClickProgressbar( { detail: { event, percent } }: CustomEvent<{ event: MouseEvent | KeyboardEvent; percent?: number }> ) { + if (!Number.isFinite($duration) || $duration <= 0) return; + if (typeof percent === "number") { currentTime.set(percent * $duration); return; @@ -94,13 +108,12 @@ queue.playNext(); } - function onPlaybackRateChange(event: CustomEvent<{ value: number }>) { - offBinding.playbackRate = event.detail.value; + function onPlaybackRateInput(event: Event) { + offBinding.playbackRate = Number((event.currentTarget as HTMLInputElement).value); } - function onVolumeChange(event: CustomEvent<{ value: number }>) { - const newVolume = clampVolume(event.detail.value); - + function onVolumeInput(event: Event) { + const newVolume = clampVolume(Number((event.currentTarget as HTMLInputElement).value)); volume.set(newVolume); } @@ -109,8 +122,23 @@ } function onMetadataLoaded() { + finishAudioLoading(true); + } + + function onAudioCanPlay() { + finishAudioLoading(); + } + + function onAudioError() { + finishAudioLoading(); + } + + function finishAudioLoading(shouldRestorePlaybackTime: boolean = false) { isLoading = false; + if (!shouldRestorePlaybackTime || hasRestoredPlaybackTime) return; + + hasRestoredPlaybackTime = true; restorePlaybackTime(); } @@ -168,6 +196,8 @@ }); const unsubCurrentEpisode = currentEpisode.subscribe((episode) => { + isLoading = true; + hasRestoredPlaybackTime = false; srcPromise = getSrc($currentEpisode); // Fetch chapters when episode changes @@ -195,7 +225,14 @@ }); onDestroy(() => { - playedEpisodes.setEpisodeTime($currentEpisode, $currentTime, $duration, ($currentTime === $duration)); + const safeDuration = Number.isFinite($duration) && $duration > 0 ? $duration : 0; + const safeCurrentTime = Number.isFinite($currentTime) && $currentTime > 0 ? $currentTime : 0; + playedEpisodes.setEpisodeTime( + $currentEpisode, + safeCurrentTime, + safeDuration, + safeDuration > 0 && safeCurrentTime === safeDuration, + ); isPaused.set(true); }); @@ -231,7 +268,8 @@ } -
+
+
-

{$currentEpisode.title}

- - {#await srcPromise then src} - - {/await} - -
- {formatSeconds($currentTime, "HH:mm:ss")} - - {formatSeconds($duration - $currentTime, "HH:mm:ss")} -
- -
-
+
Volume: {Math.round(playerVolume * 100)}% -
{offBinding.playbackRate}x -
@@ -358,35 +417,52 @@ flex-direction: column; flex: 1 1 auto; min-height: 0; - padding: 0 1rem; + padding: 0.75rem 1rem 1rem; overflow-y: auto; - gap: 0.35rem; + overflow-x: hidden; + gap: 0.75rem; + } + + .now-playing-panel { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr); + align-items: center; + gap: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--background-modifier-border); } .episode-image-container { - width: 100%; - max-width: 20rem; - margin: 0 auto 0.5rem; - padding: 1rem 0 0.5rem; + width: 7rem; + max-width: 7rem; + margin: 0; + padding: 0; + } + + .now-playing-details { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 0; } .hover-container { - width: 100%; - height: 0; - padding-bottom: 100%; + width: 7rem; + height: 7rem; + padding: 0; display: block; position: relative; border: none; background: transparent; cursor: pointer; - border-radius: 0.75rem; + border-radius: 0.625rem; overflow: hidden; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); transition: box-shadow 200ms ease, transform 200ms ease; } .hover-container:hover { - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22); } .hover-container:active { @@ -447,27 +523,32 @@ .podcast-title { font-size: 1.125rem; font-weight: 600; - line-height: 1.4; - margin: 0 0 0.75rem; - text-align: center; + line-height: 1.3; + margin: 0; + text-align: left; color: var(--text-normal); white-space: normal; word-break: break-word; } .status-container { - display: flex; + display: grid; + grid-template-columns: 4.25rem minmax(0, 1fr) 4.25rem; align-items: center; - gap: 0.75rem; - padding: 0 0.25rem; + gap: 0.625rem; + padding: 0; + min-width: 0; + overflow: hidden; } .status-container span { font-size: 0.8rem; font-variant-numeric: tabular-nums; color: var(--text-muted); - min-width: 4rem; + min-width: 0; text-align: center; + white-space: nowrap; + overflow: hidden; } .status-container span:first-child { @@ -480,50 +561,68 @@ :global(.episode-player .status-container .progress) { height: var(--episode-player-progress-height, 0.5rem); - flex: 1 1 auto; + width: 100%; + min-width: 0; } .controls-container { display: flex; align-items: center; - justify-content: center; - gap: 2rem; - margin: 1.25rem 0; + justify-content: flex-start; + gap: 0.375rem; + margin: 0; } :global(.player-control-button) { margin: 0; cursor: pointer; - padding: 0.5rem; - border-radius: 50%; + width: 2rem; + height: 2rem; + min-height: 2rem; + padding: 0; + border-radius: 0.375rem; + box-shadow: none !important; transition: background-color 120ms ease; } + :global(.player-play-button) { + color: var(--text-accent); + background-color: var(--background-modifier-hover); + } + :global(.player-control-button:hover) { background-color: var(--background-modifier-hover); } .slider-stack { - display: flex; - flex-direction: column; - gap: 1rem; - padding: 1rem 0 0.75rem; - border-top: 1px solid var(--background-modifier-border); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem 1rem; + padding: 0; } .playbackrate-container, .volume-container { - display: flex; + display: grid; + grid-template-columns: 5.75rem minmax(0, 1fr); align-items: center; - gap: 1rem; - padding: 0 0.5rem; + gap: 0.75rem; + padding: 0; + min-width: 0; } .playbackrate-container span, .volume-container span { font-size: 0.8rem; color: var(--text-muted); - min-width: 5rem; + min-width: 0; + white-space: nowrap; + } + + .native-slider { + width: 100%; + min-width: 0; + accent-color: var(--interactive-accent); } :global(.episode-player h3) { @@ -556,4 +655,37 @@ :global(.lists-container .episode-list-view-container) { height: auto; } + + @media (max-width: 520px) { + .now-playing-panel { + grid-template-columns: 1fr; + justify-items: center; + } + + .episode-image-container { + width: 10rem; + max-width: 10rem; + } + + .hover-container { + width: 10rem; + height: 10rem; + } + + .now-playing-details { + width: 100%; + } + + .podcast-title { + text-align: center; + } + + .controls-container { + justify-content: center; + } + + .slider-stack { + grid-template-columns: 1fr; + } + } diff --git a/src/ui/PodcastView/EpisodePlayer.test.ts b/src/ui/PodcastView/EpisodePlayer.test.ts index e175b1e..465257b 100644 --- a/src/ui/PodcastView/EpisodePlayer.test.ts +++ b/src/ui/PodcastView/EpisodePlayer.test.ts @@ -9,6 +9,7 @@ import { isPaused, playedEpisodes, plugin, + queue, requestedPlaybackTime, } from "src/store"; import type { Episode } from "src/types/Episode"; @@ -30,6 +31,13 @@ beforeEach(() => { duration.set(3600); isPaused.set(true); playedEpisodes.set({}); + queue.set({ + icon: "list-ordered", + name: "Queue", + episodes: [], + shouldEpisodeRemoveAfterPlay: true, + shouldRepeat: false, + }); requestedPlaybackTime.set(null); HTMLMediaElement.prototype.play = vi.fn(() => Promise.resolve()); HTMLMediaElement.prototype.pause = vi.fn(); @@ -84,4 +92,20 @@ describe("EpisodePlayer", () => { expect(get(isPaused)).toBe(false); expect(get(requestedPlaybackTime)).toBeNull(); }); + + test("clears loading overlay when audio fails to load", async () => { + const { container } = render(EpisodePlayer); + await waitFor(() => { + expect(container.querySelector("audio")).not.toBeNull(); + }); + const audio = container.querySelector("audio") as HTMLAudioElement; + + expect(container.querySelector(".podcast-artwork-isloading-overlay")).not.toBeNull(); + + await fireEvent.error(audio); + + await waitFor(() => { + expect(container.querySelector(".podcast-artwork-isloading-overlay")).toBeNull(); + }); + }); }); diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index 3e04885..3ca0c64 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { TFile } from "obsidian"; import { get } from "svelte/store"; import { afterEach, @@ -12,10 +13,13 @@ import { import createPodcastNote from "src/createPodcastNote"; import { currentEpisode, + downloadedEpisodes, episodeCache, + favorites, hidePlayedEpisodes, playedEpisodes, plugin, + queue, savedFeeds, viewState, } from "src/store"; @@ -64,6 +68,21 @@ function resetStores() { episodeCache.set({}); hidePlayedEpisodes.set(false); playedEpisodes.set({}); + downloadedEpisodes.set({}); + queue.set({ + icon: "list-ordered", + name: "Queue", + episodes: [], + shouldEpisodeRemoveAfterPlay: true, + shouldRepeat: false, + }); + favorites.set({ + icon: "lucide-star", + name: "Favorites", + episodes: [], + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, + }); viewState.set(ViewState.PodcastGrid); currentEpisode.update(() => undefined as unknown as Episode); plugin.set(undefined as never); @@ -234,6 +253,94 @@ describe("PodcastView integration flow", () => { ); }); + test("shows episode state badges and handles row quick actions", async () => { + const { appMock } = bootstrapAppMock(); + const episodeWithDuration: Episode = { + ...testEpisode, + duration: 120, + }; + + mockGetEpisodes.mockResolvedValue([episodeWithDuration]); + playedEpisodes.set({ + [`${testFeed.title}::${testEpisode.title}`]: { + title: testEpisode.title, + podcastName: testFeed.title, + time: 30, + duration: 120, + finished: false, + }, + }); + downloadedEpisodes.set({ + [testFeed.title]: [ + { + ...episodeWithDuration, + filePath: "Downloads/episode.mp3", + size: 1200, + }, + ], + }); + queue.set({ + icon: "list-ordered", + name: "Queue", + episodes: [episodeWithDuration], + shouldEpisodeRemoveAfterPlay: true, + shouldRepeat: false, + }); + favorites.set({ + icon: "lucide-star", + name: "Favorites", + episodes: [episodeWithDuration], + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, + }); + const existingNote = Object.assign(Object.create(TFile.prototype), { + path: "Podcasts/Episode 1 Launch.md", + }) as TFile; + appMock.vault.getAbstractFileByPath.mockImplementationOnce( + () => existingNote as never, + ); + plugin.set({ + settings: { + note: { + path: "Podcasts/{{title}}", + template: "# {{title}}", + }, + download: { + path: "Downloads/{{title}}", + }, + feedCache: { + enabled: false, + ttlHours: 6, + }, + }, + } as never); + + render(PodcastView); + + await fireEvent.click(await screen.findByAltText(testFeed.title)); + expect(await screen.findByText(testEpisode.title)).toBeInTheDocument(); + + expect(screen.getByText("2:00")).toBeInTheDocument(); + expect(screen.getByText("25%")).toBeInTheDocument(); + expect(screen.getByLabelText("Downloaded")).toBeInTheDocument(); + expect(screen.getByLabelText("Queued")).toBeInTheDocument(); + expect(screen.getByLabelText("Favorited")).toBeInTheDocument(); + expect(screen.getByLabelText("Podcast note exists")).toBeInTheDocument(); + + await fireEvent.click( + screen.getByRole("button", { name: "Remove from favorites" }), + ); + expect(get(favorites).episodes).toHaveLength(0); + + await fireEvent.click( + screen.getByRole("button", { name: "Remove from queue" }), + ); + expect(get(queue).episodes).toHaveLength(0); + + await fireEvent.click(screen.getByRole("button", { name: "Mark played" })); + expect(playedEpisodes.get(episodeWithDuration)?.finished).toBe(true); + }); + test("opens a global played episodes view from the podcast grid", async () => { const playedEpisode: Episode = { title: "Already Finished", @@ -264,7 +371,9 @@ describe("PodcastView integration flow", () => { await fireEvent.click(playedCard); expect(await screen.findByText("Already Finished")).toBeInTheDocument(); - expect(screen.getByText("Played")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Played" }), + ).toBeInTheDocument(); }); test("does not apply a delayed played feed refresh after leaving the played view", async () => { diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 3cb5c8c..2f952ff 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -35,13 +35,30 @@ } from "src/services/FeedCacheService"; import { get } from "svelte/store"; import { PLAYED_SETTINGS } from "src/constants"; - import { getFinishedPlayedEpisodeRecords } from "src/utility/episodeStatus"; + import { + getFinishedPlayedEpisodeRecords, + isEpisodeFinished, + } from "src/utility/episodeStatus"; import { buildPlayedEpisodeListEntries, createPlayedEpisodePlaceholder, type EpisodeListEntry, type PlayedEpisodeListEntry, } from "src/utility/episodeListEntry"; + import createPodcastNote, { + getPodcastNote, + openPodcastNote, + } from "src/createPodcastNote"; + import downloadEpisodeWithProgressNotice from "src/downloadEpisode"; + import { getEpisodeKey } from "src/utility/episodeKey"; + + type EpisodeQuickAction = + | "play" + | "togglePlayed" + | "download" + | "note" + | "favorite" + | "queue"; let feeds: PodcastFeed[] = []; let selectedFeed: PodcastFeed | null = null; @@ -57,6 +74,7 @@ let loadingFeedNames: string[] = []; let loadingFeedSummary: string = ""; let isMounted: boolean = true; + let noteRefreshToken: number = 0; onDestroy(() => { isMounted = false; @@ -375,6 +393,130 @@ ); } + function hasEpisode(episodes: Episode[], episode: Episode): boolean { + return episodes.some((candidate) => episodeMatches(candidate, episode)); + } + + function episodeMatches(candidate: Episode, episode: Episode): boolean { + const episodeKey = getEpisodeKey(episode); + const candidateKey = getEpisodeKey(candidate); + + return candidateKey && episodeKey + ? candidateKey === episodeKey + : candidate.title === episode.title; + } + + function toggleFavoriteEpisode(episode: Episode) { + favorites.update((playlist) => { + const episodeIsFavorite = hasEpisode(playlist.episodes, episode); + playlist.episodes = episodeIsFavorite + ? playlist.episodes.filter( + (candidate) => !episodeMatches(candidate, episode), + ) + : [...playlist.episodes, episode]; + + return playlist; + }); + } + + function toggleQueuedEpisode(episode: Episode) { + const episodeIsInQueue = hasEpisode(get(queue).episodes, episode); + + if (episodeIsInQueue) { + queue.remove(episode); + } else { + queue.add(episode); + } + } + + function togglePlayedEpisode(episode: Episode, entry: EpisodeListEntry) { + const playedEpisodeKey = getPlayedEpisodeKey(entry); + const playedEpisodeMap = get(playedEpisodes); + const episodeIsPlayed = playedEpisodeKey + ? playedEpisodeMap[playedEpisodeKey]?.finished + : isEpisodeFinished(episode, playedEpisodeMap); + + if (episodeIsPlayed) { + if (playedEpisodeKey) { + playedEpisodes.markKeyAsUnplayed(playedEpisodeKey); + } else { + playedEpisodes.markAsUnplayed(episode); + } + } else { + playedEpisodes.markAsPlayed(episode); + } + } + + function toggleDownloadedEpisode(episode: Episode) { + if (downloadedEpisodes.isEpisodeDownloaded(episode)) { + downloadedEpisodes.removeEpisode(episode, true); + return; + } + + const downloadPath = get(plugin)?.settings?.download?.path; + if (!downloadPath) { + new Notice("Please set a download path in the settings."); + return; + } + + downloadEpisodeWithProgressNotice(episode, downloadPath); + } + + async function openOrCreateNote(episode: Episode) { + const noteSettings = get(plugin)?.settings?.note; + if (!noteSettings?.path || !noteSettings.template) { + new Notice("Please set a note path and template in the settings."); + return; + } + + const episodeNoteExists = Boolean(getPodcastNote(episode)); + + if (episodeNoteExists) { + openPodcastNote(episode); + return; + } + + await createPodcastNote(episode); + noteRefreshToken += 1; + } + + async function handleQuickActionEpisode( + event: CustomEvent<{ + episode: Episode; + action: EpisodeQuickAction; + entry: EpisodeListEntry; + }>, + ) { + const { episode, action, entry } = event.detail; + + if (!entry.isAvailable && action !== "togglePlayed") { + new Notice("This played episode is no longer available in current feeds."); + return; + } + + switch (action) { + case "play": + currentEpisode.set(episode); + viewState.set(ViewState.Player); + break; + case "togglePlayed": + togglePlayedEpisode(episode, entry); + break; + case "download": + toggleDownloadedEpisode(episode); + break; + case "note": + await openOrCreateNote(episode); + break; + case "favorite": + toggleFavoriteEpisode(episode); + break; + case "queue": + toggleQueuedEpisode(episode); + break; + } + } + async function handleClickRefresh() { if (isShowingPlayedEpisodes) { await fetchEpisodesInAllFeeds(feeds, false); @@ -499,8 +641,10 @@ showPlayedToggle={!isShowingPlayedEpisodes} alwaysShowPlayedEpisodes={isShowingPlayedEpisodes} isLoading={selectedFeed ? loadingFeeds.has(selectedFeed.title) : isFetchingEpisodes} + {noteRefreshToken} on:clickEpisode={handleClickEpisode} on:contextMenuEpisode={handleContextMenuEpisode} + on:quickActionEpisode={handleQuickActionEpisode} on:clickRefresh={handleClickRefresh} on:search={handleSearch} > @@ -513,7 +657,7 @@ > Latest Episodes @@ -529,7 +673,7 @@ > Latest Episodes @@ -605,17 +749,22 @@ } .go-back { + appearance: none; display: inline-flex; align-items: center; gap: 0.375rem; - padding: 0.375rem 0.625rem; - margin: 0.5rem 0.5rem 0; - font-size: 0.85rem; + width: max-content; + min-height: 1.75rem; + padding: 0.25rem 0.5rem; + margin: 0.5rem 0.75rem 0; + font-size: 0.8125rem; + font-weight: 500; color: var(--text-muted); cursor: pointer; - background: none; - border: none; - border-radius: 0.25rem; + background: transparent; + border: none !important; + border-radius: 0.375rem; + box-shadow: none !important; transition: color 120ms ease, background-color 120ms ease; } @@ -635,4 +784,165 @@ padding: 0.5rem; color: var(--text-muted); } + + :global(.podcast-view .episode-list-view-container) { + display: flex !important; + flex-direction: column !important; + align-items: stretch !important; + width: 100% !important; + overflow: hidden; + } + + :global(.podcast-view .podcast-episode-list) { + display: flex !important; + flex-direction: column !important; + align-items: stretch !important; + width: 100% !important; + overflow-x: hidden; + } + + :global(.podcast-view .podcast-episode-item) { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + justify-content: space-between !important; + gap: 0.625rem !important; + width: 100% !important; + min-height: 3.5rem !important; + padding: 0.375rem 0.5rem !important; + border: 0 !important; + border-bottom: 1px solid var(--background-modifier-border) !important; + background: transparent !important; + text-align: left !important; + box-sizing: border-box !important; + } + + :global(.podcast-view .podcast-episode-item:hover), + :global(.podcast-view .podcast-episode-item:focus-within) { + background: var(--background-secondary-alt) !important; + } + + :global(.podcast-view .podcast-episode-main) { + appearance: none !important; + display: flex !important; + flex: 1 1 auto !important; + align-items: center !important; + justify-content: flex-start !important; + gap: 0.625rem !important; + min-width: 0 !important; + width: 100% !important; + min-height: 0 !important; + height: auto !important; + margin: 0 !important; + padding: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; + color: inherit !important; + font: inherit !important; + text-align: left !important; + overflow: hidden !important; + } + + :global(.podcast-view .podcast-episode-thumbnail-container) { + flex: 0 0 2.625rem !important; + width: 2.625rem !important; + height: 2.625rem !important; + border-radius: 0.375rem !important; + overflow: hidden !important; + background: var(--background-secondary) !important; + } + + :global(.podcast-view .podcast-episode-information) { + display: flex !important; + flex: 1 1 auto !important; + flex-direction: column !important; + align-items: flex-start !important; + justify-content: center !important; + gap: 0.1875rem !important; + min-width: 0 !important; + } + + :global(.podcast-view .episode-item-date) { + font-size: 0.6875rem !important; + line-height: 1 !important; + letter-spacing: 0.04em !important; + color: var(--text-muted) !important; + white-space: nowrap !important; + } + + :global(.podcast-view .episode-item-title) { + display: block !important; + max-width: 100% !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + font-size: 0.875rem !important; + line-height: 1.25 !important; + color: var(--text-normal) !important; + text-align: left !important; + } + + :global(.podcast-view .episode-item-meta) { + display: flex !important; + align-items: center !important; + flex-wrap: wrap !important; + gap: 0.4375rem !important; + min-height: 0.875rem !important; + } + + :global(.podcast-view .episode-item-badge) { + display: inline-flex !important; + align-items: center !important; + gap: 0.1875rem !important; + padding: 0 !important; + border: 0 !important; + background: transparent !important; + color: var(--text-muted) !important; + font-size: 0.75rem !important; + line-height: 1 !important; + } + + :global(.podcast-view .episode-item-badge-strong) { + color: var(--text-accent) !important; + font-weight: 500 !important; + } + + :global(.podcast-view .episode-quick-actions) { + display: flex !important; + flex: 0 0 auto !important; + align-items: center !important; + gap: 0.125rem !important; + opacity: 0 !important; + pointer-events: none !important; + } + + :global(.podcast-view .podcast-episode-item:hover .episode-quick-actions), + :global(.podcast-view .podcast-episode-item:focus-within .episode-quick-actions) { + opacity: 1 !important; + pointer-events: auto !important; + } + + :global(.podcast-view .episode-quick-action) { + appearance: none !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 1.75rem !important; + height: 1.75rem !important; + min-height: 1.75rem !important; + padding: 0 !important; + border: 0 !important; + border-radius: 0.375rem !important; + background: transparent !important; + box-shadow: none !important; + color: var(--text-muted) !important; + } + + :global(.podcast-view .episode-quick-action:hover), + :global(.podcast-view .episode-quick-action:focus-visible) { + background: var(--background-modifier-hover) !important; + color: var(--text-normal) !important; + } diff --git a/src/ui/PodcastView/TopBar.svelte b/src/ui/PodcastView/TopBar.svelte index 4bf505f..35008d6 100644 --- a/src/ui/PodcastView/TopBar.svelte +++ b/src/ui/PodcastView/TopBar.svelte @@ -44,7 +44,7 @@ aria-pressed={viewState === ViewState.PodcastGrid} title={gridTooltip} > - +
@@ -91,25 +91,28 @@ display: flex; flex-direction: row; align-items: center; - justify-content: stretch; - gap: 0.375rem; - padding: 0.5rem; - min-height: 3rem; + justify-content: center; + gap: 0.25rem; + padding: 0.375rem 0.75rem; + min-height: 2.5rem; border-bottom: 1px solid var(--background-modifier-border); - background: var(--background-secondary); + background: var(--background-primary); box-sizing: border-box; } .topbar-menu-button { + appearance: none; display: flex; align-items: center; justify-content: center; + width: 2.25rem; height: 2rem; - padding: 0 0.75rem; - flex: 1 1 0; + padding: 0; + flex: 0 0 auto; border: 1px solid transparent; border-radius: 0.375rem; background: transparent; + box-shadow: none !important; color: var(--text-muted); transition: background-color 120ms ease, @@ -137,8 +140,9 @@ .topbar-selected, .topbar-selected:hover { - color: var(--text-on-accent); - background: var(--interactive-accent); + color: var(--text-accent); + background: var(--background-modifier-hover); + border-color: var(--background-modifier-border); } .topbar-disabled, diff --git a/src/ui/obsidian/Button.svelte b/src/ui/obsidian/Button.svelte index 2fc8606..b4a356f 100644 --- a/src/ui/obsidian/Button.svelte +++ b/src/ui/obsidian/Button.svelte @@ -20,11 +20,15 @@ let styles: CSSObject; let button: ButtonComponent; + let appliedClassTokens: string[] = []; + let clickHandlerRegistered: boolean = false; const dispatch = createEventDispatcher(); onMount(() => createButton(buttonRef)); - afterUpdate(() => updateButtonAttributes(button)); + afterUpdate(() => { + if (button) updateButtonAttributes(button); + }); function createButton(container: HTMLElement) { button = new ButtonComponent(container); @@ -38,12 +42,10 @@ if (icon) btn.setIcon(icon); if (disabled) btn.setDisabled(disabled); if (warning) btn.setWarning(); else btn.buttonEl.classList.remove('mod-warning'); - if (className) btn.setClass(className); + updateButtonClasses(btn, className); if (cta) btn.setCta(); else btn.removeCta(); - btn.onClick((event: MouseEvent) => { - dispatch("click", { event }); - }); + registerClickHandler(btn); if (styles) { btn.buttonEl.setAttr('style', extractStylesFromObj(styles)); @@ -56,6 +58,26 @@ } } + function updateButtonClasses(btn: ButtonComponent, currentClassName: string | undefined) { + const nextClassTokens = currentClassName?.split(/\s+/).filter(Boolean) ?? []; + + if (appliedClassTokens.length) { + btn.buttonEl.classList.remove(...appliedClassTokens); + } + + nextClassTokens.forEach((token) => btn.setClass(token)); + appliedClassTokens = nextClassTokens; + } + + function registerClickHandler(btn: ButtonComponent) { + if (clickHandlerRegistered) return; + + btn.onClick((event: MouseEvent) => { + dispatch("click", { event }); + }); + clickHandlerRegistered = true; + } + { + test("applies multiple class tokens from Svelte class prop", async () => { + const { container } = render(Button, { + props: { + class: "player-control-button player-play-button", + icon: "play", + }, + }); + + const button = await waitFor(() => { + const element = container.querySelector("button"); + expect(element).not.toBeNull(); + return element as HTMLButtonElement; + }); + + expect(button).toHaveClass("player-control-button"); + expect(button).toHaveClass("player-play-button"); + }); +}); diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 03dcf89..9cdd992 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -63,7 +63,7 @@ export class ButtonComponent extends BaseInteractiveElement { } setClass(value: string) { - this.buttonEl.className = value; + this.buttonEl.classList.add(value); return this; }