From 8af60f9e5f3c8ee13bae540739ae12fa08d68c7d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 07:31:43 +0100 Subject: [PATCH] Improve nav cues and native lazy images --- src/ui/PodcastView/EpisodeListItem.svelte | 11 ++- src/ui/PodcastView/TopBar.svelte | 83 +++++++++++++++++++---- src/ui/PodcastView/TopBar.test.ts | 27 ++++++-- src/ui/common/Image.svelte | 3 + src/ui/common/ImageLoader.svelte | 24 +++---- src/ui/common/IntersectionObserver.svelte | 61 ----------------- 6 files changed, 112 insertions(+), 97 deletions(-) delete mode 100644 src/ui/common/IntersectionObserver.svelte diff --git a/src/ui/PodcastView/EpisodeListItem.svelte b/src/ui/PodcastView/EpisodeListItem.svelte index 54d440f..8dfe6d6 100644 --- a/src/ui/PodcastView/EpisodeListItem.svelte +++ b/src/ui/PodcastView/EpisodeListItem.svelte @@ -93,16 +93,21 @@ } .podcast-episode-thumbnail-container { - flex-basis: 20%; + flex: 0 0 5rem; + width: 5rem; + height: 5rem; + max-width: 5rem; + max-height: 5rem; display: flex; align-items: center; justify-content: center; } :global(.podcast-episode-thumbnail) { + width: 100%; + height: 100%; + object-fit: cover; border-radius: 15%; - max-width: 5rem; - max-height: 5rem; cursor: pointer !important; } diff --git a/src/ui/PodcastView/TopBar.svelte b/src/ui/PodcastView/TopBar.svelte index 39e2866..feef28c 100644 --- a/src/ui/PodcastView/TopBar.svelte +++ b/src/ui/PodcastView/TopBar.svelte @@ -6,6 +6,19 @@ export let canShowEpisodeList: boolean = false; export let canShowPlayer: boolean = false; + const gridTooltip = "Browse podcast grid"; + const disabledEpisodeTooltip = + "Select a podcast or playlist to view its episodes."; + const disabledPlayerTooltip = + "Start playing an episode to open the player."; + + $: episodeTooltip = canShowEpisodeList + ? "View episode list" + : disabledEpisodeTooltip; + $: playerTooltip = canShowPlayer + ? "Open player" + : disabledPlayerTooltip; + function handleClickMenuItem(newState: ViewState) { if (viewState === newState) return; @@ -29,6 +42,7 @@ `} aria-label="Podcast grid" aria-pressed={viewState === ViewState.PodcastGrid} + title={gridTooltip} > @@ -38,11 +52,16 @@ class={` topbar-menu-button ${viewState === ViewState.EpisodeList ? "topbar-selected" : ""} - ${canShowEpisodeList ? "topbar-selectable" : ""} + ${canShowEpisodeList ? "topbar-selectable" : "topbar-disabled"} `} - aria-label="Episode list" + aria-label={ + canShowEpisodeList + ? "Episode list" + : "Episode list (select a podcast or playlist first)" + } aria-pressed={viewState === ViewState.EpisodeList} disabled={!canShowEpisodeList} + title={episodeTooltip} > @@ -52,11 +71,16 @@ class={` topbar-menu-button ${viewState === ViewState.Player ? "topbar-selected" : ""} - ${canShowPlayer ? "topbar-selectable" : ""} + ${canShowPlayer ? "topbar-selectable" : "topbar-disabled"} `} - aria-label="Player" + aria-label={ + canShowPlayer + ? "Player" + : "Player (start playing an episode to open the player)" + } aria-pressed={viewState === ViewState.Player} disabled={!canShowPlayer} + title={playerTooltip} > @@ -68,9 +92,12 @@ flex-direction: row; align-items: center; justify-content: space-between; + gap: 0.5rem; + padding: 0.25rem 0.5rem; height: 50px; min-height: 50px; border-bottom: 1px solid var(--background-divider); + box-sizing: border-box; } .topbar-menu-button { @@ -78,19 +105,51 @@ align-items: center; justify-content: center; width: 100%; - height: 100%; - opacity: 0.1; - border: none; - background: none; - padding: 0; + padding: 0.4rem 0.25rem; + flex: 1 1 0; + border: 1px solid var(--background-modifier-border, #3a3a3a); + border-radius: 8px; + background: var(--background-secondary, transparent); + color: var(--text-muted, #8a8a8a); + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease, + box-shadow 120ms ease, + opacity 120ms ease; } - .topbar-selected { - opacity: 1 !important; + .topbar-menu-button:focus-visible { + outline: 2px solid var(--interactive-accent, #5c6bf7); + outline-offset: 2px; } .topbar-selectable { cursor: pointer; - opacity: 0.5; + color: var(--text-normal, #e6e6e6); + background: var(--background-secondary-alt, rgba(255, 255, 255, 0.02)); + } + + .topbar-menu-button:hover.topbar-selectable:not(.topbar-selected) { + background: var(--background-modifier-hover, rgba(255, 255, 255, 0.06)); + border-color: var(--interactive-accent, #5c6bf7); + color: var(--text-normal, #e6e6e6); + } + + .topbar-selected, + .topbar-selected:hover { + color: var(--text-on-accent, #ffffff); + background: var(--interactive-accent, #5c6bf7); + border-color: var(--interactive-accent, #5c6bf7); + box-shadow: 0 0 0 1px var(--interactive-accent, #5c6bf7); + } + + .topbar-disabled, + .topbar-menu-button:disabled { + cursor: not-allowed; + color: var(--text-faint, #a0a0a0); + background: var(--background-modifier-border, #3a3a3a); + border-style: dashed; + opacity: 1; } diff --git a/src/ui/PodcastView/TopBar.test.ts b/src/ui/PodcastView/TopBar.test.ts index cf441cd..07a5b8c 100644 --- a/src/ui/PodcastView/TopBar.test.ts +++ b/src/ui/PodcastView/TopBar.test.ts @@ -15,14 +15,19 @@ describe("TopBar", () => { }); const grid = getByLabelText("Podcast grid"); - const episode = getByLabelText("Episode list"); - const player = getByLabelText("Player"); + const episode = getByLabelText(/Episode list/); + const player = getByLabelText(/Player/); expect(grid.className).toContain("topbar-selected"); expect(episode.className).toContain("topbar-selectable"); expect(episode).not.toBeDisabled(); expect(player).toBeDisabled(); expect(player.className).not.toContain("topbar-selectable"); + expect(player.className).toContain("topbar-disabled"); + expect(player.getAttribute("title")).toBe( + "Start playing an episode to open the player." + ); + expect(episode.getAttribute("title")).toBe("View episode list"); }); test("activates episode list when clicked", async () => { @@ -34,8 +39,8 @@ describe("TopBar", () => { }, }); - const episodeButton = getByLabelText("Episode list"); - const playerButton = getByLabelText("Player"); + const episodeButton = getByLabelText(/Episode list/); + const playerButton = getByLabelText(/Player/); await fireEvent.click(episodeButton); @@ -52,12 +57,20 @@ describe("TopBar", () => { }, }); - const episodeButton = getByLabelText("Episode list"); - const playerButton = getByLabelText("Player"); + const episodeButton = getByLabelText(/Episode list/); + const playerButton = getByLabelText(/Player/); expect(episodeButton).toBeDisabled(); expect(playerButton).toBeDisabled(); expect(episodeButton.className).not.toContain("topbar-selectable"); + expect(episodeButton.className).toContain("topbar-disabled"); + expect(playerButton.className).toContain("topbar-disabled"); + expect(episodeButton.getAttribute("title")).toBe( + "Select a podcast or playlist to view its episodes." + ); + expect(playerButton.getAttribute("title")).toBe( + "Start playing an episode to open the player." + ); }); test("activates player control when clicked", async () => { @@ -69,7 +82,7 @@ describe("TopBar", () => { }, }); - const playerButton = getByLabelText("Player"); + const playerButton = getByLabelText(/Player/); await fireEvent.click(playerButton); diff --git a/src/ui/common/Image.svelte b/src/ui/common/Image.svelte index fbcfb6c..7e3ba07 100644 --- a/src/ui/common/Image.svelte +++ b/src/ui/common/Image.svelte @@ -6,6 +6,7 @@ export let fadeIn: boolean = false; export let opacity: number = 0; // Falsey value so condition isn't triggered if not set. export let interactive: boolean = false; + export let loadingMode: "lazy" | "eager" | undefined = "lazy"; export {_class as class}; let _class = ""; @@ -31,6 +32,7 @@ draggable="false" {src} {alt} + loading={loadingMode} class={_class} style:opacity={opacity ? opacity : !fadeIn ? 1 : loaded ? 1 : 0} style:transition={fadeIn ? "opacity 0.5s ease-out" : ""} @@ -44,6 +46,7 @@ draggable="false" {src} {alt} + loading={loadingMode} class={_class} style:opacity={opacity ? opacity : !fadeIn ? 1 : loaded ? 1 : 0} style:transition={fadeIn ? "opacity 0.5s ease-out" : ""} diff --git a/src/ui/common/ImageLoader.svelte b/src/ui/common/ImageLoader.svelte index 5ea1908..2d1d610 100644 --- a/src/ui/common/ImageLoader.svelte +++ b/src/ui/common/ImageLoader.svelte @@ -1,29 +1,25 @@ - - {#if intersecting} - dispatcher('click', { event })} - class={_class} - /> - {/if} - + dispatcher("click", { event })} + class={_class} +/> diff --git a/src/ui/common/IntersectionObserver.svelte b/src/ui/common/IntersectionObserver.svelte deleted file mode 100644 index 1ebc769..0000000 --- a/src/ui/common/IntersectionObserver.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - - - -
- -