feat(player): add PiP button when supported (C3)
This commit is contained in:
parent
0c811dfcfd
commit
2424986ebf
3 changed files with 91 additions and 2 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
51
apps/web/src/features/player/hooks/usePictureInPicture.ts
Normal file
51
apps/web/src/features/player/hooks/usePictureInPicture.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue