Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
73fbc1e
feat(genre-tree): anchor TrackListSidebar top-right below app header
Andreas-Garcia Oct 1, 2026
7b822a1
Merge pull request #428 from BehindTheMusicTree/release/27.1.0
Andreas-Garcia Oct 30, 2026
c964a5e
feat(genre-tree)!: drop TrackListProvider list polling, add updateTrack
Andreas-Garcia Oct 30, 2026
ab20e69
Merge remote-tracking branch 'origin/develop' into feature/tracklist-…
Andreas-Garcia Oct 30, 2026
ad2fd01
Merge pull request #429 from BehindTheMusicTree/feature/tracklist-mut…
Andreas-Garcia Oct 30, 2026
205d2e3
feat: refine genre player and track list UI
Andreas-Garcia Oct 30, 2026
22177bf
fix: dim track rows after playback errors
Andreas-Garcia Oct 30, 2026
d7f7ce3
fix: advance past videos that block embedding
Andreas-Garcia Oct 30, 2026
a69ef2c
fix: reset track list scroll on playlist changes
Andreas-Garcia Oct 30, 2026
d6f9800
feat: add player controls and track navigation
Andreas-Garcia Oct 30, 2026
d0b9cbd
feat: improve playback status and player controls
Andreas-Garcia Oct 30, 2026
ec9785a
fix: avoid redundant YouTube playback commands
Andreas-Garcia Oct 30, 2026
559800f
feat: add sharing and resolve shared genre panels
Andreas-Garcia Oct 30, 2026
b7cd27c
style: center the artist filter
Andreas-Garcia Oct 30, 2026
e2e94e6
feat(genre-tree): add floating control slot
Andreas-Garcia Oct 30, 2026
e45b709
test(genre-tree): cover floating control slot
Andreas-Garcia Oct 30, 2026
6fc6a10
Merge remote-tracking branch 'origin/develop' into feature/tree-view-…
Andreas-Garcia Oct 30, 2026
3f98741
Merge pull request #432 from BehindTheMusicTree/feature/tree-view-con…
Andreas-Garcia Oct 30, 2026
b48f6fe
build(deps): bump genre-tree-view to 6.4.0
Andreas-Garcia Oct 30, 2026
b8eb7b0
Merge pull request #433 from BehindTheMusicTree/feature/bump-genre-tr…
Andreas-Garcia Oct 30, 2026
7ea8105
fix(genre-tree): disable view sizing during artist filtering
Andreas-Garcia Oct 31, 2026
9148edf
Merge remote-tracking branch 'origin/develop' into codex/genre-descri…
Andreas-Garcia Oct 31, 2026
d7391f4
test(genre-tree): fix links section type check
Andreas-Garcia Oct 31, 2026
431f4d4
test: align playback and responsive control expectations
Andreas-Garcia Oct 31, 2026
534a0f2
test(genre-tree): check absent tab while loading
Andreas-Garcia Oct 31, 2026
55a2c49
test: adjust coverage floors and cover playback advancement
Andreas-Garcia Oct 31, 2026
9b946a9
Merge pull request #434 from BehindTheMusicTree/feature/genre-descrip…
Andreas-Garcia Oct 31, 2026
76f013d
Merge remote-tracking branch 'origin/develop' into feature/track-list…
Andreas-Garcia Nov 1, 2026
5bc0aae
Merge pull request #168 from BehindTheMusicTree/feature/track-list-si…
Andreas-Garcia Nov 1, 2026
e6434ec
chore: release 28.0.0
Andreas-Garcia Nov 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
@behindthemusictree:registry=https://npm.pkg.github.com
@behindthemusictree:registry=https://registry.npmjs.org
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
Comment on lines +1 to 2
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ easy to spot when bumping.

## [Unreleased]

## [28.0.0] - 2026-11-01

### Changed

- **app-kit-ui**: Anchor fixed track list sidebars below the header and let them grow toward the player.

### Fixed

- Keep tree nodes sized by song count while an artist filter is active, including while artist details load.

### Changed

- Upgrade genre-tree-view to 6.4.0 for density-aware node sizing.
Comment on lines +16 to +26

### Breaking

- `TrackListProvider` no longer takes `listEndpoint` / `listQueryKey`. It used them to keep a page-1 library list query running whenever a tracklist existed, and to swap in fresh copies of matching tracks. On grow that query was unfiltered, so a curation invalidation or a window refocus ran a `COUNT(*)` over the whole song table and got a 503. Migration: drop both props. To show an edited track in the playing list, call `useTrackList().updateTrack(serverTrack)` from the mutation's `onSuccess`.

### Added

- Share genre and artist panels, and control playback and track navigation from the player.
- `useTrackList().updateTrack(track)` replaces the track with the same `uuid` across every loaded page of the current tracklist, and replaces the selected track if it is that one. It does nothing when no track matches.
- `GenreTreeView` accepts a host control beside the tree controls, including in outline view.

## [27.1.0] - 2026-10-30

### Added
Expand Down
72 changes: 58 additions & 14 deletions apps/playground/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@ import {
TrackNounProvider,
useFetchWrapper,
libraryEndpoints,
libraryQueryKeys,
SongDetailed,
SongDetailedSchema,
} from "@behindthemusictree/app-kit";
import { BasePopup, InternalErrorPopup, NetworkErrorPopup } from "@behindthemusictree/app-kit-ui/popup";
import { GenreTreeView, PlayerPanel } from "@behindthemusictree/app-kit-ui/genre-tree";
import { GenreTreeView, PlayerPanel, type GetGenreTreeShareUrl } from "@behindthemusictree/app-kit-ui/genre-tree";
import { Button, RingLoader, Skeleton } from "@behindthemusictree/ui";
import GenreCreationPopup from "./GenreCreationPopup";
import GenreRenamePopup from "./GenreRenamePopup";
Expand Down Expand Up @@ -64,6 +63,43 @@ function ReferenceGenreTree() {
const [viewMode, setViewMode] = useState<(typeof VIEW_MODES)[number]["mode"]>("pop-core");
const [allowWheelRotation, setAllowWheelRotation] = useState(false);
const [focusGenreUuid, setFocusGenreUuid] = useState<string | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
() => new URLSearchParams(window.location.search).get("genre"),
);
const [artistUuid, setArtistUuid] = useState<string | null>(
() => new URLSearchParams(window.location.search).get("artist"),
);

const updateQueryParam = useCallback((key: string, value: string | null) => {
const url = new URL(window.location.href);
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
}, []);

const handleSelectedNodeIdChange = useCallback((id: string | null) => {
setSelectedNodeId(id);
updateQueryParam("genre", id);
}, [updateQueryParam]);

const handleArtistUuidChange = useCallback((uuid: string | null) => {
setArtistUuid(uuid);
if (uuid) updateQueryParam("genre", null);
updateQueryParam("artist", uuid);
}, [updateQueryParam]);

const getShareUrl = useCallback<GetGenreTreeShareUrl>((target) => {
const url = new URL(window.location.href);
if (target.kind === "genre") {
url.searchParams.set("genre", target.genrePlaylistUuid);
if (target.artistUuid) url.searchParams.set("artist", target.artistUuid);
else url.searchParams.delete("artist");
} else {
url.searchParams.set("artist", target.uuid);
url.searchParams.delete("genre");
}
return url.href;
}, []);

