/** * Utility for rendering track/album/artist names as clickable links. * * A name links to its MusicBrainz page when the entity is tagged, and * to the local library page for the same thing when it is not. Both * destinations are the same two components — `explore-album-details` * and `explore-artist-details` both accept a local id instead of an * MBID — so an untagged album is not a dead end, it is just a page with * less on it. * * Falling back rather than rendering plain text is deliberate: a list * where some rows are clickable and others silently are not reads as a * bug, not as a statement about metadata. The only case that still * renders as text is one we genuinely cannot route (no name at all, or * nothing in the library by that name). * * ## Below the phone breakpoint a name is not a link (#67) * * A few characters of text inside a row is not a touch target, and the * click handling below is explicitly a *desktop* compromise: the * navigation is held for one double-click interval so double-clicking * the row can still play it, which means nothing at all on touch. On * a phone the row's own gesture wins anyway — a claimed `yj-tap` has * its click swallowed by `utils/touch-gestures.ts`, so the link was * unreachable as well as fiddly. * * So the rule lives here rather than at twenty call sites, which is * what the Findings on #67 ask for: a name renders as plain text below * `PHONE_QUERY`, and the row's context menu carries "Go to Artist" / * "Go to Album" in its place (`goToMenuItems`). * * The exception is `keepOnPhone`, and it is not a preference. Three * surfaces render a name with **no menu to carry the destination** — * `now-playing-view`, `explore-album-details`' header credit and * `top-results-row` — so suppressing the link there takes the action * away entirely rather than moving it, which is what plan 018's "no * action is unreachable at any supported size" refuses. Each of those * call sites says so. */ import { html, css } from 'lit'; import type { TemplateResult } from 'lit'; import { libraryStore } from '../store/library-store'; import { PHONE_QUERY } from './breakpoints'; /** Shared CSS for explore link styling. Import into component styles. */ export const exploreLinkStyles = css` .explore-link { color: inherit; text-decoration: none; cursor: pointer; } .explore-link:hover { text-decoration: underline; } `; /** * Options every link function takes, for the one case that is not the * default. */ export interface LinkOptions { /** * Keep the name navigable at phone width. * * For a surface with no context menu to carry the destination — * see the header of this file. A row must not pass it: the row's * tap already means "play", and the menu is where the destination * went. */ keepOnPhone?: boolean; } /** * The live phone breakpoint, made once and read per link. * * A `MediaQueryList` is live, so one object answers for the life of * the page and a resize needs nothing from here. The identity check * is the test seam: this tier's viewport is fixed by the runner, so a * spec answers the query by replacing `window.matchMedia` (the same * stub `now-playing-phone.test.ts` installs), and swapping the * function is what tells us to ask again. */ let phoneQuery: MediaQueryList | undefined; let phoneQuerySource: typeof window.matchMedia | undefined; /** * Whether an inline name still navigates. * * Exported because the menus that carry the destination in its place * are drawn under exactly the same condition -- one answer, not two. */ export function inlineLinksSuppressed(): boolean { if (!window.matchMedia) return false; if (phoneQuerySource !== window.matchMedia) { phoneQuerySource = window.matchMedia; phoneQuery = window.matchMedia(PHONE_QUERY); } return phoneQuery?.matches ?? false; } /** Whether this call site should render plain text rather than a link. */ function plainText(options?: LinkOptions): boolean { return !options?.keepOnPhone && inlineLinksSuppressed(); } /** Fire a navigate event from the clicked element. */ function navigate(target: EventTarget, detail: Record): void { target.dispatchEvent( new CustomEvent('navigate', { bubbles: true, composed: true, detail, }), ); } /** Case-insensitive compare that tolerates undefined. */ function sameName(a: string | undefined, b: string | undefined): boolean { return (a ?? '').toLowerCase() === (b ?? '').toLowerCase(); } /** * Find the library album row for a name, loading the album cache first * if a view that populates it has not been opened yet. */ async function findLocalAlbum( albumName: string, artistName?: string, ): Promise<{ ID: number; Name: string; ArtistName: string } | null> { let albums = libraryStore.cachedAlbums; if (!albums) { try { albums = await libraryStore.getAlbums(); } catch { return null; } } let fallback: (typeof albums)[0] | null = null; for (const album of albums ?? []) { if (!sameName(album.Name, albumName)) continue; if (artistName && sameName(album.ArtistName, artistName)) return album; fallback ??= album; } return fallback; } /** Find the library artist row for a name, loading the cache if needed. */ async function findLocalArtist( artistName: string, ): Promise<{ ID: number; Name: string; MBID: string } | null> { let artists = libraryStore.cachedArtists; if (!artists) { try { artists = await libraryStore.getArtists(); } catch { return null; } } for (const artist of artists ?? []) { if (sameName(artist.Name, artistName)) return artist; } return null; } /** * How long a link waits before navigating. * * Every list these links appear in also plays a row on double-click, * and the title is the widest thing in the row — so the same gesture * that plays a track starts with a click on its name. Navigating on * the first of those two clicks means double-clicking a track title * opens a page instead of playing it. Holding the navigation for one * double-click interval, and dropping it if the second click arrives, * lets one element serve both without the row having to know links * exist. */ const DOUBLE_CLICK_GRACE_MS = 250; /** * Wrap a link action so it fires on a genuine single click only. * * The click's propagation is stopped (the row must not also treat it as * a selection) but the *double*-click is left alone, so it still * reaches the row and plays the track. */ function singleClick( run: (target: EventTarget) => void, ): (e: MouseEvent) => void { return (e: MouseEvent) => { e.stopPropagation(); e.preventDefault(); // detail > 1 is the second click of a double click; the first // one already scheduled and is about to be cancelled. if (e.detail > 1) return; const target = (e.currentTarget ?? e.target) as EventTarget; const timer = window.setTimeout(() => { target.removeEventListener('dblclick', cancel); run(target); }, DOUBLE_CLICK_GRACE_MS); function cancel(): void { window.clearTimeout(timer); } target.addEventListener('dblclick', cancel, { once: true }); }; } /** * Render an artist name as a link to the artist page — the * MusicBrainz one when tagged, the library one when not. * * @param artistName - The artist name to display. * @param mbid - The MusicBrainz artist ID. Empty string = local only. * @param content - Optional custom content to render inside the link * (e.g. highlighted search result). Defaults to artistName. * @param options - See `LinkOptions`. */ export function artistLink( artistName: string, mbid: string, content?: TemplateResult | string, options?: LinkOptions, ): TemplateResult | string { if (!artistName) return artistName; if (plainText(options)) return content ?? artistName; const onClick = singleClick((target) => { void openArtistPage(target, artistName, mbid); }); return html`${content ?? artistName}`; } /** * Render an album name as a link to the album page — the MusicBrainz * one when tagged, the library one when not. * * @param albumName - The album name to display. * @param mbid - The MusicBrainz release group ID. Empty = local only. * @param content - Optional custom content to render inside the link. * @param artistName - Disambiguates same-named albums in the library. * @param options - See `LinkOptions`. */ export function albumLink( albumName: string, mbid: string, content?: TemplateResult | string, artistName?: string, options?: LinkOptions, ): TemplateResult | string { if (!albumName) return albumName; if (plainText(options)) return content ?? albumName; return html` { void openAlbumPage(target, albumName, mbid, artistName); })} title=${mbid ? 'View album on Explore' : 'View album in your library'} >${content ?? albumName}`; } /** * Render a track name as a link that opens the track's album with the * track highlighted. An untagged track highlights by title on the * library album page instead, so every row in a list behaves the same. * * @param trackName - The track name to display. * @param albumName - The album name (for the page title). * @param releaseGroupMBID - The album's MusicBrainz release group ID. * @param recordingMBID - The track's MusicBrainz recording ID. * @param content - Optional custom content (e.g. highlighted text). * @param artistName - Disambiguates same-named albums in the library. * @param options - See `LinkOptions`. */ export function trackLink( trackName: string, albumName: string, releaseGroupMBID: string, recordingMBID: string, content?: TemplateResult | string, artistName?: string, options?: LinkOptions, ): TemplateResult | string { if (!trackName) return trackName; if (!albumName) return content ?? trackName; if (plainText(options)) return content ?? trackName; return html` { void openAlbumPage( target, albumName, releaseGroupMBID, artistName, recordingMBID, trackName, ); })} title=${releaseGroupMBID ? 'View track on the album page' : 'View track on the album page in your library'} >${content ?? trackName}`; } /** * Route to an artist page, preferring the catalog and falling back to * the library copy. * * Exported because a menu item goes to the same place a name does, and * two routings of "go to this artist" is how the two come to disagree * about an untagged one. */ export async function openArtistPage( target: EventTarget, artistName: string, mbid: string, ): Promise { if (mbid) { navigate(target, { view: 'explore-artist-details', artistMBID: mbid, artistName, }); return; } const local = await findLocalArtist(artistName); if (!local) return; // The caller's row had no MBID, but the library row for the // same artist may — the grid routes by exactly this field, // so reading it here is what keeps the two paths agreeing. navigate(target, { view: 'explore-artist-details', artistMBID: local.MBID || '', artistName, localArtistId: local.ID, }); } /** * Route to an album page, preferring the catalog and falling back to * the library copy. `highlight*` marks one track on arrival. */ export async function openAlbumPage( target: EventTarget, albumName: string, releaseGroupMBID: string, artistName?: string, highlightTrackMBID?: string, highlightTrackTitle?: string, ): Promise { const detail: Record = { view: 'explore-album-details', releaseGroupMBID, albumName, artistName: artistName ?? '', }; if (highlightTrackMBID) detail.highlightTrackMBID = highlightTrackMBID; if (highlightTrackTitle) detail.highlightTrackTitle = highlightTrackTitle; if (!releaseGroupMBID) { const local = await findLocalAlbum(albumName, artistName); if (!local) return; detail.localAlbumId = local.ID; detail.artistName = local.ArtistName; } navigate(target, detail); } /** * One credited artist within a multi-artist credit. * * Mirrors `artist_credit_part` / `file_artists`: the name **as * credited** (which is not the artist's own name — MusicBrainz credits * "Snoop Dogg" on a track by the artist called "Snoop Doggy Dogg"), the * MBID to navigate to, and the literal connector that follows this * part. */ export interface CreditPart { /** The name as credited. Display uses this. */ creditedName: string; /** The artist's MusicBrainz ID. Navigation uses this. */ artistMbid: string; /** The connector following this part: " feat. ", " & ", ", ", "". */ joinPhrase: string; } /** * Render a credit as links, one per credited artist, with the join * phrases as plain text between them. * * Join phrases are **assembly instructions, not disassembly * instructions**. This concatenates parts; it never searches for a * name inside a credit string. That distinction is the whole point: * the stored credit text may have come from a file's tags while the * parts come from the catalog, and measured on a real library those * disagree for about one in three multi-artist credits ("Skrillex * feat. Swae Lee" tagged against "Skrillex & Swae Lee" upstream). A * search would miss, or match the wrong span. Building from parts, * the link boundaries are known by construction. * * Falls back to `artistLink(fallbackName, fallbackMbid)` — today's * behaviour exactly — when there are no parts. That is the common * case and not a degraded one: a single-artist credit *is* one link, * and a file with no recording MBID or no catalog row has nothing to * decompose. Do not try to split the fallback string; there is * genuinely no information in it to split on. * * @param parts - The credit's parts in position order, if known. * @param fallbackName - The credit as a single string. * @param fallbackMbid - The primary artist's MBID. * @param options - See `LinkOptions`. */ export function creditLink( parts: readonly CreditPart[] | undefined, fallbackName: string, fallbackMbid: string, options?: LinkOptions, ): TemplateResult | string { // One part is one link, so it is the fallback rather than a special // case — and a zero-part credit reaching here would otherwise // render as nothing at all, which is worse than the single-artist // answer it replaced. if (!parts || parts.length < 2) { return artistLink(fallbackName, fallbackMbid, undefined, options); } // A decomposed credit is rendered from the same parts either way, // so the join phrases survive the suppression and the text reads // as it did — which is `creditText`'s job, and it is the string // the `title=` beside these already uses. if (plainText(options)) return creditText(parts, fallbackName); return html`${parts.map( (part) => html`${artistLink(part.creditedName, part.artistMbid, undefined, options)}${part.joinPhrase}`, )}`; } /** * The plain-text form of a credit, for `title=` attributes and any * other place that needs a string rather than a template. * * Rendered from the same parts by the same concatenation, so the * tooltip cannot disagree with the links beneath it. */ export function creditText( parts: readonly CreditPart[] | undefined, fallbackName: string, ): string { if (!parts || parts.length < 2) return fallbackName; return parts.map((p) => p.creditedName + p.joinPhrase).join(''); }