Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ easy to spot when bumping.

### Breaking

- `PlayerPanel` no longer accepts the already-ignored `onClose` prop; remove it. Closing is built in (see Added).
- `ParentChange.to` (app-kit-ui, `onParentChanged`) is now `GenreUuid | null`; `null` means the genre was made a root. Handle it before using `to`.
- `fetchWrapper` / `useFetchWrapper().fetch` / `usePlaybackFetch` now reject with the typed `AppError` on a failed GET or HEAD after reporting it to the global handler, instead of resolving `null`. Direct `await fetch(...)` callers on reads must handle the rejection (query functions need no change).

### Added

- `usePlayer().stop()` stops playback and unloads the current track (`playerTrackObject` becomes `null`); `PlayerPanel` shows a **Close player** header button that calls it.
- Parent popup (`ChangeParentPopup`): a **Make root** button, shown when the genre has a parent, with an inline confirm step.
- `isRetryableError` (transport) and `AUTH_DETAIL_ERROR_CODES`: one classification of which failures a retry can fix.

Expand Down
12 changes: 10 additions & 2 deletions packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ describe("PlayerPanel", () => {
expect(screen.queryByRole("button", { name: "Extend song list panel" })).not.toBeInTheDocument();
});

it("stops playback from the close button", () => {
const stop = vi.fn();
setup(true);
usePlayerMock.mockReturnValue({ playerTrackObject: { track: { title: "Song A" } }, stop });
render(<PlayerPanel />);
fireEvent.click(screen.getByRole("button", { name: "Close player" }));
expect(stop).toHaveBeenCalledTimes(1);
});

it("hides the song list from the From header", () => {
setup(true);
render(<PlayerPanel />);
Expand Down Expand Up @@ -106,7 +115,7 @@ describe("PlayerPanel", () => {
expect(onExpandedChange).toHaveBeenCalledWith(false);
});

it("minimizes to video-only without closing or unmounting the player", () => {
it("minimizes to video-only without unmounting the player", () => {
setup(true);
const onExpandedChange = vi.fn();
const { container, rerender } = render(
Expand All @@ -124,7 +133,6 @@ describe("PlayerPanel", () => {
expect(container.firstChild).toHaveClass("w-[var(--layout-right-w)]");
expect(screen.getByTestId("video")).toBeInTheDocument();
expect(screen.getByTestId("sidebar")).toHaveAttribute("data-collapsed", "true");
expect(screen.queryByRole("button", { name: "Close player" })).not.toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Show tracks" }));
expect(toggleTrackListSidebar).toHaveBeenCalledTimes(2);
Expand Down
12 changes: 8 additions & 4 deletions packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { twMerge } from "tailwind-merge";
import {
usePlayer,
Expand All @@ -11,16 +12,14 @@ import {
type RenderArtistLink,
type RenderSongLink,
} from "@behindthemusictree/app-kit/genre-tree";
import { PanelEdgeTab } from "@behindthemusictree/ui";
import { IconButton, PanelEdgeTab } from "@behindthemusictree/ui";
import PlayerControls from "./PlayerControls";
import TrackListSidebar from "./track-list-sidebar/TrackListSidebar";

export interface PlayerPanelProps {
className?: string;
/** Hidden via CSS, never unmounted, so the video keeps playing while the host hides the panel. */
isVisible?: boolean;
/** @deprecated The player no longer closes; its header action minimizes to video-only mode. */
onClose?: () => void;
/** Controlled extended state; uncontrolled when omitted. */
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
Expand All @@ -46,7 +45,7 @@ export default function PlayerPanel({
renderArtistLink,
renderSongLink,
}: PlayerPanelProps) {
const { playerTrackObject, retryPlayback } = usePlayer();
const { playerTrackObject, retryPlayback, stop } = usePlayer();
const { toggleTrackListSidebar, isTrackListSidebarVisible } =
useTrackListSidebarVisibility();
const [lastLoadedTrackTitle, setLastLoadedTrackTitle] = useState("");
Expand Down Expand Up @@ -114,6 +113,11 @@ export default function PlayerPanel({
<span className="min-w-0 truncate text-sm font-semibold">
{loadedTrackTitle || lastLoadedTrackTitle}
</span>
{playerTrackObject && (
<IconButton aria-label="Close player" onClick={stop}>
<X aria-hidden="true" className="h-4 w-4" />
</IconButton>
)}
</div>
<div className="relative w-full shrink-0">
<PlayerVideoSurface className="aspect-video w-full bg-black" />
Expand Down
17 changes: 17 additions & 0 deletions packages/app-kit/src/player/PlayerContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,23 @@ describe("PlayerProvider - loadTrackForPlayer (audio)", () => {
expect(result.current.playbackConfirmedTrackId).toBe("audio-1");
});

it("stop() pauses and unloads the current track", async () => {
loadTrackMock.mockResolvedValue(makeAudioTrack());
const { result } = renderHook(() => usePlayer(), { wrapper });
await act(async () => {
result.current.loadTrackForPlayer("audio-1");
await Promise.resolve();
await Promise.resolve();
});

act(() => result.current.stop());

expect(audioInstances[0].pause).toHaveBeenCalled();
expect(result.current.playerTrackObject).toBeNull();
expect(result.current.isPlaying).toBe(false);
expect(result.current.playState).toBe(PlayStates.STOPPED);
});

it("sets duration and currentTimeRef from audio events", async () => {
loadTrackMock.mockResolvedValue(makeAudioTrack());
const { result } = renderHook(() => usePlayer(), { wrapper });
Expand Down
27 changes: 27 additions & 0 deletions packages/app-kit/src/player/PlayerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ interface PlayerContextType {
setPlayState: (state: PlayStates) => void;
handlePlayPauseAction: () => void;
loadTrackForPlayer: (trackId: string) => void;
/** Stops playback and unloads the current track, so `playerTrackObject` becomes `null`. */
stop: () => void;
isLoading: boolean;
handleNextTrack: (
trackList: PlayerTrack[],
Expand Down Expand Up @@ -215,6 +217,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) {
audioElementRef.current = audio;

audio.addEventListener("loadedmetadata", () => {
if (audioElementRef.current !== audio) return;
setDuration(audio.duration);
});

Expand Down Expand Up @@ -357,6 +360,28 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) {
[loadTrack, loadAudioTrack, loadYoutubeTrack, playback.key],
);

const stop = useCallback(() => {
generation.current++;
terminal.current = true;
currentTrackIdRef.current = null;
currentTimeRef.current = 0;
if (audioElementRef.current) {
audioElementRef.current.pause();
audioElementRef.current = null;
}
if (youtubePlayerRef.current) {
youtubePlayerRef.current.destroy();
youtubePlayerRef.current = null;
}
videoContainerRef.current?.replaceChildren();
setPlayerTrackObject(null);
setPlaybackConfirmedTrackId(null);
setDuration(0);
setIsLoading(false);
setPlayState(PlayStates.STOPPED);
setIsPlaying(false);
}, []);

const previousPlaybackKey = useRef(playback.key);
useEffect(() => {
if (previousPlaybackKey.current === playback.key) return;
Expand Down Expand Up @@ -470,6 +495,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) {
setPlayState,
handlePlayPauseAction,
loadTrackForPlayer,
stop,
isLoading,
handleNextTrack,
handlePreviousTrack,
Expand All @@ -489,6 +515,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) {
isLoading,
handlePlayPauseAction,
loadTrackForPlayer,
stop,
handleNextTrack,
handlePreviousTrack,
onTrackEnd,
Expand Down
Loading