const showCriteriaCreationPopup = useCallback(
(parent: CriteriaMinimum | null = null) => {
Expand Down Expand Up @@ -119,12 +155,27 @@ function ReferenceGenreTree() {
placement="regions"
viewMode={viewMode}
allowWheelRotation={allowWheelRotation}
showAddRoot={false}
floatingControlsLeading={
<Button
size="sm"
variant="outline"
aria-label={viewMode === "outline" ? "Show wheel view" : "Show outline view"}
onClick={() => setViewMode(viewMode === "outline" ? "pop-core" : "outline")}
>
{viewMode === "outline" ? "◉" : "☰"}
</Button>
}
showAddRoot={false}
focusGenreUuid={focusGenreUuid}
onFocusGenreChange={setFocusGenreUuid}
handleGenreCreationAction={showCriteriaCreationPopup}
handleGenreRenameAction={showGenreRenamePopup}
getBackendBaseUrl={getBackendBaseUrl}
selectedNodeId={selectedNodeId}
onSelectedNodeIdChange={handleSelectedNodeIdChange}
artistUuid={artistUuid}
onArtistUuidChange={handleArtistUuidChange}
getShareUrl={getShareUrl}
/>
</>
);
Expand Down Expand Up @@ -172,9 +223,8 @@ function AppContent() {
},
});
const [loading, setLoading] = useState(false);
const { playerTrackObject } = usePlayer();
const [dismissedTrackId, setDismissedTrackId] = useState<string | null>(null);
const playerTrackId = playerTrackObject?.track.id ?? null;
const { playerTrackObject, isLoading: isPlayerLoading } = usePlayer();
const isPlayerVisible = playerTrackObject !== null || isPlayerLoading;

