Merge pull request 'Rework the music UI: circles/squares instead of movie posters, a real now-playing screen' (#11) from feature/music-ui into master
This commit is contained in:
commit
2772cc70d6
13 changed files with 700 additions and 82 deletions
|
|
@ -354,6 +354,10 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
<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="musicBarRow">
|
<div class="musicBarPanelTrack">
|
||||||
<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>
|
||||||
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">▶</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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
89
client/src/views/artist/Artist.css
Normal file
89
client/src/views/artist/Artist.css
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
<button class="linklike" id="back">← Retour</button>
|
<button class="linklike" id="back">← 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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -3657,6 +3657,124 @@ 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue