@behindthemusictree/app-kit-ui 26.1.0 → 29.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  BasePopup_default,
3
- InternalErrorPopup
4
- } from "./chunk-VDXUW7PP.js";
3
+ InternalErrorPopup,
4
+ Toast
5
+ } from "./chunk-DKQQLLMY.js";
5
6
 
6
7
  // src/genre-tree/GenreTreeView.tsx
7
- import { useCallback as useCallback5, useDeferredValue, useEffect as useEffect4, useMemo as useMemo6, useRef as useRef4, useState as useState4 } from "react";
8
+ import { useCallback as useCallback6, useDeferredValue, useEffect as useEffect6, useMemo as useMemo7, useRef as useRef4, useState as useState5 } from "react";
8
9
  import { useIsMutating } from "@tanstack/react-query";
9
10
  import { Plus as Plus2 } from "lucide-react";
10
11
  import { IconTextButton, Button, SegmentedControl as SegmentedControl2 } from "@behindthemusictree/ui";
@@ -15,331 +16,8 @@ import {
15
16
  import {
16
17
  useArtistSearch
17
18
  } from "@behindthemusictree/app-kit/genre-tree";
18
- import { useGenreTreeViewState, useArtist as useArtist2, genreUpdateMutationKey } from "@behindthemusictree/app-kit/genre-tree";
19
- import { usePlayer as usePlayer8 } from "@behindthemusictree/app-kit/player";
20
- import { usePopup as usePopup4 } from "@behindthemusictree/app-kit/popup";
21
-
22
- // src/genre-tree/GenreDetailExtras.tsx
23
- import { Skeleton as Skeleton2 } from "@behindthemusictree/ui";
24
- import { useFetchGenreOverview } from "@behindthemusictree/app-kit/genre-tree";
25
-
26
- // src/genre-tree/ClampedText.tsx
27
- import { useLayoutEffect, useRef, useState } from "react";
28
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
29
- function ClampedText({ children, className = "" }) {
30
- const ref = useRef(null);
31
- const [expanded, setExpanded] = useState(false);
32
- const [overflows, setOverflows] = useState(false);
33
- useLayoutEffect(() => {
34
- const element = ref.current;
35
- if (!element || expanded) return;
36
- setOverflows(element.scrollHeight > element.clientHeight);
37
- }, [children, expanded]);
38
- return /* @__PURE__ */ jsxs(Fragment, { children: [
39
- /* @__PURE__ */ jsx("p", { ref, className: `${className} ${expanded ? "" : "line-clamp-3"}`, children }),
40
- (overflows || expanded) && /* @__PURE__ */ jsx(
41
- "button",
42
- {
43
- type: "button",
44
- "aria-expanded": expanded,
45
- onClick: () => setExpanded((value) => !value),
46
- className: "mt-1 text-xs text-[var(--color-text-muted)] underline hover:text-[var(--color-text)]",
47
- children: expanded ? "Show less" : "Show more"
48
- }
49
- )
50
- ] });
51
- }
52
-
53
- // src/genre-tree/GenrePlaylistTracks.tsx
54
- import { useEffect, useRef as useRef2 } from "react";
55
- import { useInfiniteQuery } from "@tanstack/react-query";
56
- import { z } from "zod";
57
- import { FaPlay, FaPause } from "react-icons/fa";
58
- import { RingLoader, Skeleton } from "@behindthemusictree/ui";
59
- import { useSession } from "@behindthemusictree/app-kit/auth";
60
- import { useFetchWrapper } from "@behindthemusictree/app-kit/transport";
61
- import { PaginatedResponseSchema } from "@behindthemusictree/app-kit/transport";
62
- import { parseWithLog } from "@behindthemusictree/app-kit/transport";
63
- import { usePlayer } from "@behindthemusictree/app-kit/player";
64
- import { PlayStates } from "@behindthemusictree/app-kit/player";
65
- import { usePopup } from "@behindthemusictree/app-kit/popup";
66
-
67
- // src/genre-tree/UnplayableReason.tsx
68
- import { FaYoutube } from "react-icons/fa";
69
- import { NO_VIDEO_LABEL } from "@behindthemusictree/app-kit/genre-tree";
70
- import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
71
- function UnplayableReason({ reason }) {
72
- if (reason !== NO_VIDEO_LABEL) return /* @__PURE__ */ jsx2(Fragment2, { children: reason });
73
- return /* @__PURE__ */ jsxs2("span", { role: "img", "aria-label": reason, className: "relative inline-flex shrink-0 items-center", children: [
74
- /* @__PURE__ */ jsx2(FaYoutube, { size: 16, "aria-hidden": "true" }),
75
- /* @__PURE__ */ jsx2("span", { "aria-hidden": "true", className: "absolute left-1/2 top-1/2 h-0.5 w-[130%] -translate-x-1/2 -translate-y-1/2 -rotate-45 rounded bg-current" })
76
- ] });
77
- }
78
-
79
- // src/genre-tree/GenrePlaylistTracks.tsx
80
- import { ErrorCode } from "@behindthemusictree/app-kit/transport";
81
- import { genrePlaylistQueryKeys } from "@behindthemusictree/app-kit/genre-tree";
82
- import { libraryEndpoints, libraryQueryKeys } from "@behindthemusictree/app-kit/genre-tree";
83
- import { isSong, songUnplayableReason, SongDetailedSchema } from "@behindthemusictree/app-kit/genre-tree";
84
- import { useTrackList } from "@behindthemusictree/app-kit/genre-tree";
85
-
86
- // src/genre-tree/MusicBrainzRecordingLink.tsx
87
- import { FaExternalLinkAlt } from "react-icons/fa";
88
- import { musicBrainzRecordingUrl } from "@behindthemusictree/app-kit/genre-tree";
89
- import { jsx as jsx3 } from "react/jsx-runtime";
90
- function MusicBrainzRecordingLink({ song }) {
91
- const url = musicBrainzRecordingUrl(song);
92
- if (!url) return null;
93
- return /* @__PURE__ */ jsx3(
94
- "a",
95
- {
96
- href: url,
97
- target: "_blank",
98
- rel: "noopener noreferrer",
99
- "aria-label": `View ${song.title} on MusicBrainz`,
100
- title: "View on MusicBrainz",
101
- className: "inline-flex shrink-0 items-center opacity-50 hover:opacity-100 focus-visible:opacity-100",
102
- onClick: (event) => event.stopPropagation(),
103
- children: /* @__PURE__ */ jsx3(FaExternalLinkAlt, { size: 10 })
104
- }
105
- );
106
- }
107
-
108
- // src/genre-tree/GenrePlaylistTracks.tsx
109
- import { ArtistNames } from "@behindthemusictree/app-kit/genre-tree";
110
- import { capitalize, useTrackNoun } from "@behindthemusictree/app-kit/track-noun";
111
- import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
112
- var CATALOGUE_PAGE_SIZE = 100;
113
- var SongPageSchema = PaginatedResponseSchema(SongDetailedSchema).extend({
114
- overallTotal: z.number().nullable(),
115
- page: z.number().nullable(),
116
- totalPages: z.number().nullable()
117
- });
118
- var TEXT_CELL = "min-w-0 flex-1 truncate [@container(min-width:40rem)]:flex [@container(min-width:40rem)]:gap-2";
119
- var TITLE_CELL = "[@container(min-width:40rem)]:flex-[2] [@container(min-width:40rem)]:truncate";
120
- var COLUMN_CELL = "[@container(min-width:40rem)]:flex-1 [@container(min-width:40rem)]:truncate";
121
- var ALBUM_CELL = `hidden [@container(min-width:40rem)]:block ${COLUMN_CELL}`;
122
- var SEPARATOR = "[@container(min-width:40rem)]:hidden";
123
- var nextCursor = (next) => {
124
- const cursor = new URL(next).searchParams.get("cursor");
125
- if (!cursor) throw new Error(`Songs next link has no cursor: ${next}`);
126
- return cursor;
127
- };
128
- function GenrePlaylistTracks({
129
- genreUuid,
130
- genrePlaylist,
131
- scope,
132
- getBackendBaseUrl,
133
- renderArtistLink,
134
- renderSongLeading,
135
- renderSongActions,
136
- renderTracksToolbar,
137
- artist = null
138
- }) {
139
- const { fetchGenrePlaylistTracksPage, playNewTrackListFromGenrePlaylist, playNewTrackListFromArtist } = useTrackList();
140
- const { session, sessionRestored } = useSession();
141
- const { playerTrackObject, playState, handlePlayPauseAction } = usePlayer();
142
- const { showPopup } = usePopup();
143
- const trackNoun = useTrackNoun();
144
- const { uuid } = genrePlaylist;
145
- const { fetch } = useFetchWrapper(getBackendBaseUrl);
146
- const { data, isPending, isError, refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery({
147
- queryKey: scope === "me" ? genrePlaylistQueryKeys.me.tracks(uuid) : artist ? [...libraryQueryKeys.reference.songs.byGenre(getBackendBaseUrl(), genreUuid), "artist", artist.uuid] : libraryQueryKeys.reference.songs.byGenre(getBackendBaseUrl(), genreUuid),
148
- queryFn: async ({ pageParam }) => {
149
- if (scope === "me") return fetchGenrePlaylistTracksPage(uuid, scope, Number(pageParam));
150
- const response = await fetch(
151
- libraryEndpoints.reference.songs.list(),
152
- true,
153
- false,
154
- {},
155
- { genre: genreUuid, ...artist && { artist: artist.uuid }, pageSize: CATALOGUE_PAGE_SIZE, withTotal: false, ...pageParam && { cursor: pageParam } }
156
- );
157
- const parsed = parseWithLog(SongPageSchema, response, "GenrePlaylistTracks");
158
- return { tracks: parsed.results, total: parsed.overallTotal, nextPage: parsed.next ? nextCursor(parsed.next) : null };
159
- },
160
- initialPageParam: scope === "me" ? 1 : "",
161
- getNextPageParam: (lastPage) => lastPage.nextPage ?? void 0,
162
- enabled: scope !== "me" || sessionRestored && !!session?.accessToken
163
- });
164
- const sentinelRef = useRef2(null);
165
- useEffect(() => {
166
- const sentinel = sentinelRef.current;
167
- if (!hasNextPage || isFetchingNextPage || !sentinel) return;
168
- const observer = new IntersectionObserver((entries) => {
169
- if (entries.some((entry) => entry.isIntersecting)) {
170
- void fetchNextPage();
171
- }
172
- });
173
- observer.observe(sentinel);
174
- return () => observer.disconnect();
175
- }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
176
- const pages = data?.pages ?? [];
177
- const tracks = pages.flatMap((page) => page.tracks);
178
- const total = pages[0]?.total ?? void 0;
179
- const play = (startSongUuid) => {
180
- const started = artist ? playNewTrackListFromArtist(artist, scope, { startSongUuid, ordering: "title", genreUuid }) : playNewTrackListFromGenrePlaylist(genrePlaylist, scope, { startSongUuid });
181
- started.catch((error) => {
182
- console.error("Failed to play genre playlist:", error);
183
- showPopup(/* @__PURE__ */ jsx4(InternalErrorPopup, { errorCode: ErrorCode.CLIENT_UNKNOWN }));
184
- });
185
- };
186
- return /* @__PURE__ */ jsxs3("div", { className: "gtv-info-panel-children", children: [
187
- /* @__PURE__ */ jsxs3("span", { className: "gtv-info-panel-children-title", children: [
188
- capitalize(trackNoun.plural),
189
- total !== void 0 && ` (${total})`
190
- ] }),
191
- renderTracksToolbar?.(),
192
- isPending ? /* @__PURE__ */ jsx4(Skeleton, { className: "h-4 w-3/4", "aria-busy": "true" }) : isError && tracks.length === 0 ? /* @__PURE__ */ jsxs3("p", { role: "alert", children: [
193
- "Couldn't load ",
194
- trackNoun.plural,
195
- ".",
196
- " ",
197
- /* @__PURE__ */ jsx4("button", { type: "button", className: "underline", onClick: () => void refetch(), children: "Retry" })
198
- ] }) : tracks.length === 0 ? /* @__PURE__ */ jsx4("p", { children: "\u2014" }) : /* @__PURE__ */ jsxs3("ul", { className: "mt-1 max-h-64 overflow-y-auto text-[0.8125rem] leading-[1.45] [container-type:inline-size]", "aria-label": `${genrePlaylist.name} ${trackNoun.plural}`, children: [
199
- tracks.map((track, index) => {
200
- const label = track.artists?.length ? `${track.title} \u2014 ${track.artists.map((artist2) => artist2.name).join(", ")}` : track.title;
201
- const isCurrent = playerTrackObject?.track.id === track.uuid;
202
- const isPlaying = isCurrent && playState === PlayStates.PLAYING;
203
- const text = /* @__PURE__ */ jsxs3("span", { className: TEXT_CELL, children: [
204
- /* @__PURE__ */ jsx4("span", { className: TITLE_CELL, children: track.title }),
205
- track.artists?.length ? /* @__PURE__ */ jsx4("span", { className: SEPARATOR, children: " \u2014 " }) : null,
206
- /* @__PURE__ */ jsx4("span", { className: COLUMN_CELL, children: track.artists?.length ? /* @__PURE__ */ jsx4(ArtistNames, { artists: track.artists, renderArtistLink }) : null }),
207
- track.album && /* @__PURE__ */ jsx4("span", { className: ALBUM_CELL, children: track.album.name })
208
- ] });
209
- const leading = renderSongLeading?.(track, index);
210
- const actions = renderSongActions?.(track);
211
- const unplayableReason = isSong(track) ? songUnplayableReason(track) : null;
212
- if (unplayableReason) {
213
- return /* @__PURE__ */ jsxs3("li", { className: "flex min-h-8 items-center gap-1 px-1 opacity-50", title: unplayableReason, children: [
214
- leading,
215
- /* @__PURE__ */ jsx4(
216
- "button",
217
- {
218
- type: "button",
219
- disabled: true,
220
- "aria-disabled": "true",
221
- "aria-label": `${label} \u2014 ${unplayableReason}`,
222
- className: "flex h-6 w-6 shrink-0 items-center justify-center rounded-full opacity-0 cursor-not-allowed",
223
- children: /* @__PURE__ */ jsx4(FaPlay, { size: 10 })
224
- }
225
- ),
226
- text,
227
- isSong(track) && /* @__PURE__ */ jsx4(MusicBrainzRecordingLink, { song: track }),
228
- /* @__PURE__ */ jsx4("span", { className: "shrink-0 text-xs", children: /* @__PURE__ */ jsx4(UnplayableReason, { reason: unplayableReason }) }),
229
- actions
230
- ] }, track.uuid);
231
- }
232
- return /* @__PURE__ */ jsxs3(
233
- "li",
234
- {
235
- className: `group flex min-h-8 items-center gap-1 rounded px-1 hover:bg-[var(--color-neutral-100)] ${isCurrent ? "font-medium text-[var(--color-primary)]" : ""}`,
236
- children: [
237
- leading,
238
- /* @__PURE__ */ jsx4(
239
- "button",
240
- {
241
- type: "button",
242
- "aria-label": `${isPlaying ? "Pause" : "Play"} ${label}`,
243
- className: `flex h-6 w-6 shrink-0 items-center justify-center rounded-full cursor-pointer group-hover:opacity-100 focus-visible:opacity-100 ${isCurrent && playState !== PlayStates.STOPPED ? "opacity-100" : "opacity-0 [@media(pointer:coarse)]:opacity-100"}`,
244
- onClick: () => isCurrent ? handlePlayPauseAction() : play(track.uuid),
245
- children: isCurrent && playState === PlayStates.LOADING ? /* @__PURE__ */ jsx4(RingLoader, { size: 10 }) : isPlaying ? /* @__PURE__ */ jsx4(FaPause, { size: 10 }) : /* @__PURE__ */ jsx4(FaPlay, { size: 10 })
246
- }
247
- ),
248
- text,
249
- isSong(track) && /* @__PURE__ */ jsx4(MusicBrainzRecordingLink, { song: track }),
250
- actions
251
- ]
252
- },
253
- track.uuid
254
- );
255
- }),
256
- hasNextPage && /* @__PURE__ */ jsx4("li", { ref: sentinelRef, "aria-hidden": "true", className: "h-4" })
257
- ] })
258
- ] });
259
- }
260
-
261
- // src/genre-tree/GenreDetailExtras.tsx
262
- import { useTrackNoun as useTrackNoun2 } from "@behindthemusictree/app-kit/track-noun";
263
- import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
264
- function GenreDetailExtras({
265
- genreUuid,
266
- genrePlaylist,
267
- scope,
268
- getBackendBaseUrl,
269
- criteriaOverviewSchema,
270
- renderGenreDetailExtras,
271
- renderArtistLink,
272
- renderSummary,
273
- renderEssentialTracks,
274
- family,
275
- ...trackSlots
276
- }) {
277
- const trackNoun = useTrackNoun2();
278
- const { data: overview, isPending } = useFetchGenreOverview(
279
- genreUuid,
280
- scope,
281
- getBackendBaseUrl,
282
- criteriaOverviewSchema
283
- );
284
- if (isPending) {
285
- return /* @__PURE__ */ jsxs4(Fragment3, { children: [
286
- /* @__PURE__ */ jsxs4("div", { className: "gtv-info-panel-children", "aria-busy": "true", "data-testid": "genre-detail-extras-skeleton", children: [
287
- /* @__PURE__ */ jsx5("span", { className: "gtv-info-panel-children-title", children: "Summary" }),
288
- /* @__PURE__ */ jsx5(Skeleton2, { className: "h-4 w-3/4" })
289
- ] }),
290
- family
291
- ] });
292
- }
293
- if (!overview) return /* @__PURE__ */ jsx5(Fragment3, { children: family });
294
- const { summary, description, summarySourceUrl, essentialSongs } = overview;
295
- return /* @__PURE__ */ jsxs4(Fragment3, { children: [
296
- description && /* @__PURE__ */ jsx5("div", { className: "px-3.5 py-2.5", children: /* @__PURE__ */ jsx5(ClampedText, { className: "text-[0.8125rem] leading-[1.45] text-[var(--color-text-muted)]", children: description }, genreUuid) }),
297
- renderSummary ? renderSummary(overview) : /* @__PURE__ */ jsxs4("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
298
- /* @__PURE__ */ jsx5("span", { className: "gtv-info-panel-children-title", children: "Summary" }),
299
- /* @__PURE__ */ jsx5(ClampedText, { className: "mt-1", children: summary ?? "\u2014" }, genreUuid),
300
- summarySourceUrl && /* @__PURE__ */ jsx5(
301
- "a",
302
- {
303
- href: summarySourceUrl,
304
- target: "_blank",
305
- rel: "noopener noreferrer",
306
- className: "mt-1 inline-block text-xs text-[var(--color-text-muted)] underline hover:text-[var(--color-text)]",
307
- children: "Source: Wikipedia"
308
- }
309
- )
310
- ] }),
311
- renderSummary && summarySourceUrl && /* @__PURE__ */ jsx5(
312
- "a",
313
- {
314
- href: summarySourceUrl,
315
- target: "_blank",
316
- rel: "noopener noreferrer",
317
- className: "mx-3.5 mb-2.5 inline-block text-xs text-[var(--color-text-muted)] underline hover:text-[var(--color-text)]",
318
- children: "Source: Wikipedia"
319
- }
320
- ),
321
- family,
322
- renderGenreDetailExtras?.(overview),
323
- renderEssentialTracks ? renderEssentialTracks(overview) : essentialSongs && /* @__PURE__ */ jsxs4("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
324
- /* @__PURE__ */ jsxs4("span", { className: "gtv-info-panel-children-title", children: [
325
- "Essential ",
326
- trackNoun.plural
327
- ] }),
328
- essentialSongs.length > 0 ? /* @__PURE__ */ jsx5("ul", { className: "mt-1 divide-y divide-[var(--color-border)]", children: essentialSongs.map((track) => /* @__PURE__ */ jsx5("li", { className: "py-1", children: track.title }, track.uuid)) }) : /* @__PURE__ */ jsx5("p", { className: "mt-1", children: "\u2014" })
329
- ] }),
330
- /* @__PURE__ */ jsx5(
331
- GenrePlaylistTracks,
332
- {
333
- genreUuid,
334
- genrePlaylist,
335
- scope,
336
- getBackendBaseUrl,
337
- renderArtistLink,
338
- ...trackSlots
339
- }
340
- )
341
- ] });
342
- }
19
+ import { useGenreTreeViewState, useArtist as useArtist2, useUpdateGenre as useUpdateGenre2, genreUpdateMutationKey } from "@behindthemusictree/app-kit/genre-tree";
20
+ import { usePopup as usePopup5 } from "@behindthemusictree/app-kit/popup";
343
21
 
344
22
  // src/genre-tree/playlist-tree/TreePerRoot.tsx
345
23
  import { useCallback as useCallback2, useMemo as useMemo2 } from "react";
@@ -347,22 +25,22 @@ import {
347
25
  GenreTree,
348
26
  getGenreTreeColor
349
27
  } from "@behindthemusictree/genre-tree-view";
350
- import { usePlayer as usePlayer2 } from "@behindthemusictree/app-kit/player";
351
- import { usePopup as usePopup2 } from "@behindthemusictree/app-kit/popup";
352
- import { ErrorCode as ErrorCode2 } from "@behindthemusictree/app-kit/transport";
28
+ import { usePlayer } from "@behindthemusictree/app-kit/player";
29
+ import { usePopup } from "@behindthemusictree/app-kit/popup";
30
+ import { ErrorCode } from "@behindthemusictree/app-kit/transport";
353
31
 
354
32
  // src/genre-tree/playlist-tree/useGenrePlaylistPlayback.ts
355
33
  import { useCallback, useMemo } from "react";
356
34
  import {
357
35
  TrackListOriginType,
358
- useTrackList as useTrackList2
36
+ useTrackList
359
37
  } from "@behindthemusictree/app-kit/genre-tree";