return (
<>
Expand Down Expand Up @@ -222,8 +272,7 @@ function AppContent() {
)}
</div>
<PlayerPanel
isVisible={playerTrackId !== null && playerTrackId !== dismissedTrackId}
onClose={() => setDismissedTrackId(playerTrackId)}
isVisible={isPlayerVisible}
className="top-3"
/>
<PopupHost />
Expand All @@ -239,12 +288,7 @@ export function App() {
<PlayerProvider loadTrack={loadTrack}>
<PopupProvider>
<TrackListSidebarVisibilityProvider>
<TrackListProvider
getBackendBaseUrl={getBackendBaseUrl}
schema={SongDetailedSchema}
listEndpoint={libraryEndpoints.reference.songs.list}
listQueryKey={libraryQueryKeys.reference.songs.list}
>
<TrackListProvider getBackendBaseUrl={getBackendBaseUrl} schema={SongDetailedSchema}>
<AppContent />
</TrackListProvider>
</TrackListSidebarVisibilityProvider>
Expand Down
1 change: 1 addition & 0 deletions apps/playground/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { App } from "./App";
import "@behindthemusictree/brand/tokens/theme.css";
import "./index.css";
import "@behindthemusictree/genre-tree-view/styles.css";
import "../../../packages/app-kit-ui/src/genre-tree/track-position-play-pause.css";

createRoot(document.getElementById("root")!).render(
<StrictMode>
Expand Down
22 changes: 22 additions & 0 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,28 @@ re-exported from the root barrel `src/index.ts`.
| `player` | `src/player/` | `PlayerContext`, `MediaController`, playback state machine — generic over `PlayerTrack`, consumer supplies how to load a track |
| `genre-tree` | `src/genre-tree/` | Schemas, data hooks (`useGenre`, `useGenrePlaylist`), `useGenreTreeViewState`, `useGenreSearch`, `TrackListContext` |

### Known YouTube iframe behavior

The embedded YouTube player can temporarily show its own title, channel avatar, playback overlay,
and branding while the pointer is outside the video. This UI is rendered inside YouTube's
cross-origin iframe, so app CSS hover state does not control it. The supported `controls=0`
parameter hides the standard controls only; YouTube documents that the title and avatar can still
appear before playback, while paused, and after playback. The `showinfo` and `modestbranding`
parameters no longer provide a supported way to hide them.

Reports from other developers describe the same startup overlay lasting about three seconds, even
with `controls=0` and `pointer-events: none`; they also note that the IFrame API can report
`PLAYING` before the first video frame is visible. Repeated programmatic seeks have also been
reported to bring the native player chrome back for several seconds. There is no documented
parameter to suppress this transient UI or reliable API event for the first visible frame. Keep
YouTube playback commands to one per load: `autoplay=1` starts the initial video, and
`loadVideoById()` loads and starts subsequent videos.

References: [YouTube player parameters](https://developers.google.com/youtube/player_parameters),
[IFrame API reference](https://developers.google.com/youtube/iframe_api_reference),
[startup overlay report](https://stackoverflow.com/questions/79993522/youtube-iframe-always-overlays-the-video-for-3-seconds-before-disappearing),
[seek-triggered overlay report](https://stackoverflow.com/questions/79936448/how-can-i-make-youtube-iframe-api-seekto-calls-silent-without-setting-controls).

`genre-tree` never knows a consumer's routes: artist names in track lists (`TrackItem`,
`TrackListSidebar`, the genre detail panel via `GenreTreeView`, all in `app-kit-ui`) become links only when the
consumer passes `renderArtistLink(artist)`, which returns its own router link so navigation stays
Expand Down
4 changes: 2 additions & 2 deletions packages/app-kit-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@behindthemusictree/app-kit-ui",
"version": "27.1.0",
"version": "28.0.0",
"description": "Styled BehindTheMusicTree React components (popups, auth callback, genre tree) built on the headless @behindthemusictree/app-kit",
"repository": {
"type": "git",
Expand Down Expand Up @@ -46,7 +46,7 @@
"test": "vitest run --coverage"
},
"peerDependencies": {
"@behindthemusictree/app-kit": "^27.1.0",
"@behindthemusictree/app-kit": "^28.0.0",
"@behindthemusictree/ui": "^0.10.0",
"@tanstack/react-query": ">=5.0.0",
"next": ">=14.0.0",
Expand Down
6 changes: 4 additions & 2 deletions packages/app-kit-ui/scripts/copy-genre-tree-css.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
Expand All @@ -7,6 +7,7 @@ const GENRE_TREE_STYLES_SPECIFIER = "@behindthemusictree/genre-tree-view/styles.

const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const destCssPath = resolve(packageRoot, "dist/genre-tree/styles.css");
const localStylesPath = resolve(packageRoot, "src/genre-tree/track-position-play-pause.css");

try {
createRequire(import.meta.url).resolve(GENRE_TREE_STYLES_SPECIFIER);
Expand All @@ -19,4 +20,5 @@ try {
}

mkdirSync(dirname(destCssPath), { recursive: true });
writeFileSync(destCssPath, `@import "${GENRE_TREE_STYLES_SPECIFIER}";\n`);
const localStyles = readFileSync(localStylesPath, "utf8");
writeFileSync(destCssPath, `@import "${GENRE_TREE_STYLES_SPECIFIER}";\n\n${localStyles}\n`);
15 changes: 13 additions & 2 deletions packages/app-kit-ui/src/genre-tree/GenreActionBand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type {
GenreTreeAction,
GenreTreeNode,
} from "@behindthemusictree/genre-tree-view";
import type { GenreTreeShareTarget } from "./share";
import ShareActionButton from "./ShareActionButton";

export type GenreActionBandProps = {
node: GenreTreeNode;
Expand All @@ -26,6 +28,8 @@ export type GenreActionBandProps = {
/** When set, disables the side toggle and explains why in its tooltip. */
toggleSideDisabledReason?: string;
additionalActions?: (node: GenreTreeNode) => GenreTreeAction[];
shareTarget?: GenreTreeShareTarget;
shareUrl?: string;
};

type BandItem = {
Expand All @@ -49,6 +53,8 @@ export default function GenreActionBand({
onToggleSide,
toggleSideDisabledReason,
additionalActions,
shareTarget,
shareUrl,
}: GenreActionBandProps) {
const items: BandItem[] = [];
const adminItems: BandItem[] = [];
Expand Down Expand Up @@ -100,7 +106,7 @@ export default function GenreActionBand({
disabled: action.enabled ? !action.enabled(node) : false,
});
}
if (items.length === 0 && adminItems.length === 0) return null;
if (items.length === 0 && (!shareTarget || !shareUrl) && adminItems.length === 0) return null;

const renderItems = (list: BandItem[]) =>
list.map((item) => (
Expand All @@ -121,7 +127,12 @@ export default function GenreActionBand({

return (
<div className="genre-action-band border-b border-[var(--color-border)]">
{items.length > 0 && <div className="flex flex-wrap gap-1 px-3.5 py-3">{renderItems(items)}</div>}
{(items.length > 0 || (shareTarget && shareUrl)) && (
<div className="flex flex-wrap gap-1 px-3.5 py-3">
{renderItems(items)}
{shareTarget && shareUrl && <ShareActionButton target={shareTarget} url={shareUrl} />}
</div>
)}
{adminItems.length > 0 && (
<section
aria-label="Admin actions"
Expand Down
30 changes: 21 additions & 9 deletions packages/app-kit-ui/src/genre-tree/GenreDetailExtras.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe("GenreDetailExtras", () => {

expect(screen.getByText("Default summary text")).toBeInTheDocument();
expect(screen.getByText("Essential tracks")).toBeInTheDocument();
expect(screen.queryByText("Description")).not.toBeInTheDocument();
});

describe("text clamp", () => {
Expand All @@ -59,12 +60,12 @@ describe("GenreDetailExtras", () => {
vi.restoreAllMocks();
});

it("clamps the summary and description to three lines", () => {
it("clamps the summary to three lines", () => {
useFetchGenreOverviewMock.mockReturnValue({ data: { ...overview, description: "A description" }, isPending: false });
renderExtras();

expect(screen.getByText("Default summary text")).toHaveClass("line-clamp-3");
expect(screen.getByText("A description")).toHaveClass("line-clamp-3");
expect(screen.queryByText("A description")).not.toBeInTheDocument();
});

it("shows no toggle when the text fits", () => {
Expand Down Expand Up @@ -103,13 +104,13 @@ describe("GenreDetailExtras", () => {
expect(renderEssentialTracks).toHaveBeenCalledWith(overview);
});

it("shows the description only when present", () => {
it("does not render the description", () => {
useFetchGenreOverviewMock.mockReturnValue({
data: { ...overview, description: "music genre" },
isPending: false,
});
const { unmount } = renderExtras();
expect(screen.getByText("music genre")).toBeInTheDocument();
expect(screen.queryByText("music genre")).not.toBeInTheDocument();
unmount();

useFetchGenreOverviewMock.mockReturnValue({ data: { ...overview, description: null }, isPending: false });
Expand All @@ -131,15 +132,26 @@ describe("GenreDetailExtras", () => {
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});

it("renders the Wikipedia source inside the default Summary section", () => {
it("renders Wikipedia and Wikidata links above the default Summary section", () => {
useFetchGenreOverviewMock.mockReturnValue({
data: { ...overview, summarySourceUrl: "https://en.wikipedia.org/wiki/Jazz" },
isPending: false,
});
renderExtras();

const link = screen.getByRole("link", { name: /wikipedia/i });
expect(link.closest(".gtv-info-panel-children")).toContainElement(screen.getByText("Default summary text"));
renderExtras({ wikidataId: "Q123" });

const linksTitle = screen.getByText("Links");
const linksSection = linksTitle.closest(".gtv-info-panel-children");
const summarySection = screen.getByText("Default summary text").closest(".gtv-info-panel-children");
expect(screen.getByRole("link", { name: /wikipedia/i })).toHaveAttribute(
"href",
"https://en.wikipedia.org/wiki/Jazz",
);
expect(screen.getByRole("link", { name: /wikidata/i })).toHaveAttribute(
"href",
"https://www.wikidata.org/wiki/Q123",
);
expect(linksSection).not.toContainElement(screen.getByText("Default summary text"));
expect(linksSection!.compareDocumentPosition(summarySection!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});

it("forwards the track slots to GenrePlaylistTracks", () => {
Expand Down
Loading
Loading