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

524 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLDivElement>;
shareProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
lyricsProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
lyricsOn?: boolean;
sleepTimerProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
likeProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
addToPlaylistProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
addToPlaylistLabel?: string;
likedTrackMenuVariant?: 'default' | 'otherPlaylistsOnly';
createJamProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
premiumProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
removeFromPlaylistProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
hideFromPlaylistProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
addToQueueProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
goToQueueProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
goToAlbumProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
goToArtistProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
removeFromTasteProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
goToSongRadioProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
viewProducersProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
showSpotifyCodeProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
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<TrackMenuSheetProps> = ({
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<HTMLDivElement>(null);
const dragRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(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<HTMLButtonElement>) =>
props ?? { onClick: () => onClose?.() },
[onClose],
);
// 跳转类菜单项:先关闭 sheet(back),再执行导航
const navigateRow = React.useCallback(
(props?: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
if (!props) return { onClick: () => onClose?.() };
const { onClick: origClick, ...rest } = props;
return {
...rest,
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
// back() pop 掉 sheet 层,然后同步 push 导航目标
// history: ...→ /?tab=all → /artist/xxxsheet 已被 pop
onClose?.();
origClick?.(e);
},
};
},
[onClose],
);
const handlePointerDown = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<>
<div
{...backdropRest}
className={`fixed inset-0 bg-black/50 z-[70] ${backdropClassName ?? ''}`}
/>
<div
ref={sheetRef}
className="fixed bottom-0 left-0 right-0 bg-[#282828] rounded-t-2xl z-[71] flex flex-col overflow-hidden shadow-2xl"
style={{
height: `${currentHeight}px`,
maxHeight: `${expandedHeight}px`,
transform: `translateY(${dismissOffset}px)`,
transition:
!isDragging
? '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="flex flex-col flex-shrink-0 touch-none"
onPointerDown={handlePointerDown}
>
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
<div className="w-12 h-1 bg-gray-500 rounded-full" />
</div>
<div className="px-6 py-4 flex items-center gap-4 border-b border-gray-700">
<div className="w-16 h-16 rounded-md overflow-hidden bg-gray-700 flex-shrink-0">
<img
src={track.cover}
alt={trackTitle}
className="w-full h-full object-cover"
onError={e => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-white font-semibold text-base truncate">{trackTitle}</div>
<div className="text-gray-400 text-sm truncate">
{albumLabel ? `${trackArtist}${albumLabel}` : trackArtist}
</div>
</div>
</div>
</div>
<div
ref={contentRef}
className="flex-1 overflow-y-auto py-2"
data-scroll-container="track-menu"
data-scroll-direction="vertical"
>
<button {...shareRest} className={`${rowClass} ${shareClassName ?? ''}`} style={{ touchAction: 'manipulation' }}>
<IcShare size={20} />
<span className="text-base">{s.track_menu_share}</span>
</button>
{lyricsProps && (
<button {...lyricsRest} className={`${rowClass} ${lyricsClassName ?? ''}`} style={{ touchAction: 'manipulation' }}>
<IcLyrics size={20} />
<span className="text-base">{lyricsOn ? s.track_menu_lyrics_on : s.track_menu_lyrics_off}</span>
</button>
)}
{!hideLikedToggleRow && (
<button {...likeRest} className={`${rowClass} ${likeClassName ?? ''}`} style={{ touchAction: 'manipulation' }}>
<IcHeart size={20} className={liked ? 'text-app-primary' : 'text-purple-500'} />
<span className="text-base">{liked ? s.track_menu_remove_liked : s.track_menu_add_liked}</span>
</button>
)}
<button
{...addToPlaylistRest}
className={`${rowClass} ${addToPlaylistClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<IcAdd size={20} className="text-white" />
<span className="text-base">{addToPlaylistRowLabel}</span>
</button>
{removeFromPlaylistProps && (
<button
{...removeFromPlaylistRest}
className={`${rowClass} ${removeFromPlaylistClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<IcMinusCircle size={20} />
<span className="text-base">{s.track_menu_remove_from_playlist}</span>
</button>
)}
{hideFromPlaylistProps && (
<button
{...hideFromPlaylistRest}
className={`${rowClass} ${hideFromPlaylistClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<IcMinusCircle size={20} />
<span className="text-base">{s.track_menu_hide_from_playlist}</span>
</button>
)}
<button
{...addToQueueRest}
className={`${rowClass} ${addToQueueClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<IcAdd size={20} />
<span className="text-base">{s.track_menu_add_to_queue}</span>
</button>
<button
{...goToQueueRest}
className={`${rowClass} ${goToQueueClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<IcQueue size={20} />
<span className="text-base">{s.track_menu_go_to_queue}</span>
</button>
<button
{...goToAlbumRest}
className={`${rowClass} ${goToAlbumClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<IcDisc size={20} />
<span className="text-base">{s.track_menu_go_to_album}</span>
</button>
<button
{...goToArtistRest}
className={`${rowClass} ${goToArtistClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<IcUser size={20} />
<span className="text-base">{s.track_menu_go_to_artist}</span>
</button>
<button
{...createJamMergedRest}
className={`${rowClass} justify-between ${createJamMergedClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<div className="flex items-center gap-4">
<IcZap size={20} />
<span className="text-base">{s.track_menu_create_jam}</span>
</div>
<span className="text-app-accent text-sm font-semibold">Premium</span>
</button>
<button
{...removeFromTasteRest}
className={`${rowClass} ${removeFromTasteClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<IcTrend size={20} />
<span className="text-base">{s.track_menu_remove_from_taste}</span>
</button>
{sleepTimerProps && (
<button {...sleepTimerRest} className={`${rowClass} ${sleepTimerClassName ?? ''}`} style={{ touchAction: 'manipulation' }}>
<IcTimer size={20} />
<span className="text-base">{s.track_menu_sleep_timer}</span>
</button>
)}
{!isPlaylistVariant && (
<button
{...goToSongRadioRest}
className={`${rowClass} ${goToSongRadioClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<IcRadio size={20} />
<span className="text-base">{s.track_menu_go_to_song_radio}</span>
</button>
)}
<button
{...viewProducersRest}
className={`${rowClass} ${viewProducersClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<IcUsers size={20} />
<span className="text-base">{s.track_menu_view_producers}</span>
</button>
<button
{...showSpotifyCodeRest}
className={`${rowClass} ${showSpotifyCodeClassName ?? ''}`}
style={{ touchAction: 'manipulation' }}
>
<IcQrCode size={20} />
<span className="text-base">{s.track_menu_show_spotify_code}</span>
</button>
</div>
</div>
</>
);
};