feat(player): add PiP button when supported (C3)

This commit is contained in:
senke 2026-02-20 17:52:46 +01:00
parent 0c811dfcfd
commit 2424986ebf
3 changed files with 91 additions and 2 deletions

View file

@ -1,6 +1,8 @@
import { useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { PictureInPicture2 } from 'lucide-react';
import { usePlayer } from '@/features/player/hooks/usePlayer';
import { usePictureInPicture } from '@/features/player/hooks/usePictureInPicture';
import { useKeyboardShortcuts } from '@/features/player/hooks/useKeyboardShortcuts';
import { useAudioAnalyser } from '@/features/player/hooks/useAudioAnalyser';
import { useMediaSession } from '@/features/player/hooks/useMediaSession';
@ -48,6 +50,10 @@ export function GlobalPlayer() {
const displayTrack = currentTrack || IDLE_TRACK;
const isIdle = !currentTrack;
const { setVideoRef: setPiPVideoRef, togglePiP, isPiPActive, isPiPSupported } = usePictureInPicture(
currentTrack?.cover_art_path ?? null,
);
useMediaSession({
track: currentTrack ?? null,
isPlaying: player.isPlaying,
@ -60,6 +66,15 @@ export function GlobalPlayer() {
return (
<>
<audio ref={setAudioRef} />
{isPiPSupported && (
<video
ref={setPiPVideoRef}
className="hidden w-0 h-0"
muted
playsInline
poster={currentTrack?.cover_art_path ?? undefined}
/>
)}
<PlayerExpanded
isOpen={isExpanded}
@ -151,6 +166,9 @@ export function GlobalPlayer() {
onToggleQueue={() => setShowQueue(!showQueue)}
waveformLevels={waveformLevels}
isPlaying={player.isPlaying}
pipSupported={isPiPSupported}
pipActive={isPiPActive}
onTogglePiP={togglePiP}
/>
</div>

View file

@ -1,9 +1,9 @@
/**
* PlayerBarRight Volume, waveform, queue, like
* PlayerBarRight Volume, waveform, queue, like, PiP
* Micro-interactions: hover scale on all buttons
*/
import { Heart, ListMusic, Volume2, VolumeX } from 'lucide-react';
import { Heart, ListMusic, PictureInPicture2, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { AudioWaveform } from './AudioWaveform';
@ -18,6 +18,9 @@ interface PlayerBarRightProps {
onToggleQueue: () => void;
waveformLevels: number[];
isPlaying: boolean;
pipSupported?: boolean;
pipActive?: boolean;
onTogglePiP?: () => void;
}
const btnClass = 'h-8 w-8 sm:h-9 sm:w-9 rounded-full transition-transform duration-150 active:scale-95';
@ -31,6 +34,9 @@ export function PlayerBarRight({
onToggleQueue,
waveformLevels,
isPlaying,
pipSupported,
pipActive,
onTogglePiP,
}: PlayerBarRightProps) {
return (
<section
@ -59,6 +65,20 @@ export function PlayerBarRight({
</div>
</div>
<div className="w-px h-5 bg-[var(--sumi-border-faint)] flex-shrink-0" />
{pipSupported && onTogglePiP && (
<Button
variant="ghost"
size="icon"
className={cn(
btnClass,
pipActive ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground',
)}
onClick={onTogglePiP}
aria-label={pipActive ? 'Exit Picture-in-Picture' : 'Picture-in-Picture'}
>
<PictureInPicture2 className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"

View file

@ -0,0 +1,51 @@
/**
* Hook for Picture-in-Picture when supported.
* PiP requires a video element; for audio we use a minimal video with cover art poster.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
export function usePictureInPicture(coverUrl?: string | null) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isPiPActive, setIsPiPActive] = useState(false);
const isSupported = typeof document !== 'undefined' && 'pictureInPictureEnabled' in document && document.pictureInPictureEnabled;
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onLeave = () => setIsPiPActive(false);
video.addEventListener('leavepictureinpicture', onLeave);
return () => video.removeEventListener('leavepictureinpicture', onLeave);
}, []);
const togglePiP = useCallback(async () => {
const video = videoRef.current;
if (!video || !isSupported) return;
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
setIsPiPActive(false);
} else {
await video.requestPictureInPicture();
setIsPiPActive(true);
}
} catch {
setIsPiPActive(false);
}
}, [isSupported]);
const setVideoRef = useCallback((el: HTMLVideoElement | null) => {
videoRef.current = el;
if (el) {
if (coverUrl) el.poster = coverUrl;
if (!el.src) {
const silentVideo = 'data:video/webm;base64,GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQRChYECGFOAZwH/w0BZ/5ZQZ+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BAeBhkO+BA';
el.src = silentVideo;
}
}
}, [coverUrl]);
return { setVideoRef, togglePiP, isPiPActive, isSupported };
}