From 31abdc03ef52c9d6592ad233349bccf2167a0f3e Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 16 May 2026 13:43:09 +0200 Subject: [PATCH] feat(hero): prev / next arrows on the Mainstage featured strip (#735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(hero): prev / next arrows on the Mainstage featured strip Adds left and right chevron buttons over the featured-album hero so a single click flips to the previous / next album. Hitting the 8 px dot indicators was awkward and often opened the underlying album by mistake; the arrows give a generous 44 px touch target on each edge. - New `goPrev` / `goNext` callbacks wrap with modulo, restart the auto-advance timer on click (same pattern the old dot handler used). - Buttons live in a new `.hero-nav` flex wrapper with `inset: 0` and `justify-content: space-between`, so they pin to the hero's left and right edges regardless of theme padding. `pointer-events: none` on the wrapper + `auto` on the buttons keeps the rest of the hero click-through (navigate to album). - Dots are now decorative spans, not buttons — `pointer-events: none`, no hover state, no `onClick`. Clicking near a dot used to navigate to the album because the dot was too small to land on. - i18n: `previousAlbum` / `nextAlbum` keys in all 9 locales. Reported by zunoz on Discord. * docs(changelog): note Mainstage hero prev / next arrows (#735) --- CHANGELOG.md | 8 +++++ src/components/Hero.tsx | 57 +++++++++++++++++++++++++--------- src/locales/de/hero.ts | 2 ++ src/locales/en/hero.ts | 2 ++ src/locales/es/hero.ts | 2 ++ src/locales/fr/hero.ts | 2 ++ src/locales/nb/hero.ts | 2 ++ src/locales/nl/hero.ts | 2 ++ src/locales/ro/hero.ts | 2 ++ src/locales/ru/hero.ts | 2 ++ src/locales/zh/hero.ts | 2 ++ src/styles/components/hero.css | 47 +++++++++++++++++++++++----- 12 files changed, 109 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 953cf9f1..d69a42bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -202,6 +202,14 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * New opt-in columns **Plays**, **Last played**, and **BPM** on the Album / Playlist / Favorites tracklists, plus matching rows in the Song Info modal. Pulls Navidrome's existing `playCount` / `played` / `bpm` from the Subsonic response — no extra API calls. Genre column also added to the playlist tracklist for parity with Album + Favorites. BPM cells render `—` when Navidrome returns 0 (untagged file); Plays / Last played render `—` only when truly absent. * Defensive fix in the tracklist column hook: visible columns with no saved width on an older prefs blob now fall back to the ColDef's default width instead of collapsing the row layout. +### Mainstage hero — prev / next arrow buttons + +**By [@Psychotoxical](https://github.com/Psychotoxical), thanks to zunoz for the report on the Psysonic Discord, PR [#735](https://github.com/Psychotoxical/psysonic/pull/735)** + +* The featured-album strip on Mainstage now has **Previous** / **Next** chevron buttons on each edge of the hero. The existing 8 px dot indicators were a small target, and a near-miss often opened the underlying album instead of switching slides; the new 44 px buttons give a comfortable hit area on both desktop and touch. +* Buttons live in a new **`.hero-nav`** flex wrapper (`inset: 0`, `justify-content: space-between`, **`pointer-events: none`**); the buttons themselves opt back into **`pointer-events: auto`** so the rest of the hero stays click-through to the album page. Wrap-around (last → first / first → last) and auto-advance timer restart use the same pattern as the previous dot handler. +* The dot indicators are kept as **decorative spans** — no click handler, no hover state, **`pointer-events: none`** — so a missed click no longer navigates to the album. + ## Changed ### Backend — Cargo workspace with 5 domain crates (Rust refactor) diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index e988c6c3..74114d01 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -4,7 +4,7 @@ import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Play, ListPlus } from 'lucide-react'; +import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react'; import CachedImage, { useCachedUrl } from './CachedImage'; import { usePlayerStore } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; @@ -224,9 +224,18 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { }; }, [albums.length, heroInView, startTimer]); - const goTo = useCallback((idx: number) => { - setActiveIdx(idx); - startTimer(albums.length); + const goPrev = useCallback(() => { + const len = albums.length; + if (len <= 1) return; + setActiveIdx(prev => (prev - 1 + len) % len); + startTimer(len); + }, [albums.length, startTimer]); + + const goNext = useCallback(() => { + const len = albums.length; + if (len <= 1) return; + setActiveIdx(prev => (prev + 1) % len); + startTimer(len); }, [albums.length, startTimer]); const album = albums[activeIdx] ?? null; @@ -346,18 +355,38 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { - {/* Carousel dot indicators */} + {/* Carousel navigation arrows + decorative dot indicators */} {albums.length > 1 && ( -
e.stopPropagation()}> - {albums.map((_, i) => ( + <> +
+ type="button" + className="hero-nav-arrow hero-nav-arrow--left" + onClick={e => { e.stopPropagation(); goPrev(); }} + aria-label={t('hero.previousAlbum')} + data-tooltip={t('hero.previousAlbum')} + > + + + +
+ + )} ); diff --git a/src/locales/de/hero.ts b/src/locales/de/hero.ts index eafca0c0..8f59c620 100644 --- a/src/locales/de/hero.ts +++ b/src/locales/de/hero.ts @@ -3,4 +3,6 @@ export const hero = { playAlbum: 'Album abspielen', enqueue: 'Einreihen', enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen', + previousAlbum: 'Vorheriges Album', + nextAlbum: 'Nächstes Album', }; diff --git a/src/locales/en/hero.ts b/src/locales/en/hero.ts index 56833ade..d7e112b3 100644 --- a/src/locales/en/hero.ts +++ b/src/locales/en/hero.ts @@ -3,4 +3,6 @@ export const hero = { playAlbum: 'Play Album', enqueue: 'Enqueue', enqueueTooltip: 'Add entire album to queue', + previousAlbum: 'Previous album', + nextAlbum: 'Next album', }; diff --git a/src/locales/es/hero.ts b/src/locales/es/hero.ts index cd50a669..f69d9fa8 100644 --- a/src/locales/es/hero.ts +++ b/src/locales/es/hero.ts @@ -3,4 +3,6 @@ export const hero = { playAlbum: 'Reproducir Álbum', enqueue: 'Agregar a la Cola', enqueueTooltip: 'Agregar álbum completo a la cola', + previousAlbum: 'Álbum anterior', + nextAlbum: 'Álbum siguiente', }; diff --git a/src/locales/fr/hero.ts b/src/locales/fr/hero.ts index ab9ca027..26fea6c2 100644 --- a/src/locales/fr/hero.ts +++ b/src/locales/fr/hero.ts @@ -3,4 +3,6 @@ export const hero = { playAlbum: 'Lire l\'album', enqueue: 'Mettre en file', enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente', + previousAlbum: 'Album précédent', + nextAlbum: 'Album suivant', }; diff --git a/src/locales/nb/hero.ts b/src/locales/nb/hero.ts index 52f42138..6188db8f 100644 --- a/src/locales/nb/hero.ts +++ b/src/locales/nb/hero.ts @@ -3,4 +3,6 @@ export const hero = { playAlbum: 'Spill av album', enqueue: 'Legg til i kø', enqueueTooltip: 'Legg til hele albumet i køen', + previousAlbum: 'Forrige album', + nextAlbum: 'Neste album', }; diff --git a/src/locales/nl/hero.ts b/src/locales/nl/hero.ts index 76e49af4..9135839f 100644 --- a/src/locales/nl/hero.ts +++ b/src/locales/nl/hero.ts @@ -3,4 +3,6 @@ export const hero = { playAlbum: 'Album afspelen', enqueue: 'In wachtrij', enqueueTooltip: 'Volledig album aan wachtrij toevoegen', + previousAlbum: 'Vorig album', + nextAlbum: 'Volgend album', }; diff --git a/src/locales/ro/hero.ts b/src/locales/ro/hero.ts index 534ccf4a..86d2b21d 100644 --- a/src/locales/ro/hero.ts +++ b/src/locales/ro/hero.ts @@ -3,4 +3,6 @@ export const hero = { playAlbum: 'Redă Album', enqueue: 'Pune în coadă', enqueueTooltip: 'Adaugă întregul album în coadă', + previousAlbum: 'Albumul anterior', + nextAlbum: 'Albumul următor', }; diff --git a/src/locales/ru/hero.ts b/src/locales/ru/hero.ts index fb0ec2e8..5490ce4f 100644 --- a/src/locales/ru/hero.ts +++ b/src/locales/ru/hero.ts @@ -3,4 +3,6 @@ export const hero = { playAlbum: 'Воспроизвести альбом', enqueue: 'В очередь', enqueueTooltip: 'Добавить весь альбом в очередь', + previousAlbum: 'Предыдущий альбом', + nextAlbum: 'Следующий альбом', }; diff --git a/src/locales/zh/hero.ts b/src/locales/zh/hero.ts index 5f62f588..8ae120b8 100644 --- a/src/locales/zh/hero.ts +++ b/src/locales/zh/hero.ts @@ -3,4 +3,6 @@ export const hero = { playAlbum: '播放专辑', enqueue: '加入队列', enqueueTooltip: '将整张专辑添加到播放队列', + previousAlbum: '上一张专辑', + nextAlbum: '下一张专辑', }; diff --git a/src/styles/components/hero.css b/src/styles/components/hero.css index 458b468e..3f96139f 100644 --- a/src/styles/components/hero.css +++ b/src/styles/components/hero.css @@ -30,6 +30,7 @@ display: flex; gap: 6px; z-index: 10; + pointer-events: none; } .hero-dot { @@ -37,21 +38,53 @@ height: 8px; border-radius: var(--radius-full); background: rgba(255, 255, 255, 0.35); - border: none; - padding: 0; - cursor: pointer; transition: all 0.3s ease; } -.hero-dot:hover:not(.hero-dot-active) { - background: rgba(255, 255, 255, 0.6); -} - .hero-dot-active { width: 24px; background: rgba(255, 255, 255, 0.9); } +/* Wrapper that pins prev/next arrows to the hero's left/right edges. */ +.hero-nav { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-3); + pointer-events: none; + z-index: 11; +} + +.hero-nav-arrow { + pointer-events: auto; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + background: rgba(0, 0, 0, 0.45); + color: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(255, 255, 255, 0.18); + padding: 0; + cursor: pointer; + transition: background 150ms ease, transform 150ms ease, border-color 150ms ease; +} + +.hero-nav-arrow:hover { + background: rgba(0, 0, 0, 0.65); + border-color: rgba(255, 255, 255, 0.32); + transform: scale(1.05); +} + +.hero-nav-arrow:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .hero-overlay { position: absolute; inset: 0;