Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

330 lines
13 KiB
TypeScript

import React, { useState, useRef, useCallback, useLayoutEffect } from 'react';
import { useLocale } from '@/os/locale';
import { useSpotifyStore } from '../state';
import { useSpotifyStrings } from '../hooks/useSpotifyStrings';
import { useSpotifyGestures } from '../hooks/useSpotifyGestures';
import { useShallow } from 'zustand/react/shallow';
import { IcPlay, IcPause, IcMic, IcMore, IcShuffle, IcRepeat, IcTimer } from '../res/icons';
import { localizeSpotifyText } from '../utils/localizeSpotifyText';
import { getTrackCoverLarge } from '../utils/artwork';
const DISMISS_THRESHOLD = 80;
const SNAP_THRESHOLD = 50;
const DRAG_START_THRESHOLD = 6;
interface QueueSheetPanelProps {
backdropProps?: React.HTMLAttributes<HTMLDivElement>;
coverOverride?: string;
onClose?: () => void;
}
export const QueueSheetPanel: React.FC<QueueSheetPanelProps> = ({
backdropProps,
coverOverride,
onClose,
}) => {
const locale = useLocale();
const isEnglish = locale === 'en';
const s = useSpotifyStrings();
const { bindTap } = useSpotifyGestures();
const displayText = (v: string | undefined) => localizeSpotifyText(v, isEnglish);
const { currentTrack, isPlaying, queue, shuffle, repeat } = useSpotifyStore(
useShallow(st => ({
currentTrack: st.currentTrack,
isPlaying: st.isPlaying,
queue: st.queue,
shuffle: st.shuffle,
repeat: st.repeat,
})),
);
const togglePlay = useSpotifyStore(st => st.togglePlay);
const toggleShuffle = useSpotifyStore(st => st.toggleShuffle);
const toggleRepeat = useSpotifyStore(st => st.toggleRepeat);
const playTrack = useSpotifyStore(st => st.playTrack);
const sheetRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<HTMLDivElement>(null);
const dragStartY = useRef(0);
const dragModeRef = useRef<'none' | 'pending' | 'expand' | 'collapse' | 'dismiss'>('none');
const zoomRef = useRef(1);
// Drag state in refs for stable pointer callbacks
const isDraggingRef = useRef(false);
const heightDeltaRef = useRef(0);
const dismissOffsetRef = useRef(0);
const [expanded, setExpanded] = useState(false);
const [renderHeightDelta, setRenderHeightDelta] = useState(0);
const [renderDismissOffset, setRenderDismissOffset] = useState(0);
const [collapsedHeight, setCollapsedHeight] = useState(0);
const [expandedHeight, setExpandedHeight] = useState(0);
const expandRange = Math.max(0, expandedHeight - collapsedHeight);
useLayoutEffect(() => {
const el = sheetRef.current;
if (!el) return;
const STATUS_BAR_VP_PX = 40;
const measure = () => {
const probe = document.createElement('div');
probe.style.cssText = 'position:absolute;width:200px;height:0;visibility:hidden;pointer-events:none';
el.appendChild(probe);
const zoom = probe.getBoundingClientRect().width / 200 || 1;
probe.remove();
zoomRef.current = zoom;
const maxH = (window.innerHeight - STATUS_BAR_VP_PX) / zoom;
setCollapsedHeight(Math.round(maxH * 0.65));
setExpandedHeight(Math.round(maxH));
};
measure();
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
if (ro) ro.observe(el);
return () => ro?.disconnect();
}, []);
const resetDragState = useCallback(() => {
isDraggingRef.current = false;
heightDeltaRef.current = 0;
dismissOffsetRef.current = 0;
setRenderHeightDelta(0);
setRenderDismissOffset(0);
dragModeRef.current = 'none';
}, []);
const handleClose = useCallback(() => {
resetDragState();
if (onClose) {
onClose();
} else {
backdropProps?.onClick?.(null as any);
}
}, [backdropProps, onClose, resetDragState]);
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
dragStartY.current = e.clientY;
dragModeRef.current = 'pending';
isDraggingRef.current = true;
sheetRef.current?.setPointerCapture(e.pointerId);
}, []);
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingRef.current) return;
const rawDy = e.clientY - dragStartY.current;
const dy = rawDy / zoomRef.current;
if (dragModeRef.current === 'pending') {
if (Math.abs(dy) < DRAG_START_THRESHOLD) return;
if (dy < 0 && !expanded) {
dragModeRef.current = 'expand';
} else if (dy > 0 && expanded) {
dragModeRef.current = 'collapse';
} else if (dy > 0 && !expanded) {
dragModeRef.current = 'dismiss';
} else {
dragModeRef.current = 'none';
}
}
if (dragModeRef.current === 'expand') {
const v = Math.min(expandRange, Math.max(0, -dy));
heightDeltaRef.current = v;
dismissOffsetRef.current = 0;
setRenderHeightDelta(v);
setRenderDismissOffset(0);
} else if (dragModeRef.current === 'collapse') {
const v = -Math.min(expandRange, Math.max(0, dy));
heightDeltaRef.current = v;
dismissOffsetRef.current = 0;
setRenderHeightDelta(v);
setRenderDismissOffset(0);
} else if (dragModeRef.current === 'dismiss') {
const v = Math.max(0, dy);
heightDeltaRef.current = 0;
dismissOffsetRef.current = v;
setRenderHeightDelta(0);
setRenderDismissOffset(v);
}
}, [expandRange, expanded]);
const handlePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (sheetRef.current?.hasPointerCapture(e.pointerId)) {
sheetRef.current.releasePointerCapture(e.pointerId);
}
if (!isDraggingRef.current) return;
const mode = dragModeRef.current;
const hd = heightDeltaRef.current;
const doff = dismissOffsetRef.current;
if (mode === 'expand') {
if (hd >= SNAP_THRESHOLD || hd >= expandRange * 0.3) {
setExpanded(true);
}
} else if (mode === 'collapse') {
if (-hd >= SNAP_THRESHOLD || -hd >= expandRange * 0.3) {
setExpanded(false);
}
} else if (mode === 'dismiss') {
if (doff >= DISMISS_THRESHOLD) {
handleClose();
return;
}
}
resetDragState();
}, [expandRange, handleClose, resetDragState]);
if (!currentTrack) return null;
const currentIdx = queue.findIndex(t => t.id === currentTrack.id);
const nextTracks = queue.slice(currentIdx + 1);
const coverSrc = coverOverride || getTrackCoverLarge(currentTrack);
const baseHeight = expanded ? expandedHeight : collapsedHeight;
const currentHeight = collapsedHeight > 0
? Math.max(100, Math.min(expandedHeight, baseHeight + renderHeightDelta))
: 0;
return (
<>
<div
{...backdropProps}
className={`absolute inset-0 bg-black/60 z-40 backdrop-blur-sm transition-opacity ${backdropProps?.className ?? ''}`}
/>
<div
ref={sheetRef}
className="absolute bottom-0 left-0 right-0 bg-app-surface text-white z-50 flex flex-col rounded-t-[20px] overflow-hidden shadow-2xl"
style={{
height: currentHeight > 0 ? `${currentHeight}px` : '65%',
maxHeight: expandedHeight > 0 ? `${expandedHeight}px` : undefined,
transform: `translateY(${renderDismissOffset}px)`,
transition: !isDraggingRef.current
? 'height var(--app-duration-short) var(--app-easing-decelerate), transform var(--app-duration-short) var(--app-easing-decelerate)'
: undefined,
}}
onPointerMove={handlePointerMove}
onPointerLeave={e => e.buttons === 0 && handlePointerUp(e)}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<div
ref={dragRef}
className="w-full flex-shrink-0 flex items-center justify-center pt-3 pb-2 bg-app-surface cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none' }}
onPointerDown={handlePointerDown}
>
<div className="w-10 h-1 bg-gray-600 rounded-full" />
</div>
<div
data-scroll-container="main"
data-scroll-direction="vertical"
className="flex-1 overflow-y-auto px-6 bg-app-surface"
>
<h2 className="text-lg font-bold mb-1">{s.player_queue_title}</h2>
<p className="text-gray-400 text-xs mb-6">
{s.player_queue_now_playing.replace('{artist}', displayText(currentTrack.artist))}
</p>
<div className="space-y-4">
<div className="flex items-center gap-3 bg-white/5 p-2 rounded-lg">
<div className="w-12 h-12 rounded overflow-hidden flex-shrink-0 bg-gray-800">
{coverSrc ? (
<img src={coverSrc} className="w-full h-full object-cover" alt={displayText(currentTrack.title)} />
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-700">
<IcMic size={20} className="text-gray-400" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-app-primary font-bold text-base truncate mb-1">
{displayText(currentTrack.title)}
</div>
<div className="text-gray-400 text-sm truncate">
{displayText(currentTrack.artist)}
</div>
</div>
<button
{...bindTap(
{ kind: 'action', id: 'track.play.toggle' },
{
params: { trackId: currentTrack.id, to: !isPlaying },
onTrigger: togglePlay,
stopPropagation: true,
},
)}
>
{isPlaying ? (
<IcPause size={24} fill="#1ED760" className="text-app-primary" />
) : (
<IcPlay size={24} fill="#1ED760" className="text-app-primary" />
)}
</button>
</div>
<h3 className="text-sm font-bold text-white mt-8 mb-4">{s.player_queue_up_next}</h3>
{nextTracks.map((t, i) => (
<div
key={i}
onClick={() => playTrack(t)}
className="flex items-center gap-3 opacity-100 active:bg-white/10 p-2 rounded-lg transition-colors cursor-pointer"
>
<div className="w-12 h-12 rounded bg-gray-800 overflow-hidden flex-shrink-0">
{t.cover ? (
<img src={t.cover} alt={displayText(t.title)} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-700">
<IcMic size={20} className="text-gray-400" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-white font-bold mb-1 truncate">{displayText(t.title)}</div>
<div className="text-gray-400 text-sm truncate">{displayText(t.artist)}</div>
</div>
<div className="text-gray-400"><IcMore size={20} /></div>
</div>
))}
</div>
</div>
<div className="p-4 pb-8 flex-shrink-0 grid grid-cols-3 gap-4 border-t border-white/10 bg-[#2A2A2A]">
<button
{...bindTap(
{ kind: 'action', id: 'player.shuffle.toggle' },
{ params: { to: !shuffle }, onTrigger: toggleShuffle },
)}
className="flex items-center justify-center gap-2 text-sm text-gray-300 font-bold bg-[#3E3E3E] py-3 rounded-lg active:scale-95 transition-transform"
>
<IcShuffle size={20} className={shuffle ? 'text-app-primary' : ''} />
<span>{s.player_shuffle}</span>
</button>
<button
{...bindTap(
{ kind: 'action', id: 'player.repeat.toggle' },
{ params: { to: repeat !== 'off' }, onTrigger: toggleRepeat },
)}
className="flex items-center justify-center gap-2 text-sm text-gray-300 font-bold bg-[#3E3E3E] py-3 rounded-lg active:scale-95 transition-transform"
>
<span className="relative">
<IcRepeat size={20} className={repeat !== 'off' ? 'text-app-primary' : ''} />
{repeat === 'track' && <span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[7px] font-bold text-app-primary pt-px">1</span>}
</span>
<span>{repeat === 'track' ? s.player_repeat_one : s.player_repeat}</span>
</button>
<button
{...bindTap('player.timer.open')}
className="flex items-center justify-center gap-2 text-sm text-gray-300 font-bold bg-[#3E3E3E] py-3 rounded-lg active:scale-95 transition-transform"
>
<IcTimer size={20} />
<span>{s.player_timer}</span>
</button>
</div>
</div>
</>
);
};