From 098f447e57319517f0674d674a7770261073293a Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 16 Sep 2026 15:44:14 +0200 Subject: [PATCH] Rework the music UI: circles/squares instead of movie posters, a real now-playing screen The music library reused the movie-poster tile (2:3, hover-revealed actions) for artists and albums too, since it was already there for video - functional, but never reads as "this is music" the way real apps do. Explored as mockups first, then implemented without touching any of MusicBar's existing sync/cast/HLS logic: - tiles.ts's tileHtml() now dispatches on Emby type: artists get a circle with no hover overlay, albums a square - same "whole tile is the one link" idea, since there's only ever one action either way. - Artist/album pages gain a real hero header (circular/square art, a pill-shaped play button) instead of a plain title + text button. Album's per-track buttons shrink from a wide text button repeated on every row to a small icon that swaps play/plus with the same two states as before. - MusicBar gains a real mobile "now playing" full-screen view (tap the collapsed mini bar to expand it) instead of only ever being a thin strip - the screen every music app has that this one was missing. The existing thin top-edge progress bar didn't change shape at all, on either breakpoint. Nothing here invents a feature without a real data source behind it - no popular tracks, no previous-track, no favorites, none of that exists server-side. Caught and fixed one real bug via computed-style verification, not just reading the CSS back: the new circular play button was silently losing to .iconBtn's own background/radius at equal specificity, outcome depending on esbuild's bundle order rather than either rule's own declared intent. Co-Authored-By: Claude Sonnet 5 --- README.md | 4 + client/src/musicBar/MusicBar.css | 204 +++++++++++++++++++++++++++- client/src/musicBar/MusicBar.html | 87 ++++++++---- client/src/musicBar/MusicBar.ts | 89 +++++++++--- client/src/styles/trackList.css | 23 +++- client/src/tiles.css | 30 ++++ client/src/tiles.ts | 50 ++++++- client/src/views/album/Album.html | 5 +- client/src/views/album/Album.ts | 50 ++++--- client/src/views/artist/Artist.css | 89 ++++++++++++ client/src/views/artist/Artist.html | 13 +- client/src/views/artist/Artist.ts | 20 ++- docs/architecture.md | 118 ++++++++++++++++ 13 files changed, 700 insertions(+), 82 deletions(-) create mode 100644 client/src/views/artist/Artist.css diff --git a/README.md b/README.md index 9024002..1f6d9a1 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,10 @@ Then put it behind your existing reverse proxy like everything else action — resolved server-side (one atomic operation, not one request per track) the same way launching a playlist already expands into everything it contains — see docs/architecture.md +- [x] Music UI reworked to look like a music app instead of reusing + movie-poster tiles — circular artist tiles, square album tiles, + a real hero header, and a mobile "now playing" full-screen player + — see docs/architecture.md - [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is confirmed here — no real device/session) diff --git a/client/src/musicBar/MusicBar.css b/client/src/musicBar/MusicBar.css index 4ec2e9f..fccbd56 100644 --- a/client/src/musicBar/MusicBar.css +++ b/client/src/musicBar/MusicBar.css @@ -50,12 +50,97 @@ pointer-events: none; } -.musicBarRow { +/* ---- Collapsed mobile preview (see MusicBar.html) - display:none by + default since #musicBarPanel below is already always visible on + desktop, nothing to collapse into a preview of there. Turned on + under the 720px breakpoint further down. ---- */ +.mobileMiniBar { + display: none; + align-items: center; + gap: 10px; + padding: 8px 12px; + position: relative; + cursor: pointer; +} + +/* Passive - not draggable, unlike #trackProgress inside the panel below. + Glanceable progress while collapsed is enough; dragging is one tap + away once expanded, same as most real players' own mini-bar. */ +.mobileMiniProgress { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: rgba(255, 255, 255, .12); +} + +.mobileMiniProgressFill { + height: 100%; + width: 0%; + background: var(--accent-bright); +} + +.mobileMiniArt { + width: 36px; + height: 36px; + object-fit: cover; + border-radius: 6px; + background: var(--panel); + flex-shrink: 0; +} + +.mobileMiniArt.noImage { visibility: hidden; } + +.mobileMiniMeta { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; +} + +.mobileMiniMeta strong { + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---- The actual controls - always in place on desktop (this IS the + bar, same as before); a full-screen "now playing" overlay on mobile, + opened via #mobileMiniBar above and closed via #panelCloseBtn - see + the 720px breakpoint further down for the fixed-overlay/transform + part of this, unchanged here. ---- */ +.musicBarPanel { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 16px; + padding: 8px 16px; +} + +.panelCloseBtn { display: none; } + +.musicBarPanelTrack { display: flex; align-items: center; gap: 12px; - padding: 8px 16px; - flex-wrap: wrap; + min-width: 0; +} + +.musicBarPanelTransport { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; +} + +.musicBarPanelSide { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; } /* Smaller than the video player's presence row (Player.css/presence.css's @@ -95,6 +180,60 @@ .musicBarPosition { font-variant-numeric: tabular-nums; white-space: nowrap; } +/* The one primary transport action, bigger and filled so it clearly + outranks the plain small .iconBtn (skip-vote/queue) either side of + it - same "obviously the main button" treatment + views/player/Player.ts's own controls already get, instead of + blending into a row of equally-weighted buttons. Shared between + #playPauseBtn (inside .musicBarPanel) and #miniPlayPauseBtn (the + collapsed mobile preview) - same look, same size, both wired to the + exact same toggle in MusicBar.ts. */ +/* .iconBtn.musicBarPlayBig, not just .musicBarPlayBig: both classes are + always present together on these two buttons (see MusicBar.html), so + this compound selector's higher specificity is what reliably beats + .iconBtn's own background/border-radius regardless of which of the + two files esbuild happens to bundle later - two plain single-class + selectors at equal specificity would otherwise depend on that bundle + order, which isn't something to build a visual guarantee on. */ +.iconBtn.musicBarPlayBig { + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--text); + color: var(--bg); + display: flex; + align-items: center; + justify-content: center; + padding: 0; + flex-shrink: 0; +} + +.iconBtn.musicBarPlayBig:hover { background: var(--accent-bright); color: #fff; } +.musicBarPlayBig svg { width: 14px; height: 14px; display: block; } + +/* #voteSkipBtn's own vote tally (see MusicBar.ts's updateVoteSkipLabel) - + a badge instead of the old "Passer (vote) - X/Y" text label, now that + the button itself is icon-only like every other transport control + here. Hidden (see MusicBar.html) until there's an actual tally to + show - matches every other conditionally-shown badge/pill elsewhere + in this app rather than showing "0/1" before anyone's voted. */ +#voteSkipBtn { position: relative; } + +.voteBadge { + position: absolute; + top: -4px; + right: -6px; + background: var(--accent); + color: #fff; + font-size: 9px; + font-weight: 700; + border-radius: 99px; + padding: 1px 4px; + min-width: 14px; + text-align: center; + line-height: 1.4; +} + /* #castBtn while actively casting - same "clearly-on" treatment as views/player/Player.css's own .iconBtn[aria-pressed="true"] (ID-scoped here rather than a shared .iconBtn rule since #playPauseBtn's own @@ -135,9 +274,62 @@ } @media (max-width: 720px) { - .musicBarRow { justify-content: center; } - .musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; } - .musicBarQueue { margin: 0; border-radius: 0; } + .mobileMiniBar { display: flex; } + + /* Same fixed-overlay/slide-up-from-bottom pattern as Shell.css's + .mobileNavSheet - see docs/architecture.md's "Mobile navigation". + display stays flex unconditionally (not toggled) so the transform + transition below actually animates instead of jumping. */ + .musicBarPanel { + position: fixed; + inset: 0; + z-index: 25; + display: flex; + flex-direction: column; + padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px)); + background: var(--bg); + transform: translateY(100%); + transition: transform .25s ease; + overflow-y: auto; + } + + .musicBarPanel.open { transform: translateY(0); } + + .panelCloseBtn { + display: flex; + color: var(--muted); + margin-bottom: 24px; + } + + .musicBarPanelTrack { + flex-direction: column; + text-align: center; + margin-bottom: 24px; + } + + .musicBarPanelTrack .musicBarImage { + width: min(220px, 60vw); + height: min(220px, 60vw); + border-radius: 12px; + } + + .musicBarInfo { margin-right: 0; align-items: center; } + .musicBarInfo strong { font-size: 19px; white-space: normal; } + .musicBarPosition { margin-top: 6px; } + + .musicBarPanelTransport { gap: 28px; margin-bottom: 28px; } + .musicBarPanelTransport .musicBarPlayBig { width: 56px; height: 56px; } + .musicBarPanelTransport .musicBarPlayBig svg { width: 22px; height: 22px; } + + .musicBarPanelSide { margin-top: auto; justify-content: center; gap: 20px; } + + /* Positioned + given a real z-index only here: a normal-flow sibling + otherwise loses to .musicBarPanel's fixed overlay above it once + that's open (its own z-index applies regardless of DOM order) - + opening the queue while the now-playing panel is expanded needs + to still show it on top, matching desktop's existing "queue pops + up above the bar" behavior. */ + .musicBarQueue { position: relative; z-index: 26; margin: 0; border-radius: 0; } } /* One queue entry - a plain flex row (image, then a shrinking/truncating diff --git a/client/src/musicBar/MusicBar.html b/client/src/musicBar/MusicBar.html index ce0eb8d..9dc8826 100644 --- a/client/src/musicBar/MusicBar.html +++ b/client/src/musicBar/MusicBar.html @@ -5,35 +5,70 @@

Rien en attente.

- -
- -
-
- - -
- - - + +
+
+ +
+ - +
+ +
- 0:00 / 0:00 - - - - - + + +
+ +
+ +
+ - + +
+ 0:00 / 0:00 +
+ +
+ + + +
+ +
+
+ + +
diff --git a/client/src/musicBar/MusicBar.ts b/client/src/musicBar/MusicBar.ts index ff62139..e735f37 100644 --- a/client/src/musicBar/MusicBar.ts +++ b/client/src/musicBar/MusicBar.ts @@ -34,6 +34,12 @@ import type { PlaybackDriver } from '../cast'; * for music parties. */ +// #playPauseBtn/#miniPlayPauseBtn's two states - real SVGs (not the old +// '▶'/'⏸' text glyphs, which never matched Player.ts's own icon-button +// convention used everywhere else this app shows a play/pause control). +const PLAY_ICON_SVG = ''; +const PAUSE_ICON_SVG = ''; + // See views/player/Player.ts's own HLS_CONFIG for why these are raised // well above hls.js's defaults (10s manifest/level/frag load timeouts) - // every track here also goes through CatalogService.proxyStream into a @@ -94,22 +100,39 @@ export function initMusicBar(root: HTMLElement): void { const audio = barEl.querySelector('#barAudio') as HTMLAudioElement; const playPauseBtn = barEl.querySelector('#playPauseBtn') as HTMLButtonElement; + const miniPlayPauseBtn = barEl.querySelector('#miniPlayPauseBtn') as HTMLButtonElement; const castBtn = barEl.querySelector('#castBtn') as HTMLButtonElement; const voteSkipBtn = barEl.querySelector('#voteSkipBtn') as HTMLButtonElement; const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!; const queuePanel = barEl.querySelector('#musicBarQueue')!; const leaveBtn = barEl.querySelector('#leaveBtn')!; const trackProgress = barEl.querySelector('#trackProgress') as HTMLElement; + const mobileMiniBar = barEl.querySelector('#mobileMiniBar')!; + const musicBarPanel = barEl.querySelector('#musicBarPanel')!; + const panelCloseBtn = barEl.querySelector('#panelCloseBtn')!; activeDriver = localDriver(audio); audio.addEventListener('play', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(false); }); audio.addEventListener('pause', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(true); }); playPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused)); + miniPlayPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused)); voteSkipBtn.addEventListener('click', () => conn?.voteSkip()); toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide')); leaveBtn.addEventListener('click', () => leaveMusicParty()); + // Mobile only (see MusicBar.css's 720px breakpoint - .musicBarPanel is + // already always visible on desktop, so these two are simply never + // clicked/reachable there). The mini bar itself is the tap target + // (not just an icon on it) so there's a generous hit area on a touch + // screen - #miniPlayPauseBtn's own click above stops that one + // specific tap from also opening the panel via this listener. + mobileMiniBar.addEventListener('click', (e) => { + if ((e.target as HTMLElement).closest('#miniPlayPauseBtn')) return; + musicBarPanel.classList.add('open'); + }); + panelCloseBtn.addEventListener('click', () => musicBarPanel.classList.remove('open')); + // Click/drag to seek - no host check, unlike the video player's own // seek bar, since a listen party has no host at all (see activeDriver's // own comment) - anyone dragging this updates their own driver @@ -172,8 +195,13 @@ function updatePositionDisplay(): void { const currentTime = activeDriver!.currentTime; const position = barEl!.querySelector('#trackPosition')!; position.textContent = `${formatDuration(currentTime)} / ${formatDuration(currentDurationSeconds)}`; - const fill = barEl!.querySelector('#trackProgressFill') as HTMLElement; - fill.style.width = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%'; + const percent = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%'; + (barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = percent; + // Same passive indicator on the collapsed mobile preview - see + // MusicBar.css's .mobileMiniProgress. Kept a plain width sync rather + // than its own pointer-seek wiring: dragging is one tap (expand the + // panel) away, see #mobileMiniBar's own click handler. + (barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = percent; } /** @@ -244,6 +272,7 @@ export function leaveMusicParty(): void { barEl!.classList.add('hide'); barEl!.querySelector('#musicBarQueue')!.classList.add('hide'); + barEl!.querySelector('#musicBarPanel')!.classList.remove('open'); leaveParty(partyId).catch(() => {}); } @@ -277,11 +306,36 @@ async function castTrack(title: string): Promise { * pause listeners already do (see initMusicBar), since casting here has * no host gate to check first (see activeDriver's own comment). */ function onCastStateChanged(): void { - const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement; - playPauseBtn.textContent = activeDriver!.paused ? '▶' : '⏸'; + setPlayIcon(!activeDriver!.paused); if (!suppressLocalEvents) conn?.setMusicPaused(activeDriver!.paused); } +/** Both play/pause buttons - the main one inside .musicBarPanel and the + * collapsed mobile preview's own - always show the same state, so + * every call site that used to touch just #playPauseBtn's textContent + * updates both here instead of duplicating the innerHTML swap at each + * one. */ +function setPlayIcon(isPlaying: boolean): void { + const icon = isPlaying ? PAUSE_ICON_SVG : PLAY_ICON_SVG; + (barEl!.querySelector('#playPauseBtn') as HTMLButtonElement).innerHTML = icon; + (barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement).innerHTML = icon; +} + +/** Same pairing as setPlayIcon above, for the track metadata shown in + * both .musicBarPanel and the collapsed mobile preview. */ +function setTrackMeta(imageUrl: string, name: string, artist: string): void { + const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement; + const miniTrackImage = barEl!.querySelector('#miniTrackImage') as HTMLImageElement; + trackImage.src = imageUrl; + trackImage.classList.remove('noImage'); + miniTrackImage.src = imageUrl; + miniTrackImage.classList.remove('noImage'); + barEl!.querySelector('#trackName')!.textContent = name; + barEl!.querySelector('#miniTrackName')!.textContent = name; + barEl!.querySelector('#trackArtist')!.textContent = artist; + barEl!.querySelector('#miniTrackArtist')!.textContent = artist; +} + /** * Starts casting - available to any listener, not just... there is no * "just", a listen party has no host (see activeDriver's own comment). @@ -355,16 +409,9 @@ function endCastAndResync(): void { * (isCasting) delegated to castTrack/loadTrackLocal below. */ function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void { - const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement; - const trackName = barEl!.querySelector('#trackName')!; - const trackArtist = barEl!.querySelector('#trackArtist')!; - currentItemId = track.itemId; currentDurationSeconds = track.durationSeconds || 0; - trackImage.src = track.itemImageUrl || ''; - trackImage.classList.remove('noImage'); - trackName.textContent = track.itemName || ''; - trackArtist.textContent = [track.artistName, track.albumName].filter(Boolean).join(' - '); + setTrackMeta(track.itemImageUrl || '', track.itemName || '', [track.artistName, track.albumName].filter(Boolean).join(' - ')); if (isCasting) { castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => { @@ -442,33 +489,34 @@ function loadTrackLocal(track: MusicCurrentPublic, elapsedSeconds: number, isPau */ function updateVoteSkipLabel(): void { const voteSkipBtn = barEl!.querySelector('#voteSkipBtn') as HTMLButtonElement; + const voteBadge = barEl!.querySelector('#voteBadge') as HTMLElement; const current = lastMusicState?.current; voteSkipBtn.disabled = !current; if (!current) { - voteSkipBtn.textContent = 'Passer (vote)'; + voteBadge.classList.add('hide'); return; } const connectedCount = latestMembers.filter((m) => m.connected).length || 1; const needed = Math.floor(connectedCount / 2) + 1; - voteSkipBtn.textContent = `Passer (vote) - ${lastMusicState!.skipVotes}/${needed}`; + voteBadge.textContent = `${lastMusicState!.skipVotes}/${needed}`; + voteBadge.classList.remove('hide'); } function applyMusicState(music: MusicPublicState): void { lastMusicState = music; const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement; + const miniPlayPauseBtn = barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement; const castBtn = barEl!.querySelector('#castBtn') as HTMLButtonElement; const queueList = barEl!.querySelector('#queueList')!; - const trackName = barEl!.querySelector('#trackName')!; - const trackArtist = barEl!.querySelector('#trackArtist')!; - const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement; const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement; // Queue/vote-button/play-icon updates happen first, unconditionally, // deliberately BEFORE the track-loading branch below (see loadTrack's // own comment on why - same ordering as Player.ts/the old MusicPlayer.ts). - playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶'; + setPlayIcon(Boolean(music.current && !music.current.isPaused)); playPauseBtn.disabled = !music.current; + miniPlayPauseBtn.disabled = !music.current; // Nothing for startCasting to actually cast with an empty queue - same // "disable, don't just silently no-op" treatment as playPauseBtn above. castBtn.disabled = !music.current && !isCasting; @@ -495,13 +543,12 @@ function applyMusicState(music: MusicPublicState): void { if (!music.current) { currentItemId = null; - trackName.textContent = 'Rien ne joue pour le moment.'; - trackArtist.textContent = ''; - trackImage.src = ''; + setTrackMeta('', 'Rien ne joue pour le moment.', ''); if (hls) { hls.destroy(); hls = null; } activeDriver!.pause(); audio.removeAttribute('src'); // harmless no-op while casting - the local element is already srcless then, see startCasting (barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = '0%'; + (barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = '0%'; } else if (music.current.itemId !== currentItemId) { loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused); } else { diff --git a/client/src/styles/trackList.css b/client/src/styles/trackList.css index 7d107e4..235c67f 100644 --- a/client/src/styles/trackList.css +++ b/client/src/styles/trackList.css @@ -20,7 +20,28 @@ .trackIndex { color: var(--muted); font-size: 13px; text-align: center; } .trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } -.trackRow button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; } + +/* A compact circle, not the wide text button this used to be (views/album's + own "Écouter ensemble"/"Ajouter à la file") - with a whole album's worth + of these stacked in a list, one word-length pill per row read as + noisier than the same action shown as a plain play/plus icon, the + same density real track lists use. */ +.trackRow button.trackIconBtn { + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + border-radius: 50%; + background: none; + border: 1px solid rgba(255, 255, 255, .18); + color: var(--muted); + display: flex; + align-items: center; + justify-content: center; +} + +.trackRow button.trackIconBtn:hover { border-color: var(--text); color: var(--text); background: none; } +.trackRow button.trackIconBtn svg { width: 12px; height: 12px; } @media (max-width: 600px) { .trackRow { grid-template-columns: 24px 1fr; } diff --git a/client/src/tiles.css b/client/src/tiles.css index 7d02db7..b1df249 100644 --- a/client/src/tiles.css +++ b/client/src/tiles.css @@ -135,3 +135,33 @@ } .tileBtn { font-size: 11px; padding: 6px 4px; } } + +/* Artist/album tiles (tiles.ts's artistTileHtml/albumTileHtml) - an + directly, not a
with a separate absolutely-positioned + .tileLink/.tileOverlay on top of it: there's only ever the one + action (open the artist/album page), so the whole tile just being + the link is simpler than reusing machinery built for a second, + hover-revealed set of actions these two never have. */ +.tile.tileArtist, +.tile.tileAlbum { + text-decoration: none; + color: var(--text); +} + +.tile.tileArtist img, +.tile.tileAlbum img { + aspect-ratio: 1 / 1; +} + +.tile.tileArtist img { + border-radius: 50%; +} + +.tile.tileArtist .tileLabel { + text-align: center; +} + +.tile.tileArtist:hover .tileLabel, +.tile.tileAlbum:hover .tileLabel { + text-decoration: underline; +} diff --git a/client/src/tiles.ts b/client/src/tiles.ts index cd6be9d..3552540 100644 --- a/client/src/tiles.ts +++ b/client/src/tiles.ts @@ -49,7 +49,15 @@ const DRILL_DOWN: Record = { Playlist: { route: 'playlist', label: 'Voir la playlist' } }; -export function tileHtml(item: ItemDto): string { +/** + * A poster tile - movies/shows/musicvideos/playlists, anything that + * still gets Emby's own portrait artwork. MusicArtist/MusicAlbum get a + * completely different shape below (a circle/a square, no hover + * overlay) since a movie poster's proportions and "hover for actions" + * pattern never fit an artist photo or an album cover - see + * docs/architecture.md's "Music UI rework". + */ +function posterTileHtml(item: ItemDto): string { const drill = item.type ? DRILL_DOWN[item.type] : undefined; const drillHref = drill ? `#/${drill.route}/${item.id}` : null; const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party'; @@ -66,6 +74,46 @@ export function tileHtml(item: ItemDto): string {
`; } +/** + * An artist tile - a circle, the one shape every music app uses for a + * person/act, never a rectangle. No hover overlay at all: unlike a + * poster tile, there's only ever one thing to do with an artist photo + * (open the artist page), so the whole tile is just that one link - + * see DRILL_DOWN's own MusicArtist entry. + */ +function artistTileHtml(item: ItemDto): string { + return ` +
+ + ${escapeHtml(item.name)} + `; +} + +/** An album tile - square, matching the cover art's own real proportions + * (a movie-poster 2:3 box only ever stretched/cropped it oddly). Same + * "whole tile is the one link" reasoning as artistTileHtml above. */ +function albumTileHtml(item: ItemDto): string { + return ` + + + ${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''} + `; +} + +/** + * Renders one item as a tile - the shape depends entirely on its Emby + * type. Dispatching here, rather than making every call site + * (home carousels, views/library, views/artist) pick a renderer itself, + * is what keeps them all visually/behaviorally identical instead of + * drifting - same reasoning this module's own header comment already + * gives for being shared in the first place. + */ +export function tileHtml(item: ItemDto): string { + if (item.type === 'MusicArtist') return artistTileHtml(item); + if (item.type === 'MusicAlbum') return albumTileHtml(item); + return posterTileHtml(item); +} + /** * Wires up every "Lancer une watch party" button inside `container`, now * and for any tile added to it later. `onStart(itemId)` does the actual diff --git a/client/src/views/album/Album.html b/client/src/views/album/Album.html index 78e7cc5..0076541 100644 --- a/client/src/views/album/Album.html +++ b/client/src/views/album/Album.html @@ -2,10 +2,11 @@
+ Album

-
- +
+ Voir sur Emby
diff --git a/client/src/views/album/Album.ts b/client/src/views/album/Album.ts index 3d7a991..a79cb9d 100644 --- a/client/src/views/album/Album.ts +++ b/client/src/views/album/Album.ts @@ -11,6 +11,13 @@ import type { RouteParams } from '../../router'; const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions +// Same "small local constant" convention as musicBar/MusicBar.ts's own +// icon strings - PLAY_ICON_SVG is duplicated from views/artist/Artist.ts +// rather than shared, see that file's own comment on why. +const PLAY_ICON_SVG = ''; +const PLUS_ICON_SVG = ''; +const CHECK_ICON_SVG = ''; + /** Route `/album/:id` - the last level of the music hierarchy: one album's * tracks, each startable as its own listen party (mirrors views/series's * episode list, but flat - an album has no further sub-level the way a @@ -31,9 +38,9 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi root.innerHTML = html; root.querySelector('#back')!.addEventListener('click', () => history.back()); (root.querySelector('#albumCover') as HTMLImageElement).src = album.imageUrl; - root.querySelector('#albumTitle')!.innerHTML = album.year - ? `${escapeHtml(album.name)} (${album.year})` - : escapeHtml(album.name); + root.querySelector('#albumKicker')!.textContent = album.year ? `Album · ${album.year}` : 'Album'; + root.querySelector('#albumTitle')!.textContent = album.name; + root.querySelector('#launchBtnIcon')!.innerHTML = PLAY_ICON_SVG; if (album.artistName) { const artistEl = root.querySelector('#albumArtist')!; artistEl.textContent = album.artistName; @@ -43,6 +50,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi const listEl = root.querySelector('#trackList')!; const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement; + const launchBtnLabel = root.querySelector('#launchBtnLabel')!; if (!tracks.length) { listEl.innerHTML = '

Aucun morceau dans cet album.

'; root.querySelector('#searchInput')!.remove(); @@ -50,12 +58,16 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi return; } - // Label reflects whether a listen party is already active *at render - // time* - a harmless, occasionally-stale label (see refreshLabels() - // below for when it's corrected). The actual behavior on click always - // re-checks getActiveMusicPartyId() fresh, so clicking never does the - // wrong thing even if the label itself hasn't caught up yet (e.g. - // someone hit "Quitter" on the bar while this page was already open). + // aria-label reflects whether a listen party is already active *at + // render time* - a harmless, occasionally-stale label (see + // refreshLabels() below for when it's corrected). The actual behavior + // on click always re-checks getActiveMusicPartyId() fresh, so clicking + // never does the wrong thing even if the label hasn't caught up yet + // (e.g. someone hit "Quitter" on the bar while this page was already + // open). Icon-only buttons (play vs plus, mirroring the same two + // states) - the label lives in aria-label/title for accessibility + // rather than as visible text, now that there's one per track instead + // of one per page (see docs/architecture.md's "Music UI rework"). const startLabel = 'Écouter ensemble'; const addLabel = 'Ajouter à la file'; const startAlbumLabel = 'Écouter cet album'; @@ -66,7 +78,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi ${t.indexNumber != null ? t.indexNumber : ''} ${escapeHtml(t.name)} ${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''} - +
`).join(''); @@ -88,9 +100,13 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi function refreshLabels(): void { const active = getActiveMusicPartyId(); listEl.querySelectorAll('.startPartyBtn').forEach((b) => { - if (!b.disabled) b.textContent = active ? addLabel : startLabel; + if (b.disabled) return; + const label = active ? addLabel : startLabel; + b.innerHTML = active ? PLUS_ICON_SVG : PLAY_ICON_SVG; + b.title = label; + b.setAttribute('aria-label', label); }); - if (!launchBtn.disabled) launchBtn.textContent = active ? addAlbumLabel : startAlbumLabel; + if (!launchBtn.disabled) launchBtnLabel.textContent = active ? addAlbumLabel : startAlbumLabel; } refreshLabels(); // sets launchBtn's own initial label - the per-track buttons already get theirs from the template above @@ -101,10 +117,8 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi const activePartyId = getActiveMusicPartyId(); try { if (activePartyId) { - btn.textContent = 'Ajout...'; await addTrackToQueue(activePartyId, trackInputFor(track)); } else { - btn.textContent = 'Démarrage...'; // Attaches the persistent bottom bar directly and stays right // here - no navigate() - so adding a few more tracks from this // same album is just clicking their buttons too. See @@ -113,7 +127,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi const party = await createParty(track.id); joinMusicParty(party.partyId, party); } - btn.textContent = 'Ajouté !'; + btn.innerHTML = CHECK_ICON_SVG; setTimeout(() => { btn.disabled = false; refreshLabels(); }, 1500); } catch (err) { alert((err as Error).message); @@ -134,14 +148,14 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi const activePartyId = getActiveMusicPartyId(); try { if (activePartyId) { - launchBtn.textContent = 'Ajout...'; + launchBtnLabel.textContent = 'Ajout...'; await queueItem(activePartyId, albumId); } else { - launchBtn.textContent = 'Démarrage...'; + launchBtnLabel.textContent = 'Démarrage...'; const party = await createParty(albumId); joinMusicParty(party.partyId, party); } - launchBtn.textContent = 'Ajouté !'; + launchBtnLabel.textContent = 'Ajouté !'; setTimeout(() => { launchBtn.disabled = false; refreshLabels(); }, 1500); } catch (err) { alert((err as Error).message); diff --git a/client/src/views/artist/Artist.css b/client/src/views/artist/Artist.css new file mode 100644 index 0000000..8f16647 --- /dev/null +++ b/client/src/views/artist/Artist.css @@ -0,0 +1,89 @@ +/* ---- Artist hero - see Artist.ts. A circular photo, never a poster: + the same reasoning tiles.ts's artistTileHtml already gives for the + artist grid applies just as much to the one photo at the top of the + artist's own page. ---- */ +.artistHero { + display: flex; + align-items: flex-end; + gap: 20px; + margin-bottom: 24px; +} + +.artistHeroPhoto { + width: 132px; + height: 132px; + border-radius: 50%; + object-fit: cover; + background: var(--panel); + flex-shrink: 0; +} + +.artistHeroPhoto.noImage { visibility: hidden; } + +.artistHeroInfo { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.artistHeroInfo h1 { margin: 0; font-size: 26px; line-height: 1.15; } + +/* ---- Shared with views/album's own hero (Album.css reuses these three + classes directly rather than redefining them - same "one rule, + whichever file needs it first" precedent Album.css already set for + .seriesInfo/.playlistActions). ---- */ +.heroKicker { + font-size: 12px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .05em; + font-weight: 600; +} + +.heroActions { + display: flex; + align-items: center; + gap: 14px; + margin-top: 10px; + flex-wrap: wrap; +} + +/* A pill, not a plain rectangular button - the one visual actually + borrowed from real music apps' big circular play button, while + keeping the explicit text label this app already standardized on + for every request/action control (see docs/architecture.md's + player-rework section) - nothing here is icon-only. */ +.heroPlayBtn { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 18px 6px 6px; + border-radius: 99px; + background: var(--panel); + color: var(--text); + font-size: 14px; + font-weight: 600; +} + +.heroPlayBtn:hover { background: #333; } + +.heroPlayIcon { + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--accent); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.heroPlayIcon svg { width: 14px; height: 14px; display: block; } + +@media (max-width: 600px) { + .artistHero { flex-direction: column; align-items: center; text-align: center; } + .artistHeroPhoto { width: 104px; height: 104px; } + .artistHeroInfo { align-items: center; } +} diff --git a/client/src/views/artist/Artist.html b/client/src/views/artist/Artist.html index 534bd01..6250870 100644 --- a/client/src/views/artist/Artist.html +++ b/client/src/views/artist/Artist.html @@ -1,7 +1,14 @@ -