mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(hero): prev / next arrows on the Mainstage featured strip (#735)
* 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)
This commit is contained in:
committed by
GitHub
parent
2233e8fb91
commit
31abdc03ef
@@ -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.
|
* 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.
|
* 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
|
## Changed
|
||||||
|
|
||||||
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
||||||
|
|||||||
+43
-14
@@ -4,7 +4,7 @@ import type { SubsonicAlbum } from '../api/subsonicTypes';
|
|||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 CachedImage, { useCachedUrl } from './CachedImage';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -224,9 +224,18 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
};
|
};
|
||||||
}, [albums.length, heroInView, startTimer]);
|
}, [albums.length, heroInView, startTimer]);
|
||||||
|
|
||||||
const goTo = useCallback((idx: number) => {
|
const goPrev = useCallback(() => {
|
||||||
setActiveIdx(idx);
|
const len = albums.length;
|
||||||
startTimer(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]);
|
}, [albums.length, startTimer]);
|
||||||
|
|
||||||
const album = albums[activeIdx] ?? null;
|
const album = albums[activeIdx] ?? null;
|
||||||
@@ -346,18 +355,38 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Carousel dot indicators */}
|
{/* Carousel navigation arrows + decorative dot indicators */}
|
||||||
{albums.length > 1 && (
|
{albums.length > 1 && (
|
||||||
<div className="hero-dots" onClick={e => e.stopPropagation()}>
|
<>
|
||||||
{albums.map((_, i) => (
|
<div className="hero-nav" aria-hidden="false">
|
||||||
<button
|
<button
|
||||||
key={i}
|
type="button"
|
||||||
className={`hero-dot${i === activeIdx ? ' hero-dot-active' : ''}`}
|
className="hero-nav-arrow hero-nav-arrow--left"
|
||||||
onClick={() => goTo(i)}
|
onClick={e => { e.stopPropagation(); goPrev(); }}
|
||||||
aria-label={`Album ${i + 1}`}
|
aria-label={t('hero.previousAlbum')}
|
||||||
/>
|
data-tooltip={t('hero.previousAlbum')}
|
||||||
))}
|
>
|
||||||
</div>
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hero-nav-arrow hero-nav-arrow--right"
|
||||||
|
onClick={e => { e.stopPropagation(); goNext(); }}
|
||||||
|
aria-label={t('hero.nextAlbum')}
|
||||||
|
data-tooltip={t('hero.nextAlbum')}
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hero-dots" aria-hidden="true">
|
||||||
|
{albums.map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`hero-dot${i === activeIdx ? ' hero-dot-active' : ''}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export const hero = {
|
|||||||
playAlbum: 'Album abspielen',
|
playAlbum: 'Album abspielen',
|
||||||
enqueue: 'Einreihen',
|
enqueue: 'Einreihen',
|
||||||
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
|
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
|
||||||
|
previousAlbum: 'Vorheriges Album',
|
||||||
|
nextAlbum: 'Nächstes Album',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export const hero = {
|
|||||||
playAlbum: 'Play Album',
|
playAlbum: 'Play Album',
|
||||||
enqueue: 'Enqueue',
|
enqueue: 'Enqueue',
|
||||||
enqueueTooltip: 'Add entire album to queue',
|
enqueueTooltip: 'Add entire album to queue',
|
||||||
|
previousAlbum: 'Previous album',
|
||||||
|
nextAlbum: 'Next album',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export const hero = {
|
|||||||
playAlbum: 'Reproducir Álbum',
|
playAlbum: 'Reproducir Álbum',
|
||||||
enqueue: 'Agregar a la Cola',
|
enqueue: 'Agregar a la Cola',
|
||||||
enqueueTooltip: 'Agregar álbum completo a la cola',
|
enqueueTooltip: 'Agregar álbum completo a la cola',
|
||||||
|
previousAlbum: 'Álbum anterior',
|
||||||
|
nextAlbum: 'Álbum siguiente',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export const hero = {
|
|||||||
playAlbum: 'Lire l\'album',
|
playAlbum: 'Lire l\'album',
|
||||||
enqueue: 'Mettre en file',
|
enqueue: 'Mettre en file',
|
||||||
enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente',
|
enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente',
|
||||||
|
previousAlbum: 'Album précédent',
|
||||||
|
nextAlbum: 'Album suivant',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export const hero = {
|
|||||||
playAlbum: 'Spill av album',
|
playAlbum: 'Spill av album',
|
||||||
enqueue: 'Legg til i kø',
|
enqueue: 'Legg til i kø',
|
||||||
enqueueTooltip: 'Legg til hele albumet i køen',
|
enqueueTooltip: 'Legg til hele albumet i køen',
|
||||||
|
previousAlbum: 'Forrige album',
|
||||||
|
nextAlbum: 'Neste album',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export const hero = {
|
|||||||
playAlbum: 'Album afspelen',
|
playAlbum: 'Album afspelen',
|
||||||
enqueue: 'In wachtrij',
|
enqueue: 'In wachtrij',
|
||||||
enqueueTooltip: 'Volledig album aan wachtrij toevoegen',
|
enqueueTooltip: 'Volledig album aan wachtrij toevoegen',
|
||||||
|
previousAlbum: 'Vorig album',
|
||||||
|
nextAlbum: 'Volgend album',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export const hero = {
|
|||||||
playAlbum: 'Redă Album',
|
playAlbum: 'Redă Album',
|
||||||
enqueue: 'Pune în coadă',
|
enqueue: 'Pune în coadă',
|
||||||
enqueueTooltip: 'Adaugă întregul album în coadă',
|
enqueueTooltip: 'Adaugă întregul album în coadă',
|
||||||
|
previousAlbum: 'Albumul anterior',
|
||||||
|
nextAlbum: 'Albumul următor',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export const hero = {
|
|||||||
playAlbum: 'Воспроизвести альбом',
|
playAlbum: 'Воспроизвести альбом',
|
||||||
enqueue: 'В очередь',
|
enqueue: 'В очередь',
|
||||||
enqueueTooltip: 'Добавить весь альбом в очередь',
|
enqueueTooltip: 'Добавить весь альбом в очередь',
|
||||||
|
previousAlbum: 'Предыдущий альбом',
|
||||||
|
nextAlbum: 'Следующий альбом',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export const hero = {
|
|||||||
playAlbum: '播放专辑',
|
playAlbum: '播放专辑',
|
||||||
enqueue: '加入队列',
|
enqueue: '加入队列',
|
||||||
enqueueTooltip: '将整张专辑添加到播放队列',
|
enqueueTooltip: '将整张专辑添加到播放队列',
|
||||||
|
previousAlbum: '上一张专辑',
|
||||||
|
nextAlbum: '下一张专辑',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-dot {
|
.hero-dot {
|
||||||
@@ -37,21 +38,53 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
background: rgba(255, 255, 255, 0.35);
|
background: rgba(255, 255, 255, 0.35);
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-dot:hover:not(.hero-dot-active) {
|
|
||||||
background: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-dot-active {
|
.hero-dot-active {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
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 {
|
.hero-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user