Compare commits

..

No commits in common. "2772cc70d6ea3f739462e9e279c00e6e9133a7b5" and "e9b6a2541c1b2a24a5cb7c2368c3f90611f23ebb" have entirely different histories.

13 changed files with 83 additions and 701 deletions

View file

@ -354,10 +354,6 @@ Then put it behind your existing reverse proxy like everything else
- [x] Mobile navigation redesigned as a bottom sheet (Accueil / - [x] Mobile navigation redesigned as a bottom sheet (Accueil /
Bibliothèques / Listes de lecture) instead of a horizontally Bibliothèques / Listes de lecture) instead of a horizontally
scrolling sidebar — see docs/architecture.md scrolling sidebar — 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,97 +50,12 @@
pointer-events: none; pointer-events: none;
} }
/* ---- Collapsed mobile preview (see MusicBar.html) - display:none by .musicBarRow {
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;
min-width: 0; padding: 8px 16px;
} 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
@ -180,60 +95,6 @@
.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
@ -274,62 +135,9 @@
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.mobileMiniBar { display: flex; } .musicBarRow { justify-content: center; }
.musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; }
/* Same fixed-overlay/slide-up-from-bottom pattern as Shell.css's .musicBarQueue { margin: 0; border-radius: 0; }
.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,59 +5,25 @@
<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>
<!-- Mobile-only collapsed preview (see MusicBar.css's 720px breakpoint) - <!-- Click/drag to seek - see MusicBar.ts's pointerdown/move/up handlers.
#musicBarPanel below is already always visible on desktop, so this No host check needed to enable this, unlike the video player's own
only ever renders there. Tapping anywhere on it except the play seek bar: a listen party has no host at all, and a seek here gets
button opens the panel - see MusicBar.ts. --> the exact same "anyone can do it" treatment play/pause already has
<div id="mobileMiniBar" class="mobileMiniBar"> (see MusicQueueService.seek's own comment for why that's the right
<div class="mobileMiniProgress"><div id="miniProgressFill" class="mobileMiniProgressFill"></div></div> model for this, not voteSkip's majority gate). -->
<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>
<!-- Always in place on desktop (the bar itself, unconditionally visible
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 id="trackProgress" class="musicBarProgressTrack"><div id="trackProgressFill" class="musicBarProgressFill"></div></div>
<div class="musicBarPanelTrack"> <div class="musicBarRow">
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div>
<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>
</div> <button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button>
<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>
@ -66,9 +32,8 @@
<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="iconBtn" id="leaveBtn" title="Quitter" aria-label="Quitter"> <button class="linklike" id="voteSkipBtn">Passer (vote)</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="toggleQueueBtn">File d'attente</button>
</button> <button class="linklike" id="leaveBtn">Quitter</button>
</div>
</div> </div>
<audio id="barAudio"></audio> <audio id="barAudio"></audio>

View file

@ -34,12 +34,6 @@ 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
@ -100,39 +94,22 @@ 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
@ -195,13 +172,8 @@ 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 percent = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%'; const fill = barEl!.querySelector('#trackProgressFill') as HTMLElement;
(barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = percent; fill.style.width = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%';
// 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;
} }
/** /**
@ -272,7 +244,6 @@ 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(() => {});
} }
@ -306,36 +277,11 @@ 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 {
setPlayIcon(!activeDriver!.paused); const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
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).
@ -409,9 +355,16 @@ 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;
setTrackMeta(track.itemImageUrl || '', track.itemName || '', [track.artistName, track.albumName].filter(Boolean).join(' - ')); trackImage.src = track.itemImageUrl || '';
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) => {
@ -489,34 +442,33 @@ 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) {
voteBadge.classList.add('hide'); voteSkipBtn.textContent = 'Passer (vote)';
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;
voteBadge.textContent = `${lastMusicState!.skipVotes}/${needed}`; voteSkipBtn.textContent = `Passer (vote) - ${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).
setPlayIcon(Boolean(music.current && !music.current.isPaused)); playPauseBtn.textContent = 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;
@ -543,12 +495,13 @@ function applyMusicState(music: MusicPublicState): void {
if (!music.current) { if (!music.current) {
currentItemId = null; currentItemId = null;
setTrackMeta('', 'Rien ne joue pour le moment.', ''); trackName.textContent = '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,28 +20,7 @@
.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,33 +135,3 @@
} }
.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,15 +49,7 @@ 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';
@ -74,46 +66,6 @@ function posterTileHtml(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,11 +2,10 @@
<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="heroActions"> <div class="playlistActions">
<button id="launchBtn" class="heroPlayBtn"><span class="heroPlayIcon" id="launchBtnIcon"></span><span id="launchBtnLabel">Écouter cet album</span></button> <button id="launchBtn">Écouter cet album</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,13 +11,6 @@ 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
@ -38,9 +31,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('#albumKicker')!.textContent = album.year ? `Album · ${album.year}` : 'Album'; root.querySelector('#albumTitle')!.innerHTML = album.year
root.querySelector('#albumTitle')!.textContent = album.name; ? `${escapeHtml(album.name)} <span class="seriesYear">(${album.year})</span>`
root.querySelector('#launchBtnIcon')!.innerHTML = PLAY_ICON_SVG; : escapeHtml(album.name);
if (album.artistName) { if (album.artistName) {
const artistEl = root.querySelector('#albumArtist')!; const artistEl = root.querySelector('#albumArtist')!;
artistEl.textContent = album.artistName; artistEl.textContent = album.artistName;
@ -50,7 +43,6 @@ 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();
@ -58,16 +50,12 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
return; return;
} }
// aria-label reflects whether a listen party is already active *at // Label reflects whether a listen party is already active *at render
// render time* - a harmless, occasionally-stale label (see // time* - a harmless, occasionally-stale label (see refreshLabels()
// refreshLabels() below for when it's corrected). The actual behavior // below for when it's corrected). The actual behavior on click always
// on click always re-checks getActiveMusicPartyId() fresh, so clicking // re-checks getActiveMusicPartyId() fresh, so clicking never does the
// never does the wrong thing even if the label hasn't caught up yet // wrong thing even if the label itself hasn't caught up yet (e.g.
// (e.g. someone hit "Quitter" on the bar while this page was already // someone hit "Quitter" on the bar while this page was already open).
// 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';
@ -78,7 +66,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 trackIconBtn" data-item-id="${t.id}" title="${getActiveMusicPartyId() ? addLabel : startLabel}" aria-label="${getActiveMusicPartyId() ? addLabel : startLabel}">${getActiveMusicPartyId() ? PLUS_ICON_SVG : PLAY_ICON_SVG}</button> <button class="startPartyBtn" data-item-id="${t.id}">${getActiveMusicPartyId() ? addLabel : startLabel}</button>
</div> </div>
`).join(''); `).join('');
@ -100,13 +88,9 @@ 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) return; if (!b.disabled) b.textContent = active ? addLabel : startLabel;
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) launchBtnLabel.textContent = active ? addAlbumLabel : startAlbumLabel; if (!launchBtn.disabled) launchBtn.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
@ -117,8 +101,10 @@ 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
@ -127,7 +113,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.innerHTML = CHECK_ICON_SVG; btn.textContent = 'Ajouté !';
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);
@ -148,14 +134,14 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
const activePartyId = getActiveMusicPartyId(); const activePartyId = getActiveMusicPartyId();
try { try {
if (activePartyId) { if (activePartyId) {
launchBtnLabel.textContent = 'Ajout...'; launchBtn.textContent = 'Ajout...';
await queueItem(activePartyId, albumId); await queueItem(activePartyId, albumId);
} else { } else {
launchBtnLabel.textContent = 'Démarrage...'; launchBtn.textContent = 'Démarrage...';
const party = await createParty(albumId); const party = await createParty(albumId);
joinMusicParty(party.partyId, party); joinMusicParty(party.partyId, party);
} }
launchBtnLabel.textContent = 'Ajouté !'; launchBtn.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

@ -1,89 +0,0 @@
/* ---- 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,14 +1,7 @@
<button class="linklike" id="back">&larr; Retour</button> <button class="linklike" id="back">&larr; Retour</button>
<div class="artistHero"> <div class="pageHeader">
<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>
<span class="hint" id="albumCount"></span> <button id="launchBtn">Écouter toute la discographie</button>
<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,4 +1,3 @@
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';
@ -7,13 +6,6 @@ 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
@ -37,14 +29,10 @@ 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();
@ -61,7 +49,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) launchBtnLabel.textContent = getActiveMusicPartyId() ? addLabel : startLabel; if (!launchBtn.disabled) launchBtn.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
} }
refreshLabel(); refreshLabel();
@ -74,16 +62,16 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom
const activePartyId = getActiveMusicPartyId(); const activePartyId = getActiveMusicPartyId();
try { try {
if (activePartyId) { if (activePartyId) {
launchBtnLabel.textContent = 'Ajout...'; launchBtn.textContent = 'Ajout...';
await queueItem(activePartyId, artistId); await queueItem(activePartyId, artistId);
} else { } else {
launchBtnLabel.textContent = 'Démarrage...'; launchBtn.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);
} }
launchBtnLabel.textContent = 'Ajouté !'; launchBtn.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

@ -3657,124 +3657,6 @@ above was done by forcing the same CSS rules through an injected
override instead of a real ≤720px viewport. Worth an early real-phone override instead of a real ≤720px viewport. Worth an early real-phone
check once deployed. check once deployed.
## 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