@behindthemusictree/app-kit-ui 24.0.1 → 28.0.2

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