Rework the music UI: circles/squares instead of movie posters, a real now-playing screen #11

Merged
kyuno merged 2 commits from feature/music-ui into master 2026-09-16 15:59:34 +02:00
13 changed files with 700 additions and 82 deletions
Showing only changes of commit 098f447e57 - Show all commits

View file

@ -344,6 +344,10 @@ Then put it behind your existing reverse proxy like everything else
action — resolved server-side (one atomic operation, not one action — resolved server-side (one atomic operation, not one
request per track) the same way launching a playlist already request per track) the same way launching a playlist already
expands into everything it contains — see docs/architecture.md 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 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 - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session) confirmed here — no real device/session)

View file

@ -50,12 +50,97 @@
pointer-events: none; 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; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 8px 16px; min-width: 0;
flex-wrap: wrap; }
.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 /* 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; } .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 /* #castBtn while actively casting - same "clearly-on" treatment as
views/player/Player.css's own .iconBtn[aria-pressed="true"] (ID-scoped views/player/Player.css's own .iconBtn[aria-pressed="true"] (ID-scoped
here rather than a shared .iconBtn rule since #playPauseBtn's own here rather than a shared .iconBtn rule since #playPauseBtn's own
@ -135,9 +274,62 @@
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.musicBarRow { justify-content: center; } .mobileMiniBar { display: flex; }
.musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; }
.musicBarQueue { margin: 0; border-radius: 0; } /* 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 /* One queue entry - a plain flex row (image, then a shrinking/truncating

View file

@ -5,25 +5,59 @@
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div> <div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
</div> </div>
<!-- Click/drag to seek - see MusicBar.ts's pointerdown/move/up handlers. <!-- Mobile-only collapsed preview (see MusicBar.css's 720px breakpoint) -
No host check needed to enable this, unlike the video player's own #musicBarPanel below is already always visible on desktop, so this
seek bar: a listen party has no host at all, and a seek here gets only ever renders there. Tapping anywhere on it except the play
the exact same "anyone can do it" treatment play/pause already has button opens the panel - see MusicBar.ts. -->
(see MusicQueueService.seek's own comment for why that's the right <div id="mobileMiniBar" class="mobileMiniBar">
model for this, not voteSkip's majority gate). --> <div class="mobileMiniProgress"><div id="miniProgressFill" class="mobileMiniProgressFill"></div></div>
<div id="trackProgress" class="musicBarProgressTrack"><div id="trackProgressFill" class="musicBarProgressFill"></div></div> <img id="miniTrackImage" class="mobileMiniArt" alt="" onerror="this.classList.add('noImage')" />
<div class="mobileMiniMeta">
<strong id="miniTrackName">-</strong>
<span class="hint" id="miniTrackArtist"></span>
</div>
<button class="iconBtn musicBarPlayBig" id="miniPlayPauseBtn" title="Lecture / pause" aria-label="Lecture / pause"></button>
</div>
<div class="musicBarRow"> <!-- Always in place on desktop (the bar itself, unconditionally visible
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div> like before); a full-screen "now playing" overlay on mobile,
opened via #mobileMiniBar above and closed via #panelCloseBtn. -->
<div id="musicBarPanel" class="musicBarPanel">
<button class="linklike panelCloseBtn" id="panelCloseBtn" aria-label="Réduire">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<!-- Click/drag to seek - see MusicBar.ts's pointerdown/move/up
handlers. No host check needed to enable this, unlike the video
player's own seek bar: a listen party has no host at all - see
MusicQueueService.seek's own comment for why that's the right
model for this, not voteSkip's majority gate. -->
<div id="trackProgress" class="musicBarProgressTrack"><div id="trackProgressFill" class="musicBarProgressFill"></div></div>
<div class="musicBarPanelTrack">
<img id="trackImage" class="musicBarImage" alt="" onerror="this.classList.add('noImage')" /> <img id="trackImage" class="musicBarImage" alt="" onerror="this.classList.add('noImage')" />
<div class="musicBarInfo"> <div class="musicBarInfo">
<strong id="trackName">-</strong> <strong id="trackName">-</strong>
<span class="hint" id="trackArtist"></span> <span class="hint" id="trackArtist"></span>
</div> </div>
<span id="trackPosition" class="hint musicBarPosition">0:00 / 0:00</span> <span id="trackPosition" class="hint musicBarPosition">0:00 / 0:00</span>
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button> </div>
<div class="musicBarPanelTransport">
<button class="iconBtn" id="voteSkipBtn" title="Passer (vote)" aria-label="Passer (vote)">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 5v14l10-7Z" fill="currentColor"></path><rect x="17" y="5" width="2.5" height="14" rx="1" fill="currentColor"></rect></svg>
<span id="voteBadge" class="voteBadge hide"></span>
</button>
<button class="iconBtn musicBarPlayBig" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause"></button>
<button class="iconBtn" id="toggleQueueBtn" title="File d'attente" aria-label="File d'attente">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"></circle><circle cx="4.5" cy="12" r="1.3" fill="currentColor"></circle><circle cx="4.5" cy="17" r="1.3" fill="currentColor"></circle><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></line><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></line><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></line></svg>
</button>
</div>
<div class="musicBarPanelSide">
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div>
<button class="iconBtn hide" id="castBtn" title="Caster sur un Chromecast" aria-label="Caster sur un Chromecast" aria-pressed="false"> <button class="iconBtn hide" id="castBtn" title="Caster sur un Chromecast" aria-label="Caster sur un Chromecast" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"> <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="1" y="4" width="22" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect> <rect x="1" y="4" width="22" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
@ -32,8 +66,9 @@
<circle cx="4" cy="20" r="1.5" fill="currentColor"></circle> <circle cx="4" cy="20" r="1.5" fill="currentColor"></circle>
</svg> </svg>
</button> </button>
<button class="linklike" id="voteSkipBtn">Passer (vote)</button> <button class="iconBtn" id="leaveBtn" title="Quitter" aria-label="Quitter">
<button class="linklike" id="toggleQueueBtn">File d'attente</button> <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M9 4.5H6a1.5 1.5 0 0 0-1.5 1.5v12A1.5 1.5 0 0 0 6 19.5h3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M13 8l4 4-4 4M9.5 12H20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>
<button class="linklike" id="leaveBtn">Quitter</button> </button>
</div>
</div> </div>
<audio id="barAudio"></audio> <audio id="barAudio"></audio>

View file

@ -34,6 +34,12 @@ import type { PlaybackDriver } from '../cast';
* for music parties. * 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 = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4.5v15l14-7.5Z" fill="currentColor"></path></svg>';
const PAUSE_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="4" width="5" height="16" rx="1" fill="currentColor"></rect><rect x="14" y="4" width="5" height="16" rx="1" fill="currentColor"></rect></svg>';
// See views/player/Player.ts's own HLS_CONFIG for why these are raised // 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) - // well above hls.js's defaults (10s manifest/level/frag load timeouts) -
// every track here also goes through CatalogService.proxyStream into a // 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 audio = barEl.querySelector('#barAudio') as HTMLAudioElement;
const playPauseBtn = barEl.querySelector('#playPauseBtn') as HTMLButtonElement; const playPauseBtn = barEl.querySelector('#playPauseBtn') as HTMLButtonElement;
const miniPlayPauseBtn = barEl.querySelector('#miniPlayPauseBtn') as HTMLButtonElement;
const castBtn = barEl.querySelector('#castBtn') as HTMLButtonElement; const castBtn = barEl.querySelector('#castBtn') as HTMLButtonElement;
const voteSkipBtn = barEl.querySelector('#voteSkipBtn') as HTMLButtonElement; const voteSkipBtn = barEl.querySelector('#voteSkipBtn') as HTMLButtonElement;
const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!; const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!;
const queuePanel = barEl.querySelector('#musicBarQueue')!; const queuePanel = barEl.querySelector('#musicBarQueue')!;
const leaveBtn = barEl.querySelector('#leaveBtn')!; const leaveBtn = barEl.querySelector('#leaveBtn')!;
const trackProgress = barEl.querySelector('#trackProgress') as HTMLElement; 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); activeDriver = localDriver(audio);
audio.addEventListener('play', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(false); }); audio.addEventListener('play', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(false); });
audio.addEventListener('pause', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(true); }); audio.addEventListener('pause', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(true); });
playPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused)); playPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused));
miniPlayPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused));
voteSkipBtn.addEventListener('click', () => conn?.voteSkip()); voteSkipBtn.addEventListener('click', () => conn?.voteSkip());
toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide')); toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide'));
leaveBtn.addEventListener('click', () => leaveMusicParty()); 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 // 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 // seek bar, since a listen party has no host at all (see activeDriver's
// own comment) - anyone dragging this updates their own driver // own comment) - anyone dragging this updates their own driver
@ -172,8 +195,13 @@ function updatePositionDisplay(): void {
const currentTime = activeDriver!.currentTime; const currentTime = activeDriver!.currentTime;
const position = barEl!.querySelector('#trackPosition')!; const position = barEl!.querySelector('#trackPosition')!;
position.textContent = `${formatDuration(currentTime)} / ${formatDuration(currentDurationSeconds)}`; position.textContent = `${formatDuration(currentTime)} / ${formatDuration(currentDurationSeconds)}`;
const fill = barEl!.querySelector('#trackProgressFill') as HTMLElement; const percent = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%';
fill.style.width = 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!.classList.add('hide');
barEl!.querySelector('#musicBarQueue')!.classList.add('hide'); barEl!.querySelector('#musicBarQueue')!.classList.add('hide');
barEl!.querySelector('#musicBarPanel')!.classList.remove('open');
leaveParty(partyId).catch(() => {}); leaveParty(partyId).catch(() => {});
} }
@ -277,11 +306,36 @@ async function castTrack(title: string): Promise<void> {
* pause listeners already do (see initMusicBar), since casting here has * pause listeners already do (see initMusicBar), since casting here has
* no host gate to check first (see activeDriver's own comment). */ * no host gate to check first (see activeDriver's own comment). */
function onCastStateChanged(): void { function onCastStateChanged(): void {
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement; setPlayIcon(!activeDriver!.paused);
playPauseBtn.textContent = activeDriver!.paused ? '▶' : '⏸';
if (!suppressLocalEvents) conn?.setMusicPaused(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 * Starts casting - available to any listener, not just... there is no
* "just", a listen party has no host (see activeDriver's own comment). * "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. * (isCasting) delegated to castTrack/loadTrackLocal below.
*/ */
function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void { 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; currentItemId = track.itemId;
currentDurationSeconds = track.durationSeconds || 0; currentDurationSeconds = track.durationSeconds || 0;
trackImage.src = track.itemImageUrl || ''; setTrackMeta(track.itemImageUrl || '', track.itemName || '', [track.artistName, track.albumName].filter(Boolean).join(' - '));
trackImage.classList.remove('noImage');
trackName.textContent = track.itemName || '';
trackArtist.textContent = [track.artistName, track.albumName].filter(Boolean).join(' - ');
if (isCasting) { if (isCasting) {
castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => { castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => {
@ -442,33 +489,34 @@ function loadTrackLocal(track: MusicCurrentPublic, elapsedSeconds: number, isPau
*/ */
function updateVoteSkipLabel(): void { function updateVoteSkipLabel(): void {
const voteSkipBtn = barEl!.querySelector('#voteSkipBtn') as HTMLButtonElement; const voteSkipBtn = barEl!.querySelector('#voteSkipBtn') as HTMLButtonElement;
const voteBadge = barEl!.querySelector('#voteBadge') as HTMLElement;
const current = lastMusicState?.current; const current = lastMusicState?.current;
voteSkipBtn.disabled = !current; voteSkipBtn.disabled = !current;
if (!current) { if (!current) {
voteSkipBtn.textContent = 'Passer (vote)'; voteBadge.classList.add('hide');
return; return;
} }
const connectedCount = latestMembers.filter((m) => m.connected).length || 1; const connectedCount = latestMembers.filter((m) => m.connected).length || 1;
const needed = Math.floor(connectedCount / 2) + 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 { function applyMusicState(music: MusicPublicState): void {
lastMusicState = music; lastMusicState = music;
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement; const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
const miniPlayPauseBtn = barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement;
const castBtn = barEl!.querySelector('#castBtn') as HTMLButtonElement; const castBtn = barEl!.querySelector('#castBtn') as HTMLButtonElement;
const queueList = barEl!.querySelector('#queueList')!; 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; const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
// Queue/vote-button/play-icon updates happen first, unconditionally, // Queue/vote-button/play-icon updates happen first, unconditionally,
// deliberately BEFORE the track-loading branch below (see loadTrack's // deliberately BEFORE the track-loading branch below (see loadTrack's
// own comment on why - same ordering as Player.ts/the old MusicPlayer.ts). // 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; playPauseBtn.disabled = !music.current;
miniPlayPauseBtn.disabled = !music.current;
// Nothing for startCasting to actually cast with an empty queue - same // Nothing for startCasting to actually cast with an empty queue - same
// "disable, don't just silently no-op" treatment as playPauseBtn above. // "disable, don't just silently no-op" treatment as playPauseBtn above.
castBtn.disabled = !music.current && !isCasting; castBtn.disabled = !music.current && !isCasting;
@ -495,13 +543,12 @@ function applyMusicState(music: MusicPublicState): void {
if (!music.current) { if (!music.current) {
currentItemId = null; currentItemId = null;
trackName.textContent = 'Rien ne joue pour le moment.'; setTrackMeta('', 'Rien ne joue pour le moment.', '');
trackArtist.textContent = '';
trackImage.src = '';
if (hls) { hls.destroy(); hls = null; } if (hls) { hls.destroy(); hls = null; }
activeDriver!.pause(); activeDriver!.pause();
audio.removeAttribute('src'); // harmless no-op while casting - the local element is already srcless then, see startCasting 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('#trackProgressFill') as HTMLElement).style.width = '0%';
(barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = '0%';
} else if (music.current.itemId !== currentItemId) { } else if (music.current.itemId !== currentItemId) {
loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused); loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused);
} else { } else {

View file

@ -20,7 +20,28 @@
.trackIndex { color: var(--muted); font-size: 13px; text-align: center; } .trackIndex { color: var(--muted); font-size: 13px; text-align: center; }
.trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } .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) { @media (max-width: 600px) {
.trackRow { grid-template-columns: 24px 1fr; } .trackRow { grid-template-columns: 24px 1fr; }

View file

@ -135,3 +135,33 @@
} }
.tileBtn { font-size: 11px; padding: 6px 4px; } .tileBtn { font-size: 11px; padding: 6px 4px; }
} }
/* Artist/album tiles (tiles.ts's artistTileHtml/albumTileHtml) - an <a>
directly, not a <div> 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;
}

View file

@ -49,7 +49,15 @@ const DRILL_DOWN: Record<string, { route: string; label: string }> = {
Playlist: { route: 'playlist', label: 'Voir la playlist' } 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 drill = item.type ? DRILL_DOWN[item.type] : undefined;
const drillHref = drill ? `#/${drill.route}/${item.id}` : null; const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party'; const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
@ -66,6 +74,46 @@ export function tileHtml(item: ItemDto): string {
</div>`; </div>`;
} }
/**
* 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 `
<a href="#/artist/${item.id}" class="tile tileArtist" aria-label="${escapeHtml(item.name)}">
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<span class="tileLabel">${escapeHtml(item.name)}</span>
</a>`;
}
/** 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 `
<a href="#/album/${item.id}" class="tile tileAlbum" aria-label="${escapeHtml(item.name)}">
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
</a>`;
}
/**
* 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 * Wires up every "Lancer une watch party" button inside `container`, now
* and for any tile added to it later. `onStart(itemId)` does the actual * and for any tile added to it later. `onStart(itemId)` does the actual

View file

@ -2,10 +2,11 @@
<div class="albumHeader"> <div class="albumHeader">
<img class="albumCover" id="albumCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" /> <img class="albumCover" id="albumCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo"> <div class="seriesInfo">
<span class="heroKicker" id="albumKicker">Album</span>
<h1 id="albumTitle"></h1> <h1 id="albumTitle"></h1>
<p class="hint hide" id="albumArtist"></p> <p class="hint hide" id="albumArtist"></p>
<div class="playlistActions"> <div class="heroActions">
<button id="launchBtn">Écouter cet album</button> <button id="launchBtn" class="heroPlayBtn"><span class="heroPlayIcon" id="launchBtnIcon"></span><span id="launchBtnLabel">Écouter cet album</span></button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a> <a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div> </div>
</div> </div>

View file

@ -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 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 = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4.5v15l14-7.5Z" fill="currentColor"></path></svg>';
const PLUS_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"></line><line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"></line></svg>';
const CHECK_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12.5 9 17.5 20 6.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path></svg>';
/** Route `/album/:id` - the last level of the music hierarchy: one album's /** 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 * 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 * 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.innerHTML = html;
root.querySelector('#back')!.addEventListener('click', () => history.back()); root.querySelector('#back')!.addEventListener('click', () => history.back());
(root.querySelector('#albumCover') as HTMLImageElement).src = album.imageUrl; (root.querySelector('#albumCover') as HTMLImageElement).src = album.imageUrl;
root.querySelector('#albumTitle')!.innerHTML = album.year root.querySelector('#albumKicker')!.textContent = album.year ? `Album · ${album.year}` : 'Album';
? `${escapeHtml(album.name)} <span class="seriesYear">(${album.year})</span>` root.querySelector('#albumTitle')!.textContent = album.name;
: escapeHtml(album.name); root.querySelector('#launchBtnIcon')!.innerHTML = PLAY_ICON_SVG;
if (album.artistName) { if (album.artistName) {
const artistEl = root.querySelector('#albumArtist')!; const artistEl = root.querySelector('#albumArtist')!;
artistEl.textContent = album.artistName; artistEl.textContent = album.artistName;
@ -43,6 +50,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
const listEl = root.querySelector('#trackList')!; const listEl = root.querySelector('#trackList')!;
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement; const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
const launchBtnLabel = root.querySelector('#launchBtnLabel')!;
if (!tracks.length) { if (!tracks.length) {
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>'; listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
root.querySelector('#searchInput')!.remove(); root.querySelector('#searchInput')!.remove();
@ -50,12 +58,16 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
return; return;
} }
// Label reflects whether a listen party is already active *at render // aria-label reflects whether a listen party is already active *at
// time* - a harmless, occasionally-stale label (see refreshLabels() // render time* - a harmless, occasionally-stale label (see
// below for when it's corrected). The actual behavior on click always // refreshLabels() below for when it's corrected). The actual behavior
// re-checks getActiveMusicPartyId() fresh, so clicking never does the // on click always re-checks getActiveMusicPartyId() fresh, so clicking
// wrong thing even if the label itself hasn't caught up yet (e.g. // never does the wrong thing even if the label hasn't caught up yet
// someone hit "Quitter" on the bar while this page was already open). // (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 startLabel = 'Écouter ensemble';
const addLabel = 'Ajouter à la file'; const addLabel = 'Ajouter à la file';
const startAlbumLabel = 'Écouter cet album'; const startAlbumLabel = 'Écouter cet album';
@ -66,7 +78,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span> <span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span> <span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span> <span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span>
<button class="startPartyBtn" data-item-id="${t.id}">${getActiveMusicPartyId() ? addLabel : startLabel}</button> <button class="startPartyBtn trackIconBtn" data-item-id="${t.id}" title="${getActiveMusicPartyId() ? addLabel : startLabel}" aria-label="${getActiveMusicPartyId() ? addLabel : startLabel}">${getActiveMusicPartyId() ? PLUS_ICON_SVG : PLAY_ICON_SVG}</button>
</div> </div>
`).join(''); `).join('');
@ -88,9 +100,13 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
function refreshLabels(): void { function refreshLabels(): void {
const active = getActiveMusicPartyId(); const active = getActiveMusicPartyId();
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((b) => { listEl.querySelectorAll<HTMLButtonElement>('.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 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(); const activePartyId = getActiveMusicPartyId();
try { try {
if (activePartyId) { if (activePartyId) {
btn.textContent = 'Ajout...';
await addTrackToQueue(activePartyId, trackInputFor(track)); await addTrackToQueue(activePartyId, trackInputFor(track));
} else { } else {
btn.textContent = 'Démarrage...';
// Attaches the persistent bottom bar directly and stays right // Attaches the persistent bottom bar directly and stays right
// here - no navigate() - so adding a few more tracks from this // here - no navigate() - so adding a few more tracks from this
// same album is just clicking their buttons too. See // 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); const party = await createParty(track.id);
joinMusicParty(party.partyId, party); joinMusicParty(party.partyId, party);
} }
btn.textContent = 'Ajouté !'; btn.innerHTML = CHECK_ICON_SVG;
setTimeout(() => { btn.disabled = false; refreshLabels(); }, 1500); setTimeout(() => { btn.disabled = false; refreshLabels(); }, 1500);
} catch (err) { } catch (err) {
alert((err as Error).message); alert((err as Error).message);
@ -134,14 +148,14 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
const activePartyId = getActiveMusicPartyId(); const activePartyId = getActiveMusicPartyId();
try { try {
if (activePartyId) { if (activePartyId) {
launchBtn.textContent = 'Ajout...'; launchBtnLabel.textContent = 'Ajout...';
await queueItem(activePartyId, albumId); await queueItem(activePartyId, albumId);
} else { } else {
launchBtn.textContent = 'Démarrage...'; launchBtnLabel.textContent = 'Démarrage...';
const party = await createParty(albumId); const party = await createParty(albumId);
joinMusicParty(party.partyId, party); joinMusicParty(party.partyId, party);
} }
launchBtn.textContent = 'Ajouté !'; launchBtnLabel.textContent = 'Ajouté !';
setTimeout(() => { launchBtn.disabled = false; refreshLabels(); }, 1500); setTimeout(() => { launchBtn.disabled = false; refreshLabels(); }, 1500);
} catch (err) { } catch (err) {
alert((err as Error).message); alert((err as Error).message);

View file

@ -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; }
}

View file

@ -1,7 +1,14 @@
<button class="linklike" id="back">&larr; Retour</button> <button class="linklike" id="back">&larr; Retour</button>
<div class="pageHeader"> <div class="artistHero">
<img class="artistHeroPhoto" id="artistPhoto" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="artistHeroInfo">
<span class="heroKicker">Artiste</span>
<h1></h1> <h1></h1>
<button id="launchBtn">Écouter toute la discographie</button> <span class="hint" id="albumCount"></span>
<div class="heroActions">
<button id="launchBtn" class="heroPlayBtn"><span class="heroPlayIcon" id="launchBtnIcon"></span><span id="launchBtnLabel">Écouter toute la discographie</span></button>
</div>
</div>
</div> </div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un album..." /> <input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un album..." />
<p class="hint searchEmpty hide">Aucun résultat.</p> <p class="hint searchEmpty hide">Aucun résultat.</p>

View file

@ -1,3 +1,4 @@
import './Artist.css';
import html from './Artist.html'; import html from './Artist.html';
import { getArtistAlbums, getItem } from '../../api/embyApi'; import { getArtistAlbums, getItem } from '../../api/embyApi';
import { createParty, queueItem } from '../../api/party'; import { createParty, queueItem } from '../../api/party';
@ -6,6 +7,13 @@ import type { RouteParams } from '../../router';
import { tileHtml, wireSearchFilter } from '../../tiles'; import { tileHtml, wireSearchFilter } from '../../tiles';
import { LOADING_HINT } from '../../loading'; import { LOADING_HINT } from '../../loading';
// Same icon, same "small local constant" convention as musicBar/MusicBar.ts's
// own PLAY_ICON_SVG - not shared from there since neither file otherwise
// depends on the other, and duplicating one SVG string is cheaper than a
// shared icons module for exactly two current call sites (here and
// views/album/Album.ts).
const PLAY_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4.5v15l14-7.5Z" fill="currentColor"></path></svg>';
/** Route `/artist/:id` - one artist's albums, reached from a music /** Route `/artist/:id` - one artist's albums, reached from a music
* library's top-level grid (see views/library) via a MusicArtist tile's * library's top-level grid (see views/library) via a MusicArtist tile's
* "Voir les albums" link (tiles.ts's DRILL_DOWN table). Structurally the * "Voir les albums" link (tiles.ts's DRILL_DOWN table). Structurally the
@ -29,10 +37,14 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom
root.innerHTML = html; root.innerHTML = html;
root.querySelector('h1')!.textContent = artist.name; root.querySelector('h1')!.textContent = artist.name;
(root.querySelector('#artistPhoto') as HTMLImageElement).src = artist.imageUrl;
root.querySelector('#launchBtnIcon')!.innerHTML = PLAY_ICON_SVG;
root.querySelector('#back')!.addEventListener('click', () => history.back()); root.querySelector('#back')!.addEventListener('click', () => history.back());
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement; const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
const launchBtnLabel = root.querySelector('#launchBtnLabel')!;
const grid = root.querySelector('#grid')!; const grid = root.querySelector('#grid')!;
root.querySelector('#albumCount')!.textContent = `${albums.length} album${albums.length > 1 ? 's' : ''}`;
if (!albums.length) { if (!albums.length) {
grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>'; grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
root.querySelector('#searchInput')!.remove(); root.querySelector('#searchInput')!.remove();
@ -49,7 +61,7 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom
const startLabel = 'Écouter toute la discographie'; const startLabel = 'Écouter toute la discographie';
const addLabel = 'Ajouter la discographie à la file'; const addLabel = 'Ajouter la discographie à la file';
function refreshLabel(): void { function refreshLabel(): void {
if (!launchBtn.disabled) launchBtn.textContent = getActiveMusicPartyId() ? addLabel : startLabel; if (!launchBtn.disabled) launchBtnLabel.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
} }
refreshLabel(); refreshLabel();
@ -62,16 +74,16 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom
const activePartyId = getActiveMusicPartyId(); const activePartyId = getActiveMusicPartyId();
try { try {
if (activePartyId) { if (activePartyId) {
launchBtn.textContent = 'Ajout...'; launchBtnLabel.textContent = 'Ajout...';
await queueItem(activePartyId, artistId); await queueItem(activePartyId, artistId);
} else { } else {
launchBtn.textContent = 'Démarrage...'; launchBtnLabel.textContent = 'Démarrage...';
// Same "attach the bar, stay put" pattern as views/album - see // Same "attach the bar, stay put" pattern as views/album - see
// its own comment. // its own comment.
const party = await createParty(artistId); const party = await createParty(artistId);
joinMusicParty(party.partyId, party); joinMusicParty(party.partyId, party);
} }
launchBtn.textContent = 'Ajouté !'; launchBtnLabel.textContent = 'Ajouté !';
setTimeout(() => { launchBtn.disabled = false; refreshLabel(); }, 1500); setTimeout(() => { launchBtn.disabled = false; refreshLabel(); }, 1500);
} catch (err) { } catch (err) {
alert((err as Error).message); alert((err as Error).message);

View file

@ -3419,6 +3419,124 @@ tokens) rather than eyeballed. **Not verified**: an actual large
discography queuing correctly end to end against a real server - no discography queuing correctly end to end against a real server - no
Emby login reachable from this sandbox. Emby login reachable from this sandbox.
## Music UI rework: circles and squares instead of movie posters, a real now-playing screen
Asked for directly, "inspired by other apps": the music side of this
app reused `tiles.ts`'s poster tile (2:3, hover-revealed actions) for
artists and albums too, since it was already there for movies/shows -
functional, but a 2:3 box stretches an artist photo or a square album
cover oddly, and doesn't read as "this is music" at a glance the way a
circle/a square immediately does. Explored as mockups first (an
interactive artifact covering all four screens, mobile and desktop)
before writing any real code.
**Nothing invented that doesn't have a real data source behind it** -
no "popular tracks" (Emby has no listening-stats endpoint this app
uses), no "previous track" (there's no playback history, just the
shared queue), no favorites (no such concept server-side). Every
control below does exactly what its equivalent already did.
- **`tiles.ts`** - `tileHtml()` now dispatches on the item's Emby type:
`MusicArtist` gets a new `artistTileHtml()` (a circle, no hover
overlay - there's only ever the one action, open the artist page, so
the whole tile is just that link) and `MusicAlbum` gets
`albumTileHtml()` (a square, same "whole tile is the link"
reasoning). Dispatching inside the one shared function - not adding
a renderer parameter every call site has to pass - is what keeps the
home carousels, the library grid, and the artist page's own album
grid all rendering identically with zero changes to any of those
three call sites.
- **`views/artist`** - a real hero: circular photo, name, album count,
and a pill-shaped play button (a circular accent-colored icon badge
+ the existing text label, not icon-only - see the note below on
why). New `Artist.css` (this page had none before).
- **`views/album`** - the existing header already had the right shape
(a square cover, a flex row) - it keeps its own class names, just
gains the same pill play button and a `heroKicker` ("Album · 2023")
pulled out of the title instead of appended inline. Each track row's
wide text button ("Écouter ensemble"/"Ajouter à la file") becomes a
small circular icon (play, or a plus once a party's already active -
the same two states, just an icon swap instead of a label swap) -
with a whole album's worth of rows stacked, one word-length pill per
row read as noisier than the plain icon every real track list uses.
- **Explicit labels stay, deliberately, on the two hero play buttons**
(`heroPlayBtn`/`heroPlayIcon`, shared between the artist and album
pages) - a circular icon badge sits inside the button next to real
text, rather than the button being icon-only. This app already
standardized on icon-only for pure transport controls (Player.ts's
own play/pause/mute/fullscreen/cast) but explicit text for anything
request- or action-shaped (see the player-rework section above) -
starting or queueing a whole discography/album reads as the latter,
so the label stayed. The per-track buttons above are the one
deliberate exception - not because the convention changed, but
because a whole album's worth of them repeats the exact same action
as the hero button one row above, where the icon alone is
unambiguous in context.
- **`musicBar/MusicBar`** - the biggest change, restructured without
touching any of its existing sync/cast/HLS logic:
- `#playPauseBtn`/`#voteSkipBtn`/`#toggleQueueBtn`/`#leaveBtn` all
move from `.linklike` text (`"Passer (vote) - 2/3"`,
`"File d'attente"`, `"Quitter"`) to icon buttons, matching
`#castBtn`'s own pre-existing icon treatment instead of standing
out as the only text links in the bar. The vote tally becomes a
small badge (`#voteBadge`) on the skip icon instead of appended
text.
- New `#mobileMiniBar` - a collapsed preview (art, name/artist, a big
play button, a thin passive progress line) that's `display: none`
on desktop, where `#musicBarPanel` below is already always
visible - nothing to collapse into a preview of there.
- `#musicBarPanel` now wraps everything that used to be
`.musicBarRow` - on desktop it's the same always-visible bar,
restyled as a 3-column grid (track info / centered transport /
presence+cast+leave) instead of one wrapping flex row. On mobile
it becomes a full-screen "now playing" overlay (`position: fixed;
inset: 0`, sliding up via `transform`), opened by tapping
`#mobileMiniBar` and closed via a new `#panelCloseBtn` - the actual
mobile screen this app was missing entirely, not just a resized
version of the bar.
- `#trackProgress` (the existing thin top-edge seek strip) didn't
move or change shape at all - it was already the "thin bar along
the top" treatment asked for, just needed to not be reinvented as
a different, boxed, labeled scrub bar for desktop specifically
(an earlier mockup draft did exactly that before this same
feedback landed and got fixed there first).
- `MusicBar.css`'s `.musicBar` no longer positions itself at all -
unchanged from the mobile-nav work above, `.bottomStack` owns the
`position: fixed` wrapping so `#mobileMiniBar` always sits flush
above whatever height the (now-hidden-behind-it-when-collapsed)
panel would otherwise occupy.
**A real bug caught by verifying computed styles, not just reading the
CSS**: `.musicBarPlayBig` (the big circular play button, shared between
`#playPauseBtn` and `#miniPlayPauseBtn`) was silently losing its own
background/border-radius to `.iconBtn`'s (`Player.css`) at equal
selector specificity - which one wins depends on which file esbuild
happens to bundle later, not on anything either file's author controls.
Fixed by qualifying the selector as `.iconBtn.musicBarPlayBig` (both
classes are always present together on these two buttons), whose
higher specificity wins regardless of bundle order. Caught by checking
`getComputedStyle` in a real browser against the real compiled
`app.css`, not by re-reading the source and assuming the declared rule
wins - it visibly didn't, rendering as a plain 6px-radius transparent
button instead of a filled circle.
Verified: full `typecheck`/`build` (client only). Real compiled
`app.css` + the real `Shell.html`/`MusicBar.html` markup, driven with
fake artist/album/track data (no Emby login reachable from this
sandbox): circular vs square tiles and their exact aspect-ratio/
border-radius confirmed via `getComputedStyle`, the per-track icon
swap (play vs plus) confirmed per row, and `#mobileMiniBar`'s open/
close/guard-against-tapping-the-play-button behavior all driven
end to end and confirmed via real click events - not just read back
as source. This sandbox's browser tool's own viewport emulation
stayed unreliable for the rest of this session (`innerWidth` didn't
track a requested resize), so the mobile-width visual checks were
done the same way as the mobile-nav work above: forcing the mobile
media query's declarations through an injected override rather than a
genuinely narrow viewport. **Not verified**: any of this against a
real Emby server's actual artist/album images and names, or on a real
phone.
## Known limitations ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's