diff --git a/CHANGELOG.md b/CHANGELOG.md index d336d05..c5f631d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx b/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx index 3037147..110f6d2 100644 --- a/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx +++ b/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx @@ -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(); + fireEvent.click(screen.getByRole("button", { name: "Close player" })); + expect(stop).toHaveBeenCalledTimes(1); + }); + it("hides the song list from the From header", () => { setup(true); render(); @@ -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( @@ -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); diff --git a/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx b/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx index 4774ca9..83a14cb 100644 --- a/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx +++ b/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { X } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { usePlayer, @@ -11,7 +12,7 @@ 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"; @@ -19,8 +20,6 @@ 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; @@ -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(""); @@ -114,6 +113,11 @@ export default function PlayerPanel({ {loadedTrackTitle || lastLoadedTrackTitle} + {playerTrackObject && ( + + + )}
diff --git a/packages/app-kit/src/player/PlayerContext.test.tsx b/packages/app-kit/src/player/PlayerContext.test.tsx index ef19d5d..060eb7b 100644 --- a/packages/app-kit/src/player/PlayerContext.test.tsx +++ b/packages/app-kit/src/player/PlayerContext.test.tsx @@ -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 }); diff --git a/packages/app-kit/src/player/PlayerContext.tsx b/packages/app-kit/src/player/PlayerContext.tsx index 384a98d..6715f51 100644 --- a/packages/app-kit/src/player/PlayerContext.tsx +++ b/packages/app-kit/src/player/PlayerContext.tsx @@ -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[], @@ -215,6 +217,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) { audioElementRef.current = audio; audio.addEventListener("loadedmetadata", () => { + if (audioElementRef.current !== audio) return; setDuration(audio.duration); }); @@ -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; @@ -470,6 +495,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) { setPlayState, handlePlayPauseAction, loadTrackForPlayer, + stop, isLoading, handleNextTrack, handlePreviousTrack, @@ -489,6 +515,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) { isLoading, handlePlayPauseAction, loadTrackForPlayer, + stop, handleNextTrack, handlePreviousTrack, onTrackEnd,