import React from 'react'; import { useLocale } from '@/os/locale'; import { IcAdd, IcDisc, IcHeart, IcMinusCircle, IcQrCode, IcQueue, IcRadio, IcShare, IcLyrics, IcTimer, IcTrend, IcUser, IcUsers, IcZap, } from '../res/icons'; import { useSpotifyStrings } from '../hooks/useSpotifyStrings'; import type { SpotifyTrack } from '../types'; import { localizeSpotifyText } from '../utils/localizeSpotifyText'; type TrackMenuVariant = 'search' | 'playlist' | 'ownPlaylist' | 'likedSongs'; interface TrackMenuSheetProps { track: SpotifyTrack; isOpen: boolean; liked?: boolean; variant?: TrackMenuVariant; onClose?: () => void; backdropProps?: React.HTMLAttributes; shareProps?: React.ButtonHTMLAttributes; lyricsProps?: React.ButtonHTMLAttributes; lyricsOn?: boolean; sleepTimerProps?: React.ButtonHTMLAttributes; likeProps?: React.ButtonHTMLAttributes; addToPlaylistProps?: React.ButtonHTMLAttributes; addToPlaylistLabel?: string; likedTrackMenuVariant?: 'default' | 'otherPlaylistsOnly'; createJamProps?: React.ButtonHTMLAttributes; premiumProps?: React.ButtonHTMLAttributes; removeFromPlaylistProps?: React.ButtonHTMLAttributes; hideFromPlaylistProps?: React.ButtonHTMLAttributes; addToQueueProps?: React.ButtonHTMLAttributes; goToQueueProps?: React.ButtonHTMLAttributes; goToAlbumProps?: React.ButtonHTMLAttributes; goToArtistProps?: React.ButtonHTMLAttributes; removeFromTasteProps?: React.ButtonHTMLAttributes; goToSongRadioProps?: React.ButtonHTMLAttributes; viewProducersProps?: React.ButtonHTMLAttributes; showSpotifyCodeProps?: React.ButtonHTMLAttributes; albumTitle?: string; } const rowClass = 'w-full px-6 py-3.5 flex items-center gap-4 text-white hover:bg-[#333] active:bg-[#333]'; const DISMISS_THRESHOLD = 120; const SNAP_THRESHOLD = 48; const DRAG_START_THRESHOLD = 8; export const TrackMenuSheet: React.FC = ({ track, isOpen, liked = false, variant = 'search', onClose, backdropProps, shareProps, lyricsProps, lyricsOn = false, sleepTimerProps, likeProps, addToPlaylistProps, addToPlaylistLabel, likedTrackMenuVariant = 'default', createJamProps, premiumProps, removeFromPlaylistProps, hideFromPlaylistProps, addToQueueProps, goToQueueProps, goToAlbumProps, goToArtistProps, removeFromTasteProps, goToSongRadioProps, viewProducersProps, showSpotifyCodeProps, albumTitle, }) => { const locale = useLocale(); const isEnglish = locale === 'en'; const s = useSpotifyStrings(); const sheetRef = React.useRef(null); const dragRef = React.useRef(null); const contentRef = React.useRef(null); const dragStartY = React.useRef(0); const dragMode = React.useRef<'none' | 'pending' | 'expand' | 'collapse' | 'dismiss'>('none'); const zoomRef = React.useRef(1); const [expanded, setExpanded] = React.useState(false); const [heightDelta, setHeightDelta] = React.useState(0); const [dismissOffset, setDismissOffset] = React.useState(0); const [isDragging, setIsDragging] = React.useState(false); const [maxSheetHeight, setMaxSheetHeight] = React.useState(0); const [measuredCollapsedHeight, setMeasuredCollapsedHeight] = React.useState(0); const [measuredExpandedHeight, setMeasuredExpandedHeight] = React.useState(0); const isPlaylistVariant = variant === 'playlist' || variant === 'ownPlaylist' || variant === 'likedSongs'; const isOwnVariant = variant === 'ownPlaylist' || variant === 'likedSongs'; const hideLikedToggleRow = isOwnVariant || (liked && likedTrackMenuVariant === 'otherPlaylistsOnly'); const STATUS_BAR_VP_PX = 40; const effectiveMax = maxSheetHeight > 0 ? maxSheetHeight : (window.innerHeight || 800) * 0.9; const collapsedHeight = measuredCollapsedHeight > 0 ? Math.max(360, Math.min(effectiveMax, measuredCollapsedHeight)) : Math.min(Math.max(effectiveMax * 0.56, 420), effectiveMax); const expandedHeight = measuredExpandedHeight > 0 ? Math.min(Math.max(collapsedHeight, measuredExpandedHeight), effectiveMax) : Math.min(effectiveMax * 0.92, effectiveMax); const expandRange = Math.max(0, expandedHeight - collapsedHeight); React.useEffect(() => { if (isOpen) return; setExpanded(false); setHeightDelta(0); setDismissOffset(0); setIsDragging(false); dragMode.current = 'none'; setMaxSheetHeight(0); setMeasuredCollapsedHeight(0); setMeasuredExpandedHeight(0); }, [isOpen]); React.useLayoutEffect(() => { if (!isOpen) return; const COLLAPSED_ITEM_COUNT = 7; const measure = () => { const el = sheetRef.current; if (!el) return; 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; setMaxSheetHeight((window.innerHeight - STATUS_BAR_VP_PX) / zoom); const dragRect = dragRef.current?.getBoundingClientRect(); const contentEl = contentRef.current; if (!dragRect || !contentEl) return; const contentRect = contentEl.getBoundingClientRect(); const rows = contentEl.querySelectorAll(':scope > button'); const lastRow = rows[rows.length - 1]; const anchorRow = rows[COLLAPSED_ITEM_COUNT - 1] ?? lastRow; const contentStyles = window.getComputedStyle(contentEl); const contentPaddingBottom = Number.parseFloat(contentStyles.paddingBottom || '0'); if (lastRow) { const expandedContentHeight = (lastRow.getBoundingClientRect().bottom - contentRect.top + contentPaddingBottom) / zoom; setMeasuredExpandedHeight(dragRect.height / zoom + expandedContentHeight); } if (anchorRow) { const collapsedContentHeight = (anchorRow.getBoundingClientRect().bottom - contentRect.top) / zoom; setMeasuredCollapsedHeight(dragRect.height / zoom + collapsedContentHeight); } }; measure(); const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => measure()) : null; if (ro && dragRef.current) ro.observe(dragRef.current); if (ro && contentRef.current) ro.observe(contentRef.current); return () => ro?.disconnect(); }, [hideLikedToggleRow, isOpen, likedTrackMenuVariant, removeFromPlaylistProps]); const defaultRow = React.useCallback( (props?: React.ButtonHTMLAttributes) => props ?? { onClick: () => onClose?.() }, [onClose], ); // 跳转类菜单项:先关闭 sheet(back),再执行导航 const navigateRow = React.useCallback( (props?: React.ButtonHTMLAttributes) => { if (!props) return { onClick: () => onClose?.() }; const { onClick: origClick, ...rest } = props; return { ...rest, onClick: (e: React.MouseEvent) => { // back() pop 掉 sheet 层,然后同步 push 导航目标 // history: ...→ /?tab=all → /artist/xxx(sheet 已被 pop) onClose?.(); origClick?.(e); }, }; }, [onClose], ); const handlePointerDown = React.useCallback((e: React.PointerEvent) => { if (e.button !== 0) return; dragStartY.current = e.clientY; dragMode.current = 'pending'; setIsDragging(true); sheetRef.current?.setPointerCapture(e.pointerId); }, []); const handlePointerMove = React.useCallback((e: React.PointerEvent) => { if (!isDragging) return; const dy = (e.clientY - dragStartY.current) / zoomRef.current; if (dragMode.current === 'pending') { if (Math.abs(dy) < DRAG_START_THRESHOLD) return; if (dy < 0 && !expanded) { dragMode.current = 'expand'; } else if (dy > 0 && expanded) { dragMode.current = 'collapse'; } else if (dy > 0 && !expanded) { dragMode.current = 'dismiss'; } else { dragMode.current = 'none'; } } if (dragMode.current === 'expand') { setHeightDelta(Math.min(expandRange, Math.max(0, -dy))); setDismissOffset(0); return; } if (dragMode.current === 'collapse') { setHeightDelta(-Math.min(expandRange, Math.max(0, dy))); setDismissOffset(0); return; } if (dragMode.current === 'dismiss') { setHeightDelta(0); setDismissOffset(Math.max(0, dy)); return; } }, [expandRange, expanded, isDragging]); const resetDragState = React.useCallback(() => { setIsDragging(false); setHeightDelta(0); setDismissOffset(0); dragMode.current = 'none'; }, []); const handlePointerUp = React.useCallback((e: React.PointerEvent) => { if (sheetRef.current?.hasPointerCapture(e.pointerId)) { sheetRef.current.releasePointerCapture(e.pointerId); } if (!isDragging) return; const mode = dragMode.current; if (mode === 'expand') { if (heightDelta >= SNAP_THRESHOLD || heightDelta >= expandRange * 0.3) { setExpanded(true); } } else if (mode === 'collapse') { if (-heightDelta >= SNAP_THRESHOLD || -heightDelta >= expandRange * 0.3) { setExpanded(false); } } else if (mode === 'dismiss') { if (dismissOffset >= DISMISS_THRESHOLD) { resetDragState(); onClose?.(); return; } } resetDragState(); }, [dismissOffset, expandRange, heightDelta, isDragging, onClose, resetDragState]); const { className: backdropClassName, ...backdropRest } = backdropProps ?? {}; const { className: shareClassName, ...shareRest } = defaultRow(shareProps); const { className: lyricsClassName, ...lyricsRest } = defaultRow(lyricsProps); const { className: sleepTimerClassName, ...sleepTimerRest } = defaultRow(sleepTimerProps); const { className: likeClassName, ...likeRest } = defaultRow(likeProps); const { className: addToPlaylistClassName, ...addToPlaylistRest } = defaultRow(addToPlaylistProps); const { className: removeFromPlaylistClassName, ...removeFromPlaylistRest } = removeFromPlaylistProps ?? {}; const { className: hideFromPlaylistClassName, ...hideFromPlaylistRest } = defaultRow(hideFromPlaylistProps); const { className: addToQueueClassName, ...addToQueueRest } = defaultRow(addToQueueProps); const { className: goToQueueClassName, ...goToQueueRest } = defaultRow(goToQueueProps); const { className: goToAlbumClassName, ...goToAlbumRest } = navigateRow(goToAlbumProps); const { className: goToArtistClassName, ...goToArtistRest } = navigateRow(goToArtistProps); const { className: removeFromTasteClassName, ...removeFromTasteRest } = defaultRow(removeFromTasteProps); const { className: goToSongRadioClassName, ...goToSongRadioRest } = defaultRow(goToSongRadioProps); const { className: viewProducersClassName, ...viewProducersRest } = defaultRow(viewProducersProps); const { className: showSpotifyCodeClassName, ...showSpotifyCodeRest } = defaultRow(showSpotifyCodeProps); const createJamMerged = createJamProps ?? premiumProps ?? { onClick: () => onClose?.() }; const { className: createJamMergedClassName, ...createJamMergedRest } = createJamMerged; const trackTitle = localizeSpotifyText(track.title, isEnglish); const trackArtist = localizeSpotifyText(track.artist, isEnglish); const albumLabel = localizeSpotifyText(albumTitle, isEnglish); const addToPlaylistRowLabel = isOwnVariant || liked ? (addToPlaylistLabel ?? s.track_menu_add_to_other_playlists) : (addToPlaylistLabel ?? s.track_menu_add_to_playlist); if (!isOpen) return null; const baseHeight = expanded ? expandedHeight : collapsedHeight; const currentHeight = Math.max(collapsedHeight, Math.min(expandedHeight, baseHeight + heightDelta)); return ( <>
e.buttons === 0 && handlePointerUp(e)} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} >
{trackTitle} { (e.target as HTMLImageElement).style.display = 'none'; }} />
{trackTitle}
{albumLabel ? `${trackArtist} • ${albumLabel}` : trackArtist}
{lyricsProps && ( )} {!hideLikedToggleRow && ( )} {removeFromPlaylistProps && ( )} {hideFromPlaylistProps && ( )} {sleepTimerProps && ( )} {!isPlaylistVariant && ( )}
); };