From 7fbeca57199c60cdbf8b6c8e1350a30dff9ed051 Mon Sep 17 00:00:00 2001 From: intergrav Date: Sat, 15 Aug 2026 22:59:09 -0400 Subject: [PATCH] feat: redesign infomodal --- src/lib/Library.svelte | 196 +---- src/lib/LibraryLabel.svelte | 59 ++ src/lib/LibraryTreeItem.svelte | 32 +- src/lib/Queue.svelte | 13 +- src/lib/client.svelte.ts | 87 +- src/lib/dialogs/InfoModal.svelte | 1024 +++++++++++++++++++++- src/lib/dialogs/Modal.svelte | 3 + src/lib/dialogs/ShareModal.svelte | 2 +- src/lib/info.ts | 16 + src/lib/languages/locales/ar.ts | 3 + src/lib/languages/locales/bn.ts | 3 + src/lib/languages/locales/cs.ts | 3 + src/lib/languages/locales/da.ts | 3 + src/lib/languages/locales/de.ts | 3 + src/lib/languages/locales/el.ts | 3 + src/lib/languages/locales/en.ts | 3 + src/lib/languages/locales/es.ts | 3 + src/lib/languages/locales/fi.ts | 3 + src/lib/languages/locales/fr.ts | 3 + src/lib/languages/locales/he.ts | 3 + src/lib/languages/locales/hi.ts | 3 + src/lib/languages/locales/id.ts | 3 + src/lib/languages/locales/it.ts | 3 + src/lib/languages/locales/ja.ts | 3 + src/lib/languages/locales/ko.ts | 3 + src/lib/languages/locales/nl.ts | 3 + src/lib/languages/locales/no.ts | 3 + src/lib/languages/locales/pl.ts | 3 + src/lib/languages/locales/pt.ts | 3 + src/lib/languages/locales/ro.ts | 3 + src/lib/languages/locales/ru.ts | 3 + src/lib/languages/locales/sv.ts | 3 + src/lib/languages/locales/th.ts | 3 + src/lib/languages/locales/tl.ts | 3 + src/lib/languages/locales/tr.ts | 3 + src/lib/languages/locales/uk.ts | 3 + src/lib/languages/locales/vi.ts | 3 + src/lib/languages/locales/zhcn.ts | 3 + src/lib/languages/locales/zhtw.ts | 3 + src/lib/library.svelte.ts | 20 +- src/lib/offline/offlineManager.svelte.ts | 2 +- src/lib/theme.svelte.ts | 32 +- src/lib/treeAnimations.ts | 86 ++ src/lib/ui.svelte.ts | 8 +- 44 files changed, 1440 insertions(+), 230 deletions(-) create mode 100644 src/lib/LibraryLabel.svelte create mode 100644 src/lib/treeAnimations.ts diff --git a/src/lib/Library.svelte b/src/lib/Library.svelte index 15d7f54..8e91b94 100644 --- a/src/lib/Library.svelte +++ b/src/lib/Library.svelte @@ -1,13 +1,7 @@ + + + + diff --git a/src/lib/LibraryTreeItem.svelte b/src/lib/LibraryTreeItem.svelte index 633fc01..39b44a0 100644 --- a/src/lib/LibraryTreeItem.svelte +++ b/src/lib/LibraryTreeItem.svelte @@ -4,10 +4,16 @@ import { auth, isOwner as checkIsOwner } from "./auth.svelte.js"; import { api, type Playlist, type Song, songArtId } from "./client.svelte.js"; import type { Item } from "./components/ContextMenu.svelte"; import Icon from "./icons/Icon.svelte"; -import { isInfoItemType } from "./info.js"; +import { isInfoItemType, trackInfoItem } from "./info.js"; import { t } from "./languages/translate.svelte.js"; -import type { LibraryItem, TreeType } from "./library.svelte.js"; -import { lib, loadShares, recursiveSongs } from "./library.svelte.js"; +import { + type ChildFactory, + type LibraryItem, + lib, + loadShares, + recursiveSongs, + type TreeType, +} from "./library.svelte.js"; import { offlineManager } from "./offline/offlineManager.svelte.js"; import { settings } from "./settings.svelte.js"; import { ui } from "./ui.svelte.js"; @@ -24,6 +30,7 @@ let { type, owner = null, item, + artworkUrl, depth, expanded, busy, @@ -39,13 +46,12 @@ let { rowKey: string; label: string; fetcher?: () => Promise; - childFactory?: ( - item: LibraryItem, - ) => (() => Promise) | undefined; + childFactory?: ChildFactory; track?: Song; type: TreeType; owner?: string | null; item?: LibraryItem; + artworkUrl?: string; depth: number; expanded: boolean; busy: boolean; @@ -135,7 +141,11 @@ const artSize = $derived.by(() => { const artId = $derived(track ? songArtId(track) : item?.coverArt || ""); const coverArtUrl = $derived( - artSize > 0 && artId ? api.getCoverArt(artId, artSize) : "", + artSize > 0 + ? artId + ? api.getCoverArt(artId, artSize) + : artworkUrl || "" + : "", ); const coverArtUrl2x = $derived( artSize > 0 && artId ? api.getCoverArt(artId, artSize * 2) : "", @@ -296,7 +306,11 @@ const menuItems = $derived.by(() => { if (list.length) list.push({ type: "separator" }); list.push({ label: t("common.info"), - action: () => (ui.infoItem = { type, id, label }), + action: () => + (ui.infoItem = + type === "song" && track + ? trackInfoItem(track) + : { type, id, label }), }); } return list; @@ -364,7 +378,7 @@ function onDragStart(event: DragEvent) { loading="lazy" alt="" src={coverArtUrl} - srcset={coverArtUrl.startsWith("blob:") + srcset={!artId || coverArtUrl.startsWith("blob:") ? undefined : `${coverArtUrl} 1x, ${coverArtUrl2x} 2x`} class="artwork-image" diff --git a/src/lib/Queue.svelte b/src/lib/Queue.svelte index 1a26543..e2c8bce 100644 --- a/src/lib/Queue.svelte +++ b/src/lib/Queue.svelte @@ -18,6 +18,7 @@ import { songArtist, } from "./client.svelte.js"; import ContextMenu, { type Item } from "./components/ContextMenu.svelte"; +import { trackInfoItem } from "./info.js"; import { t } from "./languages/translate.svelte.js"; import { offlineManager } from "./offline/offlineManager.svelte.js"; import { formatTime } from "./player.svelte.js"; @@ -585,13 +586,11 @@ function getQueueItems(index: number): Item[] { { type: "separator" as const }, { label: t("common.info"), - action: () => { - ui.infoItem = { - type: isRadio(selectedTrack) ? "radio" : "song", - id: selectedTrack.id, - label: selectedTrack.title, - }; - }, + action: () => + (ui.infoItem = trackInfoItem( + selectedTrack, + isRadio(selectedTrack), + )), }, ] : []), diff --git a/src/lib/client.svelte.ts b/src/lib/client.svelte.ts index 3859e2c..52fe4da 100644 --- a/src/lib/client.svelte.ts +++ b/src/lib/client.svelte.ts @@ -39,7 +39,57 @@ export interface Artist { name: string; coverArt?: string; starred?: string; + userRating?: number; +} + +export interface Album { + id: string; + name: string; + song?: Song | Song[]; + coverArt?: string; + artistId?: string; + artist?: string; + displayArtist?: string; + year?: number; + starred?: string; + userRating?: number; +} + +interface ArtistWithAlbums extends Artist { + album?: Album | Album[]; +} + +interface InfoBase { + musicBrainzId?: string; + lastFmUrl?: string; + smallImageUrl?: string; + mediumImageUrl?: string; + largeImageUrl?: string; +} + +export interface AlbumInfo extends InfoBase { + notes?: string; } + +interface SimilarArtist { + id: string; + name: string; + coverArt?: string; + artistImageUrl?: string; + starred?: string; +} + +export interface ArtistInfo extends InfoBase { + biography?: string; + similarArtist?: SimilarArtist[]; +} + +export interface InfoResponse { + artist?: ArtistWithAlbums; + album?: Album; + song?: Song; +} + export interface Playlist { id: string; name: string; @@ -97,6 +147,7 @@ export interface Song { discNumber?: number; track?: number; year?: number; + musicBrainzId?: string; playCount?: number; playcount?: number; bitRate?: number; @@ -180,8 +231,8 @@ export const persistCredentials = () => { : localStorage.removeItem("tinysub_credentials"); }; -export const asArray = (value: unknown) => - Array.isArray(value) ? value : value ? [value] : []; +export const asArray = (value: T | T[] | null | undefined): T[] => + Array.isArray(value) ? value : value == null ? [] : [value]; const getParams = (params: Record = {}) => { if (!credentials) throw "Auth required"; @@ -428,7 +479,7 @@ export const api = { return request("ping"); }, forcePing: async () => request("ping"), - getInfo: (type: InfoItemType, id: string) => { + getInfo: (type: InfoItemType, id: string): Promise => { const method = type === "artist" ? "getArtist" @@ -443,6 +494,32 @@ export const api = { : "getInternetRadioStations"; return request(method, type === "share" || type === "radio" ? {} : { id }); }, + getAlbumInfo2: async (id: string): Promise => { + const response = await request("getAlbumInfo2", { id }); + return response.albumInfo || null; + }, + getArtistInfo2: async (id: string): Promise => { + const response = await request("getArtistInfo2", { id }); + const info = response.artistInfo2 as ArtistInfo | undefined; + return info + ? { ...info, similarArtist: asArray(info.similarArtist) } + : null; + }, + getTopSongs: async (artist: string, id?: string): Promise => { + const params = + id && extensions.has("topsongsbyartistid") + ? { id, count: 10 } + : { artist, count: 10 }; + const response = await request("getTopSongs", params); + return asArray(response.topSongs?.song); + }, + getSonicSimilarTracks: async (id: string): Promise => { + if (!extensions.has("sonicsimilarity")) return []; + const response = await request("getSonicSimilarTracks", { id, count: 10 }); + return asArray(response.sonicMatch).flatMap((match: { entry?: Song }) => + match.entry ? [match.entry] : [], + ); + }, getArtists: async () => { if (offlineManager.isOfflineMode) { return (await store.getAllArtists()).sort((a, b) => @@ -451,7 +528,7 @@ export const api = { } const response = await request("getArtists"); const artists: Artist[] = asArray(response.artists?.index).flatMap( - (index: { artist?: unknown }) => asArray(index.artist), + (index: { artist?: Artist | Artist[] }) => asArray(index.artist), ); rememberArtists(artists); return artists; @@ -492,7 +569,7 @@ export const api = { return list; }); }, - getArtist: async (id: string) => { + getArtistAlbums: async (id: string): Promise => { if (offlineManager.isOfflineMode) { const albums = await store.getAllAlbums(); return albums diff --git a/src/lib/dialogs/InfoModal.svelte b/src/lib/dialogs/InfoModal.svelte index 848a4bc..766da9d 100644 --- a/src/lib/dialogs/InfoModal.svelte +++ b/src/lib/dialogs/InfoModal.svelte @@ -1,62 +1,1044 @@ +{#snippet externalLinks( + info: { musicBrainzId?: string; lastFmUrl?: string }, + type: "artist" | "recording" | "release", +)} + {#if info.musicBrainzId || info.lastFmUrl} + + {/if} +{/snippet} + +{#snippet libraryItem( + type: MiniType, + item: LibraryItem, + fetcher: (() => Promise) | undefined, + rowKey: string, + depth = 0, + artworkUrl?: string, +)} + {@const track = type === "song" && isSong(item) ? item : undefined} + {@const childFactory = + type === "artist" + ? (child: LibraryItem) => () => api.getAlbum(child.id) + : undefined} + void toggleMiniNode(rowKey, fetcher)} + onAdd={(asNext) => + void addToQueue(rowKey, fetcher, item, childFactory, asNext)} + onMenu={(nextMenu) => (menu = nextMenu)} + onItemChange={(changes) => Object.assign(item, changes)} + /> + {#if miniNode(rowKey).expanded && miniNode(rowKey).items} + {@const childType = type === "artist" ? "album" : "song"} + {#each miniNode(rowKey).items ?? [] as child, index (itemKey(rowKey, childType, child.id, index))} + {@const childFetcher = + childType === "album" ? () => api.getAlbum(child.id) : undefined} + {@render libraryItem( + childType, + child, + childFetcher, + itemKey(rowKey, childType, child.id, index), + depth + 1, + )} + {/each} + {/if} +{/snippet} + +{#snippet infoArtwork( + info: AlbumInfo | ArtistInfo, + onload: (event: Event) => void, +)} + {@const image = infoImage(info, infoArtworkSize)} + {@const image2x = infoImage(info, infoArtworkSize * 2)} + {#if image} + + {/if} +{/snippet} + +{#snippet ratingControls( + item: Album | Artist | Song, + type: "album" | "artist" | "song", +)} +
+ +
+ {#each [1, 2, 3, 4, 5] as rating} + + {/each} +
+
+{/snippet} + (ui.infoItem = null)} + class="info-modal" + style={ + modalTheme + ? `--info-bg: ${modalTheme.bg}; --info-text: ${modalTheme.text}; color-scheme: ${modalTheme.dark ? "dark" : "light"}` + : "" + } title={`${t("common.info")}: ${ui.infoItem?.label ?? ""}`} >
- {#if loading} -

{t("common.busy")}

- {:else if error} -

{error}

- {:else if details !== null} -
{JSON.stringify(details, null, 2)}
+ {#if artistInfo || albumInfo} + {@const backgroundInfo = artistInfo ?? albumInfo} + {@const backgroundImage = backgroundInfo && infoImage(backgroundInfo, 512)} + {#if backgroundImage} + + {/if} {/if} +
+ {#if loading} +

{t("common.busy")}

+ {:else if error} +

{error}

+ {:else if !artistItem && !albumItem && !songItem && details !== null} +
{JSON.stringify(details, null, 2)}
+ {:else} +
+
+ {#if artistItem} +
+
+ {#if artistInfo} + {@const info = artistInfo} + {@render infoArtwork( + info, + (event) => applyModalTheme(event, info), + )} + {/if} +
+

{artistLabel}

+
+ {@render ratingControls(artistItem, "artist")} +
+ {#if artistInfo} + {@render externalLinks(artistInfo, "artist")} + {/if} +
+
+ {#if artistInfo?.biography} +

{artistInfo.biography}

+ {/if} +
+ {/if} + {#if songItem} + {@const artId = songArtId(songItem)} +
+
+ {#if artId} + {@const artwork = api.getCoverArt(artId, infoArtworkSize)} + {@const artwork2x = api.getCoverArt( + artId, + infoArtworkSize * 2, + )} + + {/if} +
+

{songItem.title}

+
+ {#if albumLabel} + {#if albumId} + + {:else} + {albumLabel} + {/if} + {/if} + {#if artistLabel} + • + {#if artistId} + + {:else} + {artistLabel} + {/if} + {/if} + {#if songItem.year !== undefined} + • + {songItem.year} + {/if} + • + {@render ratingControls(songItem, "song")} +
+ {@render externalLinks(songItem, "recording")} +
+
+
+ {/if} + {#if albumItem} +
+
+ {#if albumInfo} + {@const info = albumInfo} + {@render infoArtwork( + info, + (event) => applyModalTheme(event, info), + )} + {/if} +
+

{albumLabel}

+
+ {#if artistLabel} + {#if artistId} + + {:else} + {artistLabel} + {/if} + {/if} + {#if albumItem?.year !== undefined} + • + {albumItem.year} + {/if} + {#if albumItem} + • + {@render ratingControls(albumItem, "album")} + {/if} +
+ {#if albumInfo} + {@render externalLinks(albumInfo, "release")} + {/if} +
+
+ {#if albumInfo?.notes} +

{albumInfo.notes}

+ {/if} +
+ {/if} +
+ {#if ui.infoItem?.type === "artist" || albumItem || artistAlbums.length || artistInfo?.similarArtist?.length || (songItem && extensions.has("sonicsimilarity"))} + + {/if} +
+ {#if details !== null} +
+ API +
{JSON.stringify(
+								{ item: details, artistInfo, albumInfo, artistAlbums },
+								null,
+								2,
+							)}
+
+ {/if} + {/if} +
+ {#if menu} + (menu = null)} + /> + {/if}
diff --git a/src/lib/dialogs/Modal.svelte b/src/lib/dialogs/Modal.svelte index 857bd65..292b967 100644 --- a/src/lib/dialogs/Modal.svelte +++ b/src/lib/dialogs/Modal.svelte @@ -11,6 +11,7 @@ let { closable = true, onclose, class: className = "", + style = "", } = $props<{ open?: boolean; title?: string; @@ -19,6 +20,7 @@ let { closable?: boolean; onclose?: () => void; class?: string; + style?: string; }>(); let dialog: HTMLDialogElement; @@ -34,6 +36,7 @@ $effect(() => { { if (closable) { open = false; diff --git a/src/lib/dialogs/ShareModal.svelte b/src/lib/dialogs/ShareModal.svelte index 8e86a08..c8114a6 100644 --- a/src/lib/dialogs/ShareModal.svelte +++ b/src/lib/dialogs/ShareModal.svelte @@ -28,7 +28,7 @@ async function create() { ids = [id]; } else if (ui.shareItemType === "artist") { const songs = await recursiveSongs( - await api.getArtist(id), + await api.getArtistAlbums(id), (item) => () => api.getAlbum(item.id as string), ); ids = songs.map((s) => s.id); diff --git a/src/lib/info.ts b/src/lib/info.ts index 894af75..e78db00 100644 --- a/src/lib/info.ts +++ b/src/lib/info.ts @@ -1,3 +1,5 @@ +import type { Song } from "./client.svelte.js"; + export type InfoItemType = | "artist" | "album" @@ -6,6 +8,20 @@ export type InfoItemType = | "song" | "share"; +export type InfoItem = { + type: InfoItemType; + id: string; + label: string; +}; + +export const trackInfoItem = (track: Song, radio = false): InfoItem => { + return { + type: radio ? "radio" : "song", + id: track.id, + label: track.title, + }; +}; + export const isInfoItemType = (type: string): type is InfoItemType => type === "artist" || type === "album" || diff --git a/src/lib/languages/locales/ar.ts b/src/lib/languages/locales/ar.ts index 61cb311..9abd912 100644 --- a/src/lib/languages/locales/ar.ts +++ b/src/lib/languages/locales/ar.ts @@ -49,6 +49,9 @@ const ar: BaseDict = { albums: "ألبومات", artist: "فنان", artists: "فنانون", + similar_artists: "فنانون مشابهون", + similar_songs: "أغانٍ مشابهة", + top_songs: "أبرز الأغاني", busy: "...", clear: "مسح", close: "إغلاق", diff --git a/src/lib/languages/locales/bn.ts b/src/lib/languages/locales/bn.ts index 40daf7c..71f463d 100644 --- a/src/lib/languages/locales/bn.ts +++ b/src/lib/languages/locales/bn.ts @@ -49,6 +49,9 @@ const bn: BaseDict = { albums: "অ্যালবামসমূহ", artist: "শিল্পী", artists: "শিল্পীরা", + similar_artists: "অনুরূপ শিল্পী", + similar_songs: "অনুরূপ গান", + top_songs: "জনপ্রিয় গান", busy: "...", clear: "মুছে ফেলুন", close: "বন্ধ করুন", diff --git a/src/lib/languages/locales/cs.ts b/src/lib/languages/locales/cs.ts index a0034dd..38f0b27 100644 --- a/src/lib/languages/locales/cs.ts +++ b/src/lib/languages/locales/cs.ts @@ -49,6 +49,9 @@ const cs: BaseDict = { albums: "Alba", artist: "Umělec", artists: "Umělci", + similar_artists: "Podobní umělci", + similar_songs: "Podobné skladby", + top_songs: "Nejlepší skladby", busy: "...", clear: "Vyčistit", close: "Zavřít", diff --git a/src/lib/languages/locales/da.ts b/src/lib/languages/locales/da.ts index ad42b59..1e81514 100644 --- a/src/lib/languages/locales/da.ts +++ b/src/lib/languages/locales/da.ts @@ -49,6 +49,9 @@ const da: BaseDict = { albums: "Albummer", artist: "Kunstner", artists: "Kunstnere", + similar_artists: "Lignende kunstnere", + similar_songs: "Lignende sange", + top_songs: "Top-sange", busy: "...", clear: "Ryd", close: "Luk", diff --git a/src/lib/languages/locales/de.ts b/src/lib/languages/locales/de.ts index 2036b22..498715c 100644 --- a/src/lib/languages/locales/de.ts +++ b/src/lib/languages/locales/de.ts @@ -49,6 +49,9 @@ const de: BaseDict = { albums: "Alben", artist: "Künstler", artists: "Künstler", + similar_artists: "Ähnliche Künstler", + similar_songs: "Ähnliche Songs", + top_songs: "Top-Songs", busy: "...", clear: "Leeren", close: "Schließen", diff --git a/src/lib/languages/locales/el.ts b/src/lib/languages/locales/el.ts index 79edcba..e54469a 100644 --- a/src/lib/languages/locales/el.ts +++ b/src/lib/languages/locales/el.ts @@ -49,6 +49,9 @@ const el: BaseDict = { albums: "Άλμπουμ", artist: "Καλλιτέχνης", artists: "Καλλιτέχνες", + similar_artists: "Παρόμοιοι καλλιτέχνες", + similar_songs: "Παρόμοια τραγούδια", + top_songs: "Κορυφαία κομμάτια", busy: "...", clear: "Εκκαθάριση", close: "Κλείσιμο", diff --git a/src/lib/languages/locales/en.ts b/src/lib/languages/locales/en.ts index b93c695..ab74af8 100644 --- a/src/lib/languages/locales/en.ts +++ b/src/lib/languages/locales/en.ts @@ -47,6 +47,9 @@ const en = { albums: "Albums", artist: "Artist", artists: "Artists", + similar_artists: "Similar Artists", + similar_songs: "Similar Songs", + top_songs: "Top Songs", busy: "...", clear: "Clear", close: "Close", diff --git a/src/lib/languages/locales/es.ts b/src/lib/languages/locales/es.ts index 4e10b1d..fbc199f 100644 --- a/src/lib/languages/locales/es.ts +++ b/src/lib/languages/locales/es.ts @@ -49,6 +49,9 @@ const es: BaseDict = { albums: "Álbumes", artist: "Artista", artists: "Artistas", + similar_artists: "Artistas similares", + similar_songs: "Canciones similares", + top_songs: "Canciones destacadas", busy: "...", clear: "Limpiar", close: "Cerrar", diff --git a/src/lib/languages/locales/fi.ts b/src/lib/languages/locales/fi.ts index 46be56d..c0db182 100644 --- a/src/lib/languages/locales/fi.ts +++ b/src/lib/languages/locales/fi.ts @@ -49,6 +49,9 @@ const fi: BaseDict = { albums: "Albumit", artist: "Artisti", artists: "Artistit", + similar_artists: "Samankaltaiset artistit", + similar_songs: "Samankaltaiset kappaleet", + top_songs: "Suosituimmat kappaleet", busy: "...", clear: "Tyhjennä", close: "Sulje", diff --git a/src/lib/languages/locales/fr.ts b/src/lib/languages/locales/fr.ts index f245f34..ddfdb46 100644 --- a/src/lib/languages/locales/fr.ts +++ b/src/lib/languages/locales/fr.ts @@ -49,6 +49,9 @@ const fr: BaseDict = { albums: "Albums", artist: "Artiste", artists: "Artistes", + similar_artists: "Artistes similaires", + similar_songs: "Morceaux similaires", + top_songs: "Meilleurs morceaux", busy: "...", clear: "Effacer", close: "Fermer", diff --git a/src/lib/languages/locales/he.ts b/src/lib/languages/locales/he.ts index dbc392b..928b0ba 100644 --- a/src/lib/languages/locales/he.ts +++ b/src/lib/languages/locales/he.ts @@ -49,6 +49,9 @@ const he: BaseDict = { albums: "אלבומים", artist: "אמן", artists: "אמנים", + similar_artists: "אמנים דומים", + similar_songs: "שירים דומים", + top_songs: "השירים המובילים", busy: "...", clear: "ניקוי", close: "סגירה", diff --git a/src/lib/languages/locales/hi.ts b/src/lib/languages/locales/hi.ts index 58a20c3..fd04283 100644 --- a/src/lib/languages/locales/hi.ts +++ b/src/lib/languages/locales/hi.ts @@ -49,6 +49,9 @@ const hi: BaseDict = { albums: "एल्बम", artist: "कलाकार", artists: "कलाकार", + similar_artists: "समान कलाकार", + similar_songs: "समान गाने", + top_songs: "शीर्ष गाने", busy: "...", clear: "साफ़ करें", close: "बंद करें", diff --git a/src/lib/languages/locales/id.ts b/src/lib/languages/locales/id.ts index 41393d0..b82ce2d 100644 --- a/src/lib/languages/locales/id.ts +++ b/src/lib/languages/locales/id.ts @@ -49,6 +49,9 @@ const id: BaseDict = { albums: "Album", artist: "Artis", artists: "Artis", + similar_artists: "Artis Serupa", + similar_songs: "Lagu Serupa", + top_songs: "Lagu Teratas", busy: "...", clear: "Bersihkan", close: "Tutup", diff --git a/src/lib/languages/locales/it.ts b/src/lib/languages/locales/it.ts index d401dd8..4fa47b4 100644 --- a/src/lib/languages/locales/it.ts +++ b/src/lib/languages/locales/it.ts @@ -49,6 +49,9 @@ const it: BaseDict = { albums: "Album", artist: "Artista", artists: "Artisti", + similar_artists: "Artisti simili", + similar_songs: "Brani simili", + top_songs: "Brani migliori", busy: "...", clear: "Pulisci", close: "Chiudi", diff --git a/src/lib/languages/locales/ja.ts b/src/lib/languages/locales/ja.ts index 99d6d3f..69f3e8c 100644 --- a/src/lib/languages/locales/ja.ts +++ b/src/lib/languages/locales/ja.ts @@ -49,6 +49,9 @@ const ja: BaseDict = { albums: "アルバム", artist: "アーティスト", artists: "アーティスト", + similar_artists: "類似アーティスト", + similar_songs: "類似曲", + top_songs: "人気曲", busy: "...", clear: "クリア", close: "閉じる", diff --git a/src/lib/languages/locales/ko.ts b/src/lib/languages/locales/ko.ts index 87bde39..1944189 100644 --- a/src/lib/languages/locales/ko.ts +++ b/src/lib/languages/locales/ko.ts @@ -49,6 +49,9 @@ const ko: BaseDict = { albums: "앨범", artist: "아티스트", artists: "아티스트", + similar_artists: "비슷한 아티스트", + similar_songs: "비슷한 노래", + top_songs: "인기 곡", busy: "...", clear: "지우기", close: "닫기", diff --git a/src/lib/languages/locales/nl.ts b/src/lib/languages/locales/nl.ts index 7a37bb5..a2d2896 100644 --- a/src/lib/languages/locales/nl.ts +++ b/src/lib/languages/locales/nl.ts @@ -49,6 +49,9 @@ const nl: BaseDict = { albums: "Albums", artist: "Artiest", artists: "Artiesten", + similar_artists: "Vergelijkbare artiesten", + similar_songs: "Vergelijkbare nummers", + top_songs: "Topnummers", busy: "...", clear: "Wissen", close: "Sluiten", diff --git a/src/lib/languages/locales/no.ts b/src/lib/languages/locales/no.ts index 73001b2..83e2fc0 100644 --- a/src/lib/languages/locales/no.ts +++ b/src/lib/languages/locales/no.ts @@ -49,6 +49,9 @@ const no: BaseDict = { albums: "Album", artist: "Artist", artists: "Artister", + similar_artists: "Lignende artister", + similar_songs: "Lignende sanger", + top_songs: "Toppsanger", busy: "...", clear: "Tøm", close: "Lukk", diff --git a/src/lib/languages/locales/pl.ts b/src/lib/languages/locales/pl.ts index 5d1969a..130d76e 100644 --- a/src/lib/languages/locales/pl.ts +++ b/src/lib/languages/locales/pl.ts @@ -49,6 +49,9 @@ const pl: BaseDict = { albums: "Albumy", artist: "Wykonawca", artists: "Wykonawcy", + similar_artists: "Podobni wykonawcy", + similar_songs: "Podobne utwory", + top_songs: "Najpopularniejsze utwory", busy: "...", clear: "Wyczyść", close: "Zamknij", diff --git a/src/lib/languages/locales/pt.ts b/src/lib/languages/locales/pt.ts index be3630c..ec9ba21 100644 --- a/src/lib/languages/locales/pt.ts +++ b/src/lib/languages/locales/pt.ts @@ -49,6 +49,9 @@ const pt: BaseDict = { albums: "Álbuns", artist: "Artista", artists: "Artistas", + similar_artists: "Artistas semelhantes", + similar_songs: "Músicas semelhantes", + top_songs: "Músicas principais", busy: "...", clear: "Limpar", close: "Fechar", diff --git a/src/lib/languages/locales/ro.ts b/src/lib/languages/locales/ro.ts index d7808e7..0bddc20 100644 --- a/src/lib/languages/locales/ro.ts +++ b/src/lib/languages/locales/ro.ts @@ -49,6 +49,9 @@ const ro: BaseDict = { albums: "Albume", artist: "Artist", artists: "Artiști", + similar_artists: "Artiști similari", + similar_songs: "Melodii similare", + top_songs: "Melodii de top", busy: "...", clear: "Golește", close: "Închide", diff --git a/src/lib/languages/locales/ru.ts b/src/lib/languages/locales/ru.ts index 2e7d778..e855873 100644 --- a/src/lib/languages/locales/ru.ts +++ b/src/lib/languages/locales/ru.ts @@ -49,6 +49,9 @@ const ru: BaseDict = { albums: "Альбомы", artist: "Исполнитель", artists: "Исполнители", + similar_artists: "Похожие исполнители", + similar_songs: "Похожие песни", + top_songs: "Лучшие песни", busy: "...", clear: "Очистить", close: "Закрыть", diff --git a/src/lib/languages/locales/sv.ts b/src/lib/languages/locales/sv.ts index 448ffd0..eec884d 100644 --- a/src/lib/languages/locales/sv.ts +++ b/src/lib/languages/locales/sv.ts @@ -49,6 +49,9 @@ const sv: BaseDict = { albums: "Album", artist: "Artist", artists: "Artister", + similar_artists: "Liknande artister", + similar_songs: "Liknande låtar", + top_songs: "Topplåtar", busy: "...", clear: "Rensa", close: "Stäng", diff --git a/src/lib/languages/locales/th.ts b/src/lib/languages/locales/th.ts index 4eae9df..bff0b9c 100644 --- a/src/lib/languages/locales/th.ts +++ b/src/lib/languages/locales/th.ts @@ -49,6 +49,9 @@ const th: BaseDict = { albums: "อัลบั้ม", artist: "ศิลปิน", artists: "ศิลปิน", + similar_artists: "ศิลปินที่คล้ายกัน", + similar_songs: "เพลงที่คล้ายกัน", + top_songs: "เพลงยอดนิยม", busy: "...", clear: "ล้าง", close: "ปิด", diff --git a/src/lib/languages/locales/tl.ts b/src/lib/languages/locales/tl.ts index 45a1a82..b85a27c 100644 --- a/src/lib/languages/locales/tl.ts +++ b/src/lib/languages/locales/tl.ts @@ -49,6 +49,9 @@ const tl: BaseDict = { albums: "Mga Album", artist: "Artista", artists: "Mga Artista", + similar_artists: "Mga Katulad na Artista", + similar_songs: "Mga Katulad na Kanta", + top_songs: "Mga Nangungunang Kanta", busy: "...", clear: "Linisin", close: "Isara", diff --git a/src/lib/languages/locales/tr.ts b/src/lib/languages/locales/tr.ts index 71d7d66..11c3c36 100644 --- a/src/lib/languages/locales/tr.ts +++ b/src/lib/languages/locales/tr.ts @@ -49,6 +49,9 @@ const tr: BaseDict = { albums: "Albümler", artist: "Sanatçı", artists: "Sanatçılar", + similar_artists: "Benzer Sanatçılar", + similar_songs: "Benzer Şarkılar", + top_songs: "En İyi Şarkılar", busy: "...", clear: "Temizle", close: "Kapat", diff --git a/src/lib/languages/locales/uk.ts b/src/lib/languages/locales/uk.ts index 4d3a167..a55fa66 100644 --- a/src/lib/languages/locales/uk.ts +++ b/src/lib/languages/locales/uk.ts @@ -49,6 +49,9 @@ const uk: BaseDict = { albums: "Альбоми", artist: "Виконавець", artists: "Виконавці", + similar_artists: "Схожі виконавці", + similar_songs: "Схожі пісні", + top_songs: "Найкращі пісні", busy: "...", clear: "Очистити", close: "Закрити", diff --git a/src/lib/languages/locales/vi.ts b/src/lib/languages/locales/vi.ts index b260856..5911dd7 100644 --- a/src/lib/languages/locales/vi.ts +++ b/src/lib/languages/locales/vi.ts @@ -49,6 +49,9 @@ const vi: BaseDict = { albums: "Album", artist: "Nghệ sĩ", artists: "Nghệ sĩ", + similar_artists: "Nghệ sĩ tương tự", + similar_songs: "Bài hát tương tự", + top_songs: "Bài hát hàng đầu", busy: "...", clear: "Xóa", close: "Đóng", diff --git a/src/lib/languages/locales/zhcn.ts b/src/lib/languages/locales/zhcn.ts index c7ad22e..d8f6801 100644 --- a/src/lib/languages/locales/zhcn.ts +++ b/src/lib/languages/locales/zhcn.ts @@ -49,6 +49,9 @@ const zhcn: BaseDict = { albums: "专辑", artist: "艺术家", artists: "艺术家", + similar_artists: "相似艺术家", + similar_songs: "相似歌曲", + top_songs: "热门歌曲", busy: "...", clear: "清除", close: "关闭", diff --git a/src/lib/languages/locales/zhtw.ts b/src/lib/languages/locales/zhtw.ts index 5fdbfab..e639849 100644 --- a/src/lib/languages/locales/zhtw.ts +++ b/src/lib/languages/locales/zhtw.ts @@ -49,6 +49,9 @@ const zhtw: BaseDict = { albums: "專輯", artist: "藝術家", artists: "藝術家", + similar_artists: "相似藝術家", + similar_songs: "相似歌曲", + top_songs: "熱門歌曲", busy: "...", clear: "清除", close: "關閉", diff --git a/src/lib/library.svelte.ts b/src/lib/library.svelte.ts index 1d46473..06e46f3 100644 --- a/src/lib/library.svelte.ts +++ b/src/lib/library.svelte.ts @@ -97,6 +97,13 @@ export type TreeType = | "new-playlist" | "new-station"; +export const itemKey = ( + parentKey: string, + type: TreeType, + id: string, + occurrence = 0, +) => `${parentKey}/${type}:${id}${occurrence ? `#${occurrence}` : ""}`; + export type LibraryItem = | Song | Artist @@ -104,14 +111,19 @@ export type LibraryItem = | Share | InternetRadioStation; -const isSong = (item: LibraryItem): item is Song => +export type ChildFactory = ( + item: LibraryItem, +) => (() => Promise) | undefined; + +export const isSong = (item: LibraryItem): item is Song => "title" in item && "artist" in item && "album" in item; +export const itemLabel = (item: LibraryItem) => + "title" in item ? item.title || "" : "name" in item ? item.name || "" : ""; + export const recursiveSongs = async ( items: LibraryItem[], - childFactory?: ( - item: LibraryItem, - ) => (() => Promise) | undefined, + childFactory?: ChildFactory, ): Promise => { const walk = async ( items: LibraryItem[], diff --git a/src/lib/offline/offlineManager.svelte.ts b/src/lib/offline/offlineManager.svelte.ts index 8b02115..b7f69c3 100644 --- a/src/lib/offline/offlineManager.svelte.ts +++ b/src/lib/offline/offlineManager.svelte.ts @@ -607,7 +607,7 @@ class OfflineManager { async downloadArtist(artistId: string) { await this.downloadSongsBatch(async () => { - const albums = await api.getArtist(artistId); + const albums = await api.getArtistAlbums(artistId); const allSongs: Song[] = []; for (const album of albums) { try { diff --git a/src/lib/theme.svelte.ts b/src/lib/theme.svelte.ts index ab27209..8bf4115 100644 --- a/src/lib/theme.svelte.ts +++ b/src/lib/theme.svelte.ts @@ -3,18 +3,27 @@ import { offlineImages } from "./offline/offlineManager.svelte.js"; import { player } from "./player.svelte.js"; import { settings } from "./settings.svelte.js"; -interface DominantColor { - css(): string; - textColor: string; - isDark: boolean; -} - const cache = new Map(); let colorThiefPromise: Promise | null = null; const darkMQ = window.matchMedia("(prefers-color-scheme: dark)"); let themeMeta: HTMLMetaElement | null = null; +export const getDominantColor = async (image: HTMLImageElement) => { + if (!colorThiefPromise) { + colorThiefPromise = import("colorthief").catch((err: unknown) => { + colorThiefPromise = null; + throw err; + }); + } + const { getColor } = await colorThiefPromise; + const dominant = await getColor(image, { + ignoreWhite: false, + }); + if (!dominant) throw new Error("unable to extract image color"); + return dominant; +}; + const resolveCanvasColor = (): string => { const temp = document.createElement("div"); temp.style.display = "none"; @@ -83,16 +92,7 @@ export const initTheme = () => { img.onerror = null; if (!settings.dynamicColors || player.track?.id !== track.id) return; try { - if (!colorThiefPromise) { - colorThiefPromise = import("colorthief").catch((err: unknown) => { - colorThiefPromise = null; - throw err; - }); - } - const { getColor } = await colorThiefPromise; - const dominant = (await getColor(img, { - ignoreWhite: false, - })) as unknown as DominantColor; + const dominant = await getDominantColor(img); const theme = { bg: dominant.css(), text: dominant.textColor, diff --git a/src/lib/treeAnimations.ts b/src/lib/treeAnimations.ts new file mode 100644 index 0000000..cc880fc --- /dev/null +++ b/src/lib/treeAnimations.ts @@ -0,0 +1,86 @@ +import { tick } from "svelte"; +import { + animateEntryRows as animateEntries, + animationsEnabled, + subtleMoveAnimation, +} from "./animations.js"; + +const entryAnimations = new WeakMap(); +const layoutAnimations = new WeakMap(); + +export const animateTreeEntries = ( + container: HTMLElement | undefined, + keys?: readonly string[], + includeLabels = false, +) => { + if (!container || !animationsEnabled()) return; + + void tick().then(() => + requestAnimationFrame(() => { + if (!animationsEnabled()) return; + const rows = [ + ...container.querySelectorAll("[data-tree-key]"), + ].filter( + (row) => + !keys || + keys.some( + (key) => + row.dataset.treeKey?.startsWith(`${key}/`) || + (includeLabels && row.dataset.treeKey === key), + ), + ); + animateEntries(rows, entryAnimations); + }), + ); +}; + +export const captureTreeItemPositions = ( + container: HTMLElement | undefined, +) => { + if (!container || !animationsEnabled()) return; + return new Map( + [...container.querySelectorAll("[data-tree-key]")].map( + (row) => [row, row.getBoundingClientRect().top], + ), + ); +}; + +const animateTreeItemPositions = ( + positions: Map | undefined, +) => { + if (!positions) return; + + void tick().then(() => + requestAnimationFrame(() => { + if (!animationsEnabled()) return; + for (const [row, previousTop] of positions) { + if (!row.isConnected) continue; + const offset = Math.max( + -20, + Math.min(20, previousTop - row.getBoundingClientRect().top), + ); + if (!offset) continue; + + layoutAnimations.get(row)?.cancel(); + const animation = row.animate( + [{ translate: `0 ${offset}px` }, { translate: "0" }], + subtleMoveAnimation, + ); + layoutAnimations.set(row, animation); + animation.onfinish = animation.oncancel = () => { + if (layoutAnimations.get(row) === animation) + layoutAnimations.delete(row); + }; + } + }), + ); +}; + +export const animateTreeExpansion = ( + container: HTMLElement | undefined, + key: string, + positions: Map | undefined, +) => { + animateTreeEntries(container, [key]); + animateTreeItemPositions(positions); +}; diff --git a/src/lib/ui.svelte.ts b/src/lib/ui.svelte.ts index ffa6186..8d70545 100644 --- a/src/lib/ui.svelte.ts +++ b/src/lib/ui.svelte.ts @@ -1,4 +1,4 @@ -import type { InfoItemType } from "./info.js"; +import type { InfoItem } from "./info.js"; export const nav = $state({ anchor: -1, head: -1 }); type StationForm = { @@ -32,11 +32,7 @@ export const ui = $state({ playlistForm: null as PlaylistForm | null, confirmDeletePlaylist: null as DeletePlaylistState | null, shareItemId: null as string | null, - infoItem: null as { - type: InfoItemType; - id: string; - label: string; - } | null, + infoItem: null as InfoItem | null, shareItemLabel: "", shareItemType: "" as | "artist" -- 2.51.2