purewhiter--mobilegym
255 行
10 KiB
TypeScript
255 行
10 KiB
TypeScript
import React, { useRef } from 'react';
|
|
import { useLocale } from '@/os/locale';
|
|
import { IcAdd, IcHeart, IcAddCircle, IcLikedIndicator } from '../res/icons';
|
|
import { useSpotifyStore, selectLikedSongIds } from '../state';
|
|
import type { SpotifyPlaylist, SpotifyTrack } from '../types';
|
|
import { useSpotifyGestures } from '../hooks/useSpotifyGestures';
|
|
import { useSpotifyStrings } from '../hooks/useSpotifyStrings';
|
|
import { localizeSpotifyText } from '../utils/localizeSpotifyText';
|
|
|
|
interface Props {
|
|
isOpen: boolean;
|
|
track: SpotifyTrack;
|
|
/** 与「加入歌单」同一套 UI;`saveLocation` 仅替换标题文案(如搜索页已保存曲目) */
|
|
titleVariant?: 'default' | 'saveLocation';
|
|
backdropProps?: React.HTMLAttributes<HTMLDivElement>;
|
|
onSelect: (name: string) => void;
|
|
onRemoveFromPlaylist?: (name: string) => void;
|
|
onLike?: () => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const AddToPlaylistSheet: React.FC<Props> = ({
|
|
isOpen, track, titleVariant = 'default', backdropProps, onSelect, onRemoveFromPlaylist, onLike, onClose,
|
|
}) => {
|
|
const dragStartY = useRef(0);
|
|
const barRef = useRef<HTMLDivElement>(null);
|
|
const { go } = useSpotifyGestures();
|
|
const locale = useLocale();
|
|
const isEnglish = locale === 'en';
|
|
const s = useSpotifyStrings();
|
|
|
|
// Drag state lives in refs so pointer callbacks stay stable
|
|
const isDraggingRef = useRef(false);
|
|
const dragOffsetRef = useRef(0);
|
|
const [renderDragOffset, setRenderDragOffset] = React.useState(0);
|
|
|
|
const DISMISS_THRESHOLD = 80;
|
|
|
|
// Subscribe to store directly for live updates
|
|
const customPlaylists = useSpotifyStore(s => s.customPlaylists);
|
|
const likedSongIds = useSpotifyStore(selectLikedSongIds);
|
|
const toggleLike = useSpotifyStore(s => s.toggleLike);
|
|
const addTrackToPlaylist = useSpotifyStore(s => s.addTrackToPlaylist);
|
|
const removeTrackFromPlaylist = useSpotifyStore(s => s.removeTrackFromPlaylist);
|
|
|
|
const displayText = (value: string | undefined) => localizeSpotifyText(value, isEnglish);
|
|
|
|
const trackLiked = likedSongIds.has(track.id, track);
|
|
|
|
const unique = (() => {
|
|
const s = new Set<string>();
|
|
return (customPlaylists || []).filter(pl => {
|
|
const k = (pl as any).title || pl.id;
|
|
if (s.has(k)) return false;
|
|
s.add(k);
|
|
return true;
|
|
}) as SpotifyPlaylist[];
|
|
})();
|
|
|
|
const checkInPlaylist = (title: string): boolean => {
|
|
const pl = (customPlaylists || []).find(p => (p as any).title === title) as any;
|
|
if (!pl) return false;
|
|
const ids: string[] = pl.trackIds || [];
|
|
const stored: any[] = pl.storedTracks || [];
|
|
const tN = track.title.trim().toLowerCase();
|
|
const aN = track.artist.trim().toLowerCase();
|
|
return ids.includes(track.id) || stored.some((t: any) => t.title?.trim().toLowerCase() === tN && t.artist?.trim().toLowerCase() === aN);
|
|
};
|
|
|
|
const containingPlaylists = unique.filter(pl => checkInPlaylist((pl as any).title));
|
|
const otherPlaylists = unique.filter(pl => !checkInPlaylist((pl as any).title));
|
|
|
|
const handlePlaylistClick = (pl: SpotifyPlaylist) => {
|
|
const title = (pl as any).title;
|
|
if (checkInPlaylist(title)) {
|
|
// Find the actual stored track ID (might differ from track.id due to title+artist match)
|
|
const plData = (customPlaylists || []).find(p => (p as any).title === title) as any;
|
|
const ids: string[] = plData?.trackIds || [];
|
|
const stored: any[] = plData?.storedTracks || [];
|
|
const tN = track.title.trim().toLowerCase();
|
|
const aN = track.artist.trim().toLowerCase();
|
|
// Remove by exact ID first, then by title+artist match
|
|
let idToRemove = ids.includes(track.id) ? track.id : '';
|
|
if (!idToRemove) {
|
|
const matched = stored.find((t: any) => t.title?.trim().toLowerCase() === tN && t.artist?.trim().toLowerCase() === aN);
|
|
if (matched) idToRemove = matched.id;
|
|
}
|
|
if (idToRemove) removeTrackFromPlaylist(title, idToRemove);
|
|
onRemoveFromPlaylist?.(title);
|
|
} else {
|
|
addTrackToPlaylist(title, track);
|
|
onSelect(title);
|
|
}
|
|
};
|
|
|
|
const handleCreateNew = () => {
|
|
useSpotifyStore.setState({ pendingPlaylistTrack: track });
|
|
go('create.naming.directOpen');
|
|
};
|
|
|
|
const handleLike = () => {
|
|
toggleLike(track);
|
|
onLike?.();
|
|
};
|
|
|
|
const onPointerDown = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
dragStartY.current = e.clientY;
|
|
isDraggingRef.current = true;
|
|
dragOffsetRef.current = 0;
|
|
barRef.current?.setPointerCapture(e.pointerId);
|
|
}, []);
|
|
|
|
const onPointerMove = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!isDraggingRef.current) return;
|
|
const dy = Math.max(0, e.clientY - dragStartY.current);
|
|
const maxOffset = typeof window !== 'undefined' ? window.innerHeight : 1000;
|
|
const clamped = Math.min(dy, maxOffset);
|
|
dragOffsetRef.current = clamped;
|
|
setRenderDragOffset(clamped);
|
|
}, []);
|
|
|
|
const onPointerUp = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (barRef.current?.hasPointerCapture(e.pointerId)) {
|
|
barRef.current.releasePointerCapture(e.pointerId);
|
|
}
|
|
if (!isDraggingRef.current) return;
|
|
isDraggingRef.current = false;
|
|
const offset = dragOffsetRef.current;
|
|
dragOffsetRef.current = 0;
|
|
setRenderDragOffset(0);
|
|
if (offset >= DISMISS_THRESHOLD) {
|
|
onClose();
|
|
}
|
|
}, [onClose]);
|
|
|
|
React.useEffect(() => {
|
|
if (isOpen) return;
|
|
isDraggingRef.current = false;
|
|
dragOffsetRef.current = 0;
|
|
setRenderDragOffset(0);
|
|
}, [isOpen]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const PlaylistRow = ({ pl, inPlaylist }: { pl: SpotifyPlaylist; inPlaylist: boolean }) => (
|
|
<button
|
|
onClick={() => handlePlaylistClick(pl)}
|
|
className={`w-full flex items-center justify-between py-3 active:bg-white/5 rounded px-2 text-white ${inPlaylist ? 'border-b border-gray-700/30' : ''}`}
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-14 h-14 rounded overflow-hidden bg-gray-700 flex-shrink-0">
|
|
{pl.cover ? <img src={pl.cover} alt={displayText((pl as any).title)} className="w-full h-full object-cover" /> : <div className="w-full h-full" />}
|
|
</div>
|
|
<div className="text-left">
|
|
<div className="text-base font-medium">{displayText((pl as any).title)}</div>
|
|
<div className="text-xs text-gray-400">{displayText((pl as any).subtitle) || s.add_to_playlist_default_subtitle}</div>
|
|
</div>
|
|
</div>
|
|
{inPlaylist
|
|
? <IcLikedIndicator size={28} />
|
|
: <IcAddCircle size={28} className="text-gray-400" strokeWidth={1.5} />
|
|
}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div {...backdropProps} className="fixed inset-0 bg-black/60 z-[70] backdrop-blur-sm transition-opacity" />
|
|
<div
|
|
className="fixed bottom-0 left-0 right-0 h-[90%] bg-app-surface z-[71] flex flex-col rounded-t-[20px] overflow-hidden shadow-2xl"
|
|
style={{
|
|
transform: `translateY(${renderDragOffset}px)`,
|
|
transition: !isDraggingRef.current ? 'transform var(--app-duration-short) var(--app-easing-decelerate)' : undefined,
|
|
}}
|
|
onPointerMove={onPointerMove}
|
|
onPointerLeave={e => e.buttons === 0 && onPointerUp(e)}
|
|
onPointerUp={onPointerUp}
|
|
onPointerCancel={onPointerUp}
|
|
>
|
|
{/* Drag handle */}
|
|
<div
|
|
ref={barRef}
|
|
className="w-full flex cursor-grab items-center justify-center pt-3 pb-2"
|
|
style={{ touchAction: 'none' }}
|
|
onPointerDown={onPointerDown}
|
|
>
|
|
<div className="w-10 h-1 bg-gray-600 rounded-full" />
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="px-6 pb-4 flex items-center justify-between">
|
|
<div className="text-xl font-bold text-white">
|
|
{titleVariant === 'saveLocation' ? s.save_location_title : s.add_to_playlist_title}
|
|
</div>
|
|
<button
|
|
onClick={handleCreateNew}
|
|
className="text-app-accent text-sm font-bold"
|
|
>
|
|
{isEnglish ? 'New playlist' : '新建歌单'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Playlist list */}
|
|
<div className="flex-1 overflow-y-auto px-6 pt-2 space-y-0">
|
|
{/* Playlists containing the track (on top) */}
|
|
{containingPlaylists.map(pl => (
|
|
<PlaylistRow key={pl.id} pl={pl} inPlaylist={true} />
|
|
))}
|
|
|
|
{containingPlaylists.length > 0 && (otherPlaylists.length > 0 || onLike) && (
|
|
<div className="border-b border-gray-700/50 my-1" />
|
|
)}
|
|
|
|
{/* Liked songs row */}
|
|
{onLike && (
|
|
<button
|
|
onClick={handleLike}
|
|
className="w-full flex items-center justify-between py-3 active:bg-white/5 rounded px-2 text-white"
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-14 h-14 rounded overflow-hidden flex-shrink-0 bg-gradient-to-br from-[#4a3fc7] to-[#7b68ee] flex items-center justify-center">
|
|
<IcHeart size={24} fill="white" className="text-white" />
|
|
</div>
|
|
<div className="text-left">
|
|
<div className="text-base font-medium">{s.liked_songs_title}</div>
|
|
</div>
|
|
</div>
|
|
{trackLiked
|
|
? <IcLikedIndicator size={28} />
|
|
: <IcAddCircle size={28} className="text-gray-400" strokeWidth={1.5} />
|
|
}
|
|
</button>
|
|
)}
|
|
|
|
{/* Other playlists */}
|
|
{otherPlaylists.map(pl => (
|
|
<PlaylistRow key={pl.id} pl={pl} inPlaylist={false} />
|
|
))}
|
|
|
|
{/* New playlist row */}
|
|
<button
|
|
onClick={handleCreateNew}
|
|
className="w-full flex items-center py-3 active:bg-white/5 rounded px-2 text-white gap-4"
|
|
>
|
|
<div className="w-14 h-14 rounded bg-[#2A2A2A] flex items-center justify-center flex-shrink-0">
|
|
<IcAdd size={28} className="text-gray-400" />
|
|
</div>
|
|
<div className="text-base font-medium">{isEnglish ? 'New playlist' : '新建歌单'}</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|