360
38
  function useGenrePlaylistPlayback({
361
39
  scope,
362
40
  genrePlaylists,
363
41
  artist = null
364
42
  }) {
365
- const { trackList, playNewTrackListFromGenrePlaylist, playNewTrackListFromArtist } = useTrackList2();
43
+ const { trackList, playNewTrackListFromGenrePlaylist, playNewTrackListFromArtist } = useTrackList();
366
44
  const origin = trackList?.origin;
367
45
  const playingNodeId = useMemo(() => {
368
46
  if (!origin) return null;
@@ -383,11 +61,11 @@ function useGenrePlaylistPlayback({
383
61
  }
384
62
 
385
63
  // src/genre-tree/playlist-tree/TreePerRoot.tsx
386
- import { capitalize as capitalize2, useTrackNoun as useTrackNoun3 } from "@behindthemusictree/app-kit/track-noun";
387
- import { jsx as jsx6 } from "react/jsx-runtime";
64
+ import { capitalize, useTrackNoun } from "@behindthemusictree/app-kit/track-noun";
65
+ import { jsx } from "react/jsx-runtime";
388
66
  function GenrePlaylistTreePerRoot({
389
67
  scope,
390
- className,
68
+ className: className2,
391
69
  rootUuid,
392
70
  genrePlaylistTreePerRoot,
393
71
  handleGenreCreationAction,
@@ -403,17 +81,17 @@ function GenrePlaylistTreePerRoot({
403
81
  readOnly = false,
404
82
  showToolbar,
405
83
  wheelZoom,
406
- controlsOrientation,
407
84
  defaultViewport,
85
+ viewportRestore,
408
86
  onViewportChange,
409
87
  playback,
410
88
  ...infoPanelCuration
411
89
  }) {
412
- const { isPlaying, setIsPlaying } = usePlayer2();
90
+ const { isPlaying, setIsPlaying } = usePlayer();
413
91
  const defaultPlayback = useGenrePlaylistPlayback({ scope, genrePlaylists: genrePlaylistTreePerRoot });
414
92
  const { playingNodeId, play } = playback ?? defaultPlayback;
415
- const trackNoun = useTrackNoun3();
416
- const { showPopup } = usePopup2();
93
+ const trackNoun = useTrackNoun();
94
+ const { showPopup } = usePopup();
417
95
  const nodes = useMemo2(
418
96
  () => genrePlaylistTreePerRoot.map((genrePlaylist) => ({
419
97
  id: genrePlaylist.uuid,
@@ -440,7 +118,7 @@ function GenrePlaylistTreePerRoot({
440
118
  }
441
119
  play(genrePlaylist).catch((error) => {
442
120
  console.error("Failed to load genre playlist tracks:", error);
443
- showPopup(/* @__PURE__ */ jsx6(InternalErrorPopup, { errorCode: ErrorCode2.CLIENT_UNKNOWN }));
121
+ showPopup(/* @__PURE__ */ jsx(InternalErrorPopup, { errorCode: ErrorCode.CLIENT_UNKNOWN }));
444
122
  });
445
123
  },
446
124
  [
@@ -472,12 +150,12 @@ function GenrePlaylistTreePerRoot({
472
150
  if (confirm(`Are you sure you want to delete "${node.name}"?`)) {
473
151
  }
474
152
  }, []);
475
- return /* @__PURE__ */ jsx6(
153
+ return /* @__PURE__ */ jsx(
476
154
  GenreTree,
477
155
  {
478
- className,
156
+ className: className2,
479
157
  nodes,
480
- itemCountLabel: `${capitalize2(trackNoun.singular)} count`,
158
+ itemCountLabel: `${capitalize(trackNoun.singular)} count`,
481
159
  rootColor: getGenreTreeColor(rootUuid),
482
160
  playingNodeId,
483
161
  playState: isPlaying ? "playing" : "paused",
@@ -495,8 +173,8 @@ function GenrePlaylistTreePerRoot({
495
173
  hideInfoPanelTitle,
496
174
  showToolbar,
497
175
  wheelZoom,
498
- controlsOrientation,
499
176
  defaultViewport,
177
+ viewportRestore,
500
178
  onViewportChange,
501
179
  ...infoPanelCuration,
502
180
  readOnly
@@ -509,19 +187,26 @@ import { useCallback as useCallback3, useMemo as useMemo3 } from "react";
509
187
  import {
510
188
  GenreTreeWheel
511
189
  } from "@behindthemusictree/genre-tree-view";
512
- import { usePlayer as usePlayer3 } from "@behindthemusictree/app-kit/player";
513
- import { usePopup as usePopup3 } from "@behindthemusictree/app-kit/popup";
514
- import { ErrorCode as ErrorCode3 } from "@behindthemusictree/app-kit/transport";
190
+ import { usePlayer as usePlayer2 } from "@behindthemusictree/app-kit/player";
191
+ import { usePopup as usePopup2 } from "@behindthemusictree/app-kit/popup";
192
+ import { ErrorCode as ErrorCode2 } from "@behindthemusictree/app-kit/transport";
515
193
 
516
194
  // src/genre-tree/playlist-tree/size-metric.ts
517
- var genrePlaylistSize = (genrePlaylist, metric) => metric === "views" ? genrePlaylist.viewsCount : genrePlaylist.tracksCount;
195
+ var hasViewsCount = (genrePlaylists) => genrePlaylists.every((genrePlaylist) => genrePlaylist.viewsCount !== void 0);
196
+ var genrePlaylistSize = (genrePlaylist, metric) => {
197
+ if (metric === "songs") return genrePlaylist.tracksCount;
198
+ if (genrePlaylist.viewsCount === void 0) {
199
+ throw new Error(`Genre playlist ${genrePlaylist.uuid} has no viewsCount to size by views`);
200
+ }
201
+ return genrePlaylist.viewsCount;
202
+ };
518
203
 
519
204
  // src/genre-tree/playlist-tree/TreeWheel.tsx
520
- import { capitalize as capitalize3, useTrackNoun as useTrackNoun4 } from "@behindthemusictree/app-kit/track-noun";
521
- import { jsx as jsx7 } from "react/jsx-runtime";
205
+ import { capitalize as capitalize2, useTrackNoun as useTrackNoun2 } from "@behindthemusictree/app-kit/track-noun";
206
+ import { jsx as jsx2 } from "react/jsx-runtime";
522
207
  function GenrePlaylistTreeWheel({
523
208
  scope,
524
- className,
209
+ className: className2,
525
210
  genrePlaylists,
526
211
  handleGenreCreationAction,
527
212
  handleGenreRenameAction,
@@ -536,20 +221,20 @@ function GenrePlaylistTreeWheel({
536
221
  readOnly = false,
537
222
  allowWheelRotation,
538
223
  showToolbar,
539
- controlsOrientation,
540
224
  sizeMetric = "songs",
541
225
  floatingControlsLeading,
542
226
  busy,
543
227
  defaultViewport,
228
+ viewportRestore,
544
229
  onViewportChange,
545
230
  playback,
546
231
  ...infoPanelCuration
547
232
  }) {
548
- const { isPlaying, setIsPlaying } = usePlayer3();
233
+ const { isPlaying, setIsPlaying } = usePlayer2();
549
234
  const defaultPlayback = useGenrePlaylistPlayback({ scope, genrePlaylists });
550
235
  const { playingNodeId, play } = playback ?? defaultPlayback;
551
- const trackNoun = useTrackNoun4();
552
- const { showPopup } = usePopup3();
236
+ const trackNoun = useTrackNoun2();
237
+ const { showPopup } = usePopup2();
553
238
  const nodes = useMemo3(
554
239
  () => genrePlaylists.map((genrePlaylist) => ({
555
240
  id: genrePlaylist.uuid,
@@ -575,7 +260,7 @@ function GenrePlaylistTreeWheel({
575
260
  }
576
261
  play(genrePlaylist).catch((error) => {
577
262
  console.error("Failed to load genre playlist tracks:", error);
578
- showPopup(/* @__PURE__ */ jsx7(InternalErrorPopup, { errorCode: ErrorCode3.CLIENT_UNKNOWN }));
263
+ showPopup(/* @__PURE__ */ jsx2(InternalErrorPopup, { errorCode: ErrorCode2.CLIENT_UNKNOWN }));
579
264
  });
580
265
  },
581
266
  [
@@ -607,12 +292,12 @@ function GenrePlaylistTreeWheel({
607
292
  if (confirm(`Are you sure you want to delete "${node.name}"?`)) {
608
293
  }
609
294
  }, []);
610
- return /* @__PURE__ */ jsx7(
295
+ return /* @__PURE__ */ jsx2(
611
296
  GenreTreeWheel,
612
297
  {
613
- className,
298
+ className: className2,
614
299
  nodes,
615
- itemCountLabel: sizeMetric === "views" ? "Views" : `${capitalize3(trackNoun.singular)} count`,
300
+ itemCountLabel: sizeMetric === "views" ? "Views" : `${capitalize2(trackNoun.singular)} count`,
616
301
  playingNodeId,
617
302
  playState: isPlaying ? "playing" : "paused",
618
303
  onPlayPause: handlePlayPause,
@@ -629,10 +314,10 @@ function GenrePlaylistTreeWheel({
629
314
  hideInfoPanelTitle,
630
315
  allowWheelRotation,
631
316
  showToolbar,
632
- controlsOrientation,
633
317
  floatingControlsLeading,
634
318
  busy,
635
319
  defaultViewport,
320
+ viewportRestore,
636
321
  onViewportChange,
637
322
  ...infoPanelCuration,
638
323
  readOnly
@@ -646,13 +331,13 @@ import {
646
331
  GenreTreeOutline,
647
332
  GenreTreeWheelRadialPopCore
648
333
  } from "@behindthemusictree/genre-tree-view";
649
- import { usePlayer as usePlayer4 } from "@behindthemusictree/app-kit/player";
650
- import { capitalize as capitalize4, useTrackNoun as useTrackNoun5 } from "@behindthemusictree/app-kit/track-noun";
651
- import { jsx as jsx8 } from "react/jsx-runtime";
334
+ import { usePlayer as usePlayer3 } from "@behindthemusictree/app-kit/player";
335
+ import { capitalize as capitalize3, useTrackNoun as useTrackNoun3 } from "@behindthemusictree/app-kit/track-noun";
336
+ import { jsx as jsx3 } from "react/jsx-runtime";
652
337
  function GenrePlaylistTreeWheelRadialPopCore(props) {
653
338
  const {
654
339
  scope,
655
- className,
340
+ className: className2,
656
341
  genrePlaylists,
657
342
  handleGenreCreationAction,
658
343
  handleGenreRenameAction,
@@ -667,12 +352,12 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
667
352
  readOnly = false,
668
353
  allowWheelRotation,
669
354
  showToolbar,
670
- controlsOrientation,
671
355
  sizeMetric = "songs",
672
356
  floatingControlsLeading,
673
357
  busy,
674
358
  outline = false,
675
359
  defaultViewport,
360
+ viewportRestore,
676
361
  onViewportChange,
677
362
  playback
678
363
  } = props;
@@ -685,10 +370,10 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
685
370
  hideExpandButton: props.hideExpandButton,
686
371
  overlayPanel: props.overlayPanel
687
372
  };
688
- const { isPlaying, setIsPlaying } = usePlayer4();
373
+ const { isPlaying, setIsPlaying } = usePlayer3();
689
374
  const defaultPlayback = useGenrePlaylistPlayback({ scope, genrePlaylists });
690
375
  const { playingNodeId, play } = playback ?? defaultPlayback;
691
- const trackNoun = useTrackNoun5();
376
+ const trackNoun = useTrackNoun3();
692
377
  const nodes = useMemo4(
693
378
  () => genrePlaylists.map((genrePlaylist) => ({
694
379
  id: genrePlaylist.uuid,
@@ -742,9 +427,9 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
742
427
  }
743
428
  }, []);
744
429
  const treeProps = {
745
- className,
430
+ className: className2,
746
431
  nodes,
747
- itemCountLabel: sizeMetric === "views" ? "Views" : `${capitalize4(trackNoun.singular)} count`,
432
+ itemCountLabel: sizeMetric === "views" ? "Views" : `${capitalize3(trackNoun.singular)} count`,
748
433
  playingNodeId,
749
434
  playState: isPlaying ? "playing" : "paused",
750
435
  onPlayPause: handlePlayPause,
@@ -763,15 +448,15 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
763
448
  ...infoPanelCuration,
764
449
  readOnly
765
450
  };
766
- return outline ? /* @__PURE__ */ jsx8(GenreTreeOutline, { ...treeProps }) : /* @__PURE__ */ jsx8(
451
+ return outline ? /* @__PURE__ */ jsx3(GenreTreeOutline, { ...treeProps }) : /* @__PURE__ */ jsx3(
767
452
  GenreTreeWheelRadialPopCore,
768
453
  {
769
454
  ...treeProps,
770
455
  allowWheelRotation,
771
- controlsOrientation,
772
456
  floatingControlsLeading,
773
457
  busy,
774
458
  defaultViewport,
459
+ viewportRestore,
775
460
  onViewportChange,
776
461
  ...focus.onFocusGenreChange ? { focusNodeId: focus.focusGenreUuid, onFocusChange: focus.onFocusGenreChange } : { focusNodeId: void 0, onFocusChange: void 0 }
777
462
  }
@@ -779,13 +464,13 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
779
464
  }
780
465
 
781
466
  // src/genre-tree/GenreTreeWheelHandoff.tsx
782
- import { useEffect as useEffect2, useRef as useRef3, useState as useState2 } from "react";
783
- import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
467
+ import { useEffect, useRef, useState } from "react";
468
+ import { jsx as jsx4, jsxs } from "react/jsx-runtime";
784
469
  function GenreTreeWheelHandoff({ skeleton, children, onRevealedChange }) {
785
- const [revealed, setRevealed] = useState2(false);
786
- const onRevealedChangeRef = useRef3(onRevealedChange);
470
+ const [revealed, setRevealed] = useState(false);
471
+ const onRevealedChangeRef = useRef(onRevealedChange);
787
472
  onRevealedChangeRef.current = onRevealedChange;
788
- useEffect2(() => {
473
+ useEffect(() => {
789
474
  let innerFrame = 0;
790
475
  const outerFrame = requestAnimationFrame(() => {
791
476
  innerFrame = requestAnimationFrame(() => {
@@ -799,8 +484,8 @@ function GenreTreeWheelHandoff({ skeleton, children, onRevealedChange }) {
799
484
  cancelAnimationFrame(innerFrame);
800
485
  };
801
486
  }, []);
802
- return /* @__PURE__ */ jsxs5("div", { className: "relative w-full h-full", children: [
803
- /* @__PURE__ */ jsx9(
487
+ return /* @__PURE__ */ jsxs("div", { className: "relative w-full h-full", children: [
488
+ /* @__PURE__ */ jsx4(
804
489
  "div",
805
490
  {
806
491
  className: "absolute inset-0 transition-opacity duration-150 ease-out",
@@ -809,7 +494,7 @@ function GenreTreeWheelHandoff({ skeleton, children, onRevealedChange }) {
809
494
  children
810
495
  }
811
496
  ),
812
- !revealed && /* @__PURE__ */ jsx9("div", { className: "absolute inset-0", "aria-hidden": "true", children: skeleton })
497
+ !revealed && /* @__PURE__ */ jsx4("div", { className: "absolute inset-0", "aria-hidden": "true", children: skeleton })
813
498
  ] });
814
499
  }
815
500
 
@@ -817,7 +502,7 @@ function GenreTreeWheelHandoff({ skeleton, children, onRevealedChange }) {
817
502
  import { ArrowLeft, LoaderCircle, Search, X } from "lucide-react";
818
503
  import { Input } from "@behindthemusictree/ui";
819
504
  import { useGenreSearch } from "@behindthemusictree/app-kit/genre-tree";
820
- import { jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
505
+ import { jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
821
506
  function GenreSearch({
822
507
  genrePlaylists,
823
508
  onSelect,
@@ -839,19 +524,19 @@ function GenreSearch({
839
524
  const loading = query !== "" && query !== selectedName && (extraResults?.loading ?? false);
840
525
  const showList = results.length > 0 || extraItems.length > 0;
841
526
  const heading = "px-3 pt-2 pb-1 text-xs font-medium uppercase text-[var(--color-text-muted)]";
842
- return /* @__PURE__ */ jsxs6("div", { className: "genre-search relative w-[calc(var(--layout-detail-panel-w,25.5rem)-2*var(--layout-panel-inset,1rem))] shrink-0", children: [
843
- onPrevious && /* @__PURE__ */ jsx10(
527
+ return /* @__PURE__ */ jsxs2("div", { className: "genre-search relative w-[calc(var(--layout-detail-panel-w,25.5rem)-2*var(--layout-panel-inset,1rem))] shrink-0 max-md:w-full", children: [
528
+ onPrevious && /* @__PURE__ */ jsx5(
844
529
  "button",
845
530
  {
846
531
  type: "button",
847
532
  "aria-label": "Previous",
848
533
  title: "Previous",
849
- className: "genre-search-previous absolute left-2 top-1/2 z-10 hidden p-1.5 text-[var(--color-text-muted)] rounded-full -translate-y-1/2 hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]",
534
+ className: "genre-search-previous absolute left-2 top-1/2 z-10 hidden items-center justify-center p-1.5 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] [@media(pointer:coarse)]:min-w-[var(--layout-touch-target)] text-[var(--color-text-muted)] rounded-full -translate-y-1/2 hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]",
850
535
  onClick: onPrevious,
851
- children: /* @__PURE__ */ jsx10(ArrowLeft, { "aria-hidden": "true", className: "w-4 h-4" })
536
+ children: /* @__PURE__ */ jsx5(ArrowLeft, { "aria-hidden": "true", className: "w-4 h-4" })
852
537
  }
853
538
  ),
854
- /* @__PURE__ */ jsx10(
539
+ /* @__PURE__ */ jsx5(
855
540
  Input,
856
541
  {
857
542
  type: "text",
@@ -863,24 +548,27 @@ function GenreSearch({
863
548
  placeholder: placeholder ?? `${label}\u2026`,
864
549
  "aria-label": label,
865
550
  "aria-busy": loading,
551
+ inputMode: "search",
552
+ enterKeyHint: "search",
553
+ autoComplete: "off",
866
554
  className: "h-[var(--layout-search-h,3rem)] pl-6 pr-20 text-base transition-[padding] duration-200 ease-out motion-reduce:transition-none bg-[var(--color-surface)] border-[var(--color-border)] rounded-full shadow-md"
867
555
  }
868
556
  ),
869
- /* @__PURE__ */ jsxs6("div", { className: "absolute right-3 top-1/2 flex items-center -translate-y-1/2", children: [
870
- /* @__PURE__ */ jsxs6(
557
+ /* @__PURE__ */ jsxs2("div", { className: "absolute right-3 top-1/2 flex items-center -translate-y-1/2", children: [
558
+ /* @__PURE__ */ jsxs2(
871
559
  "span",
872
560
  {
873
561
  "data-loading": loading,
874
562
  className: "relative p-2 text-[var(--color-text-muted)] pointer-events-none",
875
563
  children: [
876
- /* @__PURE__ */ jsx10(
564
+ /* @__PURE__ */ jsx5(
877
565
  Search,
878
566
  {
879
567
  "aria-hidden": "true",
880
568
  className: `w-6 h-6 transition-opacity duration-200 motion-reduce:transition-none ${loading ? "opacity-0" : ""}`
881
569
  }
882
570
  ),
883
- /* @__PURE__ */ jsx10(
571
+ /* @__PURE__ */ jsx5(
884
572
  LoaderCircle,
885
573
  {
886
574
  "aria-hidden": "true",
@@ -890,44 +578,44 @@ function GenreSearch({
890
578
  ]
891
579
  }
892
580
  ),
893
- canClear && /* @__PURE__ */ jsx10(
581
+ canClear && /* @__PURE__ */ jsx5(
894
582
  "button",
895
583
  {
896
584
  type: "button",
897
585
  "aria-label": "Clear search",
898
- className: "p-2 text-[var(--color-text-muted)] rounded-full hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]",
586
+ className: "flex items-center justify-center p-2 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] [@media(pointer:coarse)]:min-w-[var(--layout-touch-target)] text-[var(--color-text-muted)] rounded-full hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]",
899
587
  onClick: () => {
900
588
  clear();
901
589
  onQueryChange?.("");
902
590
  },
903
- children: /* @__PURE__ */ jsx10(X, { "aria-hidden": "true", className: "w-6 h-6" })
591
+ children: /* @__PURE__ */ jsx5(X, { "aria-hidden": "true", className: "w-6 h-6" })
904
592
  }
905
593
  )
906
594
  ] }),
907
- extraResults?.status && /* @__PURE__ */ jsx10("p", { role: "status", "aria-live": "polite", className: "sr-only", children: extraResults.status }),
908
- showList && /* @__PURE__ */ jsxs6("ul", { className: "genre-search-results absolute z-40 w-full max-h-64 mt-1 overflow-y-auto bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl shadow-md", children: [
909
- extraResults && results.length > 0 && /* @__PURE__ */ jsx10("li", { className: heading, children: "Genres" }),
910
- results.map((genrePlaylist) => /* @__PURE__ */ jsx10("li", { children: /* @__PURE__ */ jsxs6(
595
+ extraResults?.status && /* @__PURE__ */ jsx5("p", { role: "status", "aria-live": "polite", className: "sr-only", children: extraResults.status }),
596
+ showList && /* @__PURE__ */ jsxs2("ul", { className: "genre-search-results absolute z-40 w-full max-h-[min(16rem,50dvh)] mt-1 overflow-y-auto bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl shadow-md", children: [
597
+ extraResults && results.length > 0 && /* @__PURE__ */ jsx5("li", { className: heading, children: "Genres" }),
598
+ results.map((genrePlaylist) => /* @__PURE__ */ jsx5("li", { children: /* @__PURE__ */ jsxs2(
911
599
  "button",
912
600
  {
913
601
  type: "button",
914
- className: "w-full px-3 py-1.5 text-left hover:bg-[var(--color-surface-muted)]",
602
+ className: "w-full px-3 py-1.5 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] text-left hover:bg-[var(--color-surface-muted)]",
915
603
  onClick: () => {
916
604
  select(genrePlaylist);
917
605
  onQueryChange?.("");
918
606
  },
919
607
  children: [
920
608
  genrePlaylist.name,
921
- genrePlaylist.isUmbrella && /* @__PURE__ */ jsx10("span", { className: "ml-2 text-xs italic text-[var(--color-text-muted)]", children: "umbrella" })
609
+ genrePlaylist.isUmbrella && /* @__PURE__ */ jsx5("span", { className: "ml-2 text-xs italic text-[var(--color-text-muted)]", children: "umbrella" })
922
610
  ]
923
611
  }
924
612
  ) }, genrePlaylist.uuid)),
925
- extraResults && extraItems.length > 0 && /* @__PURE__ */ jsx10("li", { className: heading, children: extraResults.label }),
926
- extraItems.map((item) => /* @__PURE__ */ jsx10("li", { children: /* @__PURE__ */ jsx10(
613
+ extraResults && extraItems.length > 0 && /* @__PURE__ */ jsx5("li", { className: heading, children: extraResults.label }),
614
+ extraItems.map((item) => /* @__PURE__ */ jsx5("li", { children: /* @__PURE__ */ jsx5(
927
615
  "button",
928
616
  {
929
617
  type: "button",
930
- className: "w-full px-3 py-1.5 text-left hover:bg-[var(--color-surface-muted)]",
618
+ className: "w-full px-3 py-1.5 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] text-left hover:bg-[var(--color-surface-muted)]",
931
619
  onClick: () => {
932
620
  setQuery("");
933
621
  onQueryChange?.("");
@@ -940,9 +628,231 @@ function GenreSearch({
940
628
  ] });
941
629
  }
942
630
 
631
+ // src/genre-tree/GenreInfoPanelBody.tsx
632
+ import { usePlayer as usePlayer5 } from "@behindthemusictree/app-kit/player";
633
+
634
+ // src/genre-tree/GenreActionBand.tsx
635
+ import { ArrowLeftRight, MoveUpRight, Pause, Pencil, Play, Plus, ZoomIn } from "lucide-react";
636
+
637
+ // src/genre-tree/ShareActionButton.tsx
638
+ import { Share2 as Share22 } from "lucide-react";
639
+ import { usePopup as usePopup3 } from "@behindthemusictree/app-kit/popup";
640
+
641
+ // src/genre-tree/SharePopup.tsx
642
+ import { useCallback as useCallback5, useEffect as useEffect2, useMemo as useMemo5, useState as useState2 } from "react";
643
+ import { Link2, Share2 } from "lucide-react";
644
+ import { FaWhatsapp } from "react-icons/fa";
645
+ import { SiGmail } from "react-icons/si";
646
+
647
+ // src/genre-tree/ShareDestinationButton.tsx
648
+ import { Fragment, jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
649
+ var className = "group flex min-w-0 flex-1 flex-col items-center justify-center gap-1.5 rounded-xl px-2 py-2 text-xs text-[var(--color-text)] transition-colors hover:bg-[var(--color-surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50";
650
+ function Contents({ icon, label }) {
651
+ return /* @__PURE__ */ jsxs3(Fragment, { children: [
652
+ /* @__PURE__ */ jsx6("span", { className: "flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-surface-muted)] transition-colors group-hover:bg-[var(--color-border)]", children: icon }),
653
+ /* @__PURE__ */ jsx6("span", { className: "max-w-full truncate", children: label })
654
+ ] });
655
+ }
656
+ function ShareDestinationButton({
657
+ label,
658
+ icon,
659
+ href,
660
+ onClick,
661
+ disabled = false,
662
+ ariaLabel
663
+ }) {
664
+ if (href) {
665
+ return /* @__PURE__ */ jsx6("a", { href, target: "_blank", rel: "noopener noreferrer", "aria-label": ariaLabel, className, children: /* @__PURE__ */ jsx6(Contents, { icon, label }) });
666
+ }
667
+ return /* @__PURE__ */ jsx6("button", { type: "button", onClick, disabled, "aria-label": ariaLabel, className, children: /* @__PURE__ */ jsx6(Contents, { icon, label }) });
668
+ }
669
+
670
+ // src/genre-tree/SharePreviewCard.tsx
671
+ import { Music2, UserRound } from "lucide-react";
672
+ import { jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
673
+ function SharePreviewCard({ target, url }) {
674
+ const Icon = target.kind === "genre" ? Music2 : UserRound;
675
+ const typeLabel = target.kind === "genre" ? "Genre" : "Artist";
676
+ return /* @__PURE__ */ jsx7("div", { className: "rounded-3xl bg-[var(--color-surface-muted)] p-4", children: /* @__PURE__ */ jsxs4("div", { className: "flex min-h-32 overflow-hidden rounded-2xl bg-[var(--color-surface)] shadow-lg", children: [
677
+ /* @__PURE__ */ jsx7("div", { className: "flex w-28 shrink-0 items-center justify-center bg-[var(--color-primary)]/15 text-[var(--color-primary)]", children: /* @__PURE__ */ jsx7(Icon, { "aria-hidden": "true", className: "h-10 w-10" }) }),
678
+ /* @__PURE__ */ jsxs4("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-1 p-4", children: [
679
+ /* @__PURE__ */ jsx7("span", { className: "text-xs font-medium uppercase tracking-wide text-[var(--color-text-muted)]", children: typeLabel }),
680
+ /* @__PURE__ */ jsx7("span", { className: "truncate text-base font-semibold text-[var(--color-text)]", title: target.name, children: target.name }),
681
+ /* @__PURE__ */ jsx7("span", { className: "truncate text-sm text-[var(--color-primary)]", title: url, children: url })
682
+ ] })
683
+ ] }) });
684
+ }
685
+
686
+ // src/genre-tree/SharePopup.tsx
687
+ import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
688
+ function resolveAbsoluteUrl(url) {
689
+ try {
690
+ const parsed = new URL(url, window.location.href);
691
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : void 0;
692
+ } catch {
693
+ return void 0;
694
+ }
695
+ }
696
+ async function copyText(text) {
697
+ try {
698
+ if (navigator.clipboard?.writeText) {
699
+ await navigator.clipboard.writeText(text);
700
+ return true;
701
+ }
702
+ } catch {
703
+ }
704
+ const input = document.createElement("textarea");
705
+ input.value = text;
706
+ input.setAttribute("readonly", "");
707
+ input.style.position = "fixed";
708
+ input.style.opacity = "0";
709
+ document.body.append(input);
710
+ input.select();
711
+ try {
712
+ return document.execCommand("copy");
713
+ } catch {
714
+ return false;
715
+ } finally {
716
+ input.remove();
717
+ }
718
+ }
719
+ function SharePopup({ target, url, onClose }) {
720
+ const [absoluteUrl, setAbsoluteUrl] = useState2(url);
721
+ const [copyStatus, setCopyStatus] = useState2("idle");
722
+ const [canUseNativeShare, setCanUseNativeShare] = useState2(false);
723
+ const [shareError, setShareError] = useState2(null);
724
+ const [toastId, setToastId] = useState2(0);
725
+ useEffect2(() => {
726
+ setAbsoluteUrl(resolveAbsoluteUrl(url) ?? "");
727
+ setCopyStatus("idle");
728
+ setShareError(null);
729
+ }, [url]);
730
+ useEffect2(() => {
731
+ setCanUseNativeShare(typeof navigator !== "undefined" && typeof navigator.share === "function");
732
+ }, []);
733
+ const destinationUrls = useMemo5(() => {
734
+ if (!absoluteUrl) return null;
735
+ const message = `${target.name} \u2014 ${absoluteUrl}`;
736
+ return {
737
+ whatsapp: `https://wa.me/?text=${encodeURIComponent(message)}`,
738
+ gmail: `https://mail.google.com/mail/?view=cm&fs=1&su=${encodeURIComponent(`Share: ${target.name}`)}&body=${encodeURIComponent(message)}`
739
+ };
740
+ }, [absoluteUrl, target.name]);
741
+ const handleCopy = async () => {
742
+ if (!absoluteUrl) {
743
+ setCopyStatus("failed");
744
+ return;
745
+ }
746
+ setShareError(null);
747
+ const copied = await copyText(absoluteUrl);
748
+ setCopyStatus(copied ? "idle" : "failed");
749
+ if (copied) setToastId((id) => id + 1);
750
+ };
751
+ const dismissToast = useCallback5(() => setToastId(0), []);
752
+ const handleNativeShare = async () => {
753
+ if (!absoluteUrl || !navigator.share) return;
754
+ setShareError(null);
755
+ try {
756
+ await navigator.share({ title: target.name, url: absoluteUrl });
757
+ } catch (error) {
758
+ if (error instanceof Error && error.name === "AbortError") return;
759
+ setShareError("Could not open the share options.");
760
+ }
761
+ };
762
+ return /* @__PURE__ */ jsxs5(Fragment2, { children: [
763
+ /* @__PURE__ */ jsx8(
764
+ BasePopup_default,
765
+ {
766
+ title: "Share",
767
+ onClose,
768
+ isDismissable: true,
769
+ trapFocus: true,
770
+ closeOnEscape: true,
771
+ closeOnBackdrop: true,
772
+ className: "!w-full !max-w-[28rem] overflow-hidden !rounded-[2rem] !bg-[var(--color-surface)] shadow-2xl",
773
+ overlayClassName: "!bg-black/50 p-4 backdrop-blur-[2px]",
774
+ titleClassName: "!rounded-none !bg-transparent !px-6 !pb-2 !pt-6",
775
+ titleTextClassName: "!text-xl !font-semibold !text-[var(--color-text)]",
776
+ closeButtonClassName: "!h-10 !w-10 !rounded-none !border-0 !bg-transparent !p-0 !text-[var(--color-text)] hover:!bg-transparent hover:!text-[var(--color-text)]",
777
+ contentClassName: "!rounded-none !bg-transparent !px-6 !pb-6 !pt-2",
778
+ children: /* @__PURE__ */ jsxs5("div", { className: "flex flex-col gap-4", children: [
779
+ /* @__PURE__ */ jsx8(SharePreviewCard, { target, url: absoluteUrl || url }),
780
+ /* @__PURE__ */ jsxs5("div", { className: "grid grid-cols-3 gap-1 rounded-3xl bg-[var(--color-surface)] p-3 sm:grid-cols-4", children: [
781
+ /* @__PURE__ */ jsx8(
782
+ ShareDestinationButton,
783
+ {
784
+ label: "Copy Link",
785
+ icon: /* @__PURE__ */ jsx8(Link2, { "aria-hidden": "true", className: "h-6 w-6" }),
786
+ onClick: () => void handleCopy(),
787
+ ariaLabel: "Copy link",
788
+ disabled: !absoluteUrl
789
+ }
790
+ ),
791
+ /* @__PURE__ */ jsx8(
792
+ ShareDestinationButton,
793
+ {
794
+ label: "WhatsApp",
795
+ icon: /* @__PURE__ */ jsx8(FaWhatsapp, { "aria-hidden": "true", className: "h-9 w-9 text-[#25D366]" }),
796
+ href: destinationUrls?.whatsapp,
797
+ ariaLabel: `Share ${target.name} via WhatsApp`,
798
+ disabled: !absoluteUrl
799
+ }
800
+ ),
801
+ /* @__PURE__ */ jsx8(
802
+ ShareDestinationButton,
803
+ {
804
+ label: "Gmail",
805
+ icon: /* @__PURE__ */ jsx8(SiGmail, { "aria-hidden": "true", className: "h-8 w-8 text-[#EA4335]" }),
806
+ href: destinationUrls?.gmail,
807
+ ariaLabel: `Email ${target.name} with Gmail`,
808
+ disabled: !absoluteUrl
809
+ }
810
+ ),
811
+ canUseNativeShare && /* @__PURE__ */ jsx8(
812
+ ShareDestinationButton,
813
+ {
814
+ label: "More",
815
+ icon: /* @__PURE__ */ jsx8(Share2, { "aria-hidden": "true", className: "h-6 w-6" }),
816
+ onClick: () => void handleNativeShare(),
817
+ ariaLabel: "More sharing options",
818
+ disabled: !absoluteUrl
819
+ }
820
+ )
821
+ ] }),
822
+ copyStatus === "failed" && /* @__PURE__ */ jsx8("p", { role: "alert", className: "text-center text-sm text-[var(--color-red-500)]", children: "Could not copy the link." }),
823
+ shareError && /* @__PURE__ */ jsx8("p", { role: "alert", className: "text-center text-sm text-[var(--color-red-500)]", children: shareError })
824
+ ] })
825
+ }
826
+ ),
827
+ toastId > 0 && /* @__PURE__ */ jsx8(Toast, { message: "Link copied", onClose: dismissToast }, toastId)
828
+ ] });
829
+ }
830
+
831
+ // src/genre-tree/ShareActionButton.tsx
832
+ import { jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
833
+ function ShareActionButton({ target, url }) {
834
+ const { showPopup, hidePopup } = usePopup3();
835
+ const openSharePopup = () => {
836
+ showPopup(/* @__PURE__ */ jsx9(SharePopup, { target, url, onClose: hidePopup }));
837
+ };
838
+ return /* @__PURE__ */ jsxs6(
839
+ "button",
840
+ {
841
+ type: "button",
842
+ onClick: openSharePopup,
843
+ className: "group flex flex-col items-center gap-1 w-16 text-xs text-center text-[var(--color-text)] focus-visible:outline-none",
844
+ "aria-label": `Share ${target.name}`,
845
+ title: `Share ${target.name}`,
846
+ children: [
847
+ /* @__PURE__ */ jsx9("span", { className: "flex items-center justify-center w-10 h-10 rounded-full group-focus-visible:ring-2 group-focus-visible:ring-[var(--color-focus-ring)] bg-[var(--color-surface-muted)] transition-colors group-hover:bg-[var(--color-border)]", children: /* @__PURE__ */ jsx9(Share22, { "aria-hidden": "true", className: "w-5 h-5" }) }),
848
+ "Share"
849
+ ]
850
+ }
851
+ );
852
+ }
853
+
943
854
  // src/genre-tree/GenreActionBand.tsx
944
- import { MoveUpRight, Pause, Pencil, Play, Plus, ZoomIn } from "lucide-react";
945
- import { jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
855
+ import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
946
856
  function GenreActionBand({
947
857
  node,
948
858
  onPlayPause,
@@ -951,7 +861,12 @@ function GenreActionBand({
951
861
  onAddChild,
952
862
  onRename,
953
863
  onChangeParent,
954
- additionalActions
864
+ side,
865
+ onToggleSide,
866
+ toggleSideDisabledReason,
867
+ additionalActions,
868
+ shareTarget,
869
+ shareUrl
955
870
  }) {
956
871
  const items = [];
957
872
  const adminItems = [];
@@ -959,7 +874,7 @@ function GenreActionBand({
959
874
  items.push({
960
875
  key: "play-pause",
961
876
  label: isPlaying ? "Pause" : "Play",
962
- icon: isPlaying ? /* @__PURE__ */ jsx11(Pause, { "aria-hidden": "true", className: "w-5 h-5" }) : /* @__PURE__ */ jsx11(Play, { "aria-hidden": "true", className: "w-5 h-5" }),
877
+ icon: isPlaying ? /* @__PURE__ */ jsx10(Pause, { "aria-hidden": "true", className: "w-5 h-5" }) : /* @__PURE__ */ jsx10(Play, { "aria-hidden": "true", className: "w-5 h-5" }),
963
878
  onClick: onPlayPause
964
879
  });
965
880
  }
@@ -967,18 +882,28 @@ function GenreActionBand({
967
882
  items.push({
968
883
  key: "zoom-in",
969
884
  label: "Focus",
970
- icon: /* @__PURE__ */ jsx11(ZoomIn, { "aria-hidden": "true", className: "w-5 h-5" }),
885
+ icon: /* @__PURE__ */ jsx10(ZoomIn, { "aria-hidden": "true", className: "w-5 h-5" }),
971
886
  onClick: onZoomIn
972
887
  });
973
888
  }
974
889
  if (onAddChild) {
975
- adminItems.push({ key: "add-child", label: "Add", icon: /* @__PURE__ */ jsx11(Plus, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onAddChild });
890
+ adminItems.push({ key: "add-child", label: "Add", icon: /* @__PURE__ */ jsx10(Plus, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onAddChild });
976
891
  }
977
892
  if (onRename) {
978
- adminItems.push({ key: "rename", label: "Rename", icon: /* @__PURE__ */ jsx11(Pencil, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onRename });
893
+ adminItems.push({ key: "rename", label: "Rename", icon: /* @__PURE__ */ jsx10(Pencil, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onRename });
979
894
  }
980
895
  if (onChangeParent) {
981
- adminItems.push({ key: "change-parent", label: "Parent", icon: /* @__PURE__ */ jsx11(MoveUpRight, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onChangeParent });
896
+ adminItems.push({ key: "change-parent", label: "Parent", icon: /* @__PURE__ */ jsx10(MoveUpRight, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onChangeParent });
897
+ }
898
+ if (onToggleSide) {
899
+ adminItems.push({
900
+ key: "toggle-side",
901
+ label: side === "pop" ? "Make core" : "Make pop",
902
+ icon: /* @__PURE__ */ jsx10(ArrowLeftRight, { "aria-hidden": "true", className: "w-5 h-5" }),
903
+ onClick: onToggleSide,
904
+ disabled: !!toggleSideDisabledReason,
905
+ title: toggleSideDisabledReason
906
+ });
982
907
  }
983
908
  for (const action of additionalActions?.(node) ?? []) {
984
909
  items.push({
@@ -989,48 +914,427 @@ function GenreActionBand({
989
914
  disabled: action.enabled ? !action.enabled(node) : false
990
915
  });
991
916
  }
992
- if (items.length === 0 && adminItems.length === 0) return null;
917
+ if (items.length === 0 && (!shareTarget || !shareUrl) && adminItems.length === 0) return null;
993
918
  const renderItems = (list) => list.map((item) => /* @__PURE__ */ jsxs7(
994
919
  "button",
995
920
  {
996
921
  type: "button",
997
922
  disabled: item.disabled,
923
+ title: item.title,
998
924
  onClick: item.onClick,
999
925
  className: "group flex flex-col items-center gap-1 w-16 text-xs text-center text-[var(--color-text)] focus-visible:outline-none disabled:opacity-50",
1000
926
  children: [
1001
- /* @__PURE__ */ jsx11("span", { className: "flex items-center justify-center w-10 h-10 rounded-full group-focus-visible:ring-2 group-focus-visible:ring-[var(--color-focus-ring)] bg-[var(--color-surface-muted)] transition-colors group-hover:bg-[var(--color-border)] group-disabled:group-hover:bg-[var(--color-surface-muted)]", children: item.icon }),
927
+ /* @__PURE__ */ jsx10("span", { className: "flex items-center justify-center w-10 h-10 rounded-full group-focus-visible:ring-2 group-focus-visible:ring-[var(--color-focus-ring)] bg-[var(--color-surface-muted)] transition-colors group-hover:bg-[var(--color-border)] group-disabled:group-hover:bg-[var(--color-surface-muted)]", children: item.icon }),
1002
928
  item.label
1003
929
  ]
1004
930
  },
1005
931
  item.key
1006
932
  ));
1007
933
  return /* @__PURE__ */ jsxs7("div", { className: "genre-action-band border-b border-[var(--color-border)]", children: [
1008
- items.length > 0 && /* @__PURE__ */ jsx11("div", { className: "flex flex-wrap gap-1 px-3.5 py-3", children: renderItems(items) }),
934
+ (items.length > 0 || shareTarget && shareUrl) && /* @__PURE__ */ jsxs7("div", { className: "flex flex-wrap gap-1 px-3.5 py-3", children: [
935
+ renderItems(items),
936
+ shareTarget && shareUrl && /* @__PURE__ */ jsx10(ShareActionButton, { target: shareTarget, url: shareUrl })
937
+ ] }),
1009
938
  adminItems.length > 0 && /* @__PURE__ */ jsxs7(
1010
939
  "section",
1011
940
  {
1012
941
  "aria-label": "Admin actions",
1013
942
  className: `px-3.5 py-3 ${items.length > 0 ? "border-t border-[var(--color-border)]" : ""}`,
1014
943
  children: [
1015
- /* @__PURE__ */ jsx11("h3", { className: "mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text-muted)]", children: "Admin" }),
1016
- /* @__PURE__ */ jsx11("div", { className: "flex flex-wrap gap-1", children: renderItems(adminItems) })
944
+ /* @__PURE__ */ jsx10("h3", { className: "mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text-muted)]", children: "Admin" }),
945
+ /* @__PURE__ */ jsx10("div", { className: "flex flex-wrap gap-1", children: renderItems(adminItems) })
1017
946
  ]
1018
947
  }
1019
948
  )
1020
949
  ] });
1021
950
  }
1022
951
 
952
+ // src/genre-tree/GenreDetailExtras.tsx
953
+ import { Skeleton as Skeleton2 } from "@behindthemusictree/ui";
954
+ import { useFetchGenreOverview } from "@behindthemusictree/app-kit/genre-tree";
955
+
956
+ // src/genre-tree/ClampedText.tsx
957
+ import { useLayoutEffect, useRef as useRef2, useState as useState3 } from "react";
958
+ import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
959
+ function ClampedText({ children, className: className2 = "" }) {
960
+ const ref = useRef2(null);
961
+ const [expanded, setExpanded] = useState3(false);
962
+ const [overflows, setOverflows] = useState3(false);
963
+ useLayoutEffect(() => {
964
+ const element = ref.current;
965
+ if (!element || expanded) return;
966
+ setOverflows(element.scrollHeight > element.clientHeight);
967
+ }, [children, expanded]);
968
+ return /* @__PURE__ */ jsxs8(Fragment3, { children: [
969
+ /* @__PURE__ */ jsx11("p", { ref, className: `${className2} ${expanded ? "" : "line-clamp-3"}`, children }),
970
+ (overflows || expanded) && /* @__PURE__ */ jsx11(
971
+ "button",
972
+ {
973
+ type: "button",
974
+ "aria-expanded": expanded,
975
+ onClick: () => setExpanded((value) => !value),
976
+ className: "mt-1 text-xs text-[var(--color-text-muted)] underline hover:text-[var(--color-text)]",
977
+ children: expanded ? "Show less" : "Show more"
978
+ }
979
+ )
980
+ ] });
981
+ }
982
+
983
+ // src/genre-tree/GenrePlaylistTracks.tsx
984
+ import { useEffect as useEffect3, useRef as useRef3 } from "react";
985
+ import { useInfiniteQuery } from "@tanstack/react-query";
986
+ import { z } from "zod";
987
+ import { FaPlay, FaPause } from "react-icons/fa";
988
+ import { RingLoader, Skeleton } from "@behindthemusictree/ui";
989
+ import { useSession } from "@behindthemusictree/app-kit/auth";
990
+ import { useFetchWrapper } from "@behindthemusictree/app-kit/transport";
991
+ import { PaginatedResponseSchema } from "@behindthemusictree/app-kit/transport";
992
+ import { parseWithLog } from "@behindthemusictree/app-kit/transport";
993
+ import { usePlayer as usePlayer4 } from "@behindthemusictree/app-kit/player";
994
+ import { PlayStates } from "@behindthemusictree/app-kit/player";
995
+ import { usePopup as usePopup4 } from "@behindthemusictree/app-kit/popup";
996
+ import { ErrorCode as ErrorCode3 } from "@behindthemusictree/app-kit/transport";
997
+ import { genrePlaylistQueryKeys } from "@behindthemusictree/app-kit/genre-tree";
998
+ import { libraryEndpoints, libraryQueryKeys } from "@behindthemusictree/app-kit/genre-tree";
999
+ import { isSong, songUnplayableReason, SongDetailedSchema } from "@behindthemusictree/app-kit/genre-tree";
1000
+ import { useTrackList as useTrackList2 } from "@behindthemusictree/app-kit/genre-tree";
1001
+
1002
+ // src/genre-tree/MusicBrainzRecordingLink.tsx
1003
+ import { musicBrainzRecordingUrl } from "@behindthemusictree/app-kit/genre-tree";
1004
+ import { Fragment as Fragment4, jsx as jsx12 } from "react/jsx-runtime";
1005
+ function MusicBrainzRecordingLink({
1006
+ song,
1007
+ children,
1008
+ className: className2 = ""
1009
+ }) {
1010
+ const url = musicBrainzRecordingUrl(song);
1011
+ if (!url) return /* @__PURE__ */ jsx12(Fragment4, { children });
1012
+ return /* @__PURE__ */ jsx12(
1013
+ "a",
1014
+ {
1015
+ href: url,
1016
+ target: "_blank",
1017
+ rel: "noopener noreferrer",
1018
+ "aria-label": `View ${song.title} on MusicBrainz`,
1019
+ className: className2,
1020
+ onClick: (event) => event.stopPropagation(),
1021
+ children
1022
+ }
1023
+ );
1024
+ }
1025
+
1026
+ // src/genre-tree/GenrePlaylistTracks.tsx
1027
+ import { ArtistNames } from "@behindthemusictree/app-kit/genre-tree";
1028
+ import { capitalize as capitalize4, useTrackNoun as useTrackNoun4 } from "@behindthemusictree/app-kit/track-noun";
1029
+ import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
1030
+ var CATALOGUE_PAGE_SIZE = 100;
1031
+ var SongPageSchema = PaginatedResponseSchema(SongDetailedSchema).extend({
1032
+ overallTotal: z.number().nullable(),
1033
+ page: z.number().nullable(),
1034
+ totalPages: z.number().nullable()
1035
+ });
1036
+ var TEXT_CELL = "min-w-0 flex-1 truncate [@container(min-width:40rem)]:flex [@container(min-width:40rem)]:gap-2";
1037
+ var TITLE_CELL = "[@container(min-width:40rem)]:flex-[2] [@container(min-width:40rem)]:truncate";
1038
+ var COLUMN_CELL = "[@container(min-width:40rem)]:flex-1 [@container(min-width:40rem)]:truncate";
1039
+ var ALBUM_CELL = `hidden [@container(min-width:40rem)]:block ${COLUMN_CELL}`;
1040
+ var SEPARATOR = "[@container(min-width:40rem)]:hidden";
1041
+ var nextCursor = (next) => {
1042
+ const cursor = new URL(next).searchParams.get("cursor");
1043
+ if (!cursor) throw new Error(`Songs next link has no cursor: ${next}`);
1044
+ return cursor;
1045
+ };
1046
+ function GenrePlaylistTracks({
1047
+ genreUuid,
1048
+ genrePlaylist,
1049
+ scope,
1050
+ getBackendBaseUrl,
1051
+ renderArtistLink,
1052
+ renderSongLeading,
1053
+ renderSongActions,
1054
+ renderTracksToolbar,
1055
+ artist = null
1056
+ }) {
1057
+ const { fetchGenrePlaylistTracksPage, playNewTrackListFromGenrePlaylist, playNewTrackListFromArtist } = useTrackList2();
1058
+ const { session, sessionRestored } = useSession();
1059
+ const { playerTrackObject, playState, handlePlayPauseAction } = usePlayer4();
1060
+ const { showPopup } = usePopup4();
1061
+ const trackNoun = useTrackNoun4();
1062
+ const { uuid } = genrePlaylist;
1063
+ const { fetch } = useFetchWrapper(getBackendBaseUrl);
1064
+ const { data, isPending, isError, refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery({
1065
+ queryKey: scope === "me" ? genrePlaylistQueryKeys.me.tracks(uuid) : artist ? [...libraryQueryKeys.reference.songs.byGenre(getBackendBaseUrl(), genreUuid), "artist", artist.uuid] : libraryQueryKeys.reference.songs.byGenre(getBackendBaseUrl(), genreUuid),
1066
+ queryFn: async ({ pageParam }) => {
1067
+ if (scope === "me") return fetchGenrePlaylistTracksPage(uuid, scope, Number(pageParam));
1068
+ const response = await fetch(
1069
+ libraryEndpoints.reference.songs.list(),
1070
+ true,
1071
+ false,
1072
+ {},
1073
+ { genre: genreUuid, ...artist && { artist: artist.uuid }, pageSize: CATALOGUE_PAGE_SIZE, withTotal: false, ...pageParam && { cursor: pageParam } }
1074
+ );
1075
+ const parsed = parseWithLog(SongPageSchema, response, "GenrePlaylistTracks");
1076
+ return { tracks: parsed.results, total: parsed.overallTotal, nextPage: parsed.next ? nextCursor(parsed.next) : null };
1077
+ },
1078
+ initialPageParam: scope === "me" ? 1 : "",
1079
+ getNextPageParam: (lastPage) => lastPage.nextPage ?? void 0,
1080
+ enabled: scope !== "me" || sessionRestored && !!session?.accessToken
1081
+ });
1082
+ const sentinelRef = useRef3(null);
1083
+ useEffect3(() => {
1084
+ const sentinel = sentinelRef.current;
1085
+ if (!hasNextPage || isFetchingNextPage || !sentinel) return;
1086
+ const observer = new IntersectionObserver((entries) => {
1087
+ if (entries.some((entry) => entry.isIntersecting)) {
1088
+ void fetchNextPage();
1089
+ }
1090
+ });
1091
+ observer.observe(sentinel);
1092
+ return () => observer.disconnect();
1093
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
1094
+ const pages = data?.pages ?? [];
1095
+ const tracks = pages.flatMap((page) => page.tracks);
1096
+ const total = pages[0]?.total ?? void 0;
1097
+ const play = (startSongUuid) => {
1098
+ const started = artist ? playNewTrackListFromArtist(artist, scope, { startSongUuid, ordering: "title", genreUuid }) : playNewTrackListFromGenrePlaylist(genrePlaylist, scope, { startSongUuid });
1099
+ started.catch((error) => {
1100
+ console.error("Failed to play genre playlist:", error);
1101
+ showPopup(/* @__PURE__ */ jsx13(InternalErrorPopup, { errorCode: ErrorCode3.CLIENT_UNKNOWN }));
1102
+ });
1103
+ };
1104
+ return /* @__PURE__ */ jsxs9("div", { className: "gtv-info-panel-children", children: [
1105
+ /* @__PURE__ */ jsxs9("span", { className: "gtv-info-panel-children-title", children: [
1106
+ capitalize4(trackNoun.plural),
1107
+ total !== void 0 && ` (${total})`
1108
+ ] }),
1109
+ renderTracksToolbar?.(),
1110
+ isPending ? /* @__PURE__ */ jsx13(Skeleton, { className: "h-4 w-3/4", "aria-busy": "true" }) : isError && tracks.length === 0 ? /* @__PURE__ */ jsxs9("p", { role: "alert", children: [
1111
+ "Couldn't load ",
1112
+ trackNoun.plural,
1113
+ ".",
1114
+ " ",
1115
+ /* @__PURE__ */ jsx13("button", { type: "button", className: "underline", onClick: () => void refetch(), children: "Retry" })
1116
+ ] }) : tracks.length === 0 ? /* @__PURE__ */ jsx13("p", { children: "\u2014" }) : /* @__PURE__ */ jsxs9("ul", { className: "mt-1 max-h-64 overflow-y-auto text-[0.8125rem] leading-[1.45] [container-type:inline-size]", "aria-label": `${genrePlaylist.name} ${trackNoun.plural}`, children: [
1117
+ tracks.map((track, index) => {
1118
+ const label = track.artists?.length ? `${track.title} \u2014 ${track.artists.map((artist2) => artist2.name).join(", ")}` : track.title;
1119
+ const isCurrent = playerTrackObject?.track.id === track.uuid;
1120
+ const isPlaying = isCurrent && playState === PlayStates.PLAYING;
1121
+ const text = /* @__PURE__ */ jsxs9("span", { className: TEXT_CELL, children: [
1122
+ /* @__PURE__ */ jsx13("span", { className: TITLE_CELL, children: isSong(track) ? /* @__PURE__ */ jsx13(MusicBrainzRecordingLink, { song: track, className: "hover:underline focus-visible:underline", children: track.title }) : track.title }),
1123
+ track.artists?.length ? /* @__PURE__ */ jsx13("span", { className: SEPARATOR, children: " \u2014 " }) : null,
1124
+ /* @__PURE__ */ jsx13("span", { className: COLUMN_CELL, children: track.artists?.length ? /* @__PURE__ */ jsx13(ArtistNames, { artists: track.artists, renderArtistLink }) : null }),
1125
+ track.album && /* @__PURE__ */ jsx13("span", { className: ALBUM_CELL, children: track.album.name })
1126
+ ] });
1127
+ const leading = renderSongLeading?.(track, index);
1128
+ const actions = renderSongActions?.(track);
1129
+ const isUnplayable = isSong(track) && songUnplayableReason(track) !== null;
1130
+ if (isUnplayable) {
1131
+ return /* @__PURE__ */ jsxs9("li", { className: "flex min-h-8 items-center gap-1 px-1 opacity-50", children: [
1132
+ leading,
1133
+ /* @__PURE__ */ jsx13(
1134
+ "button",
1135
+ {
1136
+ type: "button",
1137
+ disabled: true,
1138
+ "aria-disabled": "true",
1139
+ "aria-label": label,
1140
+ className: "flex h-6 w-6 shrink-0 items-center justify-center rounded-full opacity-0 cursor-not-allowed",
1141
+ children: /* @__PURE__ */ jsx13(FaPlay, { size: 10 })
1142
+ }
1143
+ ),
1144
+ text,
1145
+ actions
1146
+ ] }, track.uuid);
1147
+ }
1148
+ return /* @__PURE__ */ jsxs9(
1149
+ "li",
1150
+ {
1151
+ className: `group flex min-h-8 items-center gap-1 rounded px-1 hover:bg-[var(--color-neutral-100)] ${isCurrent ? "font-medium text-[var(--color-primary)]" : ""}`,
1152
+ children: [
1153
+ leading,
1154
+ /* @__PURE__ */ jsx13(
1155
+ "button",
1156
+ {
1157
+ type: "button",
1158
+ "aria-label": `${isPlaying ? "Pause" : "Play"} ${label}`,
1159
+ className: `flex h-6 w-6 shrink-0 items-center justify-center rounded-full cursor-pointer group-hover:opacity-100 focus-visible:opacity-100 ${isCurrent && playState !== PlayStates.STOPPED ? "opacity-100" : "opacity-0 [@media(pointer:coarse)]:opacity-100"}`,
1160
+ onClick: () => isCurrent ? handlePlayPauseAction() : play(track.uuid),
1161
+ children: isCurrent && playState === PlayStates.LOADING ? /* @__PURE__ */ jsx13(RingLoader, { size: 10 }) : isPlaying ? /* @__PURE__ */ jsx13(FaPause, { size: 10 }) : /* @__PURE__ */ jsx13(FaPlay, { size: 10 })
1162
+ }
1163
+ ),
1164
+ text,
1165
+ actions
1166
+ ]
1167
+ },
1168
+ track.uuid
1169
+ );
1170
+ }),
1171
+ hasNextPage && /* @__PURE__ */ jsx13("li", { ref: sentinelRef, "aria-hidden": "true", className: "h-4" })
1172
+ ] })
1173
+ ] });
1174
+ }
1175
+
1176
+ // src/genre-tree/GenreDetailExtras.tsx
1177
+ import { useTrackNoun as useTrackNoun5 } from "@behindthemusictree/app-kit/track-noun";
1178
+ import { SiWikidata, SiWikipedia } from "react-icons/si";
1179
+ import { Fragment as Fragment5, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
1180
+ function GenreDetailExtras({
1181
+ genreUuid,
1182
+ genrePlaylist,
1183
+ wikidataId,
1184
+ scope,
1185
+ getBackendBaseUrl,
1186
+ criteriaOverviewSchema,
1187
+ renderGenreDetailExtras,
1188
+ renderArtistLink,
1189
+ renderSummary,
1190
+ renderEssentialTracks,
1191
+ family,
1192
+ ...trackSlots
1193
+ }) {
1194
+ const trackNoun = useTrackNoun5();
1195
+ const { data: overview, isPending } = useFetchGenreOverview(
1196
+ genreUuid,
1197
+ scope,
1198
+ getBackendBaseUrl,
1199
+ criteriaOverviewSchema
1200
+ );
1201
+ if (isPending) {
1202
+ return /* @__PURE__ */ jsxs10(Fragment5, { children: [
1203
+ /* @__PURE__ */ jsxs10("div", { className: "gtv-info-panel-children", "aria-busy": "true", "data-testid": "genre-detail-extras-skeleton", children: [
1204
+ /* @__PURE__ */ jsx14("span", { className: "gtv-info-panel-children-title", children: "Summary" }),
1205
+ /* @__PURE__ */ jsx14(Skeleton2, { className: "h-4 w-3/4" })
1206
+ ] }),
1207
+ family
1208
+ ] });
1209
+ }
1210
+ if (!overview) return /* @__PURE__ */ jsx14(Fragment5, { children: family });
1211
+ const { summary, summarySourceUrl, essentialSongs } = overview;
1212
+ const wikidataUrl = wikidataId ? `https://www.wikidata.org/wiki/${encodeURIComponent(wikidataId)}` : null;
1213
+ return /* @__PURE__ */ jsxs10(Fragment5, { children: [
1214
+ (summarySourceUrl || wikidataUrl) && /* @__PURE__ */ jsxs10("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
1215
+ /* @__PURE__ */ jsx14("span", { className: "gtv-info-panel-children-title", children: "Links" }),
1216
+ /* @__PURE__ */ jsxs10("div", { className: "mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs", children: [
1217
+ summarySourceUrl && /* @__PURE__ */ jsxs10(
1218
+ "a",
1219
+ {
1220
+ href: summarySourceUrl,
1221
+ target: "_blank",
1222
+ rel: "noopener noreferrer",
1223
+ className: "inline-flex items-center gap-1.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]",
1224
+ children: [
1225
+ /* @__PURE__ */ jsx14(SiWikipedia, { "aria-hidden": "true", className: "h-3.5 w-3.5" }),
1226
+ "Wikipedia"
1227
+ ]
1228
+ }
1229
+ ),
1230
+ wikidataUrl && /* @__PURE__ */ jsxs10(
1231
+ "a",
1232
+ {
1233
+ href: wikidataUrl,
1234
+ target: "_blank",
1235
+ rel: "noopener noreferrer",
1236
+ className: "inline-flex items-center gap-1.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]",
1237
+ children: [
1238
+ /* @__PURE__ */ jsx14(SiWikidata, { "aria-hidden": "true", className: "h-3.5 w-3.5" }),
1239
+ "Wikidata"
1240
+ ]
1241
+ }
1242
+ )
1243
+ ] })
1244
+ ] }),
1245
+ renderSummary ? renderSummary(overview) : /* @__PURE__ */ jsxs10("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
1246
+ /* @__PURE__ */ jsx14("span", { className: "gtv-info-panel-children-title", children: "Summary" }),
1247
+ /* @__PURE__ */ jsx14(ClampedText, { className: "mt-1", children: summary ?? "\u2014" }, genreUuid)
1248
+ ] }),
1249
+ family,
1250
+ renderGenreDetailExtras?.(overview),
1251
+ renderEssentialTracks ? renderEssentialTracks(overview) : essentialSongs && /* @__PURE__ */ jsxs10("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
1252
+ /* @__PURE__ */ jsxs10("span", { className: "gtv-info-panel-children-title", children: [
1253
+ "Essential ",
1254
+ trackNoun.plural
1255
+ ] }),
1256
+ essentialSongs.length > 0 ? /* @__PURE__ */ jsx14("ul", { className: "mt-1 divide-y divide-[var(--color-border)]", children: essentialSongs.map((track) => /* @__PURE__ */ jsx14("li", { className: "py-1", children: track.title }, track.uuid)) }) : /* @__PURE__ */ jsx14("p", { className: "mt-1", children: "\u2014" })
1257
+ ] }),
1258
+ /* @__PURE__ */ jsx14(
1259
+ GenrePlaylistTracks,
1260
+ {
1261
+ genreUuid,
1262
+ genrePlaylist,
1263
+ scope,
1264
+ getBackendBaseUrl,
1265
+ renderArtistLink,
1266
+ ...trackSlots
1267
+ }
1268
+ )
1269
+ ] });
1270
+ }
1271
+
1272
+ // src/genre-tree/GenreInfoPanelBody.tsx
1273
+ import { Fragment as Fragment6, jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
1274
+ import { createElement } from "react";
1275
+ function GenreNodeActionBand({ genrePlaylist, playback, ...bandProps }) {
1276
+ const { isPlaying, setIsPlaying } = usePlayer5();
1277
+ const isCurrentTrackList = playback.playingNodeId === genrePlaylist.uuid;
1278
+ const handlePlayPause = () => {
1279
+ if (isCurrentTrackList) {
1280
+ setIsPlaying(!isPlaying);
1281
+ return;
1282
+ }
1283
+ playback.play(genrePlaylist).catch((error) => {
1284
+ console.error("Failed to load genre playlist tracks:", error);
1285
+ });
1286
+ };
1287
+ return /* @__PURE__ */ jsx15(
1288
+ GenreActionBand,
1289
+ {
1290
+ ...bandProps,
1291
+ onPlayPause: genrePlaylist.tracksCount > 0 ? handlePlayPause : void 0,
1292
+ isPlaying: isCurrentTrackList && isPlaying
1293
+ }
1294
+ );
1295
+ }
1296
+ function GenreInfoPanelBody({
1297
+ toolbar,
1298
+ family,
1299
+ genreUuid,
1300
+ genrePlaylist,
1301
+ wikidataId,
1302
+ scope,
1303
+ getBackendBaseUrl,
1304
+ playback,
1305
+ band,
1306
+ extras
1307
+ }) {
1308
+ return /* @__PURE__ */ jsxs11(Fragment6, { children: [
1309
+ toolbar,
1310
+ /* @__PURE__ */ jsx15(GenreNodeActionBand, { ...band, genrePlaylist, playback }),
1311
+ /* @__PURE__ */ createElement(
1312
+ GenreDetailExtras,
1313
+ {
1314
+ ...extras,
1315
+ key: genreUuid,
1316
+ family,
1317
+ genreUuid,
1318
+ genrePlaylist,
1319
+ wikidataId,
1320
+ scope,
1321
+ getBackendBaseUrl
1322
+ }
1323
+ )
1324
+ ] });
1325
+ }
1326
+
1023
1327
  // src/genre-tree/PanelEdgeTab.tsx
1024
1328
  import { ChevronLeft, ChevronRight } from "lucide-react";
1025
1329
  import { twMerge } from "tailwind-merge";
1026
- import { jsx as jsx12, jsxs as jsxs8 } from "react/jsx-runtime";
1330
+ import { jsx as jsx16, jsxs as jsxs12 } from "react/jsx-runtime";
1027
1331
  var buttonClassName = "flex h-12 w-full items-center justify-center p-0 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--color-primary)]";
1028
- function PanelEdgeTab({ side, edge, center, controls, className }) {
1332
+ function PanelEdgeTab({ side, edge, center, controls, className: className2 }) {
1029
1333
  if (!edge && !center) return null;
1030
1334
  const isLeft = side === "left";
1031
1335
  const EdgeIcon = isLeft ? ChevronLeft : ChevronRight;
1032
1336
  const CenterIcon = isLeft ? ChevronRight : ChevronLeft;
1033
- const renderButton = (action, icon) => /* @__PURE__ */ jsx12(
1337
+ const renderButton = (action, icon) => /* @__PURE__ */ jsx16(
1034
1338
  "button",
1035
1339
  {
1036
1340
  type: "button",
@@ -1043,33 +1347,46 @@ function PanelEdgeTab({ side, edge, center, controls, className }) {
1043
1347
  children: icon
1044
1348
  }
1045
1349
  );
1046
- const edgeButton = edge && renderButton(edge, /* @__PURE__ */ jsx12(EdgeIcon, { "aria-hidden": "true", className: "h-5 w-5" }));
1047
- const centerButton = center && renderButton(center, /* @__PURE__ */ jsx12(CenterIcon, { "aria-hidden": "true", className: "h-5 w-5" }));
1350
+ const edgeButton = edge && renderButton(edge, /* @__PURE__ */ jsx16(EdgeIcon, { "aria-hidden": "true", className: "h-5 w-5" }));
1351
+ const centerButton = center && renderButton(center, /* @__PURE__ */ jsx16(CenterIcon, { "aria-hidden": "true", className: "h-5 w-5" }));
1048
1352
  const buttons = isLeft ? [edgeButton, centerButton] : [centerButton, edgeButton];
1049
- return /* @__PURE__ */ jsxs8(
1353
+ return /* @__PURE__ */ jsxs12(
1050
1354
  "div",
1051
1355
  {
1052
1356
  className: twMerge(
1053
1357
  "panel-edge-tab flex w-[var(--layout-edge-tab-w)] flex-col overflow-hidden bg-[var(--color-surface)] shadow-[0_1px_2px_rgba(60,64,67,0.3),0_2px_6px_2px_rgba(60,64,67,0.15)] [clip-path:inset(-0.5rem_-0.5rem_-0.5rem_0)]",
1054
1358
  isLeft ? "rounded-r-lg" : "rounded-l-lg [clip-path:inset(-0.5rem_0_-0.5rem_-0.5rem)]",
1055
- className
1359
+ className2
1056
1360
  ),
1057
1361
  children: [
1058
1362
  buttons[0],
1059
- buttons[0] && buttons[1] && /* @__PURE__ */ jsx12("div", { role: "presentation", className: "h-px shrink-0 bg-[var(--color-border)]" }),
1363
+ buttons[0] && buttons[1] && /* @__PURE__ */ jsx16("div", { role: "presentation", className: "h-px shrink-0 bg-[var(--color-border)]" }),
1060
1364
  buttons[1]
1061
1365
  ]
1062
1366
  }
1063
1367
  );
1064
1368
  }
1065
1369
 
1370
+ // src/genre-tree/useLeftDetailPanelLayout.ts
1371
+ import { useEffect as useEffect4 } from "react";
1372
+ function useLeftDetailPanelLayout(isPanelShown, enabled = true) {
1373
+ useEffect4(() => {
1374
+ if (!enabled || isPanelShown) return void 0;
1375
+ const root = document.documentElement;
1376
+ root.style.setProperty("--layout-left-w", "0px");
1377
+ return () => {
1378
+ root.style.removeProperty("--layout-left-w");
1379
+ };
1380
+ }, [enabled, isPanelShown]);
1381
+ }
1382
+
1066
1383
  // src/genre-tree/ChangeParentPopup.tsx
1067
- import { useEffect as useEffect3, useMemo as useMemo5 } from "react";
1384
+ import { useEffect as useEffect5, useMemo as useMemo6 } from "react";
1068
1385
  import {
1069
1386
  getReparentCandidates,
1070
1387
  useUpdateGenre
1071
1388
  } from "@behindthemusictree/app-kit/genre-tree";
1072
- import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
1389
+ import { jsx as jsx17, jsxs as jsxs13 } from "react/jsx-runtime";
1073
1390
  function ChangeParentPopup({
1074
1391
  genrePlaylist,
1075
1392
  genrePlaylists,
@@ -1079,7 +1396,7 @@ function ChangeParentPopup({
1079
1396
  onParentChanged
1080
1397
  }) {
1081
1398
  const { mutate, isPending, formErrors } = useUpdateGenre(scope, getBackendBaseUrl);
1082
- const candidates = useMemo5(
1399
+ const candidates = useMemo6(
1083
1400
  () => getReparentCandidates(genrePlaylists, genrePlaylist.uuid),
1084
1401
  [genrePlaylists, genrePlaylist.uuid]
1085
1402
  );
@@ -1097,19 +1414,19 @@ function ChangeParentPopup({
1097
1414
  }
1098
1415
  );
1099
1416
  };
1100
- useEffect3(() => {
1417
+ useEffect5(() => {
1101
1418
  const handleKeyDown = (event) => {
1102
1419
  if (event.key === "Escape") onClose();
1103
1420
  };
1104
1421
  window.addEventListener("keydown", handleKeyDown);
1105
1422
  return () => window.removeEventListener("keydown", handleKeyDown);
1106
1423
  }, [onClose]);
1107
- return /* @__PURE__ */ jsxs9(BasePopup_default, { title: `Change parent of ${genrePlaylist.name}`, onClose, showCancelButton: true, fitContent: true, children: [
1108
- /* @__PURE__ */ jsxs9("p", { className: "mb-3 text-sm text-[var(--color-text-muted)]", children: [
1424
+ return /* @__PURE__ */ jsxs13(BasePopup_default, { title: `Change parent of ${genrePlaylist.name}`, onClose, showCancelButton: true, fitContent: true, children: [
1425
+ /* @__PURE__ */ jsxs13("p", { className: "mb-3 text-sm text-[var(--color-text-muted)]", children: [
1109
1426
  "Current parent: ",
1110
1427
  genrePlaylist.parent?.name ?? "none"
1111
1428
  ] }),
1112
- /* @__PURE__ */ jsx13("div", { "aria-busy": isPending, className: isPending ? "pointer-events-none opacity-60" : void 0, children: /* @__PURE__ */ jsx13(
1429
+ /* @__PURE__ */ jsx17("div", { "aria-busy": isPending, className: isPending ? "pointer-events-none opacity-60" : void 0, children: /* @__PURE__ */ jsx17(
1113
1430
  GenreSearch,
1114
1431
  {
1115
1432
  genrePlaylists: candidates,
@@ -1117,7 +1434,7 @@ function ChangeParentPopup({
1117
1434
  placeholder: "Search a new parent\u2026"
1118
1435
  }
1119
1436
  ) }),
1120
- formErrors.length > 0 && /* @__PURE__ */ jsx13("p", { role: "alert", className: "mt-3 text-sm text-[var(--color-red-500)]", children: formErrors.map(({ message }) => message).join(" ") })
1437
+ formErrors.length > 0 && /* @__PURE__ */ jsx17("p", { role: "alert", className: "mt-3 text-sm text-[var(--color-red-500)]", children: formErrors.map(({ message }) => message).join(" ") })
1121
1438
  ] });
1122
1439
  }
1123
1440
 
@@ -1129,29 +1446,29 @@ import { useArtist } from "@behindthemusictree/app-kit/genre-tree";
1129
1446
  import { FieldList } from "@behindthemusictree/ui";
1130
1447
 
1131
1448
  // src/genre-tree/artist/ArtistLinks.tsx
1132
- import { Globe, Share2 } from "lucide-react";
1449
+ import { Globe, Share2 as Share23 } from "lucide-react";
1133
1450
  import {
1134
1451
  SiBandcamp,
1135
1452
  SiDiscogs,
1136
1453
  SiMusicbrainz,
1137
1454
  SiSoundcloud,
1138
1455
  SiSpotify,
1139
- SiWikidata,
1140
- SiWikipedia,
1456
+ SiWikidata as SiWikidata2,
1457
+ SiWikipedia as SiWikipedia2,
1141
1458
  SiYoutube
1142
1459
  } from "react-icons/si";
1143
1460
  import { Chip } from "@behindthemusictree/ui";
1144
- import { jsx as jsx14 } from "react/jsx-runtime";
1461
+ import { jsx as jsx18 } from "react/jsx-runtime";
1145
1462
  var LINK_KINDS = {
1146
1463
  official: { label: "Official website", Icon: Globe },
1147
- wikipedia: { label: "Wikipedia", Icon: SiWikipedia },
1148
- wikidata: { label: "Wikidata", Icon: SiWikidata },
1464
+ wikipedia: { label: "Wikipedia", Icon: SiWikipedia2 },
1465
+ wikidata: { label: "Wikidata", Icon: SiWikidata2 },
1149
1466
  spotify: { label: "Spotify", Icon: SiSpotify },
1150
1467
  youtube: { label: "YouTube", Icon: SiYoutube },
1151
1468
  bandcamp: { label: "Bandcamp", Icon: SiBandcamp },
1152
1469
  soundcloud: { label: "SoundCloud", Icon: SiSoundcloud },
1153
1470
  discogs: { label: "Discogs", Icon: SiDiscogs },
1154
- social: { label: "Social", Icon: Share2 }
1471
+ social: { label: "Social", Icon: Share23 }
1155
1472
  };
1156
1473
  function socialLabel(url) {
1157
1474
  try {
@@ -1178,7 +1495,7 @@ function artistLinks(artist) {
1178
1495
  return links;
1179
1496
  }
1180
1497
  function ArtistLinks({ links }) {
1181
- return /* @__PURE__ */ jsx14("ul", { className: "flex flex-wrap gap-1.5", children: links.map(({ key, url, Icon, label }) => /* @__PURE__ */ jsx14("li", { children: /* @__PURE__ */ jsx14(Chip, { href: url, icon: /* @__PURE__ */ jsx14(Icon, { className: "h-3.5 w-3.5 shrink-0", "aria-hidden": "true" }), children: label }) }, key)) });
1498
+ return /* @__PURE__ */ jsx18("ul", { className: "flex flex-wrap gap-1.5", children: links.map(({ key, url, Icon, label }) => /* @__PURE__ */ jsx18("li", { children: /* @__PURE__ */ jsx18(Chip, { href: url, icon: /* @__PURE__ */ jsx18(Icon, { className: "h-3.5 w-3.5 shrink-0", "aria-hidden": "true" }), children: label }) }, key)) });
1182
1499
  }
1183
1500
 
1184
1501
  // src/genre-tree/artist/formatYears.ts
@@ -1189,7 +1506,7 @@ function formatYears(beginYear, endYear, ended) {
1189
1506
  }
1190
1507
 
1191
1508
  // src/genre-tree/artist/ArtistHeader.tsx
1192
- import { jsx as jsx15 } from "react/jsx-runtime";
1509
+ import { jsx as jsx19 } from "react/jsx-runtime";
1193
1510
  var countryNames = new Intl.DisplayNames(["en"], { type: "region" });
1194
1511
  function ArtistHeader({ artist }) {
1195
1512
  const fields = [];
@@ -1199,35 +1516,35 @@ function ArtistHeader({ artist }) {
1199
1516
  const years = formatYears(artist.beginYear, artist.endYear, artist.ended);
1200
1517
  if (years) fields.push({ label: "Active", value: years });
1201
1518
  const links = artistLinks(artist);
1202
- if (links.length > 0) fields.push({ label: "Links", value: /* @__PURE__ */ jsx15(ArtistLinks, { links }) });
1519
+ if (links.length > 0) fields.push({ label: "Links", value: /* @__PURE__ */ jsx19(ArtistLinks, { links }) });
1203
1520
  if (fields.length === 0) return null;
1204
- return /* @__PURE__ */ jsx15(FieldList, { items: fields });
1521
+ return /* @__PURE__ */ jsx19(FieldList, { items: fields });
1205
1522
  }
1206
1523
 
1207
1524
  // src/genre-tree/artist/ArtistPhoto.tsx
1208
- import { jsx as jsx16, jsxs as jsxs10 } from "react/jsx-runtime";
1525
+ import { jsx as jsx20, jsxs as jsxs14 } from "react/jsx-runtime";
1209
1526
  function ExternalLink({ href, children }) {
1210
- return /* @__PURE__ */ jsx16("a", { href, target: "_blank", rel: "noopener noreferrer", className: "underline-offset-2 hover:underline", children });
1527
+ return /* @__PURE__ */ jsx20("a", { href, target: "_blank", rel: "noopener noreferrer", className: "underline-offset-2 hover:underline", children });
1211
1528
  }
1212
1529
  function ArtistPhoto({ image, name }) {
1213
- return /* @__PURE__ */ jsxs10("figure", { className: "flex flex-col gap-1.5 px-6 py-2.5", children: [
1214
- /* @__PURE__ */ jsx16("img", { src: image.url, alt: name, className: "h-64 w-full object-contain bg-[var(--color-surface-muted)] rounded" }),
1215
- /* @__PURE__ */ jsxs10("figcaption", { className: "text-xs text-[var(--color-text-muted)]", children: [
1530
+ return /* @__PURE__ */ jsxs14("figure", { className: "flex flex-col gap-1.5 px-6 py-2.5", children: [
1531
+ /* @__PURE__ */ jsx20("img", { src: image.url, alt: name, className: "h-64 w-full object-contain bg-[var(--color-surface-muted)] rounded" }),
1532
+ /* @__PURE__ */ jsxs14("figcaption", { className: "text-xs text-[var(--color-text-muted)]", children: [
1216
1533
  "Photo: ",
1217
- /* @__PURE__ */ jsx16(ExternalLink, { href: image.pageUrl, children: image.author ?? "Wikimedia Commons" }),
1534
+ /* @__PURE__ */ jsx20(ExternalLink, { href: image.pageUrl, children: image.author ?? "Wikimedia Commons" }),
1218
1535
  ",",
1219
1536
  " ",
1220
- image.licenseUrl ? /* @__PURE__ */ jsx16(ExternalLink, { href: image.licenseUrl, children: image.license }) : image.license
1537
+ image.licenseUrl ? /* @__PURE__ */ jsx20(ExternalLink, { href: image.licenseUrl, children: image.license }) : image.license
1221
1538
  ] })
1222
1539
  ] });
1223
1540
  }
1224
1541
 
1225
1542
  // src/genre-tree/artist/ArtistMembers.tsx
1226
1543
  import { PanelGroup, PanelSection } from "@behindthemusictree/ui";
1227
- import { jsx as jsx17, jsxs as jsxs11 } from "react/jsx-runtime";
1544
+ import { jsx as jsx21, jsxs as jsxs15 } from "react/jsx-runtime";
1228
1545
  function MembershipList({ title, memberships, renderArtistLink }) {
1229
1546
  if (memberships.length === 0) return null;
1230
- return /* @__PURE__ */ jsx17(PanelGroup, { title, children: /* @__PURE__ */ jsx17("ul", { children: memberships.map((membership, index) => {
1547
+ return /* @__PURE__ */ jsx21(PanelGroup, { title, children: /* @__PURE__ */ jsx21("ul", { children: memberships.map((membership, index) => {
1231
1548
  const details = [
1232
1549
  formatYears(membership.beginYear, membership.endYear, membership.ended),
1233
1550
  ...membership.roles
@@ -1235,9 +1552,9 @@ function MembershipList({ title, memberships, renderArtistLink }) {
1235
1552
  const detailsText = details.filter(Boolean).join(" \xB7 ");
1236
1553
  return (
1237
1554
  // A member can have several periods in the same group.
1238
- /* @__PURE__ */ jsxs11("li", { className: "truncate py-0.5", title: detailsText || void 0, children: [
1239
- /* @__PURE__ */ jsx17("span", { className: "font-medium text-[var(--color-text)]", children: renderArtistLink(membership.artist) }),
1240
- detailsText && /* @__PURE__ */ jsxs11("span", { className: "text-[var(--color-text-muted)]", children: [
1555
+ /* @__PURE__ */ jsxs15("li", { className: "truncate py-0.5", title: detailsText || void 0, children: [
1556
+ /* @__PURE__ */ jsx21("span", { className: "font-medium text-[var(--color-text)]", children: renderArtistLink(membership.artist) }),
1557
+ detailsText && /* @__PURE__ */ jsxs15("span", { className: "text-[var(--color-text-muted)]", children: [
1241
1558
  " \u2014 ",
1242
1559
  detailsText
1243
1560
  ] })
@@ -1248,9 +1565,9 @@ function MembershipList({ title, memberships, renderArtistLink }) {
1248
1565
  function ArtistMembers({ title, memberships, renderArtistLink }) {
1249
1566
  if (memberships.length === 0) return null;
1250
1567
  const isCurrent = (membership) => !membership.ended && membership.endYear === null;
1251
- return /* @__PURE__ */ jsxs11(PanelSection, { title, children: [
1252
- /* @__PURE__ */ jsx17(MembershipList, { title: "Current", memberships: memberships.filter(isCurrent), renderArtistLink }),
1253
- /* @__PURE__ */ jsx17(
1568
+ return /* @__PURE__ */ jsxs15(PanelSection, { title, children: [
1569
+ /* @__PURE__ */ jsx21(MembershipList, { title: "Current", memberships: memberships.filter(isCurrent), renderArtistLink }),
1570
+ /* @__PURE__ */ jsx21(
1254
1571
  MembershipList,
1255
1572
  {
1256
1573
  title: "Past",
@@ -1263,7 +1580,7 @@ function ArtistMembers({ title, memberships, renderArtistLink }) {
1263
1580
 
1264
1581
  // src/genre-tree/artist/ArtistDiscography.tsx
1265
1582
  import { PanelGroup as PanelGroup2, PanelSection as PanelSection2 } from "@behindthemusictree/ui";
1266
- import { jsx as jsx18, jsxs as jsxs12 } from "react/jsx-runtime";
1583
+ import { jsx as jsx22, jsxs as jsxs16 } from "react/jsx-runtime";
1267
1584
  var GROUPS = ["Albums", "EPs", "Singles", "Live", "Compilations", "Other"];
1268
1585
  var PRIMARY_GROUPS = { album: "Albums", ep: "EPs", single: "Singles" };
1269
1586
  function discographyGroup({ primaryType, secondaryTypes }) {
@@ -1283,22 +1600,22 @@ function byYear(a, b) {
1283
1600
  function ArtistDiscography({ albums }) {
1284
1601
  if (albums.length === 0) return null;
1285
1602
  const sorted = [...albums].sort(byYear);
1286
- return /* @__PURE__ */ jsx18(PanelSection2, { title: "Discography", children: GROUPS.map((group) => {
1603
+ return /* @__PURE__ */ jsx22(PanelSection2, { title: "Discography", children: GROUPS.map((group) => {
1287
1604
  const groupAlbums = sorted.filter((album) => discographyGroup(album) === group);
1288
1605
  if (groupAlbums.length === 0) return null;
1289
- return /* @__PURE__ */ jsx18(PanelGroup2, { title: group, children: /* @__PURE__ */ jsx18("ul", { children: groupAlbums.map((album) => /* @__PURE__ */ jsxs12("li", { className: "grid grid-cols-[2.5rem_minmax(0,1fr)] gap-2 py-0.5", children: [
1290
- /* @__PURE__ */ jsx18("span", { className: "tabular-nums text-[var(--color-text-muted)]", children: album.year ?? "\u2014" }),
1291
- /* @__PURE__ */ jsx18("span", { className: "truncate text-[var(--color-text)]", title: album.name, children: album.name })
1606
+ return /* @__PURE__ */ jsx22(PanelGroup2, { title: group, children: /* @__PURE__ */ jsx22("ul", { children: groupAlbums.map((album) => /* @__PURE__ */ jsxs16("li", { className: "grid grid-cols-[2.5rem_minmax(0,1fr)] gap-2 py-0.5", children: [
1607
+ /* @__PURE__ */ jsx22("span", { className: "tabular-nums text-[var(--color-text-muted)]", children: album.year ?? "\u2014" }),
1608
+ /* @__PURE__ */ jsx22("span", { className: "truncate text-[var(--color-text)]", title: album.name, children: album.name })
1292
1609
  ] }, album.uuid)) }) }, group);
1293
1610
  }) });
1294
1611
  }
1295
1612
 
1296
1613
  // src/genre-tree/artist/ArtistSongs.tsx
1297
- import { useState as useState3 } from "react";
1614
+ import { useState as useState4 } from "react";
1298
1615
  import { keepPreviousData } from "@tanstack/react-query";
1299
1616
  import { Play as Play2 } from "lucide-react";
1300
1617
  import { Pagination, PanelSection as PanelSection3, Skeleton as Skeleton3 } from "@behindthemusictree/ui";
1301
- import { usePlayer as usePlayer7 } from "@behindthemusictree/app-kit/player";
1618
+ import { usePlayer as usePlayer8 } from "@behindthemusictree/app-kit/player";
1302
1619
  import {
1303
1620
  SongDetailedSchema as SongDetailedSchema2,
1304
1621
  libraryEndpoints as libraryEndpoints2,
@@ -1308,48 +1625,56 @@ import {
1308
1625
  import { PaginatedResponseSchema as PaginatedResponseSchema2, useFetchWrapper as useFetchWrapper2, useQueryWithParse } from "@behindthemusictree/app-kit/transport";
1309
1626
 
1310
1627
  // src/genre-tree/track-list-sidebar/TrackItem.tsx
1311
- import { usePlayer as usePlayer6 } from "@behindthemusictree/app-kit/player";
1628
+ import { usePlayer as usePlayer7 } from "@behindthemusictree/app-kit/player";
1312
1629
  import { useTrackList as useTrackList3 } from "@behindthemusictree/app-kit/genre-tree";
1313
1630
 
1314
1631
  // src/genre-tree/TrackPositionPlayPause.tsx
1315
1632
  import { FaPlay as FaPlay2, FaPause as FaPause2 } from "react-icons/fa";
1316
- import { usePlayer as usePlayer5 } from "@behindthemusictree/app-kit/player";
1633
+ import { usePlayer as usePlayer6 } from "@behindthemusictree/app-kit/player";
1317
1634
  import { PlayStates as PlayStates2 } from "@behindthemusictree/app-kit/player";
1318
- import { RingLoader as RingLoader2 } from "@behindthemusictree/ui";
1319
- import { jsx as jsx19, jsxs as jsxs13 } from "react/jsx-runtime";
1320
- function TrackPositionPlayPause({ position, uuid, handlePlayPauseClick }) {
1321
- const { playerTrackObject, playState } = usePlayer5();
1635
+ import { jsx as jsx23, jsxs as jsxs17 } from "react/jsx-runtime";
1636
+ function TrackPositionPlayPause({
1637
+ position,
1638
+ uuid,
1639
+ handlePlayPauseClick,
1640
+ isSelected = false
1641
+ }) {
1642
+ const { playerTrackObject, playbackConfirmedTrackId, playState, isLoading: isPlayerLoading } = usePlayer6();
1322
1643
  const isThisTrack = playerTrackObject?.track.id === uuid;
1323
- return /* @__PURE__ */ jsxs13(
1644
+ const isConfirmedPlayable = playbackConfirmedTrackId === uuid;
1645
+ const isAwaitingPlayback = (isSelected || isThisTrack) && !isConfirmedPlayable && !(isThisTrack && playerTrackObject?.loadError) && (!isThisTrack || isPlayerLoading || playState === PlayStates2.LOADING || playState === PlayStates2.PLAYING);
1646
+ const isPlaying = isThisTrack && isConfirmedPlayable && playState === PlayStates2.PLAYING;
1647
+ const isPaused = isThisTrack && isConfirmedPlayable && playState === PlayStates2.PAUSED;
1648
+ const isCurrentTrackActive = isPlaying || isPaused;
1649
+ return /* @__PURE__ */ jsxs17(
1324
1650
  "div",
1325
1651
  {
1326
- className: "track-position-play-pause flex items-center justify-center text-lg w-16 cursor-pointer",
1652
+ className: "track-position-play-pause relative flex items-center justify-center gap-2 text-lg w-16 cursor-pointer",
1327
1653
  style: { minWidth: "4rem", maxWidth: "4rem" },
1328
1654
  onClick: handlePlayPauseClick,
1329
1655
  children: [
1330
- /* @__PURE__ */ jsx19("div", { className: "group-hover:hidden", children: /* @__PURE__ */ jsx19("div", { children: isThisTrack && playState !== PlayStates2.STOPPED ? /* @__PURE__ */ jsxs13("div", { className: "flex space-x-1 items-end", children: [
1331
- /* @__PURE__ */ jsx19(
1332
- "div",
1333
- {
1334
- className: `w-playingbar bg-[var(--color-green-500)] h-3 origin-bottom ${playState === PlayStates2.PLAYING ? "animate-scale-pulse" : ""}`
1335
- }
1336
- ),
1337
- /* @__PURE__ */ jsx19(
1338
- "div",
1339
- {
1340
- className: `w-playingbar bg-[var(--color-green-500)] h-4 origin-bottom ${playState === PlayStates2.PLAYING ? "animate-scale-pulse delay-200" : ""}`,
1341
- style: { animationDelay: "0.2s" }
1342
- }
1343
- ),
1344
- /* @__PURE__ */ jsx19(
1345
- "div",
1346
- {
1347
- className: `w-playingbar bg-[var(--color-green-500)] h-3 origin-bottom ${playState === PlayStates2.PLAYING ? "animate-scale-pulse delay-400" : ""}`,
1348
- style: { animationDelay: "0.3s" }
1349
- }
1350
- )
1351
- ] }) : position }) }),
1352
- /* @__PURE__ */ jsx19("div", { className: "hidden group-hover:flex items-center justify-center", children: isThisTrack && playState === PlayStates2.LOADING ? /* @__PURE__ */ jsx19(RingLoader2, { size: 16 }) : isThisTrack && playState === PlayStates2.PLAYING ? /* @__PURE__ */ jsx19(FaPause2, {}) : /* @__PURE__ */ jsx19(FaPlay2, {}) })
1656
+ /* @__PURE__ */ jsx23("div", { className: "track-position-play-pause__status group-hover:hidden flex items-center justify-center", children: isAwaitingPlayback ? /* @__PURE__ */ jsx23("span", { className: "track-position-play-pause__spinner", role: "status", "aria-label": "Loading track" }) : isCurrentTrackActive ? /* @__PURE__ */ jsxs17(
1657
+ "span",
1658
+ {
1659
+ className: "track-position-play-pause__bars",
1660
+ "data-playing": isPlaying,
1661
+ role: "img",
1662
+ "aria-label": isPlaying ? "Playing" : "Paused",
1663
+ children: [
1664
+ /* @__PURE__ */ jsx23("span", { className: "track-position-play-pause__bar" }),
1665
+ /* @__PURE__ */ jsx23("span", { className: "track-position-play-pause__bar" }),
1666
+ /* @__PURE__ */ jsx23("span", { className: "track-position-play-pause__bar" })
1667
+ ]
1668
+ }
1669
+ ) : position }),
1670
+ /* @__PURE__ */ jsx23(
1671
+ "div",
1672
+ {
1673
+ className: `hidden group-hover:flex items-center justify-center ${isCurrentTrackActive ? "" : "absolute inset-0"}`,
1674
+ "aria-hidden": "true",
1675
+ children: isAwaitingPlayback ? /* @__PURE__ */ jsx23("span", { className: "track-position-play-pause__spinner" }) : isPlaying ? /* @__PURE__ */ jsx23(FaPause2, {}) : /* @__PURE__ */ jsx23(FaPlay2, {})
1676
+ }
1677
+ )
1353
1678
  ]
1354
1679
  }
1355
1680
  );
@@ -1358,7 +1683,7 @@ function TrackPositionPlayPause({ position, uuid, handlePlayPauseClick }) {
1358
1683
  // src/genre-tree/track-list-sidebar/TrackItem.tsx
1359
1684
  import { isSong as isSong2, songUnplayableReason as songUnplayableReason2 } from "@behindthemusictree/app-kit/genre-tree";
1360
1685
  import { ArtistNames as ArtistNames2 } from "@behindthemusictree/app-kit/genre-tree";
1361
- import { jsx as jsx20, jsxs as jsxs14 } from "react/jsx-runtime";
1686
+ import { jsx as jsx24, jsxs as jsxs18 } from "react/jsx-runtime";
1362
1687
  function TrackItem({
1363
1688
  track,
1364
1689
  position,
@@ -1366,26 +1691,27 @@ function TrackItem({
1366
1691
  renderActions,
1367
1692
  renderArtistLink
1368
1693
  }) {
1369
- const { handlePlayPauseAction, playerTrackObject, loadTrackForPlayer } = usePlayer6();
1370
- const { trackList, toTrackAtPosition } = useTrackList3();
1694
+ const { handlePlayPauseAction, playerTrackObject, loadTrackForPlayer } = usePlayer7();
1695
+ const { trackList, selectedTrack, toTrackAtPosition, runtimeUnplayableTrackIds } = useTrackList3();
1371
1696
  const scope = trackList?.origin?.scope ?? null;
1372
- const unplayableReason = isSong2(track) ? songUnplayableReason2(track) : null;
1697
+ const isCurrentTrack = playerTrackObject?.track.id === track.uuid;
1698
+ const hasPlaybackError = isCurrentTrack && !!playerTrackObject?.loadError;
1699
+ const isUnplayable = hasPlaybackError || runtimeUnplayableTrackIds?.has(track.uuid) === true || isSong2(track) && songUnplayableReason2(track) !== null;
1373
1700
  const handlePlayPauseClick = (event) => {
1374
1701
  event.stopPropagation();
1375
- if (playerTrackObject && playerTrackObject.track.id === track.uuid) {
1702
+ if (isCurrentTrack) {
1376
1703
  handlePlayPauseAction();
1377
1704
  } else if (scope != null) {
1378
1705
  toTrackAtPosition(position);
1379
1706
  loadTrackForPlayer(track.uuid);
1380
1707
  }
1381
1708
  };
1382
- return /* @__PURE__ */ jsxs14(
1709
+ return /* @__PURE__ */ jsxs18(
1383
1710
  "div",
1384
1711
  {
1385
- className: `track-item flex h-14 text-[var(--color-text)] ${unplayableReason ? "opacity-50" : "hover:bg-[var(--color-surface-muted)] group"}`,
1386
- title: unplayableReason ?? void 0,
1712
+ className: `track-item flex h-14 text-[var(--color-text)] ${isUnplayable ? "opacity-50" : "hover:bg-[var(--color-surface-muted)] group"}`,
1387
1713
  children: [
1388
- unplayableReason ? /* @__PURE__ */ jsx20(
1714
+ isUnplayable ? /* @__PURE__ */ jsx24(
1389
1715
  "div",
1390
1716
  {
1391
1717
  className: "flex items-center justify-center text-lg w-16",
@@ -1393,18 +1719,21 @@ function TrackItem({
1393
1719
  "aria-disabled": "true",
1394
1720
  children: position
1395
1721
  }
1396
- ) : /* @__PURE__ */ jsx20(TrackPositionPlayPause, { position, uuid: track.uuid, handlePlayPauseClick }),
1397
- /* @__PURE__ */ jsxs14("div", { className: "title-artist-container flex flex-col items-start justify-center min-w-0 flex-1", children: [
1398
- /* @__PURE__ */ jsxs14("div", { className: "flex max-w-full items-center gap-2", children: [
1399
- /* @__PURE__ */ jsx20("div", { className: "title text-lg font-bold text-[var(--color-text)] max-w-full truncate", children: track.title }),
1400
- isSong2(track) && /* @__PURE__ */ jsx20(MusicBrainzRecordingLink, { song: track })
1401
- ] }),
1402
- track.artists && track.artists.length > 0 ? /* @__PURE__ */ jsx20("div", { className: "artist text-base max-w-full truncate text-[var(--color-text-muted)]", children: /* @__PURE__ */ jsx20(ArtistNames2, { artists: track.artists, renderArtistLink }) }) : "",
1403
- unplayableReason && /* @__PURE__ */ jsx20("div", { className: "text-xs", children: /* @__PURE__ */ jsx20(UnplayableReason, { reason: unplayableReason }) })
1722
+ ) : /* @__PURE__ */ jsx24(
1723
+ TrackPositionPlayPause,
1724
+ {
1725
+ position,
1726
+ uuid: track.uuid,
1727
+ handlePlayPauseClick,
1728
+ isSelected: selectedTrack?.uuid === track.uuid
1729
+ }
1730
+ ),
1731
+ /* @__PURE__ */ jsxs18("div", { className: "title-artist-container flex flex-col items-start justify-center min-w-0 flex-1", children: [
1732
+ /* @__PURE__ */ jsx24("div", { className: "flex max-w-full items-center gap-2", children: /* @__PURE__ */ jsx24("div", { className: "title text-lg font-bold text-[var(--color-text)] max-w-full truncate", children: isSong2(track) ? /* @__PURE__ */ jsx24(MusicBrainzRecordingLink, { song: track, className: "hover:underline focus-visible:underline", children: track.title }) : track.title }) }),
1733
+ track.artists && track.artists.length > 0 ? /* @__PURE__ */ jsx24("div", { className: "artist text-base max-w-full truncate text-[var(--color-text-muted)]", children: /* @__PURE__ */ jsx24(ArtistNames2, { artists: track.artists, renderArtistLink }) }) : ""
1404
1734
  ] }),
1405
- /* @__PURE__ */ jsx20("div", { className: "album-name items-start justify-center w-1/3 min-w-0 ml-2 max-w-full truncate text-[var(--color-text-muted)]", children: track.album ? track.album.name : "" }),
1406
- /* @__PURE__ */ jsx20("div", { className: "genre-name-container flex items-center justify-end w-1/6 min-w-0 shrink-0", children: track.genre ? /* @__PURE__ */ jsx20("div", { className: "genre-name font-bold p-1 border border-[var(--color-text-muted)] text-xs max-w-full truncate", children: track.genre.name }) : "" }),
1407
- renderDuration && /* @__PURE__ */ jsx20(
1735
+ /* @__PURE__ */ jsx24("div", { className: "genre-name-container flex items-center justify-start w-[21.6%] min-w-0 shrink-0", children: track.genre ? /* @__PURE__ */ jsx24("div", { className: "genre-name max-w-full whitespace-normal break-words p-1 text-xs font-bold", children: track.genre.name }) : "" }),
1736
+ renderDuration && /* @__PURE__ */ jsx24(
1408
1737
  "div",
1409
1738
  {
1410
1739
  className: "duration flex text-base w-16 items-center justify-center",
@@ -1412,25 +1741,25 @@ function TrackItem({
1412
1741
  children: renderDuration(track)
1413
1742
  }
1414
1743
  ),
1415
- renderActions && /* @__PURE__ */ jsx20("div", { className: "edit flex text-base w-6 items-center justify-center mr-2 cursor-pointer", children: renderActions(track) })
1744
+ renderActions && /* @__PURE__ */ jsx24("div", { className: "edit flex text-base w-6 items-center justify-center mr-2 cursor-pointer", children: renderActions(track) })
1416
1745
  ]
1417
1746
  }
1418
1747
  );
1419
1748
  }
1420
1749
 
1421
1750
  // src/genre-tree/artist/ArtistSongs.tsx
1422
- import { Fragment as Fragment4, jsx as jsx21, jsxs as jsxs15 } from "react/jsx-runtime";
1751
+ import { Fragment as Fragment7, jsx as jsx25, jsxs as jsxs19 } from "react/jsx-runtime";
1423
1752
  var SongPageSchema2 = PaginatedResponseSchema2(SongDetailedSchema2);
1424
1753
  var SORTS = [
1425
1754
  { ordering: "title", label: "Title" },
1426
1755
  { ordering: "-albumYear", label: "Year" }
1427
1756
  ];
1428
1757
  function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1429
- const [page, setPage] = useState3(1);
1430
- const [ordering, setOrdering] = useState3("title");
1758
+ const [page, setPage] = useState4(1);
1759
+ const [ordering, setOrdering] = useState4("title");
1431
1760
  const { fetch } = useFetchWrapper2(getBackendBaseUrl);
1432
1761
  const { playNewTrackListFromArtist } = useTrackList4();
1433
- const { playerTrackObject } = usePlayer7();
1762
+ const { playerTrackObject } = usePlayer8();
1434
1763
  const { data, isPending, isError } = useQueryWithParse({
1435
1764
  queryKey: ["referenceSongs", "artist", artist.uuid, ordering, page],
1436
1765
  queryFn: () => fetch(libraryEndpoints2.reference.songs.list(), true, false, void 0, {
@@ -1452,21 +1781,21 @@ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1452
1781
  event.stopPropagation();
1453
1782
  void play(song.uuid);
1454
1783
  };
1455
- return /* @__PURE__ */ jsx21(PanelSection3, { title: "Songs", children: isPending ? /* @__PURE__ */ jsx21(Skeleton3, { className: "h-14", "aria-busy": "true" }) : isError ? /* @__PURE__ */ jsx21("p", { className: "text-red-600", children: "Could not load songs." }) : data.results.length === 0 ? /* @__PURE__ */ jsx21("p", { className: "text-[var(--color-text-muted)]", children: "No songs yet." }) : /* @__PURE__ */ jsxs15(Fragment4, { children: [
1456
- /* @__PURE__ */ jsxs15("div", { className: "flex flex-wrap items-center justify-between gap-2 mb-2", children: [
1457
- /* @__PURE__ */ jsxs15(
1784
+ return /* @__PURE__ */ jsx25(PanelSection3, { title: "Songs", children: isPending ? /* @__PURE__ */ jsx25(Skeleton3, { className: "h-14", "aria-busy": "true" }) : isError ? /* @__PURE__ */ jsx25("p", { className: "text-red-600", children: "Could not load songs." }) : data.results.length === 0 ? /* @__PURE__ */ jsx25("p", { className: "text-[var(--color-text-muted)]", children: "No songs yet." }) : /* @__PURE__ */ jsxs19(Fragment7, { children: [
1785
+ /* @__PURE__ */ jsxs19("div", { className: "flex flex-wrap items-center justify-between gap-2 mb-2", children: [
1786
+ /* @__PURE__ */ jsxs19(
1458
1787
  "button",
1459
1788
  {
1460
1789
  type: "button",
1461
1790
  onClick: () => void play(),
1462
1791
  className: "flex w-fit items-center gap-1.5 px-3 py-1 text-xs font-medium bg-gray-900 text-white rounded-full hover:bg-gray-700",
1463
1792
  children: [
1464
- /* @__PURE__ */ jsx21(Play2, { className: "h-3.5 w-3.5", "aria-hidden": "true" }),
1793
+ /* @__PURE__ */ jsx25(Play2, { className: "h-3.5 w-3.5", "aria-hidden": "true" }),
1465
1794
  "Play all"
1466
1795
  ]
1467
1796
  }
1468
1797
  ),
1469
- /* @__PURE__ */ jsx21("div", { role: "group", "aria-label": "Sort songs by", className: "flex gap-1 text-xs", children: SORTS.map((sort) => /* @__PURE__ */ jsx21(
1798
+ /* @__PURE__ */ jsx25("div", { role: "group", "aria-label": "Sort songs by", className: "flex gap-1 text-xs", children: SORTS.map((sort) => /* @__PURE__ */ jsx25(
1470
1799
  "button",
1471
1800
  {
1472
1801
  type: "button",
@@ -1481,7 +1810,7 @@ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1481
1810
  sort.ordering
1482
1811
  )) })
1483
1812
  ] }),
1484
- /* @__PURE__ */ jsx21("ul", { className: "overflow-hidden mb-2 bg-zinc-950 rounded-lg", children: data.results.map((song, index) => /* @__PURE__ */ jsx21("li", { onClickCapture: (event) => handleRowClickCapture(event, song), children: /* @__PURE__ */ jsx21(
1813
+ /* @__PURE__ */ jsx25("ul", { className: "overflow-hidden mb-2 bg-zinc-950 rounded-lg", children: data.results.map((song, index) => /* @__PURE__ */ jsx25("li", { onClickCapture: (event) => handleRowClickCapture(event, song), children: /* @__PURE__ */ jsx25(
1485
1814
  TrackItem,
1486
1815
  {
1487
1816
  track: song,
@@ -1489,15 +1818,17 @@ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1489
1818
  renderArtistLink
1490
1819
  }
1491
1820
  ) }, song.uuid)) }),
1492
- /* @__PURE__ */ jsx21(Pagination, { currentPage: page, totalPages: data.totalPages, onPageChange: setPage })
1821
+ /* @__PURE__ */ jsx25(Pagination, { currentPage: page, totalPages: data.totalPages, onPageChange: setPage })
1493
1822
  ] }) });
1494
1823
  }
1495
1824
 
1496
1825
  // src/genre-tree/artist/useArtistPanel.tsx
1497
- import { Fragment as Fragment5, jsx as jsx22, jsxs as jsxs16 } from "react/jsx-runtime";
1826
+ import { Fragment as Fragment8, jsx as jsx26, jsxs as jsxs20 } from "react/jsx-runtime";
1498
1827
  function useArtistPanel({
1499
1828
  uuid,
1829
+ scope,
1500
1830
  getBackendBaseUrl,
1831
+ getShareUrl,
1501
1832
  renderArtistLink,
1502
1833
  expanded,
1503
1834
  onExpandedChange,
@@ -1512,38 +1843,41 @@ function useArtistPanel({
1512
1843
  return {
1513
1844
  ...panel,
1514
1845
  title: "Artist",
1515
- content: /* @__PURE__ */ jsxs16("div", { "aria-busy": "true", className: "flex flex-col gap-2.5 px-6 py-2.5", children: [
1516
- /* @__PURE__ */ jsx22(Skeleton4, { className: "h-3 w-16" }),
1517
- /* @__PURE__ */ jsx22(Skeleton4, { className: "h-4 w-32" }),
1518
- /* @__PURE__ */ jsx22(Skeleton4, { className: "h-3 w-16" }),
1519
- /* @__PURE__ */ jsx22(Skeleton4, { className: "h-4 w-24" }),
1520
- /* @__PURE__ */ jsx22(Skeleton4, { className: "h-16 mt-2.5" }),
1521
- /* @__PURE__ */ jsx22(Skeleton4, { className: "h-16" })
1846
+ content: /* @__PURE__ */ jsxs20("div", { "aria-busy": "true", className: "flex flex-col gap-2.5 px-6 py-2.5", children: [
1847
+ /* @__PURE__ */ jsx26(Skeleton4, { className: "h-3 w-16" }),
1848
+ /* @__PURE__ */ jsx26(Skeleton4, { className: "h-4 w-32" }),
1849
+ /* @__PURE__ */ jsx26(Skeleton4, { className: "h-3 w-16" }),
1850
+ /* @__PURE__ */ jsx26(Skeleton4, { className: "h-4 w-24" }),
1851
+ /* @__PURE__ */ jsx26(Skeleton4, { className: "h-16 mt-2.5" }),
1852
+ /* @__PURE__ */ jsx26(Skeleton4, { className: "h-16" })
1522
1853
  ] })
1523
1854
  };
1524
1855
  case "not-found":
1525
1856
  return {
1526
1857
  ...panel,
1527
1858
  title: "Artist not found",
1528
- content: /* @__PURE__ */ jsx22("p", { className: "px-6 py-2.5 text-[var(--color-text-muted)]", children: "This artist does not exist." })
1859
+ content: /* @__PURE__ */ jsx26("p", { className: "px-6 py-2.5 text-[var(--color-text-muted)]", children: "This artist does not exist." })
1529
1860
  };
1530
1861
  case "error":
1531
1862
  return {
1532
1863
  ...panel,
1533
1864
  title: "Artist",
1534
- content: /* @__PURE__ */ jsx22("p", { className: "px-6 py-2.5 text-red-600", children: "Could not load this artist." })
1865
+ content: /* @__PURE__ */ jsx26("p", { className: "px-6 py-2.5 text-red-600", children: "Could not load this artist." })
1535
1866
  };
1536
1867
  case "success": {
1537
1868
  const { artist } = result;
1869
+ const shareTarget = scope ? { kind: "artist", uuid: artist.uuid, name: artist.name, scope } : void 0;
1870
+ const shareUrl = shareTarget ? getShareUrl?.(shareTarget) : void 0;
1538
1871
  return {
1539
1872
  ...panel,
1540
1873
  title: artist.name,
1541
- content: /* @__PURE__ */ jsxs16(Fragment5, { children: [
1542
- artist.image && /* @__PURE__ */ jsx22(ArtistPhoto, { image: artist.image, name: artist.name }),
1543
- /* @__PURE__ */ jsx22(ArtistHeader, { artist }),
1544
- /* @__PURE__ */ jsx22(ArtistMembers, { title: "Members", memberships: artist.members, renderArtistLink }),
1545
- /* @__PURE__ */ jsx22(ArtistMembers, { title: "Member of", memberships: artist.memberOf, renderArtistLink }),
1546
- artist.tracksCount > 0 && /* @__PURE__ */ jsx22(
1874
+ content: /* @__PURE__ */ jsxs20(Fragment8, { children: [
1875
+ shareUrl && /* @__PURE__ */ jsx26("div", { className: "genre-action-band border-b border-[var(--color-border)]", children: /* @__PURE__ */ jsx26("div", { className: "flex flex-wrap gap-1 px-3.5 py-3", children: shareTarget && /* @__PURE__ */ jsx26(ShareActionButton, { target: shareTarget, url: shareUrl }) }) }),
1876
+ artist.image && /* @__PURE__ */ jsx26(ArtistPhoto, { image: artist.image, name: artist.name }),
1877
+ /* @__PURE__ */ jsx26(ArtistHeader, { artist }),
1878
+ /* @__PURE__ */ jsx26(ArtistMembers, { title: "Members", memberships: artist.members, renderArtistLink }),
1879
+ /* @__PURE__ */ jsx26(ArtistMembers, { title: "Member of", memberships: artist.memberOf, renderArtistLink }),
1880
+ artist.tracksCount > 0 && /* @__PURE__ */ jsx26(
1547
1881
  ArtistSongs,
1548
1882
  {
1549
1883
  artist: { uuid: artist.uuid, name: artist.name },
@@ -1552,7 +1886,7 @@ function useArtistPanel({
1552
1886
  }
1553
1887
  )
1554
1888
  ] }),
1555
- extraContent: artist.albums.length > 0 ? /* @__PURE__ */ jsx22(ArtistDiscography, { albums: artist.albums }) : void 0
1889
+ extraContent: artist.albums.length > 0 ? /* @__PURE__ */ jsx26(ArtistDiscography, { albums: artist.albums }) : void 0
1556
1890
  };
1557
1891
  }
1558
1892
  }
@@ -1561,27 +1895,27 @@ function useArtistPanel({
1561
1895
  // src/genre-tree/SizeMetricToggle.tsx
1562
1896
  import { MdMusicNote, MdVisibility } from "react-icons/md";
1563
1897
  import { SegmentedControl } from "@behindthemusictree/ui";
1564
- import { jsx as jsx23 } from "react/jsx-runtime";
1898
+ import { jsx as jsx27 } from "react/jsx-runtime";
1565
1899
  function SizeMetricToggle({
1566
1900
  value,
1567
1901
  onChange
1568
1902
  }) {
1569
- return /* @__PURE__ */ jsx23(
1903
+ return /* @__PURE__ */ jsx27(
1570
1904
  SegmentedControl,
1571
1905
  {
1572
1906
  "aria-label": "Size nodes by",
1573
1907
  value,
1574
1908
  onChange,
1575
1909
  options: [
1576
- { value: "songs", label: /* @__PURE__ */ jsx23(MdMusicNote, { size: 18, "aria-hidden": true }), "aria-label": "Size by songs", title: "Node size by song count" },
1577
- { value: "views", label: /* @__PURE__ */ jsx23(MdVisibility, { size: 18, "aria-hidden": true }), "aria-label": "Size by views", title: "Node size by song views" }
1910
+ { value: "songs", label: /* @__PURE__ */ jsx27(MdMusicNote, { size: 18, "aria-hidden": true }), "aria-label": "Size by songs", title: "Node size by song count" },
1911
+ { value: "views", label: /* @__PURE__ */ jsx27(MdVisibility, { size: 18, "aria-hidden": true }), "aria-label": "Size by views", title: "Node size by song views" }
1578
1912
  ]
1579
1913
  }
1580
1914
  );
1581
1915
  }
1582
1916
 
1583
1917
  // src/genre-tree/GenreTreeView.tsx
1584
- import { Fragment as Fragment6, jsx as jsx24, jsxs as jsxs17 } from "react/jsx-runtime";
1918
+ import { Fragment as Fragment9, jsx as jsx28, jsxs as jsxs21 } from "react/jsx-runtime";
1585
1919
  var PREVIOUS_BUTTON_CSS = `
1586
1920
  .genre-tree-view .search-overlay .gtv-info-panel-previous { display: none; }
1587
1921
  .genre-tree-view:has(.search-overlay .gtv-info-panel-previous) .genre-search input { padding-left: 2.75rem; }
@@ -1591,27 +1925,6 @@ var PREVIOUS_BUTTON_CSS = `
1591
1925
  .genre-search-previous { animation: genre-tree-view-previous-in 200ms ease-out; }
1592
1926
  }
1593
1927
  `;
1594
- function GenreNodeActionBand({ genrePlaylist, scope, playback, ...bandProps }) {
1595
- const { isPlaying, setIsPlaying } = usePlayer8();
1596
- const isCurrentTrackList = playback.playingNodeId === genrePlaylist.uuid;
1597
- const handlePlayPause = () => {
1598
- if (isCurrentTrackList) {
1599
- setIsPlaying(!isPlaying);
1600
- return;
1601
- }
1602
- playback.play(genrePlaylist).catch((error) => {
1603
- console.error("Failed to load genre playlist tracks:", error);
1604
- });
1605
- };
1606
- return /* @__PURE__ */ jsx24(
1607
- GenreActionBand,
1608
- {
1609
- ...bandProps,
1610
- onPlayPause: genrePlaylist.tracksCount > 0 ? handlePlayPause : void 0,
1611
- isPlaying: isCurrentTrackList && isPlaying
1612
- }
1613
- );
1614
- }
1615
1928
  var NO_POP_ROOT_TITLE = "This genre tree has no 'Mainstream Pop' root yet";
1616
1929
  var REGIONS_CLASS = "[--gtv-center-left:var(--layout-center-left)] [--gtv-center-right:var(--layout-center-right)] [--gtv-center-top:var(--layout-band-top)] [&_.gtv-info-panel--overlay]:[--gtv-info-panel-header-inset:0px]";
1617
1930
  function GenreTreeView({
@@ -1625,15 +1938,16 @@ function GenreTreeView({
1625
1938
  selectedNodeId: controlledSelectedNodeId,
1626
1939
  onSelectedNodeIdChange,
1627
1940
  defaultViewport,
1941
+ viewportRestore,
1628
1942
  onViewportChange,
1629
1943
  readOnly = false,
1630
- className,
1944
+ className: className2,
1631
1945
  showDisplayOptions = true,
1632
1946
  showAddRoot = true,
1633
1947
  allowWheelRotation: controlledAllowWheelRotation,
1634
1948
  criteriaOverviewSchema,
1635
1949
  renderGenreDetailExtras,
1636
- controlsOrientation = "horizontal",
1950
+ floatingControlsLeading,
1637
1951
  sizeMetric: controlledSizeMetric,
1638
1952
  onSizeMetricChange,
1639
1953
  renderArtistLink,
@@ -1641,6 +1955,7 @@ function GenreTreeView({
1641
1955
  onArtistUuidChange,
1642
1956
  artistExpanded: controlledArtistExpanded,
1643
1957
  onArtistExpandedChange,
1958
+ getShareUrl,
1644
1959
  artistSearch = true,
1645
1960
  onArtistSelect,
1646
1961
  artistSearchBaseUrl,
@@ -1658,10 +1973,12 @@ function GenreTreeView({
1658
1973
  placement = "default",
1659
1974
  ...focus
1660
1975
  }) {
1661
- const [internalArtistUuid, setInternalArtistUuid] = useState4(null);
1976
+ const getShareUrlRef = useRef4(getShareUrl);
1977
+ getShareUrlRef.current = getShareUrl;
1978
+ const [internalArtistUuid, setInternalArtistUuid] = useState5(null);
1662
1979
  const isArtistControlled = controlledArtistUuid !== void 0;
1663
1980
  const artistUuid = isArtistControlled ? controlledArtistUuid : internalArtistUuid;
1664
- const [internalSizeMetric, setInternalSizeMetric] = useState4("songs");
1981
+ const [internalSizeMetric, setInternalSizeMetric] = useState5("songs");
1665
1982
  const sizeMetric = controlledSizeMetric ?? internalSizeMetric;
1666
1983
  const deferredSizeMetric = useDeferredValue(sizeMetric);
1667
1984
  const changeSizeMetric = (metric) => {
@@ -1699,25 +2016,41 @@ function GenreTreeView({
1699
2016
  criteriaOverviewSchema,
1700
2017
  artistUuid
1701
2018
  });
2019
+ const restoreRevision = viewportRestore?.revision;
2020
+ const [stackedRestoreOwner, setStackedRestoreOwner] = useState5({ revision: restoreRevision, rootUuid: selectedGenrePlaylist?.root.uuid });
2021
+ let restoreRootUuid = stackedRestoreOwner.rootUuid;
2022
+ if (stackedRestoreOwner.revision !== restoreRevision) {
2023
+ restoreRootUuid = selectedGenrePlaylist?.root.uuid;
2024
+ setStackedRestoreOwner({ revision: restoreRevision, rootUuid: restoreRootUuid });
2025
+ } else if (restoreRootUuid === void 0 && selectedGenrePlaylist) {
2026
+ restoreRootUuid = selectedGenrePlaylist.root.uuid;
2027
+ setStackedRestoreOwner({ revision: restoreRevision, rootUuid: restoreRootUuid });
2028
+ }
1702
2029
  const isGenreUpdating = useIsMutating({ mutationKey: genreUpdateMutationKey }) > 0;
2030
+ const { mutate: updateGenre } = useUpdateGenre2(scope, getBackendBaseUrl);
1703
2031
  const busy = deferredSizeMetric !== sizeMetric || isTreeFetching || isGenreUpdating;
2032
+ const isArtistFiltered = artistUuid !== null;
2033
+ const canSizeByViews = !isArtistFiltered && hasViewsCount(visibleGenrePlaylists);
1704
2034
  const artistResult = useArtist2(artistUuid, artistSearchBaseUrl ?? getBackendBaseUrl);
1705
2035
  const filterArtistName = artistResult.status === "success" ? artistResult.artist.name : null;
1706
- const filterArtist = useMemo6(
2036
+ const filterArtist = useMemo7(
1707
2037
  () => artistUuid !== null && filterArtistName !== null ? { uuid: artistUuid, name: filterArtistName } : null,
1708
2038
  [artistUuid, filterArtistName]
1709
2039
  );
2040
+ const hasSelectedGenreNode = selectedGenrePlaylist !== void 0 && visibleGenrePlaylists.some(({ uuid }) => uuid === selectedGenrePlaylist.uuid);
1710
2041
  const playback = useGenrePlaylistPlayback({ scope, genrePlaylists: visibleGenrePlaylists, artist: filterArtist });
1711
2042
  const openArtist = (uuid) => {
1712
2043
  if (selectedNodeId !== null) clearSelection();
1713
2044
  changeArtist(uuid);
1714
2045
  };
1715
- const [expandedArtistUuid, setExpandedArtistUuid] = useState4(null);
2046
+ const [expandedArtistUuid, setExpandedArtistUuid] = useState5(null);
1716
2047
  const artistBaseUrl = artistSearchBaseUrl ?? getBackendBaseUrl;
1717
2048
  const artistPanel = useArtistPanel({
1718
2049
  uuid: artistUuid,
2050
+ scope,
1719
2051
  getBackendBaseUrl: artistBaseUrl,
1720
- renderArtistLink: renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ jsx24("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name })),
2052
+ getShareUrl: getShareUrlRef.current,
2053
+ renderArtistLink: renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ jsx28("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name })),
1721
2054
  expanded: controlledArtistExpanded ?? (artistUuid !== null && expandedArtistUuid === artistUuid),
1722
2055
  onExpandedChange: (on) => {
1723
2056
  if (controlledArtistExpanded === void 0) setExpandedArtistUuid(on ? artistUuid : null);
@@ -1725,10 +2058,10 @@ function GenreTreeView({
1725
2058
  },
1726
2059
  onClose: () => changeArtist(null)
1727
2060
  });
1728
- const builtInArtistPanel = overlayPanel || selectedNodeId !== null ? void 0 : artistPanel;
2061
+ const builtInArtistPanel = overlayPanel || hasSelectedGenreNode ? void 0 : artistPanel;
1729
2062
  const activeOverlayPanel = overlayPanel ?? (builtInArtistPanel && { ...builtInArtistPanel, hideTitle: true, hideClose: true });
1730
- const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState4(false);
1731
- const [uncontrolledExpanded, setUncontrolledExpanded] = useState4(false);
2063
+ const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState5(false);
2064
+ const [uncontrolledExpanded, setUncontrolledExpanded] = useState5(false);
1732
2065
  const isCollapsed = collapsed ?? uncontrolledCollapsed;
1733
2066
  const isExpanded = expanded ?? uncontrolledExpanded;
1734
2067
  const changeCollapsed = (next) => {
@@ -1739,18 +2072,11 @@ function GenreTreeView({
1739
2072
  setUncontrolledExpanded(next);
1740
2073
  onExpandedChange?.(next);
1741
2074
  };
1742
- useEffect4(() => {
2075
+ useEffect6(() => {
1743
2076
  setUncontrolledCollapsed(false);
1744
2077
  }, [selectedNodeId, artistUuid]);
1745
- const isDetailHidden = isCollapsed || !selectedNodeId && !activeOverlayPanel;
1746
- useEffect4(() => {
1747
- if (placement !== "regions" || !isDetailHidden) return void 0;
1748
- const root = document.documentElement;
1749
- root.style.setProperty("--layout-left-w", "0px");
1750
- return () => {
1751
- root.style.removeProperty("--layout-left-w");
1752
- };
1753
- }, [placement, isDetailHidden]);
2078
+ const isDetailHidden = isCollapsed || !hasSelectedGenreNode && !activeOverlayPanel;
2079
+ useLeftDetailPanelLayout(!isDetailHidden, placement === "regions");
1754
2080
  const detailOverlay = placement === "regions" && activeOverlayPanel?.onExpandedChange ? activeOverlayPanel : void 0;
1755
2081
  const detailExpanded = detailOverlay ? !!detailOverlay.expanded : isExpanded;
1756
2082
  const onDetailExpandedChange = detailOverlay ? detailOverlay.onExpandedChange : changeExpanded;
@@ -1762,29 +2088,38 @@ function GenreTreeView({
1762
2088
  hideExpandButton: placement === "regions" ? true : void 0,
1763
2089
  overlayPanel: detailOverlay ? { ...detailOverlay, collapsed: isCollapsed, hideExpandButton: true } : activeOverlayPanel
1764
2090
  };
1765
- const [isWheelRevealed, setIsWheelRevealed] = useState4(false);
2091
+ const [isWheelRevealed, setIsWheelRevealed] = useState5(false);
1766
2092
  const usesHandoff = viewMode === "wheel" || viewMode === "pop-core";
1767
2093
  const isTreeReady = !isLoading && (!usesHandoff || isWheelRevealed);
1768
2094
  const rootRef = useRef4(null);
1769
2095
  const handlePrevious = () => {
1770
2096
  rootRef.current?.querySelector(".search-overlay .gtv-info-panel-previous")?.click();
1771
2097
  };
1772
- const { showPopup, hidePopup } = usePopup4();
1773
- const [searchText, setSearchText] = useState4("");
2098
+ useEffect6(() => {
2099
+ if (!isTreeReady || viewMode !== "pop-core" || !hasSelectedGenreNode || !selectedGenrePlaylist) return;
2100
+ const root = rootRef.current;
2101
+ const centerChip = root?.querySelector(".gtv-wheel-center-node .gtv-wheel-chip");
2102
+ if (!centerChip || centerChip.getAttribute("aria-label") !== selectedGenrePlaylist.name) return;
2103
+ const panelTitle = root?.querySelector(".gtv-info-panel-title");
2104
+ if (panelTitle?.textContent?.trim().startsWith(selectedGenrePlaylist.name)) return;
2105
+ centerChip.click();
2106
+ }, [hasSelectedGenreNode, isTreeReady, selectedGenrePlaylist, viewMode]);
2107
+ const { showPopup, hidePopup } = usePopup5();
2108
+ const [searchText, setSearchText] = useState5("");
1774
2109
  const artistSearchResult = useArtistSearch(artistBaseUrl, artistSearch ? searchText : "");
1775
2110
  const artistExtraResults = artistSearch ? {
1776
2111
  label: "Artists",
1777
2112
  items: artistSearchResult.artists.map((artist) => ({
1778
2113
  key: artist.uuid,
1779
- content: /* @__PURE__ */ jsxs17(Fragment6, { children: [
1780
- /* @__PURE__ */ jsxs17("span", { className: "block truncate font-medium", children: [
2114
+ content: /* @__PURE__ */ jsxs21(Fragment9, { children: [
2115
+ /* @__PURE__ */ jsxs21("span", { className: "block truncate font-medium", children: [
1781
2116
  artist.name,
1782
- artist.disambiguation && /* @__PURE__ */ jsxs17("span", { className: "font-normal text-[var(--color-neutral-500)]", children: [
2117
+ artist.disambiguation && /* @__PURE__ */ jsxs21("span", { className: "font-normal text-[var(--color-neutral-500)]", children: [
1783
2118
  " \xB7 ",
1784
2119
  artist.disambiguation
1785
2120
  ] })
1786
2121
  ] }),
1787
- artist.matchedAlias && /* @__PURE__ */ jsxs17("span", { className: "block truncate text-xs text-[var(--color-neutral-500)]", children: [
2122
+ artist.matchedAlias && /* @__PURE__ */ jsxs21("span", { className: "block truncate text-xs text-[var(--color-neutral-500)]", children: [
1788
2123
  "also known as ",
1789
2124
  artist.matchedAlias
1790
2125
  ] })
@@ -1812,29 +2147,50 @@ function GenreTreeView({
1812
2147
  };
1813
2148
  const zoomInRef = useRef4(void 0);
1814
2149
  zoomInRef.current = canZoomIn ? handleGenreSearchZoomIn : void 0;
1815
- const renderInfoPanelBody = useCallback5(
2150
+ const renderInfoPanelBody = useCallback6(
1816
2151
  (node, { toolbar, ancestors, family }) => {
1817
2152
  const genrePlaylist = genrePlaylistByNodeId.get(node.id);
1818
2153
  const criteria = genrePlaylist?.criteria;
1819
- if (!genrePlaylist || !criteria) return { primary: /* @__PURE__ */ jsxs17(Fragment6, { children: [
2154
+ if (!genrePlaylist || !criteria) return { primary: /* @__PURE__ */ jsxs21(Fragment9, { children: [
1820
2155
  toolbar,
1821
2156
  family
1822
2157
  ] }), aside: ancestors };
1823
2158
  const genreUuid = criteria.uuid;
2159
+ const parentPlaylist = visibleGenrePlaylists.find(({ uuid }) => uuid === genrePlaylist.parent?.uuid);
2160
+ const canToggleSide = !readOnly && parentPlaylist !== void 0 && parentPlaylist.parent === null;
2161
+ const side = criteria.side ?? "core";
2162
+ const shareTarget = {
2163
+ kind: "genre",
2164
+ genrePlaylistUuid: genrePlaylist.uuid,
2165
+ name: criteria.name,
2166
+ scope,
2167
+ artistUuid
2168
+ };
2169
+ const shareUrl = getShareUrlRef.current?.(shareTarget) ?? void 0;
2170
+ const popSibling = canToggleSide && side === "core" ? visibleGenrePlaylists.find(
2171
+ (sibling) => sibling.parent?.uuid === parentPlaylist.uuid && sibling.uuid !== genrePlaylist.uuid && sibling.criteria?.side === "pop"
2172
+ ) : void 0;
1824
2173
  return {
1825
- primary: /* @__PURE__ */ jsxs17(Fragment6, { children: [
1826
- toolbar,
1827
- /* @__PURE__ */ jsx24(
1828
- GenreNodeActionBand,
1829
- {
2174
+ primary: /* @__PURE__ */ jsx28(
2175
+ GenreInfoPanelBody,
2176
+ {
2177
+ toolbar,
2178
+ family,
2179
+ genreUuid,
2180
+ genrePlaylist,
2181
+ wikidataId: criteria.wikidataId,
2182
+ scope,
2183
+ getBackendBaseUrl,
2184
+ playback,
2185
+ band: {
1830
2186
  node,
1831
- genrePlaylist,
1832
- scope,
2187
+ shareTarget: shareUrl ? shareTarget : void 0,
2188
+ shareUrl,
1833
2189
  onZoomIn: zoomInRef.current,
1834
2190
  onAddChild: readOnly ? void 0 : () => handleGenreCreationAction(criteria),
1835
2191
  onRename: readOnly ? void 0 : () => handleGenreRenameAction(criteria),
1836
2192
  onChangeParent: readOnly ? void 0 : () => showPopup(
1837
- /* @__PURE__ */ jsx24(
2193
+ /* @__PURE__ */ jsx28(
1838
2194
  ChangeParentPopup,
1839
2195
  {
1840
2196
  genrePlaylist: { ...genrePlaylist, criteria },
@@ -1846,18 +2202,12 @@ function GenreTreeView({
1846
2202
  }
1847
2203
  )
1848
2204
  ),
1849
- additionalActions,
1850
- playback
1851
- }
1852
- ),
1853
- /* @__PURE__ */ jsx24(
1854
- GenreDetailExtras,
1855
- {
1856
- family,
1857
- genreUuid,
1858
- genrePlaylist,
1859
- scope,
1860
- getBackendBaseUrl,
2205
+ side,
2206
+ onToggleSide: canToggleSide ? () => updateGenre({ uuid: genreUuid, data: { side: side === "pop" ? "core" : "pop" } }) : void 0,
2207
+ toggleSideDisabledReason: popSibling ? `${popSibling.name} is already the pop side of ${parentPlaylist?.name}` : void 0,
2208
+ additionalActions
2209
+ },
2210
+ extras: {
1861
2211
  criteriaOverviewSchema,
1862
2212
  renderGenreDetailExtras,
1863
2213
  renderArtistLink,
@@ -1867,10 +2217,9 @@ function GenreTreeView({
1867
2217
  renderSongLeading,
1868
2218
  renderSongActions,
1869
2219
  renderTracksToolbar
1870
- },
1871
- genreUuid
1872
- )
1873
- ] }),
2220
+ }
2221
+ }
2222
+ ),
1874
2223
  aside: ancestors
1875
2224
  };
1876
2225
  },
@@ -1884,6 +2233,7 @@ function GenreTreeView({
1884
2233
  hidePopup,
1885
2234
  onParentChanged,
1886
2235
  visibleGenrePlaylists,
2236
+ updateGenre,
1887
2237
  playback,
1888
2238
  filterArtist,
1889
2239
  scope,
@@ -1900,7 +2250,7 @@ function GenreTreeView({
1900
2250
  );
1901
2251
  const isRotationControlled = controlledAllowWheelRotation !== void 0;
1902
2252
  const allowWheelRotation = controlledAllowWheelRotation ?? internalAllowWheelRotation;
1903
- const detailTab = placement === "regions" && (selectedNodeId || detailOverlay) ? /* @__PURE__ */ jsx24(
2253
+ const detailTab = placement === "regions" && (hasSelectedGenreNode || detailOverlay) ? /* @__PURE__ */ jsx28(
1904
2254
  PanelEdgeTab,
1905
2255
  {
1906
2256
  side: "left",
@@ -1908,25 +2258,14 @@ function GenreTreeView({
1908
2258
  center: detailExpanded ? null : isCollapsed ? { label: "Show panel", onClick: () => changeCollapsed(false) } : onDetailExpandedChange ? { label: "Extend panel", onClick: () => onDetailExpandedChange(true) } : null
1909
2259
  }
1910
2260
  ) : null;
1911
- const actions = /* @__PURE__ */ jsxs17(Fragment6, { children: [
1912
- !isLoading && /* @__PURE__ */ jsx24(
1913
- GenreSearch,
1914
- {
1915
- genrePlaylists: visibleGenrePlaylists,
1916
- onSelect: handleGenreSearchSelect,
1917
- selectedName: selectedGenrePlaylist?.name ?? builtInArtistPanel?.title ?? null,
1918
- onClear: builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
1919
- onPrevious: handlePrevious,
1920
- onQueryChange: artistSearch ? setSearchText : void 0,
1921
- extraResults: artistExtraResults
1922
- }
1923
- ),
1924
- !isLoading && filterArtist && /* @__PURE__ */ jsxs17("span", { className: "inline-flex items-center overflow-hidden rounded-full bg-[var(--color-neutral-100)] text-sm", children: [
1925
- /* @__PURE__ */ jsx24(
2261
+ const renderArtistFilter = () => {
2262
+ if (isLoading || !filterArtist) return null;
2263
+ return /* @__PURE__ */ jsxs21("span", { className: "inline-flex items-center overflow-hidden rounded-full bg-[var(--color-neutral-100)] text-sm", children: [
2264
+ /* @__PURE__ */ jsx28(
1926
2265
  "button",
1927
2266
  {
1928
2267
  type: "button",
1929
- className: "py-1 pl-3 pr-2 hover:underline",
2268
+ className: "py-1 pl-3 pr-2 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] hover:underline",
1930
2269
  "aria-label": `Show artist ${filterArtist.name}`,
1931
2270
  onClick: () => {
1932
2271
  if (selectedNodeId !== null) clearSelection();
@@ -1934,86 +2273,174 @@ function GenreTreeView({
1934
2273
  children: filterArtist.name
1935
2274
  }
1936
2275
  ),
1937
- /* @__PURE__ */ jsx24(
2276
+ /* @__PURE__ */ jsx28(
1938
2277
  "button",
1939
2278
  {
1940
2279
  type: "button",
1941
- className: "py-1 pl-1 pr-3 hover:text-[var(--color-text)]",
2280
+ className: "py-1 pl-1 pr-3 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] hover:text-[var(--color-text)]",
1942
2281
  "aria-label": `Clear artist filter ${filterArtist.name}`,
1943
2282
  onClick: () => changeArtist(null),
1944
2283
  children: "\xD7"
1945
2284
  }
1946
2285
  )
1947
- ] }),
1948
- !isLoading && !isControlled && /* @__PURE__ */ jsx24(
1949
- SegmentedControl2,
1950
- {
1951
- "aria-label": "Tree view mode",
1952
- value: viewMode,
1953
- onChange: setViewMode,
1954
- options: [
1955
- { value: "pop-core", label: "Pop/Core", disabled: !canShowPopCore, title: canShowPopCore ? void 0 : NO_POP_ROOT_TITLE },
1956
- { value: "wheel", label: "Wheel" },
1957
- { value: "stacked", label: "Stacked" },
1958
- { value: "outline", label: "Outline", disabled: !canShowPopCore, title: canShowPopCore ? void 0 : NO_POP_ROOT_TITLE }
1959
- ]
1960
- }
1961
- ),
1962
- !isLoading && showDisplayOptions && /* @__PURE__ */ jsx24(
1963
- "div",
2286
+ ] });
2287
+ };
2288
+ const actions = /* @__PURE__ */ jsxs21(Fragment9, { children: [
2289
+ !isLoading && /* @__PURE__ */ jsx28(
2290
+ GenreSearch,
1964
2291
  {
1965
- className: "flex items-center gap-1",
1966
- role: "group",
1967
- "aria-label": "Tree display options",
1968
- children: !isRotationControlled && viewMode !== "stacked" && viewMode !== "outline" && /* @__PURE__ */ jsx24(
1969
- Button,
1970
- {
1971
- variant: allowWheelRotation ? "default" : "outline",
1972
- size: "sm",
1973
- onClick: toggleWheelRotation,
1974
- children: "Rotation"
1975
- }
1976
- )
2292
+ genrePlaylists: visibleGenrePlaylists,
2293
+ onSelect: handleGenreSearchSelect,
2294
+ selectedName: selectedGenrePlaylist?.name ?? builtInArtistPanel?.title ?? null,
2295
+ onClear: builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
2296
+ onPrevious: handlePrevious,
2297
+ onQueryChange: artistSearch ? setSearchText : void 0,
2298
+ extraResults: artistExtraResults
1977
2299
  }
1978
2300
  ),
1979
- !isLoading && !readOnly && showAddRoot && /* @__PURE__ */ jsx24(
1980
- IconTextButton,
1981
- {
1982
- icon: Plus2,
1983
- text: "Add root",
1984
- onClick: () => handleGenreCreationAction(null)
1985
- }
1986
- )
2301
+ !isLoading && filterArtist && /* @__PURE__ */ jsx28("div", { className: "hidden w-full justify-center max-md:flex", children: renderArtistFilter() }),
2302
+ /* @__PURE__ */ jsxs21("div", { className: "contents max-md:flex max-md:w-full max-md:items-center max-md:gap-2 max-md:overflow-x-auto max-md:[scrollbar-width:none] max-md:[&>*]:shrink-0", children: [
2303
+ !isLoading && !isControlled && /* @__PURE__ */ jsx28(
2304
+ SegmentedControl2,
2305
+ {
2306
+ "aria-label": "Tree view mode",
2307
+ value: viewMode,
2308
+ onChange: setViewMode,
2309
+ options: [
2310
+ { value: "pop-core", label: "Pop/Core", disabled: !canShowPopCore, title: canShowPopCore ? void 0 : NO_POP_ROOT_TITLE },
2311
+ { value: "wheel", label: "Wheel" },
2312
+ { value: "stacked", label: "Stacked" },
2313
+ { value: "outline", label: "Outline", disabled: !canShowPopCore, title: canShowPopCore ? void 0 : NO_POP_ROOT_TITLE }
2314
+ ]
2315
+ }
2316
+ ),
2317
+ !isLoading && showDisplayOptions && /* @__PURE__ */ jsx28(
2318
+ "div",
2319
+ {
2320
+ className: "flex items-center gap-1",
2321
+ role: "group",
2322
+ "aria-label": "Tree display options",
2323
+ children: !isRotationControlled && viewMode !== "stacked" && viewMode !== "outline" && /* @__PURE__ */ jsx28(
2324
+ Button,
2325
+ {
2326
+ variant: allowWheelRotation ? "default" : "outline",
2327
+ size: "sm",
2328
+ onClick: toggleWheelRotation,
2329
+ children: "Rotation"
2330
+ }
2331
+ )
2332
+ }
2333
+ ),
2334
+ !isLoading && !readOnly && showAddRoot && /* @__PURE__ */ jsx28(
2335
+ IconTextButton,
2336
+ {
2337
+ icon: Plus2,
2338
+ text: "Add root",
2339
+ onClick: () => handleGenreCreationAction(null)
2340
+ }
2341
+ )
2342
+ ] })
1987
2343
  ] });
1988
- return /* @__PURE__ */ jsxs17(
2344
+ return /* @__PURE__ */ jsxs21(
1989
2345
  "div",
1990
2346
  {
1991
2347
  ref: rootRef,
1992
- className: ["genre-tree-view relative flex flex-col h-full", placement === "regions" && REGIONS_CLASS, className].filter(Boolean).join(" "),
2348
+ className: ["genre-tree-view relative flex flex-col h-full", placement === "regions" && REGIONS_CLASS, className2].filter(Boolean).join(" "),
1993
2349
  children: [
1994
- /* @__PURE__ */ jsx24("style", { children: PREVIOUS_BUTTON_CSS }),
1995
- /* @__PURE__ */ jsx24(
2350
+ /* @__PURE__ */ jsx28("style", { children: PREVIOUS_BUTTON_CSS }),
2351
+ /* @__PURE__ */ jsx28(
1996
2352
  "div",
1997
2353
  {
1998
- className: `actions-container absolute z-30 flex items-center gap-2 ${placement === "regions" ? "left-[calc(var(--layout-gutter)+var(--layout-panel-inset))] top-[calc(var(--layout-gutter)+var(--layout-panel-inset))]" : "left-5 top-5"}`,
2354
+ className: `actions-container absolute z-30 flex items-center gap-2 ${placement === "regions" ? "left-[calc(var(--layout-gutter)+var(--layout-panel-inset))] top-[calc(var(--layout-gutter)+var(--layout-panel-inset))] max-md:right-[calc(var(--layout-gutter)+var(--layout-panel-inset))] max-md:top-[var(--layout-band-top)] max-md:flex-col max-md:items-stretch" : "left-5 top-5"}`,
1999
2355
  children: actions
2000
2356
  }
2001
2357
  ),
2002
- detailTab && /* @__PURE__ */ jsx24(
2358
+ !isLoading && filterArtist && /* @__PURE__ */ jsx28(
2359
+ "div",
2360
+ {
2361
+ className: `absolute left-1/2 z-30 hidden -translate-x-1/2 md:block ${placement === "regions" ? "top-[calc(var(--layout-gutter)+var(--layout-panel-inset))]" : "top-5"}`,
2362
+ children: renderArtistFilter()
2363
+ }
2364
+ ),
2365
+ detailTab && /* @__PURE__ */ jsx28(
2003
2366
  "div",
2004
2367
  {
2005
- className: `absolute top-1/2 z-30 -translate-y-1/2 transition-[opacity,visibility] duration-150 ease-out ${isTreeReady ? "opacity-100" : "pointer-events-none invisible opacity-0"} ${detailExpanded ? "right-[calc(var(--layout-center-right)-var(--layout-edge-tab-w))]" : isCollapsed ? "left-0" : "left-[calc(var(--layout-gutter)+var(--layout-left-w))]"}`,
2368
+ className: `absolute top-1/2 z-30 -translate-y-1/2 max-md:hidden transition-[opacity,visibility] duration-150 ease-out ${isTreeReady ? "opacity-100" : "pointer-events-none invisible opacity-0"} ${detailExpanded ? "right-[calc(var(--layout-center-right)-var(--layout-edge-tab-w))]" : isCollapsed ? "left-0" : "left-[calc(var(--layout-gutter)+var(--layout-left-w))]"}`,
2006
2369
  children: detailTab
2007
2370
  }
2008
2371
  ),
2009
- /* @__PURE__ */ jsx24("div", { className: "content-container flex-1 min-h-0 flex flex-row gap-4", children: /* @__PURE__ */ jsx24("div", { className: "tree-view-container flex-1 min-w-0 flex flex-col h-full", children: isLoading ? /* @__PURE__ */ jsx24(GenreTreeViewSkeleton, { viewMode }) : viewMode === "wheel" ? (
2010
- // The info panel's top edge aligns with the tree's; the search bar floats `--layout-panel-inset` below it,
2011
- // and the header inset (the bar's height plus that inset) extends the header's fill behind the bar,
2012
- // Google Maps style; the 8px bottom padding mirrors the inset above the bar, and the hidden title's height is zeroed.
2013
- // The panel's own Zoom in button is hidden: the search bar carries it.
2014
- /* @__PURE__ */ jsx24("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem))] [&_.gtv-info-panel-header]:pb-2 [&_.gtv-info-panel-title--hidden]:h-0 [&_.gtv-toolbar]:hidden", children: /* @__PURE__ */ jsx24(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx24(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx24(
2015
- GenrePlaylistTreeWheel,
2372
+ /* @__PURE__ */ jsx28("div", { className: "content-container flex-1 min-h-0 flex flex-row gap-4", children: /* @__PURE__ */ jsxs21("div", { className: "tree-view-container relative flex-1 min-w-0 flex flex-col h-full", children: [
2373
+ !isLoading && viewMode === "outline" && floatingControlsLeading && /* @__PURE__ */ jsx28("div", { className: "absolute bottom-3 right-3 z-10", children: floatingControlsLeading }),
2374
+ isLoading ? /* @__PURE__ */ jsx28(GenreTreeViewSkeleton, { viewMode }) : viewMode === "wheel" ? (
2375
+ // The info panel's top edge aligns with the tree's; the search bar floats `--layout-panel-inset` below it,
2376
+ // and the header inset (the bar's height plus that inset) extends the header's fill behind the bar,
2377
+ // Google Maps style; the 8px bottom padding mirrors the inset above the bar, and the hidden title's height is zeroed.
2378
+ // The panel's own Zoom in button is hidden: the search bar carries it.
2379
+ /* @__PURE__ */ jsx28("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem))] [&_.gtv-info-panel-header]:pb-2 [&_.gtv-info-panel-title--hidden]:h-0 [&_.gtv-toolbar]:hidden", children: /* @__PURE__ */ jsx28(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx28(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx28(
2380
+ GenrePlaylistTreeWheel,
2381
+ {
2382
+ scope,
2383
+ genrePlaylists: visibleGenrePlaylists,
2384
+ handleGenreCreationAction,
2385
+ handleGenreRenameAction,
2386
+ additionalActions,
2387
+ onSelectedNodeChange: handleSelectedNodeChange,
2388
+ hideInfoPanelClose: true,
2389
+ hideInfoPanelTitle: true,
2390
+ onNodeHover: handleNodeHover,
2391
+ renderInfoPanelBody,
2392
+ playback,
2393
+ selectedNodeId,
2394
+ readOnly,
2395
+ allowWheelRotation,
2396
+ sizeMetric: isArtistFiltered || !canSizeByViews ? "songs" : deferredSizeMetric,
2397
+ busy,
2398
+ floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ jsxs21("div", { className: "flex items-center gap-2", children: [
2399
+ floatingControlsLeading,
2400
+ canSizeByViews && /* @__PURE__ */ jsx28(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2401
+ ] }) : void 0,
2402
+ defaultViewport,
2403
+ viewportRestore,
2404
+ onViewportChange,
2405
+ ...infoPanelCuration
2406
+ }
2407
+ ) }) })
2408
+ ) : viewMode === "pop-core" ? (
2409
+ // Same as the wheel: the header inset extends its fill behind the floating search bar, Google Maps style.
2410
+ /* @__PURE__ */ jsx28("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem))] [&_.gtv-info-panel-header]:pb-2 [&_.gtv-info-panel-title--hidden]:h-0 [&_.gtv-toolbar]:hidden [&_.gtv-info-panel-zoom-in]:hidden", children: /* @__PURE__ */ jsx28(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx28(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx28(
2411
+ GenrePlaylistTreeWheelRadialPopCore,
2412
+ {
2413
+ scope,
2414
+ genrePlaylists: visibleGenrePlaylists,
2415
+ handleGenreCreationAction,
2416
+ handleGenreRenameAction,
2417
+ additionalActions,
2418
+ onSelectedNodeChange: handleSelectedNodeChange,
2419
+ hideInfoPanelClose: true,
2420
+ hideInfoPanelTitle: true,
2421
+ onNodeHover: handleNodeHover,
2422
+ renderInfoPanelBody,
2423
+ playback,
2424
+ selectedNodeId,
2425
+ readOnly,
2426
+ allowWheelRotation,
2427
+ sizeMetric: isArtistFiltered || !canSizeByViews ? "songs" : deferredSizeMetric,
2428
+ busy,
2429
+ floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ jsxs21("div", { className: "flex items-center gap-2", children: [
2430
+ floatingControlsLeading,
2431
+ canSizeByViews && /* @__PURE__ */ jsx28(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2432
+ ] }) : void 0,
2433
+ defaultViewport,
2434
+ viewportRestore,
2435
+ onViewportChange,
2436
+ ...infoPanelCuration,
2437
+ ...focus
2438
+ }
2439
+ ) }) })
2440
+ ) : viewMode === "outline" ? /* @__PURE__ */ jsx28("div", { className: "tree-container flex-1 min-h-0 w-full overflow-y-auto relative [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem)+0.75rem)] [&_.gtv-toolbar]:hidden", children: /* @__PURE__ */ jsx28(
2441
+ GenrePlaylistTreeWheelRadialPopCore,
2016
2442
  {
2443
+ outline: true,
2017
2444
  scope,
2018
2445
  genrePlaylists: visibleGenrePlaylists,
2019
2446
  handleGenreCreationAction,
@@ -2027,105 +2454,167 @@ function GenreTreeView({
2027
2454
  playback,
2028
2455
  selectedNodeId,
2029
2456
  readOnly,
2030
- allowWheelRotation,
2031
- controlsOrientation,
2032
- sizeMetric: filterArtist ? "songs" : deferredSizeMetric,
2033
- busy,
2034
- floatingControlsLeading: filterArtist ? void 0 : /* @__PURE__ */ jsx24(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric }),
2035
- defaultViewport,
2036
- onViewportChange,
2037
2457
  ...infoPanelCuration
2038
2458
  }
2039
- ) }) })
2040
- ) : viewMode === "pop-core" ? (
2041
- // Same as the wheel: the header inset extends its fill behind the floating search bar, Google Maps style.
2042
- /* @__PURE__ */ jsx24("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem))] [&_.gtv-info-panel-header]:pb-2 [&_.gtv-info-panel-title--hidden]:h-0 [&_.gtv-toolbar]:hidden [&_.gtv-info-panel-zoom-in]:hidden", children: /* @__PURE__ */ jsx24(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx24(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx24(
2043
- GenrePlaylistTreeWheelRadialPopCore,
2044
- {
2045
- scope,
2046
- genrePlaylists: visibleGenrePlaylists,
2047
- handleGenreCreationAction,
2048
- handleGenreRenameAction,
2049
- additionalActions,
2050
- onSelectedNodeChange: handleSelectedNodeChange,
2051
- hideInfoPanelClose: true,
2052
- hideInfoPanelTitle: true,
2053
- onNodeHover: handleNodeHover,
2054
- renderInfoPanelBody,
2055
- playback,
2056
- selectedNodeId,
2057
- readOnly,
2058
- allowWheelRotation,
2059
- controlsOrientation,
2060
- sizeMetric: filterArtist ? "songs" : deferredSizeMetric,
2061
- busy,
2062
- floatingControlsLeading: filterArtist ? void 0 : /* @__PURE__ */ jsx24(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric }),
2063
- defaultViewport,
2064
- onViewportChange,
2065
- ...infoPanelCuration,
2066
- ...focus
2459
+ ) }) : /* @__PURE__ */ jsx28("div", { className: "tree-container relative flex flex-col gap-4 w-full pt-14 overflow-x-auto overflow-y-auto text-[var(--color-text)] [&_.gtv-toolbar]:hidden", children: Object.entries(groupedGenrePlaylistsByRoot).map(
2460
+ ([uuid, genrePlaylistTreePerRoot], rootIndex) => {
2461
+ return /* @__PURE__ */ jsx28(
2462
+ "div",
2463
+ {
2464
+ className: "tree-per-root-container relative shrink-0 h-[28rem] mt-2 mr-16 p-2 bg-[var(--color-neutral-100)] rounded-lg",
2465
+ children: /* @__PURE__ */ jsx28("div", { className: "graph-container relative z-10 h-full", children: /* @__PURE__ */ jsx28(
2466
+ GenrePlaylistTreePerRoot,
2467
+ {
2468
+ scope,
2469
+ rootUuid: uuid,
2470
+ genrePlaylistTreePerRoot,
2471
+ handleGenreCreationAction,
2472
+ handleGenreRenameAction,
2473
+ additionalActions,
2474
+ onSelectedNodeChange: handleSelectedNodeChange,
2475
+ hideInfoPanelClose: true,
2476
+ hideInfoPanelTitle: true,
2477
+ onNodeHover: handleNodeHover,
2478
+ renderInfoPanelBody,
2479
+ playback,
2480
+ selectedNodeId: uuid === selectedGenrePlaylist?.root.uuid ? selectedNodeId : null,
2481
+ readOnly,
2482
+ wheelZoom: "modifier",
2483
+ defaultViewport: uuid === selectedGenrePlaylist?.root.uuid ? defaultViewport : void 0,
2484
+ viewportRestore: uuid === selectedGenrePlaylist?.root.uuid && uuid === restoreRootUuid ? viewportRestore : void 0,
2485
+ onViewportChange: uuid === selectedGenrePlaylist?.root.uuid ? onViewportChange : void 0,
2486
+ ...infoPanelCuration,
2487
+ overlayPanel: rootIndex === 0 ? activeOverlayPanel : void 0
2488
+ }
2489
+ ) })
2490
+ },
2491
+ uuid
2492
+ );
2067
2493
  }
2068
- ) }) })
2069
- ) : viewMode === "outline" ? /* @__PURE__ */ jsx24("div", { className: "tree-container flex-1 min-h-0 w-full overflow-y-auto relative [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem)+0.75rem)] [&_.gtv-toolbar]:hidden", children: /* @__PURE__ */ jsx24(
2070
- GenrePlaylistTreeWheelRadialPopCore,
2071
- {
2072
- outline: true,
2073
- scope,
2074
- genrePlaylists: visibleGenrePlaylists,
2075
- handleGenreCreationAction,
2076
- handleGenreRenameAction,
2077
- additionalActions,
2078
- onSelectedNodeChange: handleSelectedNodeChange,
2079
- hideInfoPanelClose: true,
2080
- hideInfoPanelTitle: true,
2081
- onNodeHover: handleNodeHover,
2082
- renderInfoPanelBody,
2083
- playback,
2084
- selectedNodeId,
2085
- readOnly,
2086
- ...infoPanelCuration
2087
- }
2088
- ) }) : /* @__PURE__ */ jsx24("div", { className: "tree-container relative flex flex-col gap-4 w-full pt-14 overflow-x-auto overflow-y-auto text-[var(--color-text)] [&_.gtv-toolbar]:hidden", children: Object.entries(groupedGenrePlaylistsByRoot).map(
2089
- ([uuid, genrePlaylistTreePerRoot], rootIndex) => {
2090
- return /* @__PURE__ */ jsx24(
2091
- "div",
2092
- {
2093
- className: "tree-per-root-container relative shrink-0 h-[28rem] mt-2 mr-16 p-2 bg-[var(--color-neutral-100)] rounded-lg",
2094
- children: /* @__PURE__ */ jsx24("div", { className: "graph-container relative z-10 h-full", children: /* @__PURE__ */ jsx24(
2095
- GenrePlaylistTreePerRoot,
2096
- {
2097
- scope,
2098
- rootUuid: uuid,
2099
- genrePlaylistTreePerRoot,
2100
- handleGenreCreationAction,
2101
- handleGenreRenameAction,
2102
- additionalActions,
2103
- onSelectedNodeChange: handleSelectedNodeChange,
2104
- hideInfoPanelClose: true,
2105
- hideInfoPanelTitle: true,
2106
- onNodeHover: handleNodeHover,
2107
- renderInfoPanelBody,
2108
- playback,
2109
- selectedNodeId: uuid === selectedGenrePlaylist?.root.uuid ? selectedNodeId : null,
2110
- readOnly,
2111
- wheelZoom: "modifier",
2112
- controlsOrientation,
2113
- defaultViewport: uuid === selectedGenrePlaylist?.root.uuid ? defaultViewport : void 0,
2114
- onViewportChange: uuid === selectedGenrePlaylist?.root.uuid ? onViewportChange : void 0,
2115
- ...infoPanelCuration,
2116
- overlayPanel: rootIndex === 0 ? activeOverlayPanel : void 0
2117
- }
2118
- ) })
2119
- },
2120
- uuid
2121
- );
2122
- }
2123
- ) }) }) })
2494
+ ) })
2495
+ ] }) })
2124
2496
  ]
2125
2497
  }
2126
2498
  );
2127
2499
  }
2128
2500
 
2501
+ // src/genre-tree/GenreDetailPanel.tsx
2502
+ import { useMemo as useMemo8 } from "react";
2503
+ import {
2504
+ ACCENT_TEXT_COLOR,
2505
+ getGenreTreeColor as getGenreTreeColor2,
2506
+ InfoPanel,
2507
+ TEXT_COLOR,
2508
+ tintSurface
2509
+ } from "@behindthemusictree/genre-tree-view";
2510
+ import { Skeleton as Skeleton5 } from "@behindthemusictree/ui";
2511
+ import {
2512
+ useFetchGenreDetail
2513
+ } from "@behindthemusictree/app-kit/genre-tree";
2514
+ import { jsx as jsx29, jsxs as jsxs22 } from "react/jsx-runtime";
2515
+ var ITEM_COUNT_LABEL = "Songs";
2516
+ var RELATIVE_ITEM_COUNT = 1;
2517
+ function toNode(genre, parentId) {
2518
+ return {
2519
+ id: genre.uuid,
2520
+ parentId,
2521
+ name: genre.name,
2522
+ itemCount: RELATIVE_ITEM_COUNT,
2523
+ side: genre.side ?? void 0,
2524
+ wikidataId: genre.wikidataId
2525
+ };
2526
+ }
2527
+ function GenreDetailPanel({
2528
+ genreUuid,
2529
+ scope,
2530
+ getBackendBaseUrl,
2531
+ onClose,
2532
+ onSelectGenre,
2533
+ onZoomIn,
2534
+ shareUrl,
2535
+ shareTarget,
2536
+ collapsed,
2537
+ expanded,
2538
+ onExpandedChange,
2539
+ extras
2540
+ }) {
2541
+ const { data: detail, isPending, isError } = useFetchGenreDetail(genreUuid, scope, getBackendBaseUrl);
2542
+ const playback = useGenrePlaylistPlayback({ scope, genrePlaylists: [] });
2543
+ const view = useMemo8(() => {
2544
+ if (!detail) return null;
2545
+ const rootColor = getGenreTreeColor2(detail.root.wikidataId);
2546
+ const relative = (genre, parentId) => ({
2547
+ node: toNode(genre, parentId),
2548
+ fill: tintSurface(rootColor),
2549
+ textColor: TEXT_COLOR
2550
+ });
2551
+ const ancestors = [...detail.ascendants].sort((a, b) => b.degree - a.degree);
2552
+ return {
2553
+ node: {
2554
+ id: detail.uuid,
2555
+ parentId: detail.parent?.uuid ?? null,
2556
+ name: detail.name,
2557
+ itemCount: detail.tracksCount,
2558
+ wikidataId: detail.wikidataId
2559
+ },
2560
+ fill: rootColor,
2561
+ parentNode: detail.parent ? relative(detail.parent, null) : null,
2562
+ childNodes: detail.children.map((child) => relative(child, detail.uuid)),
2563
+ ancestorNodes: ancestors.map(
2564
+ ({ ascendant }, index) => relative(ascendant, index === 0 ? null : ancestors[index - 1].ascendant.uuid)
2565
+ )
2566
+ };
2567
+ }, [detail]);
2568
+ if (isError) {
2569
+ return /* @__PURE__ */ jsx29("div", { role: "alert", className: "p-4 text-sm text-[var(--color-text)]", children: "This genre could not be loaded." });
2570
+ }
2571
+ if (isPending || !detail || !view) {
2572
+ return /* @__PURE__ */ jsxs22("div", { className: "flex flex-col gap-3 p-4", "aria-busy": "true", "data-testid": "genre-detail-panel-skeleton", children: [
2573
+ /* @__PURE__ */ jsx29(Skeleton5, { className: "h-6 w-2/3" }),
2574
+ /* @__PURE__ */ jsx29(Skeleton5, { className: "h-4 w-full" }),
2575
+ /* @__PURE__ */ jsx29(Skeleton5, { className: "h-4 w-3/4" })
2576
+ ] });
2577
+ }
2578
+ const renderBody = (node, sections) => ({
2579
+ primary: /* @__PURE__ */ jsx29(
2580
+ GenreInfoPanelBody,
2581
+ {
2582
+ toolbar: sections.toolbar,
2583
+ family: sections.family,
2584
+ genreUuid: detail.uuid,
2585
+ genrePlaylist: { ...detail.criteriaPlaylist, tracksCount: detail.tracksCount },
2586
+ wikidataId: detail.wikidataId,
2587
+ scope,
2588
+ getBackendBaseUrl,
2589
+ playback,
2590
+ band: { node, onZoomIn, shareUrl, shareTarget: shareUrl ? shareTarget : void 0 },
2591
+ extras
2592
+ }
2593
+ ),
2594
+ aside: null
2595
+ });
2596
+ return /* @__PURE__ */ jsx29(
2597
+ InfoPanel,
2598
+ {
2599
+ node: view.node,
2600
+ fill: view.fill,
2601
+ textColor: ACCENT_TEXT_COLOR,
2602
+ parentNode: view.parentNode,
2603
+ childNodes: view.childNodes,
2604
+ ancestorNodes: view.ancestorNodes,
2605
+ side: "left",
2606
+ onClose,
2607
+ onSelectNode: (nodeId) => onSelectGenre(nodeId),
2608
+ itemCountLabel: ITEM_COUNT_LABEL,
2609
+ renderInfoPanelBody: renderBody,
2610
+ expanded,
2611
+ onExpandedChange,
2612
+ collapsed,
2613
+ hideExpandButton: onExpandedChange === void 0
2614
+ }
2615
+ );
2616
+ }
2617
+
2129
2618
  // src/genre-tree/index.ts
2130
2619
  import {
2131
2620
  GenreTreeSkeleton,
@@ -2135,24 +2624,35 @@ import {
2135
2624
  } from "@behindthemusictree/genre-tree-view";
2136
2625
 
2137
2626
  // src/genre-tree/track-list-sidebar/TrackListSidebar.tsx
2138
- import { useEffect as useEffect5, useRef as useRef5 } from "react";
2627
+ import { useEffect as useEffect7, useLayoutEffect as useLayoutEffect2, useRef as useRef5 } from "react";
2628
+ import { ListMusic } from "lucide-react";
2139
2629
  import { useTrackList as useTrackList5 } from "@behindthemusictree/app-kit/genre-tree";
2140
- import { useTrackListSidebarVisibility } from "@behindthemusictree/app-kit/genre-tree";
2141
2630
  import { TrackListOriginType as TrackListOriginType2 } from "@behindthemusictree/app-kit/genre-tree";
2142
2631
  import { useTrackNoun as useTrackNoun6 } from "@behindthemusictree/app-kit/track-noun";
2143
- import { jsx as jsx25, jsxs as jsxs18 } from "react/jsx-runtime";
2632
+ import { jsx as jsx30, jsxs as jsxs23 } from "react/jsx-runtime";
2633
+ var useBrowserLayoutEffect = typeof window === "undefined" ? useEffect7 : useLayoutEffect2;
2144
2634
  function TrackListSidebar({
2145
- className = "",
2635
+ className: className2 = "",
2146
2636
  renderDuration,
2147
2637
  renderActions,
2148
2638
  layout = "fixed",
2639
+ isCollapsed = false,
2640
+ onExpand,
2641
+ onMinimize,
2149
2642
  renderArtistLink
2150
2643
  }) {
2151
- const { trackList, loadMore } = useTrackList5();
2644
+ const { trackList, loadMore, autoAdvanceScrollRequest } = useTrackList5();
2152
2645
  const trackNoun = useTrackNoun6();
2646
+ const trackListRef = useRef5(null);
2647
+ const previousOriginRef = useRef5(trackList?.origin ?? null);
2648
+ const handledAutoAdvanceRequestIdRef = useRef5(null);
2649
+ const shouldFollowPlaybackRef = useRef5(true);
2650
+ const programmaticScrollTopRef = useRef5(null);
2651
+ const userScrollIntentRef = useRef5(false);
2652
+ const userScrollIntentTimerRef = useRef5(null);
2153
2653
  const loadMoreSentinelRef = useRef5(null);
2154
2654
  const hasMore = !!trackList && trackList.nextPage !== null;
2155
- useEffect5(() => {
2655
+ useEffect7(() => {
2156
2656
  const sentinel = loadMoreSentinelRef.current;
2157
2657
  if (!hasMore || !sentinel) return;
2158
2658
  const observer = new IntersectionObserver((entries) => {
@@ -2163,44 +2663,115 @@ function TrackListSidebar({
2163
2663
  observer.observe(sentinel);
2164
2664
  return () => observer.disconnect();
2165
2665
  }, [hasMore, loadMore]);
2166
- const { hideTrackListSidebar } = useTrackListSidebarVisibility();
2167
- const positionClasses = layout === "inline" ? "relative w-full flex h-full flex-col" : "fixed right-0 w-[50rem]";
2168
- return trackList ? /* @__PURE__ */ jsxs18(
2666
+ useBrowserLayoutEffect(() => {
2667
+ const origin = trackList?.origin ?? null;
2668
+ if (previousOriginRef.current === origin) return;
2669
+ previousOriginRef.current = origin;
2670
+ handledAutoAdvanceRequestIdRef.current = null;
2671
+ shouldFollowPlaybackRef.current = true;
2672
+ programmaticScrollTopRef.current = null;
2673
+ if (trackListRef.current) trackListRef.current.scrollTop = 0;
2674
+ }, [trackList?.origin]);
2675
+ useEffect7(() => {
2676
+ const list = trackListRef.current;
2677
+ if (!isCollapsed && list?.scrollTop === 0) shouldFollowPlaybackRef.current = true;
2678
+ }, [isCollapsed]);
2679
+ useEffect7(
2680
+ () => () => {
2681
+ if (userScrollIntentTimerRef.current !== null) clearTimeout(userScrollIntentTimerRef.current);
2682
+ },
2683
+ []
2684
+ );
2685
+ useBrowserLayoutEffect(() => {
2686
+ if (!autoAdvanceScrollRequest || handledAutoAdvanceRequestIdRef.current === autoAdvanceScrollRequest.id) {
2687
+ return;
2688
+ }
2689
+ if (isCollapsed) return;
2690
+ const list = trackListRef.current;
2691
+ if (!list) return;
2692
+ if (list.scrollTop <= 1) shouldFollowPlaybackRef.current = true;
2693
+ if (!shouldFollowPlaybackRef.current) {
2694
+ handledAutoAdvanceRequestIdRef.current = autoAdvanceScrollRequest.id;
2695
+ return;
2696
+ }
2697
+ const row = Array.from(list.querySelectorAll("[data-track-uuid]")).find(
2698
+ (element) => element.dataset.trackUuid === autoAdvanceScrollRequest.trackUuid
2699
+ );
2700
+ if (!row) return;
2701
+ handledAutoAdvanceRequestIdRef.current = autoAdvanceScrollRequest.id;
2702
+ const rowTop = row.getBoundingClientRect().top - list.getBoundingClientRect().top;
2703
+ const targetScrollTop = list.scrollTop + rowTop;
2704
+ programmaticScrollTopRef.current = targetScrollTop;
2705
+ list.scrollTop = targetScrollTop;
2706
+ programmaticScrollTopRef.current = list.scrollTop;
2707
+ }, [autoAdvanceScrollRequest, isCollapsed, trackList]);
2708
+ const markUserScrollIntent = () => {
2709
+ userScrollIntentRef.current = true;
2710
+ if (userScrollIntentTimerRef.current !== null) clearTimeout(userScrollIntentTimerRef.current);
2711
+ userScrollIntentTimerRef.current = setTimeout(() => {
2712
+ userScrollIntentRef.current = false;
2713
+ userScrollIntentTimerRef.current = null;
2714
+ }, 250);
2715
+ };
2716
+ const handleTrackListScroll = (event) => {
2717
+ const list = event.currentTarget;
2718
+ if (userScrollIntentRef.current) {
2719
+ userScrollIntentRef.current = false;
2720
+ if (userScrollIntentTimerRef.current !== null) clearTimeout(userScrollIntentTimerRef.current);
2721
+ userScrollIntentTimerRef.current = null;
2722
+ programmaticScrollTopRef.current = null;
2723
+ shouldFollowPlaybackRef.current = list.scrollTop <= 1;
2724
+ return;
2725
+ }
2726
+ const programmaticScrollTop = programmaticScrollTopRef.current;
2727
+ if (programmaticScrollTop !== null) {
2728
+ if (Math.abs(list.scrollTop - programmaticScrollTop) <= 1) programmaticScrollTopRef.current = null;
2729
+ return;
2730
+ }
2731
+ };
2732
+ const positionClasses = layout === "inline" ? "relative w-full flex h-full flex-col" : "fixed right-0 w-[50rem] flex flex-col";
2733
+ return trackList ? /* @__PURE__ */ jsxs23(
2169
2734
  "div",
2170
2735
  {
2171
2736
  "data-theme": "dark",
2172
- className: `track-list-sidebar ${positionClasses} rounded-2xl bg-[var(--color-surface)] pb-1 ${className}`,
2173
- style: layout === "fixed" ? {
2174
- bottom: "79px"
2175
- /* Match player height exactly */
2176
- } : void 0,
2737
+ className: `track-list-sidebar ${positionClasses} rounded-2xl bg-[var(--color-surface)] pb-1 ${className2}`,
2738
+ style: layout === "fixed" ? { top: "63.5px", maxHeight: "calc(100vh - 63.5px - 79px)" } : void 0,
2177
2739
  children: [
2178
- /* @__PURE__ */ jsxs18("div", { className: "header flex h-16 px-4 py-2 text-[var(--color-text)]", children: [
2179
- /* @__PURE__ */ jsxs18("div", { className: "origin flex text-xl ", children: [
2180
- /* @__PURE__ */ jsx25("div", { className: "from h-auto flex flex-col justify-center items-center mr-2", children: "From" }),
2181
- /* @__PURE__ */ jsx25("div", { className: "name-container flex flex-col justify-center items-center text-[var(--color-text)] font-bold pr-2 max-w-[27.5rem]", children: /* @__PURE__ */ jsx25("div", { className: "name max-w-full truncate", children: trackList.origin.label }) })
2182
- ] }),
2183
- /* @__PURE__ */ jsxs18("div", { className: "info flex flex-col justify-center items-center pt-1 mr-2 text-[var(--color-text-muted)]", children: [
2184
- trackList.origin.type === TrackListOriginType2.GENRE_PLAYLIST ? "\u2022 Genre playlist \u2022 " : trackList.origin.type === TrackListOriginType2.ARTIST ? "\u2022 Artist \u2022 " : `\u2022 ${trackNoun.singular} playlist \u2022 `,
2185
- `${trackList.total} ${trackList.total > 1 ? trackNoun.plural : trackNoun.singular} \u2022`
2740
+ /* @__PURE__ */ jsxs23("div", { className: "header flex h-16 items-center px-4 py-2 text-[var(--color-text)]", children: [
2741
+ /* @__PURE__ */ jsxs23("div", { className: "min-w-0 flex flex-1 flex-col justify-center", children: [
2742
+ /* @__PURE__ */ jsxs23("div", { className: "origin flex text-xl", children: [
2743
+ /* @__PURE__ */ jsx30("div", { className: "from h-auto flex flex-col justify-center items-center mr-2", children: "From" }),
2744
+ /* @__PURE__ */ jsx30("div", { className: "name-container flex flex-col justify-center items-center text-[var(--color-text)] font-bold pr-2 max-w-[27.5rem]", children: /* @__PURE__ */ jsx30("div", { className: "name max-w-full truncate", children: trackList.origin.label }) })
2745
+ ] }),
2746
+ /* @__PURE__ */ jsxs23("div", { className: "info flex items-center text-sm text-[var(--color-text-muted)]", children: [
2747
+ trackList.origin.type === TrackListOriginType2.GENRE_PLAYLIST ? "Genre \u2022 " : trackList.origin.type === TrackListOriginType2.ARTIST ? "Artist \u2022 " : `${trackNoun.singular} playlist \u2022 `,
2748
+ `${trackList.total} ${trackList.total > 1 ? trackNoun.plural : trackNoun.singular}`
2749
+ ] })
2186
2750
  ] }),
2187
- /* @__PURE__ */ jsx25(
2751
+ (isCollapsed && onExpand || !isCollapsed && onMinimize) && /* @__PURE__ */ jsx30(
2188
2752
  "button",
2189
2753
  {
2190
2754
  type: "button",
2191
- "aria-label": "Close track list",
2192
- className: "flex-grow flex flex-col items-end justify-center h-full p-3 cursor-pointer text-inherit",
2193
- onClick: hideTrackListSidebar,
2194
- children: "\u2715"
2755
+ "aria-label": isCollapsed ? "Show tracks" : "Hide tracks",
2756
+ title: isCollapsed ? "Show tracks" : "Hide tracks",
2757
+ "aria-expanded": !isCollapsed,
2758
+ className: `ml-2 flex h-8 w-8 shrink-0 items-center justify-center rounded-md hover:bg-[var(--color-surface-muted)] ${isCollapsed ? "text-[var(--color-text-muted)]" : "text-white"}`,
2759
+ onClick: isCollapsed ? onExpand : onMinimize,
2760
+ children: /* @__PURE__ */ jsx30(ListMusic, { "aria-hidden": "true", className: "h-5 w-5" })
2195
2761
  }
2196
2762
  )
2197
2763
  ] }),
2198
- /* @__PURE__ */ jsxs18(
2764
+ !isCollapsed && /* @__PURE__ */ jsxs23(
2199
2765
  "ul",
2200
2766
  {
2201
- className: layout === "inline" ? "track-list flex-1 min-h-0 overflow-auto list-none p-0 m-0" : "track-list overflow-auto max-h-[calc(100vh-63.5px-79px-56px-3.5px)] list-none p-0 m-0",
2767
+ ref: trackListRef,
2768
+ onScroll: handleTrackListScroll,
2769
+ onWheel: markUserScrollIntent,
2770
+ onTouchMove: markUserScrollIntent,
2771
+ onPointerDown: markUserScrollIntent,
2772
+ className: layout === "inline" ? "track-list flex-1 min-h-0 overflow-auto list-none p-0 m-0" : "track-list flex-1 min-h-0 overflow-auto list-none p-0 m-0",
2202
2773
  children: [
2203
- trackList.tracks.map((track, index) => /* @__PURE__ */ jsx25("li", { children: /* @__PURE__ */ jsx25(
2774
+ trackList.tracks.map((track, index) => /* @__PURE__ */ jsx30("li", { "data-track-uuid": track.uuid, children: /* @__PURE__ */ jsx30(
2204
2775
  TrackItem,
2205
2776
  {
2206
2777
  track,
@@ -2210,7 +2781,7 @@ function TrackListSidebar({
2210
2781
  renderArtistLink
2211
2782
  }
2212
2783
  ) }, track.uuid)),
2213
- hasMore && /* @__PURE__ */ jsx25("li", { ref: loadMoreSentinelRef, "aria-hidden": "true", className: "h-px" })
2784
+ hasMore && /* @__PURE__ */ jsx30("li", { ref: loadMoreSentinelRef, "aria-hidden": "true", className: "h-px" })
2214
2785
  ]
2215
2786
  }
2216
2787
  )
@@ -2220,47 +2791,167 @@ function TrackListSidebar({
2220
2791
  }
2221
2792
 
2222
2793
  // src/genre-tree/PlayerPanel.tsx
2223
- import { useState as useState5 } from "react";
2224
- import { X as X2 } from "lucide-react";
2794
+ import { useEffect as useEffect8, useState as useState6 } from "react";
2225
2795
  import { twMerge as twMerge2 } from "tailwind-merge";
2226
2796
  import {
2227
- usePlayer as usePlayer9,
2797
+ usePlayer as usePlayer10,
2228
2798
  PlayerVideoSurface
2229
2799
  } from "@behindthemusictree/app-kit/player";
2230
2800
  import {
2231
- useTrackListSidebarVisibility as useTrackListSidebarVisibility2
2801
+ useTrackListSidebarVisibility
2232
2802
  } from "@behindthemusictree/app-kit/genre-tree";
2233
- import { jsx as jsx26, jsxs as jsxs19 } from "react/jsx-runtime";
2803
+
2804
+ // src/genre-tree/PlayerControls.tsx
2805
+ import { Pause as Pause2, Play as Play3, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
2806
+ import { formatTime, useTrackList as useTrackList6 } from "@behindthemusictree/app-kit/genre-tree";
2807
+ import { PlayStates as PlayStates3, useCurrentTime, usePlayer as usePlayer9 } from "@behindthemusictree/app-kit/player";
2808
+ import { jsx as jsx31, jsxs as jsxs24 } from "react/jsx-runtime";
2809
+ function PlayerControls() {
2810
+ const {
2811
+ duration,
2812
+ handlePlayPauseAction,
2813
+ isLoading,
2814
+ playState,
2815
+ playerTrackObject,
2816
+ seekTo,
2817
+ setVolume,
2818
+ volume
2819
+ } = usePlayer9();
2820
+ const { hasNextTrack, hasPreviousTrack, playNextTrack, playPreviousTrack } = useTrackList6();
2821
+ const currentTime = useCurrentTime();
2822
+ const isBusy = isLoading || playState === PlayStates3.LOADING;
2823
+ const isReady = !!playerTrackObject?.isReady && !isBusy && !playerTrackObject.loadError;
2824
+ const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
2825
+ const safeCurrentTime = Number.isFinite(currentTime) ? Math.max(0, Math.min(currentTime, safeDuration)) : 0;
2826
+ const isPlaying = playState === PlayStates3.PLAYING;
2827
+ return /* @__PURE__ */ jsxs24(
2828
+ "section",
2829
+ {
2830
+ "aria-label": "Player controls",
2831
+ className: "player-controls flex shrink-0 flex-col gap-2 border-b border-zinc-800 px-3 py-2 text-zinc-200",
2832
+ children: [
2833
+ /* @__PURE__ */ jsxs24("div", { className: "flex min-w-0 items-center gap-2 text-xs tabular-nums text-zinc-400", children: [
2834
+ /* @__PURE__ */ jsx31("span", { "aria-label": "Elapsed time", children: formatTime(safeCurrentTime) }),
2835
+ /* @__PURE__ */ jsx31(
2836
+ "input",
2837
+ {
2838
+ "aria-label": "Seek",
2839
+ className: "h-1 min-w-0 flex-1 cursor-pointer accent-white disabled:cursor-not-allowed disabled:opacity-40",
2840
+ type: "range",
2841
+ min: 0,
2842
+ max: safeDuration,
2843
+ step: 0.1,
2844
+ value: safeCurrentTime,
2845
+ disabled: !isReady || safeDuration <= 0,
2846
+ onChange: (event) => seekTo(Number(event.currentTarget.value))
2847
+ }
2848
+ ),
2849
+ /* @__PURE__ */ jsx31("span", { "aria-label": "Duration", children: formatTime(safeDuration) })
2850
+ ] }),
2851
+ /* @__PURE__ */ jsxs24("div", { className: "grid min-h-9 grid-cols-[1fr_auto_1fr] items-center gap-3", children: [
2852
+ /* @__PURE__ */ jsxs24("div", { className: "col-start-2 flex items-center justify-center gap-2", children: [
2853
+ /* @__PURE__ */ jsx31(
2854
+ "button",
2855
+ {
2856
+ type: "button",
2857
+ "aria-label": "Previous track",
2858
+ title: "Previous track",
2859
+ disabled: !isReady || !hasPreviousTrack,
2860
+ onClick: playPreviousTrack,
2861
+ className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-zinc-300 hover:bg-zinc-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-40",
2862
+ children: /* @__PURE__ */ jsx31(SkipBack, { "aria-hidden": "true", className: "h-4 w-4" })
2863
+ }
2864
+ ),
2865
+ /* @__PURE__ */ jsx31(
2866
+ "button",
2867
+ {
2868
+ type: "button",
2869
+ "aria-label": isPlaying ? "Pause" : "Play",
2870
+ title: isPlaying ? "Pause" : "Play",
2871
+ disabled: !isReady,
2872
+ onClick: handlePlayPauseAction,
2873
+ className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white text-zinc-950 hover:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-40",
2874
+ children: isPlaying ? /* @__PURE__ */ jsx31(Pause2, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" }) : /* @__PURE__ */ jsx31(Play3, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" })
2875
+ }
2876
+ ),
2877
+ /* @__PURE__ */ jsx31(
2878
+ "button",
2879
+ {
2880
+ type: "button",
2881
+ "aria-label": "Next track",
2882
+ title: "Next track",
2883
+ disabled: !isReady || !hasNextTrack,
2884
+ onClick: playNextTrack,
2885
+ className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-zinc-300 hover:bg-zinc-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-40",
2886
+ children: /* @__PURE__ */ jsx31(SkipForward, { "aria-hidden": "true", className: "h-4 w-4" })
2887
+ }
2888
+ )
2889
+ ] }),
2890
+ /* @__PURE__ */ jsxs24("label", { className: "col-start-3 flex w-[4.2rem] shrink-0 items-center gap-[0.35rem] justify-self-end text-zinc-400 sm:w-[4.9rem]", children: [
2891
+ volume === 0 ? /* @__PURE__ */ jsx31(VolumeX, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }) : /* @__PURE__ */ jsx31(Volume2, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }),
2892
+ /* @__PURE__ */ jsx31("span", { className: "sr-only", children: "Volume" }),
2893
+ /* @__PURE__ */ jsx31(
2894
+ "input",
2895
+ {
2896
+ "aria-label": "Volume",
2897
+ className: "h-[3px] min-w-0 flex-1 cursor-pointer accent-white disabled:cursor-not-allowed disabled:opacity-40",
2898
+ type: "range",
2899
+ min: 0,
2900
+ max: 100,
2901
+ step: 1,
2902
+ value: volume,
2903
+ disabled: !isReady,
2904
+ onChange: (event) => setVolume(Number(event.currentTarget.value))
2905
+ }
2906
+ )
2907
+ ] })
2908
+ ] })
2909
+ ]
2910
+ }
2911
+ );
2912
+ }
2913
+
2914
+ // src/genre-tree/PlayerPanel.tsx
2915
+ import { jsx as jsx32, jsxs as jsxs25 } from "react/jsx-runtime";
2234
2916
  var PANEL_ID = "player-panel";
2235
- var iconButtonClassName = "flex h-6 w-6 shrink-0 items-center justify-center p-0 rounded-md hover:bg-white/10";
2236
2917
  var DEFAULT_EXTENDED_WIDTH = "calc(100vw - var(--layout-center-left) - var(--layout-gutter))";
2237
2918
  function PlayerPanel({
2238
- className,
2919
+ className: className2,
2239
2920
  isVisible = true,
2240
- onClose,
2241
2921
  expanded,
2242
2922
  onExpandedChange,
2243
2923
  extendedWidth = DEFAULT_EXTENDED_WIDTH,
2244
2924
  renderArtistLink
2245
2925
  }) {
2246
- const { playerTrackObject } = usePlayer9();
2247
- const { toggleTrackListSidebar, isTrackListSidebarVisible } = useTrackListSidebarVisibility2();
2248
- const [uncontrolledExtended, setUncontrolledExtended] = useState5(false);
2926
+ const { playerTrackObject } = usePlayer10();
2927
+ const { toggleTrackListSidebar, isTrackListSidebarVisible } = useTrackListSidebarVisibility();
2928
+ const [lastLoadedTrackTitle, setLastLoadedTrackTitle] = useState6("");
2929
+ const loadedTrackTitle = playerTrackObject?.track.title;
2930
+ useEffect8(() => {
2931
+ if (loadedTrackTitle) setLastLoadedTrackTitle(loadedTrackTitle);
2932
+ }, [loadedTrackTitle]);
2933
+ const [uncontrolledExtended, setUncontrolledExtended] = useState6(false);
2249
2934
  const isExtendRequested = expanded ?? uncontrolledExtended;
2250
2935
  const setIsExtendRequested = (next) => {
2251
2936
  setUncontrolledExtended(next);
2252
2937
  onExpandedChange?.(next);
2253
2938
  };
2254
2939
  const isExtended = isExtendRequested && isTrackListSidebarVisible;
2255
- const tabEdge = !isTrackListSidebarVisible ? null : isExtended ? {
2256
- label: "Shrink song list panel",
2257
- onClick: () => setIsExtendRequested(false)
2258
- } : { label: "Hide song list", onClick: toggleTrackListSidebar };
2259
- const tabCenter = !isTrackListSidebarVisible ? { label: "Show song list", onClick: toggleTrackListSidebar } : isExtended ? null : {
2260
- label: "Extend song list panel",
2261
- onClick: () => setIsExtendRequested(true)
2940
+ const hideSongList = () => {
2941
+ setIsExtendRequested(false);
2942
+ if (isTrackListSidebarVisible) toggleTrackListSidebar();
2262
2943
  };
2263
- return /* @__PURE__ */ jsxs19(
2944
+ const tabEdge = isTrackListSidebarVisible && isExtended ? {
2945
+ label: "Shrink player panel",
2946
+ onClick: () => setIsExtendRequested(false),
2947
+ expanded: true
2948
+ } : null;
2949
+ const tabCenter = isTrackListSidebarVisible && !isExtended ? {
2950
+ label: "Extend player panel",
2951
+ onClick: () => setIsExtendRequested(true),
2952
+ expanded: false
2953
+ } : null;
2954
+ return /* @__PURE__ */ jsxs25(
2264
2955
  "div",
2265
2956
  {
2266
2957
  id: PANEL_ID,
@@ -2269,11 +2960,11 @@ function PlayerPanel({
2269
2960
  !isExtended && "w-[var(--layout-right-w)]",
2270
2961
  isTrackListSidebarVisible && "h-[calc(100vh-var(--layout-band-top)-3.5rem)]",
2271
2962
  !isVisible && "hidden",
2272
- className
2963
+ className2
2273
2964
  ),
2274
2965
  style: isExtended ? { width: extendedWidth } : void 0,
2275
2966
  children: [
2276
- /* @__PURE__ */ jsx26(
2967
+ /* @__PURE__ */ jsx32(
2277
2968
  PanelEdgeTab,
2278
2969
  {
2279
2970
  side: "right",
@@ -2283,7 +2974,7 @@ function PlayerPanel({
2283
2974
  className: "absolute right-full top-1/2 -translate-y-1/2"
2284
2975
  }
2285
2976
  ),
2286
- /* @__PURE__ */ jsxs19(
2977
+ /* @__PURE__ */ jsxs25(
2287
2978
  "div",
2288
2979
  {
2289
2980
  className: twMerge2(
@@ -2291,29 +2982,21 @@ function PlayerPanel({
2291
2982
  isExtended ? "flex-row" : "flex-col"
2292
2983
  ),
2293
2984
  children: [
2294
- /* @__PURE__ */ jsxs19("div", { className: "flex w-[var(--layout-right-w)] shrink-0 flex-col", children: [
2295
- /* @__PURE__ */ jsxs19("div", { className: "flex shrink-0 items-center justify-between gap-2 border-b border-zinc-800 p-2", children: [
2296
- /* @__PURE__ */ jsx26("span", { className: "min-w-0 truncate text-sm font-semibold", children: playerTrackObject?.track?.title }),
2297
- /* @__PURE__ */ jsx26("div", { className: "flex shrink-0 items-center gap-1", children: /* @__PURE__ */ jsx26(
2298
- "button",
2299
- {
2300
- type: "button",
2301
- onClick: onClose,
2302
- className: iconButtonClassName,
2303
- "aria-label": "Close player",
2304
- children: /* @__PURE__ */ jsx26(X2, { size: 16 })
2305
- }
2306
- ) })
2985
+ /* @__PURE__ */ jsxs25("div", { className: "flex w-[var(--layout-right-w)] shrink-0 flex-col", children: [
2986
+ /* @__PURE__ */ jsx32("div", { className: "flex shrink-0 items-center justify-between gap-2 border-b border-zinc-800 p-2", children: /* @__PURE__ */ jsx32("span", { className: "min-w-0 truncate text-sm font-semibold", children: loadedTrackTitle || lastLoadedTrackTitle }) }),
2987
+ /* @__PURE__ */ jsxs25("div", { className: "relative w-full shrink-0", children: [
2988
+ /* @__PURE__ */ jsx32(PlayerVideoSurface, { className: "aspect-video w-full bg-black" }),
2989
+ playerTrackObject?.loadError && /* @__PURE__ */ jsx32("span", { className: "absolute bottom-2 left-2 right-2 text-xs text-red-400", children: playerTrackObject.loadError })
2307
2990
  ] }),
2308
- /* @__PURE__ */ jsxs19("div", { className: "relative w-full shrink-0", children: [
2309
- /* @__PURE__ */ jsx26(PlayerVideoSurface, { className: "aspect-video w-full bg-black" }),
2310
- playerTrackObject?.loadError && /* @__PURE__ */ jsx26("span", { className: "absolute bottom-2 left-2 right-2 text-xs text-red-400", children: playerTrackObject.loadError })
2311
- ] })
2991
+ /* @__PURE__ */ jsx32(PlayerControls, {})
2312
2992
  ] }),
2313
- isTrackListSidebarVisible && /* @__PURE__ */ jsx26(
2993
+ /* @__PURE__ */ jsx32(
2314
2994
  TrackListSidebar,
2315
2995
  {
2316
2996
  layout: "inline",
2997
+ isCollapsed: !isTrackListSidebarVisible,
2998
+ onExpand: toggleTrackListSidebar,
2999
+ onMinimize: hideSongList,
2317
3000
  className: twMerge2(
2318
3001
  "min-h-0 min-w-0 flex-1",
2319
3002
  isExtended && "border-l border-zinc-800"
@@ -2333,11 +3016,11 @@ function PlayerPanel({
2333
3016
  import { v4 as uuidv4 } from "uuid";
2334
3017
  import { AiFillStar, AiOutlineStar } from "react-icons/ai";
2335
3018
  import { FORM_RATING_NULL_VALUE } from "@behindthemusictree/app-kit/genre-tree";
2336
- import { jsx as jsx27, jsxs as jsxs20 } from "react/jsx-runtime";
3019
+ import { jsx as jsx33, jsxs as jsxs26 } from "react/jsx-runtime";
2337
3020
  function Rating({ rating, handleChange }) {
2338
3021
  const instanceId = uuidv4();
2339
- return /* @__PURE__ */ jsx27("div", { className: "flex items-center", children: [...Array(6)].map((_, i) => /* @__PURE__ */ jsxs20("div", { className: `relative ${i === 0 ? "mr-4" : "mr-1"}`, children: [
2340
- /* @__PURE__ */ jsx27(
3022
+ return /* @__PURE__ */ jsx33("div", { className: "flex items-center", children: [...Array(6)].map((_, i) => /* @__PURE__ */ jsxs26("div", { className: `relative ${i === 0 ? "mr-4" : "mr-1"}`, children: [
3023
+ /* @__PURE__ */ jsx33(
2341
3024
  "input",
2342
3025
  {
2343
3026
  id: `star-${instanceId}-${i}`,
@@ -2349,16 +3032,16 @@ function Rating({ rating, handleChange }) {
2349
3032
  onChange: handleChange
2350
3033
  }
2351
3034
  ),
2352
- /* @__PURE__ */ jsx27("label", { htmlFor: `star-${instanceId}-${i}`, className: "cursor-pointer", children: i === 0 ? /* @__PURE__ */ jsxs20("div", { className: "flex", children: [
2353
- /* @__PURE__ */ jsx27(
3035
+ /* @__PURE__ */ jsx33("label", { htmlFor: `star-${instanceId}-${i}`, className: "cursor-pointer", children: i === 0 ? /* @__PURE__ */ jsxs26("div", { className: "flex", children: [
3036
+ /* @__PURE__ */ jsx33(
2354
3037
  AiOutlineStar,
2355
3038
  {
2356
3039
  className: `w-7 h-7 absolute top-0 left-0 ${rating === FORM_RATING_NULL_VALUE ? "text-[var(--color-accent-rating)]" : "text-[var(--color-text-muted)]"}`
2357
3040
  }
2358
3041
  ),
2359
- /* @__PURE__ */ jsx27("span", { className: "flex-grow w-8" }),
3042
+ /* @__PURE__ */ jsx33("span", { className: "flex-grow w-8" }),
2360
3043
  " "
2361
- ] }) : /* @__PURE__ */ jsx27(
3044
+ ] }) : /* @__PURE__ */ jsx33(
2362
3045
  AiFillStar,
2363
3046
  {
2364
3047
  className: `w-7 h-7 absolute top-0 left-0 ${(rating ?? -1) >= i ? "text-[var(--color-accent-rating)]" : "text-[var(--color-text-muted)]"}`
@@ -2368,12 +3051,17 @@ function Rating({ rating, handleChange }) {
2368
3051
  }
2369
3052
 
2370
3053
  export {
3054
+ useGenrePlaylistPlayback,
2371
3055
  GenrePlaylistTreePerRoot,
2372
3056
  GenreSearch,
3057
+ GenreNodeActionBand,
3058
+ GenreInfoPanelBody,
2373
3059
  PanelEdgeTab,
3060
+ useLeftDetailPanelLayout,
2374
3061
  TrackPositionPlayPause,
2375
3062
  TrackItem,
2376
3063
  GenreTreeView,
3064
+ GenreDetailPanel,
2377
3065
  TrackListSidebar,
2378
3066
  PlayerPanel,
2379
3067
  Rating,
@@ -2382,4 +3070,4 @@ export {
2382
3070
  GenreTreeViewSkeleton2 as GenreTreeViewSkeleton,
2383
3071
  GenreTreeOutlineSkeleton
2384
3072
  };
2385
- //# sourceMappingURL=chunk-KPWV4LEJ.js.map
3073
+ //# sourceMappingURL=chunk-TFLX6C5Q.js.map