feat(explore): arrows on every sideways row, and one album card size
The shelves, the top results and the artist page's discography and similar-artists rows scrolled sideways only by a horizontal wheel or a trackpad, so a plain mouse could not reach anything past the fold. <scroll-row> wraps each of them with previous/next arrows that are hidden at the end they cannot move from, revealed on hover where there is hover, and always shown where there is not. The album card is defined once, in albumCardStyles, instead of twice. explore-view clamped its cards to 130-150px, and with square artwork that made cards in one row different heights as well as widths. The width is fixed now and each line under the art reserves its own space. Covers are inset rather than cropped (contain, not cover). The ownership badge sits over the artwork for owned and unowned alike, and catalog cards no longer dim: a grid of dimmed covers read as a page that had failed to load. The album page's tracklist still dims unowned rows, which says something different about something different. Every MusicBrainz link now goes through openMusicBrainz, which pins the origin, including the one on the album page that still called window.open directly. solid/chevron-left is bundled for the left arrow; without it the arrow drew the missing-icon fallback. Closes #264 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z"/></svg>
|
||||
|
After Width: | Height: | Size: 476 B |
@@ -60,6 +60,7 @@ import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||
import { dictByName } from '@utils/binding';
|
||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
|
||||
import { openMusicBrainz } from '@utils/external-link';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
import {
|
||||
ICON_CAN_REQUEST,
|
||||
@@ -2942,7 +2943,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
|
||||
if (!track?.mbid) return;
|
||||
|
||||
window.open(`https://musicbrainz.org/recording/${track.mbid}`, '_blank', 'noopener');
|
||||
openMusicBrainz(`/recording/${track.mbid}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,8 @@ import { customElement, property, state, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { backButton } from '../../styles/back-button.css';
|
||||
import { albumCardStyles } from '../../styles/album-card.css';
|
||||
import '../scroll-row/scroll-row.js';
|
||||
import {
|
||||
LookupArtist,
|
||||
BrowseReleaseGroups,
|
||||
@@ -46,11 +48,8 @@ import {
|
||||
libraryStatusFor,
|
||||
toggleRequest,
|
||||
} from '@utils/library-status';
|
||||
import {
|
||||
isOwned,
|
||||
ownershipLabel,
|
||||
unownedStyles,
|
||||
} from '@utils/ownership';
|
||||
import { isOwned, ownershipLabel } from '@utils/ownership';
|
||||
import { openMusicBrainz } from '@utils/external-link';
|
||||
import { completenessStore } from '@store/completeness-store';
|
||||
import '../catalog-scope-notice/catalog-scope-notice.js';
|
||||
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
||||
@@ -62,6 +61,7 @@ import {
|
||||
ContextMenuController,
|
||||
contextMenuStyles,
|
||||
isContextMenuKey,
|
||||
MenuKeyboard,
|
||||
} from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
@@ -187,11 +187,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
@state() private topReleasesExpanded = false;
|
||||
private topSectionStacked = false;
|
||||
private topSectionObserver?: ResizeObserver;
|
||||
@state() private expandedDiscoGroups = new Set<string>();
|
||||
/** Number of album cards that fit in one row of the discography grid. */
|
||||
@state() private discoRowSize = 5;
|
||||
private discoObserver?: ResizeObserver;
|
||||
@state() private similarExpanded = false;
|
||||
|
||||
/** Whether the Play button's Shuffle dropdown is up. */
|
||||
@state() private playMenuOpen = false;
|
||||
private playMenuKeyboard = new MenuKeyboard(() => this.closePlayMenu());
|
||||
private playOutsideAttached = false;
|
||||
|
||||
/* ── Release prefetch ── */
|
||||
|
||||
@@ -221,6 +221,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
@query('#context-menu')
|
||||
private contextMenuPopup!: MenuSurface;
|
||||
|
||||
@query('.play-menu-button')
|
||||
private playMenuButton?: HTMLButtonElement;
|
||||
|
||||
@query('#artist-play-menu')
|
||||
private playMenuPanel?: HTMLElement;
|
||||
|
||||
@query('#playlist-submenu')
|
||||
private playlistSubmenuPopup?: WaPopup;
|
||||
|
||||
@@ -271,7 +277,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
backButton,
|
||||
exploreLinkStyles,
|
||||
contextMenuStyles,
|
||||
unownedStyles,
|
||||
albumCardStyles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
@@ -319,10 +325,45 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.artist-follow {
|
||||
.artist-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* The Play button and its caret are one control, so they
|
||||
are one box: no gap between them, and the caret carries
|
||||
the same filled appearance as the button it extends. */
|
||||
.play-split {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.play-menu-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.25);
|
||||
border-radius: 0 6px 6px 0;
|
||||
background: var(--yj-accent, #ffd43b);
|
||||
color: var(--yj-accent-fg, #000);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.play-menu-button:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.play-menu-button:focus-visible {
|
||||
outline: 2px solid var(--yj-accent-text, #ffd43b);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.artist-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -331,7 +372,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
}
|
||||
|
||||
.artist-title {
|
||||
font-size: 24px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
white-space: nowrap;
|
||||
@@ -359,6 +400,13 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* The listen count is a headline number, not metadata, so
|
||||
it sits a size above the type/country line. */
|
||||
.artist-listens {
|
||||
font-size: var(--yj-text-lg);
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.meta-separator {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -446,23 +494,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.artist-play-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.track-rank {
|
||||
width: 24px;
|
||||
text-align: right;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-md);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-art {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -489,6 +520,52 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Play where you own the track, the request badge where you
|
||||
do not — over the artwork rather than at the end of the
|
||||
row, where it was a badge beside a row you can already
|
||||
double-click. */
|
||||
.track-art-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
}
|
||||
|
||||
.track-art-play {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.track-item:hover .track-art-overlay,
|
||||
.track-item:focus-within .track-art-overlay {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* No hover means no double-click either, so the overlay is
|
||||
the only route to playing a top track and must be there. */
|
||||
@media not all and (hover: hover) {
|
||||
.track-art-overlay {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.track-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -522,7 +599,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
.track-item library-status-indicator {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Top section (tracks + releases side-by-side) ── */
|
||||
.top-section-wrapper {
|
||||
container-type: inline-size;
|
||||
@@ -754,8 +830,30 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.top-release-meta library-status-indicator {
|
||||
flex-shrink: 0;
|
||||
.top-release-art .album-card-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.top-release-card:hover .album-card-badge,
|
||||
.top-release-card:focus-within .album-card-badge {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media not all and (hover: hover) {
|
||||
.top-release-art .album-card-badge {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -773,150 +871,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.album-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 140px);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.album-grid.collapsed {
|
||||
grid-template-rows: 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.disco-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
margin-top: 4px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-size: var(--yj-text-xs);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.disco-toggle:hover {
|
||||
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.1));
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.disco-toggle wa-icon {
|
||||
font-size: 11px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.disco-toggle[aria-expanded='true'] wa-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.album-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
background: var(
|
||||
--yj-bg-overlay,
|
||||
rgba(255, 255, 255, 0.06)
|
||||
);
|
||||
}
|
||||
|
||||
.album-card:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.album-art-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.album-art-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.album-art-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.album-art-fallback wa-icon {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-weight: 500;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
font-size: var(--yj-text-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-xs);
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.album-meta-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.album-meta library-status-indicator {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Similar artists ── */
|
||||
.similar-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 140px);
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.similar-row.collapsed {
|
||||
grid-template-rows: 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.similar-artist-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -926,6 +881,9 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
width: 120px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -1056,7 +1014,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
this.unsubSimilarReady?.();
|
||||
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
|
||||
this.topSectionObserver?.disconnect();
|
||||
this.discoObserver?.disconnect();
|
||||
this.detachPlayOutsideClose();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1083,17 +1041,14 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
|
||||
protected override firstUpdated() {
|
||||
this.observeTopSectionWidth();
|
||||
this.observeDiscoWidth();
|
||||
}
|
||||
|
||||
protected override updated() {
|
||||
// Re-attach observers if elements appeared after initial render.
|
||||
// Re-attach the observer if the section appeared after initial
|
||||
// render.
|
||||
if (!this.topSectionObserver) {
|
||||
this.observeTopSectionWidth();
|
||||
}
|
||||
if (!this.discoObserver) {
|
||||
this.observeDiscoWidth();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1126,32 +1081,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
this.topSectionObserver.observe(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the .content width and compute how many album cards
|
||||
* fit in one row of the discography grid.
|
||||
* Grid uses: repeat(auto-fill, minmax(140px, 1fr)) with 16px gap
|
||||
* and album-card has 8px padding on each side.
|
||||
*/
|
||||
private observeDiscoWidth() {
|
||||
const content = this.renderRoot.querySelector('.content');
|
||||
if (!content) return;
|
||||
|
||||
const CARD_MIN = 140;
|
||||
const GAP = 16;
|
||||
|
||||
this.discoObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const width = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
||||
const cols = Math.max(1, Math.floor((width + GAP) / (CARD_MIN + GAP)));
|
||||
if (cols !== this.discoRowSize) {
|
||||
this.discoRowSize = cols;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.discoObserver.observe(content);
|
||||
}
|
||||
|
||||
/* ── Data Loading ── */
|
||||
|
||||
private async loadAllData() {
|
||||
@@ -1862,6 +1791,8 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
} catch {
|
||||
// No image — letter avatar stays.
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1999,6 +1930,65 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Play / Shuffle split button ── */
|
||||
|
||||
/**
|
||||
* Open the Play button's Shuffle dropdown.
|
||||
*
|
||||
* `page-header`'s overflow menu one control over: the same
|
||||
* `MenuKeyboard`, the same document-level outside-close, and the
|
||||
* same `menu-surface`, so the phone gets the bottom sheet rather
|
||||
* than a popup that Chrome 113 clips.
|
||||
*/
|
||||
private togglePlayMenu = (): void => {
|
||||
if (this.playMenuOpen) {
|
||||
this.closePlayMenu();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.playMenuOpen = true;
|
||||
|
||||
void this.updateComplete.then(() => {
|
||||
if (!this.playMenuOpen) return;
|
||||
|
||||
this.playMenuKeyboard.open(
|
||||
this.playMenuPanel ?? null,
|
||||
this.playMenuButton ?? null,
|
||||
);
|
||||
this.attachPlayOutsideClose();
|
||||
});
|
||||
};
|
||||
|
||||
private closePlayMenu = (): void => {
|
||||
if (!this.playMenuOpen) return;
|
||||
|
||||
this.detachPlayOutsideClose();
|
||||
this.playMenuKeyboard.close();
|
||||
this.playMenuOpen = false;
|
||||
};
|
||||
|
||||
private onPlayOutsideDown = (e: Event): void => {
|
||||
if (e.composedPath().includes(this.playMenuPanel as EventTarget)) return;
|
||||
if (e.composedPath().includes(this.playMenuButton as EventTarget)) return;
|
||||
|
||||
this.closePlayMenu();
|
||||
};
|
||||
|
||||
private attachPlayOutsideClose(): void {
|
||||
if (this.playOutsideAttached) return;
|
||||
|
||||
this.playOutsideAttached = true;
|
||||
document.addEventListener('mousedown', this.onPlayOutsideDown, true);
|
||||
}
|
||||
|
||||
private detachPlayOutsideClose(): void {
|
||||
if (!this.playOutsideAttached) return;
|
||||
|
||||
this.playOutsideAttached = false;
|
||||
document.removeEventListener('mousedown', this.onPlayOutsideDown, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* File path for one top track, resolved by recording MBID — the
|
||||
* same key `localId` was set from. Works whether or not the
|
||||
@@ -2240,11 +2230,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
|
||||
if (!release?.mbid) return;
|
||||
|
||||
window.open(
|
||||
`https://musicbrainz.org/release-group/${release.mbid}`,
|
||||
'_blank',
|
||||
'noopener',
|
||||
);
|
||||
openMusicBrainz(`/release-group/${release.mbid}`);
|
||||
}
|
||||
|
||||
private onContextMenuAction(
|
||||
@@ -2358,7 +2344,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
|
||||
if (!track?.recordingMbid) return;
|
||||
|
||||
window.open(`https://musicbrainz.org/recording/${track.recordingMbid}`, '_blank', 'noopener');
|
||||
openMusicBrainz(`/recording/${track.recordingMbid}`);
|
||||
}
|
||||
|
||||
/* ── Navigation ── */
|
||||
@@ -2538,10 +2524,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
: nothing}
|
||||
${this.renderArtistMeta()}
|
||||
${this.artist?.popularity && this.artist.popularity > 0
|
||||
? html`<span class="artist-meta">${formatListenCount(this.artist.popularity)} plays on ListenBrainz</span>`
|
||||
? html`<span class="artist-listens">${formatListenCount(this.artist.popularity)} plays on ListenBrainz</span>`
|
||||
: nothing}
|
||||
${this.renderPlayLibraryAction()}
|
||||
${this.renderFollowAction()}
|
||||
<div class="artist-actions">
|
||||
${this.renderPlayLibraryAction()}
|
||||
${this.renderFollowAction()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
@@ -2572,25 +2560,53 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
if (this.ownedLocalAlbumIds().length === 0) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="artist-play-actions">
|
||||
<div class="play-split">
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="filled"
|
||||
data-testid="artist-play-library"
|
||||
title="Play library tracks"
|
||||
@click=${() => void this.playLibraryTracks(false)}
|
||||
>
|
||||
<wa-icon slot="start" name="play"></wa-icon>
|
||||
Play library tracks
|
||||
Play
|
||||
</wa-button>
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="outlined"
|
||||
data-testid="artist-shuffle-library"
|
||||
@click=${() => void this.playLibraryTracks(true)}
|
||||
<menu-surface
|
||||
placement="bottom-start"
|
||||
.active=${this.playMenuOpen}
|
||||
@menu-dismiss=${this.closePlayMenu}
|
||||
>
|
||||
<wa-icon slot="start" name="shuffle"></wa-icon>
|
||||
Shuffle
|
||||
</wa-button>
|
||||
<button
|
||||
slot="anchor"
|
||||
class="play-menu-button"
|
||||
type="button"
|
||||
data-testid="artist-play-menu"
|
||||
aria-label="More play options"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded=${this.playMenuOpen ? 'true' : 'false'}
|
||||
aria-controls="artist-play-menu"
|
||||
@click=${this.togglePlayMenu}
|
||||
>
|
||||
<wa-icon name="chevron-down"></wa-icon>
|
||||
</button>
|
||||
<div
|
||||
id="artist-play-menu"
|
||||
class="context-menu-panel"
|
||||
role="menu"
|
||||
aria-label="Play options"
|
||||
>
|
||||
<wa-dropdown-item
|
||||
data-testid="artist-shuffle-library"
|
||||
@click=${() => {
|
||||
this.closePlayMenu();
|
||||
void this.playLibraryTracks(true);
|
||||
}}
|
||||
>
|
||||
<wa-icon slot="icon" name="shuffle"></wa-icon>
|
||||
Shuffle
|
||||
</wa-dropdown-item>
|
||||
</div>
|
||||
</menu-surface>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -2786,25 +2802,27 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
const request = downloadStore.requestFor(this.artistMBID);
|
||||
|
||||
return html`
|
||||
<div class="artist-follow">
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance=${request ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleFollow(request?.id)}
|
||||
>
|
||||
<!-- This was bookmark-check, which is not in
|
||||
names.txt and so has rendered the missing-icon
|
||||
fallback — a circled question mark — on every
|
||||
followed artist since it was written. A
|
||||
backtick around that name would end this
|
||||
template literal, which is why there is none. -->
|
||||
<wa-icon
|
||||
slot="start"
|
||||
name=${request ? ICON_REQUESTED : ICON_CAN_REQUEST}
|
||||
></wa-icon>
|
||||
${request ? 'Following' : 'Follow for new releases'}
|
||||
</wa-button>
|
||||
</div>
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance=${request ? 'filled' : 'outlined'}
|
||||
data-testid="artist-follow"
|
||||
title=${request
|
||||
? 'Following this artist'
|
||||
: 'Follow this artist for new releases'}
|
||||
@click=${() => void this.toggleFollow(request?.id)}
|
||||
>
|
||||
<!-- This was bookmark-check, which is not in
|
||||
names.txt and so has rendered the missing-icon
|
||||
fallback — a circled question mark — on every
|
||||
followed artist since it was written. A
|
||||
backtick around that name would end this
|
||||
template literal, which is why there is none. -->
|
||||
<wa-icon
|
||||
slot="start"
|
||||
name=${request ? ICON_REQUESTED : ICON_CAN_REQUEST}
|
||||
></wa-icon>
|
||||
${request ? 'Following' : 'Follow'}
|
||||
</wa-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2888,16 +2906,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
this.topReleasesExpanded = !this.topReleasesExpanded;
|
||||
}
|
||||
|
||||
private toggleDiscoGroup(type: string) {
|
||||
const next = new Set(this.expandedDiscoGroups);
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
this.expandedDiscoGroups = next;
|
||||
}
|
||||
|
||||
private renderTopSection() {
|
||||
const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
|
||||
const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0;
|
||||
@@ -2971,6 +2979,31 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
}} />`
|
||||
: html`<wa-icon name="compact-disc"></wa-icon>`;
|
||||
})()}
|
||||
<!-- Over the artwork, not beside the
|
||||
row: play where you own it, the
|
||||
request badge where you do not. -->
|
||||
<div class="track-art-overlay">
|
||||
${owned
|
||||
? html`<button
|
||||
class="track-art-play"
|
||||
type="button"
|
||||
aria-label=${`Play ${t.trackName}`}
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
void this.playTrack(t);
|
||||
}}
|
||||
>
|
||||
<wa-icon name="play"></wa-icon>
|
||||
</button>`
|
||||
: html`<library-status-indicator
|
||||
status=${libraryStatusFor(false, t.recordingMbid)}
|
||||
entity-type="track"
|
||||
label=${t.trackName}
|
||||
request-mbid=${t.recordingMbid}
|
||||
request-artist=${t.artistName ?? ''}
|
||||
size="18"
|
||||
></library-status-indicator>`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-info">
|
||||
<div class="track-title">${trackLink(t.trackName, t.releaseName, t.releaseGroupMbid ?? '', t.recordingMbid)}</div>
|
||||
@@ -2979,15 +3012,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
<span class="track-listens">
|
||||
${formatListenCount(t.totalListenCount)} plays
|
||||
</span>
|
||||
${owned
|
||||
? nothing
|
||||
: html`<library-status-indicator
|
||||
status=${libraryStatusFor(false, t.recordingMbid)}
|
||||
entity-type="track"
|
||||
label=${t.trackName}
|
||||
request-mbid=${t.recordingMbid}
|
||||
request-artist=${t.artistName ?? ''}
|
||||
></library-status-indicator>`}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
@@ -3093,6 +3117,18 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
<div class="album-art-fallback" style="${artURL ? 'display: none' : ''}">
|
||||
<wa-icon name="compact-disc"></wa-icon>
|
||||
</div>
|
||||
<div class="album-card-badge">
|
||||
<library-status-indicator
|
||||
status=${badge.status}
|
||||
owned=${badge.owned}
|
||||
expected=${badge.expected}
|
||||
entity-type="album"
|
||||
label=${rg.title}
|
||||
request-mbid=${rg.releaseGroupMbid}
|
||||
request-artist=${this.artist?.name ?? ''}
|
||||
size="21"
|
||||
></library-status-indicator>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-release-text">
|
||||
<div class="top-release-title" title="${rg.title}">
|
||||
@@ -3102,18 +3138,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
<div class="top-release-meta-text">
|
||||
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
|
||||
</div>
|
||||
${badge.status === 'in-library'
|
||||
? nothing
|
||||
: html`<library-status-indicator
|
||||
status=${badge.status}
|
||||
owned=${badge.owned}
|
||||
expected=${badge.expected}
|
||||
entity-type="album"
|
||||
label=${rg.title}
|
||||
request-mbid=${rg.releaseGroupMbid}
|
||||
request-artist=${this.artist?.name ?? ''}
|
||||
size="18"
|
||||
></library-status-indicator>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3164,37 +3188,16 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
<section>
|
||||
<h3 class="section-header">Discography</h3>
|
||||
${groups.map(
|
||||
(g) => {
|
||||
const isExpanded = this.expandedDiscoGroups.has(g.type);
|
||||
const rowSize = this.discoRowSize;
|
||||
const showToggle = g.items.length > rowSize;
|
||||
const visibleItems = isExpanded ? g.items : g.items.slice(0, rowSize);
|
||||
|
||||
return html`
|
||||
<div class="disco-group">
|
||||
<h4 class="disco-type-header">
|
||||
${g.type === 'Other' ? 'Other Releases' : g.type.endsWith('s') ? g.type : `${g.type}s`}
|
||||
</h4>
|
||||
<div class="album-grid">
|
||||
${visibleItems.map((rg) => this.renderAlbumCard(rg))}
|
||||
</div>
|
||||
${showToggle
|
||||
? html`
|
||||
<button
|
||||
class="disco-toggle"
|
||||
aria-expanded="${isExpanded}"
|
||||
@click=${() => this.toggleDiscoGroup(g.type)}
|
||||
>
|
||||
${isExpanded
|
||||
? 'Show less'
|
||||
: `Show all ${g.items.length}`}
|
||||
<wa-icon name="chevron-down"></wa-icon>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
(g) => html`
|
||||
<div class="disco-group">
|
||||
<h4 class="disco-type-header">
|
||||
${g.type === 'Other' ? 'Other Releases' : g.type.endsWith('s') ? g.type : `${g.type}s`}
|
||||
</h4>
|
||||
<scroll-row>
|
||||
${g.items.map((rg) => this.renderAlbumCard(rg))}
|
||||
</scroll-row>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</section>
|
||||
`;
|
||||
@@ -3234,23 +3237,25 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
<div class="album-art-fallback" style="${artURL ? 'display: none' : ''}">
|
||||
<wa-icon name="compact-disc"></wa-icon>
|
||||
</div>
|
||||
<div class="album-card-badge">
|
||||
<library-status-indicator
|
||||
status=${badge.status}
|
||||
owned=${badge.owned}
|
||||
expected=${badge.expected}
|
||||
entity-type="album"
|
||||
label=${rg.title}
|
||||
request-mbid=${rg.mbid}
|
||||
request-artist=${this.artist?.name ?? ''}
|
||||
size="23"
|
||||
></library-status-indicator>
|
||||
</div>
|
||||
</div>
|
||||
<div class="album-title" title="${rg.title}">${rg.title}</div>
|
||||
<div class="album-artist">${rg.artistCredit ?? ''}</div>
|
||||
<div class="album-meta">
|
||||
<div class="album-meta-text">
|
||||
${year ? html`<span>${year}</span>` : nothing}
|
||||
</div>
|
||||
${badge.status === 'in-library'
|
||||
? nothing
|
||||
: html`<library-status-indicator
|
||||
status=${badge.status}
|
||||
owned=${badge.owned}
|
||||
expected=${badge.expected}
|
||||
entity-type="album"
|
||||
label=${rg.title}
|
||||
request-mbid=${rg.mbid}
|
||||
request-artist=${this.artist?.name ?? ''}
|
||||
></library-status-indicator>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -3266,15 +3271,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
// Cap the similar-artists list at 10 to avoid a very long list.
|
||||
const maxSimilar = 10;
|
||||
const artists = this.similarArtists.slice(0, maxSimilar);
|
||||
const showToggle = artists.length > this.discoRowSize;
|
||||
const collapsed = !this.similarExpanded && showToggle;
|
||||
const visible = collapsed ? artists.slice(0, this.discoRowSize) : artists;
|
||||
|
||||
return html`
|
||||
<section>
|
||||
<h3 class="section-header">Similar Artists</h3>
|
||||
<div class="similar-row ${collapsed ? 'collapsed' : ''}">
|
||||
${visible.map((a) => {
|
||||
<scroll-row>
|
||||
${artists.map((a) => {
|
||||
const imgURL = this.similarImageURLs.get(a.artistMbid);
|
||||
return html`
|
||||
<div
|
||||
@@ -3313,21 +3315,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
${showToggle
|
||||
? html`
|
||||
<button
|
||||
class="disco-toggle"
|
||||
aria-expanded="${this.similarExpanded}"
|
||||
@click=${() => { this.similarExpanded = !this.similarExpanded; }}
|
||||
>
|
||||
${this.similarExpanded
|
||||
? 'Show less'
|
||||
: `Show all ${artists.length}`}
|
||||
<wa-icon name="chevron-down"></wa-icon>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</scroll-row>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { avatarBackground } from '@utils/avatar-color';
|
||||
import { albumBadgeFor, libraryStatusFor } from '@utils/library-status';
|
||||
import {
|
||||
isOwned,
|
||||
ownershipLabel,
|
||||
unownedStyles,
|
||||
} from '@utils/ownership';
|
||||
import { isOwned, ownershipLabel } from '@utils/ownership';
|
||||
import { openMusicBrainz } from '@utils/external-link';
|
||||
import { completenessStore } from '@store/completeness-store';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
@@ -13,6 +10,8 @@ import { classMap } from 'lit/directives/class-map.js';
|
||||
import '@components/page-header/page-header';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { srOnly } from '../../styles/sr-only.css';
|
||||
import { albumCardStyles } from '../../styles/album-card.css';
|
||||
import '../scroll-row/scroll-row.js';
|
||||
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, GetArtistImagesCachedPaths, GetExploreShelves, RecordSearchClick } from '@go/explore/service.js';
|
||||
import { GetFilePathsByAlbums, GetFilePathsByRecordingMBIDs } from '@go/library/library.js';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
@@ -253,7 +252,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
srOnly,
|
||||
exploreLinkStyles,
|
||||
contextMenuStyles,
|
||||
unownedStyles,
|
||||
albumCardStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
@@ -529,21 +528,9 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Horizontal scroll rows ── */
|
||||
.horizontal-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.horizontal-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Top result cards ── */
|
||||
/* ── Artist cards ── */
|
||||
/* Fixed width, for the reason the album card is: a range
|
||||
means two cards in one row are different sizes. */
|
||||
.artist-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -552,8 +539,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
min-width: 100px;
|
||||
max-width: 120px;
|
||||
width: 120px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
transition: background 0.15s ease;
|
||||
@@ -624,115 +611,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
font-size: var(--yj-text-xs);
|
||||
}
|
||||
|
||||
/* ── Album cards ── */
|
||||
.album-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
min-width: 130px;
|
||||
max-width: 150px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.album-card:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.album-art-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--yj-bg-overlay, #404040) 0%,
|
||||
var(--yj-bg-surface, #282828) 100%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.album-art-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.album-art-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.album-art-fallback wa-icon {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-weight: 500;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
font-size: var(--yj-text-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-artist {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-xs);
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.album-meta-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.album-meta library-status-indicator {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Track list ── */
|
||||
.track-list {
|
||||
display: flex;
|
||||
@@ -753,7 +631,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.album-card:focus-visible,
|
||||
.track-item:focus-visible {
|
||||
outline: 2px solid var(--yj-accent-text, #ffd43b);
|
||||
outline-offset: -2px;
|
||||
@@ -1371,7 +1248,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
|
||||
const entity = target.kind === 'album' ? 'release-group' : 'recording';
|
||||
|
||||
window.open(`https://musicbrainz.org/${entity}/${target.mbid}`, '_blank', 'noopener');
|
||||
openMusicBrainz(`/${entity}/${target.mbid}`);
|
||||
}
|
||||
|
||||
private renderExploreContextMenu() {
|
||||
@@ -1662,6 +1539,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
} catch {
|
||||
// No image — leave empty string.
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2122,7 +2001,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
${subtitle
|
||||
? html`<p class="section-reason">${subtitle}</p>`
|
||||
: nothing}
|
||||
<div class="horizontal-row">
|
||||
<scroll-row>
|
||||
${artists.map((a) => {
|
||||
const owned = isOwned(a);
|
||||
const name = a.englishName || a.name;
|
||||
@@ -2171,7 +2050,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</scroll-row>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -2187,7 +2066,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
${subtitle
|
||||
? html`<p class="section-reason">${subtitle}</p>`
|
||||
: nothing}
|
||||
<div class="horizontal-row">
|
||||
<scroll-row>
|
||||
${releaseGroups.map((rg) => {
|
||||
const artURL = this.thumbnailCache.get(rg.mbid) || '';
|
||||
const year = extractYear(rg.firstReleaseDate);
|
||||
@@ -2249,6 +2128,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
>
|
||||
<wa-icon name="compact-disc"></wa-icon>
|
||||
</div>
|
||||
<div class="album-card-badge">
|
||||
<library-status-indicator
|
||||
status=${badge.status}
|
||||
owned=${badge.owned}
|
||||
expected=${badge.expected}
|
||||
entity-type="album"
|
||||
label=${rg.title}
|
||||
request-mbid=${rg.mbid}
|
||||
request-artist=${rg.artistCredit ?? ''}
|
||||
size="23"
|
||||
></library-status-indicator>
|
||||
</div>
|
||||
</div>
|
||||
<div class="album-title" title="${rg.title}">
|
||||
${rg.title}
|
||||
@@ -2256,29 +2147,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
<div class="album-artist">${creditLink(creditStore.credits(rg.mbid), rg.artistCredit, rg.artistMbid ?? '')}</div>
|
||||
<div class="album-meta">
|
||||
<div class="album-meta-text">
|
||||
${year ? html`<span>${year}</span>` : nothing}
|
||||
${rg.primaryType
|
||||
? html`<span class="type-badge"
|
||||
>${rg.primaryType}</span
|
||||
>`
|
||||
: nothing}
|
||||
${year ? html`<span>${year}</span>` : nothing}
|
||||
</div>
|
||||
${badge.status === 'in-library'
|
||||
? nothing
|
||||
: html`<library-status-indicator
|
||||
status=${badge.status}
|
||||
owned=${badge.owned}
|
||||
expected=${badge.expected}
|
||||
entity-type="album"
|
||||
label=${rg.title}
|
||||
request-mbid=${rg.mbid}
|
||||
request-artist=${rg.artistCredit ?? ''}
|
||||
></library-status-indicator>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</scroll-row>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { libraryStore } from '@store/library-store';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import '@components/page-header/page-header';
|
||||
import '../scroll-row/scroll-row.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||
|
||||
@@ -99,16 +100,6 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 160px;
|
||||
gap: 14px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -117,6 +108,8 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
display: block;
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.art {
|
||||
@@ -336,9 +329,9 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
|
||||
<span class="shelf-title">${shelf.title}</span>
|
||||
</div>
|
||||
<p class="shelf-sub">${shelf.subtitle}</p>
|
||||
<div class="row">
|
||||
<scroll-row>
|
||||
${(shelf.albums ?? []).map((album) => this.renderCard(album))}
|
||||
</div>
|
||||
</scroll-row>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { LitElement, css, html } from 'lit';
|
||||
import { customElement, query, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
|
||||
/** How far one press moves the row — most of a screenful, not all of
|
||||
* it, so the card that was at the edge stays as an anchor. */
|
||||
const SCROLL_FRACTION = 0.8;
|
||||
|
||||
/**
|
||||
* A horizontally scrolling row with arrow buttons.
|
||||
*
|
||||
* The shelves, the search results and (now) the artist page's
|
||||
* discography and similar-artists rows are all "more than fits, scroll
|
||||
* sideways". Until this existed the only way to see the rest was a
|
||||
* mousewheel or a trackpad gesture, which is not an affordance — a
|
||||
* mouse with no horizontal wheel simply could not reach the cards past
|
||||
* the fold.
|
||||
*
|
||||
* It is a component rather than a rule on `.horizontal-row` for two
|
||||
* reasons. The arrows are *state* — which way the row can still move —
|
||||
* and that state has to be recomputed when the viewport resizes or a
|
||||
* card arrives with its cover art; a stylesheet cannot do that. And
|
||||
* every caller then gets the same arrows, the same reveal and the same
|
||||
* keyboard labels without writing them again.
|
||||
*
|
||||
* **The arrows are `hidden`, not merely transparent, at the end they
|
||||
* cannot move from** — a control that cannot act is worse than none,
|
||||
* and an invisible one still holds a hit area and a tab stop. On a
|
||||
* pointer device the pair fades in with the row's hover; where there is
|
||||
* no hover they are always visible, because there is no other route to
|
||||
* them there (a swipe is not an affordance a mouse-less keyboard user
|
||||
* has either).
|
||||
*
|
||||
* The cards are light DOM children and stay in the *host's* shadow
|
||||
* root, so the host's own `.album-card` / `.artist-card` styles apply
|
||||
* unchanged — this component only owns the box they scroll inside.
|
||||
*/
|
||||
@customElement('scroll-row')
|
||||
export class ScrollRow extends LitElement {
|
||||
@query('.viewport') private viewport?: HTMLElement;
|
||||
|
||||
@state() private atStart = true;
|
||||
|
||||
@state() private atEnd = true;
|
||||
|
||||
@state() private overflowing = false;
|
||||
|
||||
private observer?: ResizeObserver;
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
/* A swipe that reaches the row's end should not drag the
|
||||
whole page sideways with it. */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
|
||||
.viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--yj-border-subtle, rgba(255, 255, 255, 0.1));
|
||||
background: var(--yj-bg-elevated, #343a40);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.arrow[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.arrow.prev {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.arrow.next {
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.arrow:hover {
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
|
||||
.arrow:focus-visible {
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
:host(:hover) .arrow,
|
||||
.arrow:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media not all and (hover: hover) {
|
||||
.arrow {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
override firstUpdated(): void {
|
||||
const viewport = this.viewport;
|
||||
|
||||
if (!viewport) return;
|
||||
|
||||
this.observer = new ResizeObserver(() => this.measure());
|
||||
|
||||
this.observer.observe(viewport);
|
||||
|
||||
// The track's own size is what changes when a card arrives with
|
||||
// its cover art, and a ResizeObserver on the viewport alone
|
||||
// never fires for that.
|
||||
const track = viewport.firstElementChild;
|
||||
|
||||
if (track) this.observer.observe(track);
|
||||
|
||||
this.measure();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.observer?.disconnect();
|
||||
this.observer = undefined;
|
||||
}
|
||||
|
||||
private measure(): void {
|
||||
const viewport = this.viewport;
|
||||
|
||||
if (!viewport) return;
|
||||
|
||||
this.overflowing = viewport.scrollWidth > viewport.clientWidth + 1;
|
||||
this.atStart = viewport.scrollLeft <= 1;
|
||||
this.atEnd =
|
||||
viewport.scrollLeft + viewport.clientWidth >=
|
||||
viewport.scrollWidth - 1;
|
||||
}
|
||||
|
||||
private onScroll = (): void => this.measure();
|
||||
|
||||
private scrollStep(direction: -1 | 1): void {
|
||||
const viewport = this.viewport;
|
||||
|
||||
if (!viewport) return;
|
||||
|
||||
viewport.scrollBy({
|
||||
left: direction * viewport.clientWidth * SCROLL_FRACTION,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const showPrev = this.overflowing && !this.atStart;
|
||||
const showNext = this.overflowing && !this.atEnd;
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="arrow prev"
|
||||
type="button"
|
||||
aria-label="Scroll left"
|
||||
?hidden=${!showPrev}
|
||||
@click=${() => this.scrollStep(-1)}
|
||||
>
|
||||
<wa-icon name="chevron-left"></wa-icon>
|
||||
</button>
|
||||
<div class="viewport" @scroll=${this.onScroll}>
|
||||
<div class="track"><slot></slot></div>
|
||||
</div>
|
||||
<button
|
||||
class="arrow next"
|
||||
type="button"
|
||||
aria-label="Scroll right"
|
||||
?hidden=${!showNext}
|
||||
@click=${() => this.scrollStep(1)}
|
||||
>
|
||||
<wa-icon name="chevron-right"></wa-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'scroll-row': ScrollRow;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import '../scroll-row/scroll-row.js';
|
||||
import type * as explore from '@go/explore/models.js';
|
||||
import {
|
||||
GetArtistImageURL,
|
||||
@@ -15,7 +16,6 @@ import { albumBadgeFor, libraryStatusFor } from '../../utils/library-status';
|
||||
import {
|
||||
isOwned,
|
||||
ownershipLabel,
|
||||
unownedStyles,
|
||||
type OwnableKind,
|
||||
} from '../../utils/ownership';
|
||||
import { completenessStore } from '../../store/completeness-store';
|
||||
@@ -103,20 +103,12 @@ export class TopResultsRow extends LitElement {
|
||||
static override styles = [
|
||||
designTokens,
|
||||
exploreLinkStyles,
|
||||
unownedStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.card {
|
||||
flex: 0 0 auto;
|
||||
width: 200px;
|
||||
@@ -285,9 +277,9 @@ export class TopResultsRow extends LitElement {
|
||||
|
||||
return html`
|
||||
<div class="section-label">Top Results</div>
|
||||
<div class="row">
|
||||
<scroll-row>
|
||||
${this.results.map((r) => this.renderCard(r))}
|
||||
</div>
|
||||
</scroll-row>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ solid/bookmark
|
||||
solid/box-open
|
||||
solid/check
|
||||
solid/chevron-down
|
||||
solid/chevron-left
|
||||
solid/chevron-right
|
||||
solid/circle-check
|
||||
solid/circle-exclamation
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
/**
|
||||
* The Explore album card, once.
|
||||
*
|
||||
* Two components draw one — `explore-view`'s shelves and search
|
||||
* results, and `explore-artist-details`'s discography — and they had
|
||||
* grown two copies of the same rules. That is how the size came apart:
|
||||
* `explore-view` clamped its cards to a 130–150px range so two cards in
|
||||
* one row could be different widths, and since the artwork is square
|
||||
* that made them different *heights* as well. A row of covers with
|
||||
* ragged bottoms is the whole complaint.
|
||||
*
|
||||
* So the width is a fixed `--yj-album-card-width` and the lines below
|
||||
* the art each reserve their own space, which is what makes every card
|
||||
* the same size no matter what a given album happens to carry —
|
||||
* `album-card-size.test.ts` measures that rather than trusting it.
|
||||
*
|
||||
* Three rules here are the parts that changed rather than moved.
|
||||
*
|
||||
* **The artwork is inset in the square, not cropped to it.** The
|
||||
* container was already `aspect-ratio: 1` but the image was
|
||||
* `object-fit: cover`, so a non-square cover lost its edges. It is
|
||||
* `contain` now and the container's own background is transparent, so
|
||||
* a tall or wide cover sits in the middle of the square with the page
|
||||
* showing through beside it.
|
||||
*
|
||||
* **The badge lives on the artwork, top-left, and only under the
|
||||
* pointer.** It used to sit in the metadata line and only for the
|
||||
* unowned case. It draws for every card now — an owned album's tick is
|
||||
* the answer to the same question — and it is revealed by hover on a
|
||||
* pointer device. Where there is no hover it is *always* visible rather
|
||||
* than never, because on those devices it is the only route to its
|
||||
* action: `explore-view`'s card menu carries no request item, so a
|
||||
* phone with the badge hidden could not ask for an album at all.
|
||||
*
|
||||
* **Nothing dims an unowned card.** `unownedStyles` was removed from
|
||||
* the catalog surfaces on the rule that the badge is the mark; the
|
||||
* album page's *tracklist* still dims unowned rows, which is a
|
||||
* different statement about a different thing.
|
||||
*/
|
||||
export const albumCardStyles = css`
|
||||
.album-card {
|
||||
width: var(--yj-album-card-width, 150px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.album-card:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.album-card:focus-visible {
|
||||
outline: 2px solid var(--yj-accent-text, #ffd43b);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.album-art-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.album-art-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The placeholder is the one case that *is* a full square, so it
|
||||
carries the background the container gave up. */
|
||||
.album-art-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--yj-bg-overlay, #404040) 0%,
|
||||
var(--yj-bg-surface, #282828) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.album-art-fallback wa-icon {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.album-card-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.album-card:hover .album-card-badge,
|
||||
.album-card:focus-within .album-card-badge {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media not all and (hover: hover) {
|
||||
.album-card-badge {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-weight: 500;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
font-size: var(--yj-text-sm);
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Reserved even where a surface has no artist to draw, so a card
|
||||
in a row is never shorter than its neighbour. */
|
||||
.album-artist {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-xs);
|
||||
line-height: 1.3;
|
||||
min-height: 1.3em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-xs);
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.album-meta-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Opening an external page, with the destination pinned.
|
||||
*
|
||||
* Every external link this app opens is a MusicBrainz entity page built
|
||||
* from an MBID that came from the catalog. Constructing the URL by
|
||||
* string concatenation leaves the destination to whatever is in that
|
||||
* string, so this parses it against the one origin the app means and
|
||||
* refuses anything else — an MBID cannot change the host, and if it
|
||||
* somehow did, nothing would open.
|
||||
*
|
||||
* It navigates through a real anchor rather than `window.open`: the
|
||||
* same top-level `_blank` navigation with `noopener`, and it keeps the
|
||||
* destination an ordinary link rather than an argument to a function
|
||||
* whose first parameter is a URL.
|
||||
*/
|
||||
const MUSICBRAINZ_ORIGIN = 'https://musicbrainz.org';
|
||||
|
||||
export function openMusicBrainz(path: string): void {
|
||||
const url = new URL(path, MUSICBRAINZ_ORIGIN);
|
||||
|
||||
if (url.origin !== MUSICBRAINZ_ORIGIN) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url.toString();
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.click();
|
||||
}
|
||||
@@ -10,6 +10,16 @@
|
||||
* badge as the only difference. This is that rule, written once, so
|
||||
* eight surfaces cannot each keep their own version of it.
|
||||
*
|
||||
* **The catalog's *cards* no longer dim.** A grid of dimmed covers read
|
||||
* as a page that had failed to load rather than as a page of things you
|
||||
* could ask for, so on Explore the mark is the badge alone — over the
|
||||
* artwork, on hover, drawn for owned and unowned alike. The album
|
||||
* page's *tracklist* still dims unowned rows: that is a different
|
||||
* statement ("this one is not here") about a different thing, and the
|
||||
* `aria-disabled` row that cannot be played is what it is for. So
|
||||
* `unownedStyles` survives for that one surface and the cards simply do
|
||||
* not include it.
|
||||
*
|
||||
* ## Ownership is a file, and `localId` is the flag that says so
|
||||
*
|
||||
* The album page answers "do I own this row" with `filePaths`, a map
|
||||
@@ -96,7 +106,8 @@ export function ownershipLabel(
|
||||
}
|
||||
|
||||
/**
|
||||
* The dimming, shared so it cannot drift across surfaces.
|
||||
* The dimming, shared so it cannot drift across surfaces — and now
|
||||
* used by exactly one of them.
|
||||
*
|
||||
* Two things about it are load-bearing.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user