327 lines
22 KiB
TypeScript
327 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { ChangeEvent, DragEvent, useEffect, useMemo, useRef, useState } from "react";
|
||
import mediaData from "../data/media-player.json";
|
||
import styles from "./MediaPlayer.module.css";
|
||
import lyricStyles from "./MediaLyrics.module.css";
|
||
import playlistStyles from "./MediaPlaylist.module.css";
|
||
|
||
type Mode = "video" | "music";
|
||
type PlayMode = "sequence" | "list-loop" | "single-loop" | "shuffle";
|
||
type VideoItem = (typeof mediaData.videos)[number] & { local?: boolean };
|
||
type TrackItem = (typeof mediaData.tracks)[number] & { local?: boolean };
|
||
|
||
export default function MediaPlayerExperience({ mode }: { mode: Mode }) {
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const audioRef = useRef<HTMLAudioElement>(null);
|
||
const videoShellRef = useRef<HTMLDivElement>(null);
|
||
const videoUploadRef = useRef<HTMLInputElement>(null);
|
||
const audioUploadRef = useRef<HTMLInputElement>(null);
|
||
const lyricsRef = useRef<HTMLDivElement>(null);
|
||
const localUrlsRef = useRef<string[]>([]);
|
||
const [videos, setVideos] = useState<VideoItem[]>(mediaData.videos);
|
||
const [currentVideoId, setCurrentVideoId] = useState(mediaData.videos[0].id);
|
||
const [tracks, setTracks] = useState<TrackItem[]>(mediaData.tracks);
|
||
const [trackIndex, setTrackIndex] = useState(0);
|
||
const [videoPlaying, setVideoPlaying] = useState(false);
|
||
const [musicPlaying, setMusicPlaying] = useState(false);
|
||
const [videoTime, setVideoTime] = useState(0);
|
||
const [videoDuration, setVideoDuration] = useState(0);
|
||
const [musicTime, setMusicTime] = useState(0);
|
||
const [musicDuration, setMusicDuration] = useState(0);
|
||
const [volume, setVolume] = useState(0.76);
|
||
const [muted, setMuted] = useState(false);
|
||
const [speed, setSpeed] = useState(1);
|
||
const [musicPlayMode, setMusicPlayMode] = useState<PlayMode>("sequence");
|
||
const [videoPlayMode, setVideoPlayMode] = useState<PlayMode>("sequence");
|
||
const [draggedTrackId, setDraggedTrackId] = useState<string | null>(null);
|
||
const [trackDropId, setTrackDropId] = useState<string | null>(null);
|
||
const [draggedVideoId, setDraggedVideoId] = useState<string | null>(null);
|
||
const [videoDropId, setVideoDropId] = useState<string | null>(null);
|
||
const [videoError, setVideoError] = useState(false);
|
||
const [audioError, setAudioError] = useState(false);
|
||
const [toast, setToast] = useState("");
|
||
|
||
const currentVideo = videos.find((video) => video.id === currentVideoId) ?? videos[0];
|
||
const currentTrack = tracks[trackIndex] ?? tracks[0];
|
||
const waveform = useMemo(() => Array.from({ length: 68 }, (_, index) => 18 + ((index * 29 + 17) % 70)), []);
|
||
const activeLyricIndex = useMemo(() => {
|
||
if (!currentTrack?.lyrics?.length) return -1;
|
||
let active = 0;
|
||
currentTrack.lyrics.forEach((line, index) => { if (line.time <= musicTime) active = index; });
|
||
return active;
|
||
}, [currentTrack, musicTime]);
|
||
|
||
useEffect(() => () => localUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []);
|
||
|
||
useEffect(() => {
|
||
if (videoRef.current) {
|
||
videoRef.current.volume = volume;
|
||
videoRef.current.muted = muted;
|
||
}
|
||
if (audioRef.current) {
|
||
audioRef.current.volume = volume;
|
||
audioRef.current.muted = muted;
|
||
}
|
||
}, [volume, muted]);
|
||
|
||
useEffect(() => {
|
||
setMusicTime(0);
|
||
setMusicDuration(0);
|
||
setAudioError(false);
|
||
}, [trackIndex]);
|
||
|
||
useEffect(() => {
|
||
const container = lyricsRef.current;
|
||
const line = container?.querySelector<HTMLElement>(`[data-lyric-index="${activeLyricIndex}"]`);
|
||
if (!container || !line) return;
|
||
container.scrollTo({ top: line.offsetTop - container.clientHeight / 2 + line.clientHeight / 2, behavior: "smooth" });
|
||
}, [activeLyricIndex]);
|
||
|
||
const notify = (message: string) => {
|
||
setToast(message);
|
||
window.setTimeout(() => setToast(""), 1800);
|
||
};
|
||
|
||
const goToMode = (next: Mode) => {
|
||
if (next === mode) return;
|
||
videoRef.current?.pause();
|
||
audioRef.current?.pause();
|
||
window.location.href = next === "video" ? "/media-player" : "/music-player";
|
||
};
|
||
|
||
const toggleVideo = async () => {
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
if (video.paused) {
|
||
try { await video.play(); } catch { notify("视频尚未加载,请选择本地视频重试"); }
|
||
} else video.pause();
|
||
};
|
||
|
||
const toggleMusic = async () => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
if (audio.paused) {
|
||
try { await audio.play(); } catch { notify("音乐尚未加载,请选择本地音乐重试"); }
|
||
} else audio.pause();
|
||
};
|
||
|
||
const changeVideoTime = (value: number) => {
|
||
if (!videoRef.current) return;
|
||
videoRef.current.currentTime = value;
|
||
setVideoTime(value);
|
||
};
|
||
|
||
const changeMusicTime = (value: number) => {
|
||
if (!audioRef.current) return;
|
||
audioRef.current.currentTime = value;
|
||
setMusicTime(value);
|
||
};
|
||
|
||
const changeVolume = (value: number) => {
|
||
setVolume(value);
|
||
setMuted(value === 0);
|
||
};
|
||
|
||
const toggleMute = () => setMuted((value) => !value);
|
||
|
||
const changeSpeed = (value: number) => {
|
||
setSpeed(value);
|
||
if (videoRef.current) videoRef.current.playbackRate = value;
|
||
};
|
||
|
||
const openPictureInPicture = async () => {
|
||
const video = videoRef.current;
|
||
if (!video || !document.pictureInPictureEnabled) return notify("当前浏览器不支持画中画");
|
||
try { await video.requestPictureInPicture(); } catch { notify("暂时无法进入画中画"); }
|
||
};
|
||
|
||
const openFullscreen = async () => {
|
||
try { await videoShellRef.current?.requestFullscreen(); } catch { notify("暂时无法进入全屏"); }
|
||
};
|
||
|
||
const selectTrack = async (index: number, autoPlay = true) => {
|
||
setTrackIndex(index);
|
||
setMusicPlaying(false);
|
||
if (autoPlay) window.setTimeout(() => audioRef.current?.play().catch(() => undefined), 30);
|
||
};
|
||
|
||
const nextTrack = () => {
|
||
const next = musicPlayMode === "shuffle" ? randomOtherIndex(tracks.length, trackIndex) : (trackIndex + 1) % tracks.length;
|
||
selectTrack(next);
|
||
};
|
||
|
||
const previousTrack = () => selectTrack((trackIndex - 1 + tracks.length) % tracks.length);
|
||
|
||
const handleTrackEnded = () => {
|
||
if (musicPlayMode === "single-loop" && audioRef.current) {
|
||
audioRef.current.currentTime = 0;
|
||
audioRef.current.play().catch(() => undefined);
|
||
} else if (musicPlayMode === "sequence" && trackIndex === tracks.length - 1) {
|
||
setMusicPlaying(false);
|
||
} else nextTrack();
|
||
};
|
||
|
||
const selectVideo = (video: VideoItem, autoPlay = false) => {
|
||
videoRef.current?.pause();
|
||
setCurrentVideoId(video.id);
|
||
setVideoTime(0);
|
||
setVideoDuration(0);
|
||
setVideoError(false);
|
||
setVideoPlaying(false);
|
||
if (autoPlay) window.setTimeout(() => videoRef.current?.play().catch(() => undefined), 30);
|
||
};
|
||
|
||
const handleVideoEnded = () => {
|
||
const currentIndex = videos.findIndex((video) => video.id === currentVideo.id);
|
||
if (videoPlayMode === "single-loop" && videoRef.current) {
|
||
videoRef.current.currentTime = 0;
|
||
videoRef.current.play().catch(() => undefined);
|
||
return;
|
||
}
|
||
if (videoPlayMode === "sequence" && currentIndex === videos.length - 1) {
|
||
setVideoPlaying(false);
|
||
return;
|
||
}
|
||
const nextIndex = videoPlayMode === "shuffle" ? randomOtherIndex(videos.length, currentIndex) : (currentIndex + 1) % videos.length;
|
||
selectVideo(videos[nextIndex], true);
|
||
};
|
||
|
||
const reorderTracks = (sourceId: string, targetId: string) => {
|
||
if (sourceId === targetId) return;
|
||
const activeId = currentTrack.id;
|
||
setTracks((current) => moveItem(current, sourceId, targetId));
|
||
setTrackIndex(moveItem(tracks, sourceId, targetId).findIndex((track) => track.id === activeId));
|
||
notify("歌单顺序已更新");
|
||
};
|
||
|
||
const reorderVideos = (sourceId: string, targetId: string) => {
|
||
if (sourceId === targetId) return;
|
||
setVideos((current) => moveItem(current, sourceId, targetId));
|
||
notify("视频顺序已更新");
|
||
};
|
||
|
||
const handleTrackDrop = (event: DragEvent<HTMLButtonElement>, targetId: string) => {
|
||
event.preventDefault();
|
||
const sourceId = event.dataTransfer.getData("text/plain") || draggedTrackId;
|
||
if (sourceId) reorderTracks(sourceId, targetId);
|
||
setDraggedTrackId(null);
|
||
setTrackDropId(null);
|
||
};
|
||
|
||
const handleVideoDrop = (event: DragEvent<HTMLButtonElement>, targetId: string) => {
|
||
event.preventDefault();
|
||
const sourceId = event.dataTransfer.getData("text/plain") || draggedVideoId;
|
||
if (sourceId) reorderVideos(sourceId, targetId);
|
||
setDraggedVideoId(null);
|
||
setVideoDropId(null);
|
||
};
|
||
|
||
const uploadVideo = (event: ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
const url = URL.createObjectURL(file);
|
||
localUrlsRef.current.push(url);
|
||
videoRef.current?.pause();
|
||
const video: VideoItem = { id: `local-video-${Date.now()}`, title: file.name.replace(/\.[^.]+$/, ""), subtitle: "本地视频文件", src: url, poster: "/anime/star-echoes-hero.png", durationLabel: "本地", quality: file.type.split("/")[1]?.toUpperCase() || "VIDEO", views: "刚刚添加", local: true };
|
||
setVideos((current) => [...current, video]);
|
||
setCurrentVideoId(video.id);
|
||
setVideoTime(0);
|
||
setVideoDuration(0);
|
||
setVideoError(false);
|
||
setVideoPlaying(false);
|
||
event.target.value = "";
|
||
notify("本地视频已载入");
|
||
};
|
||
|
||
const uploadTracks = (event: ChangeEvent<HTMLInputElement>) => {
|
||
const files = Array.from(event.target.files ?? []).filter((file) => file.type.startsWith("audio/"));
|
||
if (!files.length) return;
|
||
const additions = files.map<TrackItem>((file, index) => {
|
||
const url = URL.createObjectURL(file);
|
||
localUrlsRef.current.push(url);
|
||
return { id: `local-track-${Date.now()}-${index}`, title: file.name.replace(/\.[^.]+$/, ""), artist: "本地音乐", album: "本地播放列表", src: url, cover: "/anime/star-echoes-merch.png", position: "50% center", durationLabel: "本地", lyrics: [], local: true };
|
||
});
|
||
setTracks((current) => [...current, ...additions]);
|
||
setTrackIndex(tracks.length);
|
||
event.target.value = "";
|
||
notify(`已添加 ${additions.length} 首本地音乐`);
|
||
};
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<header className={styles.header}>
|
||
<Link className={styles.brand} href="/"><span>▶</span><div><strong>Orbit Player</strong><small>MEDIA EXPERIENCE</small></div></Link>
|
||
<div className={styles.modeTabs}><button className={mode === "video" ? styles.modeActive : ""} onClick={() => goToMode("video")}>▣ 视频播放器</button><button className={mode === "music" ? styles.modeActive : ""} onClick={() => goToMode("music")}>♫ 音乐播放器</button></div>
|
||
<div className={styles.headerActions}><span>HTML5 · CUSTOM UI</span><button onClick={() => mode === "video" ? videoUploadRef.current?.click() : audioUploadRef.current?.click()}>+ 添加本地{mode === "video" ? "视频" : "音乐"}</button><input ref={videoUploadRef} type="file" accept="video/*" onChange={uploadVideo} /><input ref={audioUploadRef} type="file" accept="audio/*" multiple onChange={uploadTracks} /></div>
|
||
</header>
|
||
|
||
{mode === "video" && <main className={styles.videoPage}>
|
||
<section className={styles.videoHeading}><div><p>VIDEO PLAYER</p><h1>{currentVideo.title}</h1><span>{currentVideo.subtitle}</span></div><div><b>{currentVideo.quality}</b><span>{currentVideo.views}</span></div></section>
|
||
<div className={styles.videoLayout}>
|
||
<div className={styles.videoColumn}>
|
||
<div ref={videoShellRef} className={styles.videoShell}>
|
||
<video key={currentVideo.src} ref={videoRef} src={currentVideo.src} poster={currentVideo.poster} playsInline onClick={toggleVideo} onPlay={() => setVideoPlaying(true)} onPause={() => setVideoPlaying(false)} onTimeUpdate={(event) => setVideoTime(event.currentTarget.currentTime)} onLoadedMetadata={(event) => { setVideoDuration(event.currentTarget.duration); event.currentTarget.playbackRate = speed; }} onEnded={handleVideoEnded} onError={() => setVideoError(true)} />
|
||
{!videoPlaying && !videoError && <button className={styles.centerPlay} onClick={toggleVideo} aria-label="播放视频">▶</button>}
|
||
{videoError && <div className={styles.mediaError}><span>!</span><strong>演示视频暂时无法加载</strong><p>可以点击下方按钮选择本地视频,播放器功能仍可完整使用。</p><button onClick={() => videoUploadRef.current?.click()}>选择本地视频</button></div>}
|
||
<div className={styles.videoShade} />
|
||
<div className={styles.videoControls}>
|
||
<input className={styles.progress} type="range" min="0" max={videoDuration || 100} step="0.01" value={Math.min(videoTime, videoDuration || 100)} onChange={(event) => changeVideoTime(Number(event.target.value))} aria-label="视频进度" />
|
||
<div><button onClick={toggleVideo} aria-label={videoPlaying ? "暂停" : "播放"}>{videoPlaying ? "Ⅱ" : "▶"}</button><button onClick={() => { if (videoRef.current) videoRef.current.currentTime -= 10; }} aria-label="后退十秒">↶ 10</button><span>{formatTime(videoTime)} / {formatTime(videoDuration)}</span><div className={styles.controlSpacer} /><button onClick={toggleMute} aria-label="静音">{muted ? "🔇" : "🔊"}</button><input className={styles.volume} type="range" min="0" max="1" step="0.01" value={muted ? 0 : volume} onChange={(event) => changeVolume(Number(event.target.value))} aria-label="音量" /><select value={speed} onChange={(event) => changeSpeed(Number(event.target.value))} aria-label="播放速度"><option value="0.75">0.75×</option><option value="1">1×</option><option value="1.25">1.25×</option><option value="1.5">1.5×</option><option value="2">2×</option></select><button onClick={openPictureInPicture} aria-label="画中画">▱</button><button onClick={openFullscreen} aria-label="全屏">⛶</button></div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.videoMeta}><div><span>{currentVideo.local ? "LOCAL FILE" : "DEMO VIDEO"}</span><h2>{currentVideo.title}</h2><p>{currentVideo.subtitle}。支持进度拖动、倍速、音量、画中画和全屏播放。</p></div><button onClick={() => videoUploadRef.current?.click()}>替换视频</button></div>
|
||
</div>
|
||
<aside className={styles.videoPlaylist}><div><span>UP NEXT</span><strong>播放列表</strong><small>{videos.length} 个视频</small><label className={playlistStyles.videoMode}><i>播放模式</i><select value={videoPlayMode} onChange={(event) => setVideoPlayMode(event.target.value as PlayMode)}>{playModeOptions.map((item) => <option value={item.id} key={item.id}>{item.icon} {item.label}</option>)}</select></label></div>{videos.map((video, index) => <button draggable key={video.id} className={`${playlistStyles.draggableVideo} ${currentVideo.id === video.id ? styles.videoItemActive : ""} ${draggedVideoId === video.id ? playlistStyles.draggingItem : ""} ${videoDropId === video.id ? playlistStyles.dropTarget : ""}`} onDragStart={(event) => { setDraggedVideoId(video.id); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", video.id); }} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; setVideoDropId(video.id); }} onDragLeave={() => setVideoDropId(null)} onDrop={(event) => handleVideoDrop(event, video.id)} onDragEnd={() => { setDraggedVideoId(null); setVideoDropId(null); }} onClick={() => selectVideo(video)}><em className={playlistStyles.dragHandle}>⋮⋮</em><span><img src={video.poster} alt="" /><i>{currentVideo.id === video.id && videoPlaying ? "Ⅱ" : "▶"}</i><b>{video.durationLabel}</b></span><p><strong>{video.title}</strong><small>{video.subtitle}</small><em>0{index + 1}</em></p></button>)}<button className={styles.addMedia} onClick={() => videoUploadRef.current?.click()}>+ 添加本地视频</button></aside>
|
||
</div>
|
||
</main>}
|
||
|
||
{mode === "music" && currentTrack && <main className={styles.musicPage}>
|
||
<audio key={currentTrack.id} ref={audioRef} src={currentTrack.src} onPlay={() => setMusicPlaying(true)} onPause={() => setMusicPlaying(false)} onTimeUpdate={(event) => setMusicTime(event.currentTarget.currentTime)} onLoadedMetadata={(event) => setMusicDuration(event.currentTarget.duration)} onEnded={handleTrackEnded} onError={() => setAudioError(true)} />
|
||
<section className={`${styles.musicHero} ${lyricStyles.musicHero}`} style={{ backgroundImage: `linear-gradient(110deg, rgba(8,12,25,.94), rgba(27,20,52,.75)), url(${currentTrack.cover})` }}>
|
||
<div className={`${styles.albumArt} ${musicPlaying ? styles.albumPlaying : ""}`}><img src={currentTrack.cover} alt={currentTrack.title} style={{ objectPosition: currentTrack.position }} /><i /></div>
|
||
<div className={styles.trackInfo}><p>NOW PLAYING</p><h1>{currentTrack.title}</h1><strong>{currentTrack.artist}</strong><span>{currentTrack.album}</span><div className={styles.waveform} aria-hidden="true">{waveform.map((height, index) => <i key={index} className={musicDuration && index / waveform.length <= musicTime / musicDuration ? styles.wavePlayed : ""} style={{ height: `${height}%` }} />)}</div><div className={styles.musicProgress}><span>{formatTime(musicTime)}</span><input type="range" min="0" max={musicDuration || 100} step="0.01" value={Math.min(musicTime, musicDuration || 100)} onChange={(event) => changeMusicTime(Number(event.target.value))} aria-label="音乐进度" /><span>{formatTime(musicDuration)}</span></div><div className={styles.musicControls}><button className={musicPlayMode === "shuffle" ? styles.controlActive : ""} onClick={() => setMusicPlayMode((value) => value === "shuffle" ? "sequence" : "shuffle")} aria-label="随机播放">⌘</button><button onClick={previousTrack} aria-label="上一首">◀</button><button className={styles.musicPlay} onClick={toggleMusic} aria-label={musicPlaying ? "暂停" : "播放"}>{musicPlaying ? "Ⅱ" : "▶"}</button><button onClick={nextTrack} aria-label="下一首">▶</button><button className={musicPlayMode === "single-loop" || musicPlayMode === "list-loop" ? styles.controlActive : ""} onClick={() => setMusicPlayMode((value) => value === "single-loop" ? "list-loop" : "single-loop")} aria-label="循环播放">↻</button></div>{audioError && <p className={styles.audioError}>演示音乐未能加载,可以选择本地音频继续体验播放器。</p>}</div>
|
||
<aside className={lyricStyles.lyricsPanel}>
|
||
<header><div><span>LYRICS</span><strong>同步歌词</strong></div><div className={lyricStyles.lyricVolume}><button onClick={toggleMute} aria-label="静音">{muted ? "🔇" : "🔊"}</button><input type="range" min="0" max="1" step="0.01" value={muted ? 0 : volume} onChange={(event) => changeVolume(Number(event.target.value))} aria-label="音乐音量" /></div></header>
|
||
{currentTrack.lyrics.length > 0 ? <div ref={lyricsRef} className={lyricStyles.lyricsScroll}>{currentTrack.lyrics.map((line, index) => <button key={`${currentTrack.id}-${line.time}`} data-lyric-index={index} className={index === activeLyricIndex ? lyricStyles.lyricActive : ""} onClick={() => changeMusicTime(line.time)}><strong>{line.text}</strong><small>{line.translation}</small></button>)}</div> : <div className={lyricStyles.lyricsEmpty}><span>♫</span><strong>暂无歌词</strong><small>为本地歌曲接入歌词数据后将在这里同步滚动。</small></div>}
|
||
</aside>
|
||
</section>
|
||
<section className={styles.trackSection}><div className={styles.trackHeading}><div><p>PLAYLIST</p><h2>Orbit Night Radio</h2><span>{tracks.length} 首音乐 · 拖动曲目调整播放顺序</span></div><div className={playlistStyles.playlistActions}><label><span>播放模式</span><select value={musicPlayMode} onChange={(event) => setMusicPlayMode(event.target.value as PlayMode)}>{playModeOptions.map((item) => <option value={item.id} key={item.id}>{item.icon} {item.label}</option>)}</select></label><button onClick={() => audioUploadRef.current?.click()}>+ 添加本地音乐</button></div></div><div className={styles.trackList}>{tracks.map((track, index) => <button draggable key={track.id} className={`${playlistStyles.draggableTrack} ${index === trackIndex ? styles.trackActive : ""} ${draggedTrackId === track.id ? playlistStyles.draggingItem : ""} ${trackDropId === track.id ? playlistStyles.dropTarget : ""}`} onDragStart={(event) => { setDraggedTrackId(track.id); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", track.id); }} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; setTrackDropId(track.id); }} onDragLeave={() => setTrackDropId(null)} onDrop={(event) => handleTrackDrop(event, track.id)} onDragEnd={() => { setDraggedTrackId(null); setTrackDropId(null); }} onClick={() => selectTrack(index)}><i className={playlistStyles.dragHandle}>⋮⋮</i><em>{index === trackIndex && musicPlaying ? "♫" : String(index + 1).padStart(2, "0")}</em><span className={styles.trackCover}><img src={track.cover} alt="" style={{ objectPosition: track.position }} /></span><p><strong>{track.title}</strong><small>{track.artist}</small></p><span>{track.album}</span><b>{track.durationLabel}</b></button>)}</div></section>
|
||
</main>}
|
||
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatTime(value: number) {
|
||
if (!Number.isFinite(value) || value <= 0) return "00:00";
|
||
const minutes = Math.floor(value / 60);
|
||
const seconds = Math.floor(value % 60);
|
||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||
}
|
||
|
||
const playModeOptions: { id: PlayMode; label: string; icon: string }[] = [
|
||
{ id: "sequence", label: "顺序播放", icon: "→" },
|
||
{ id: "list-loop", label: "列表循环", icon: "↻" },
|
||
{ id: "single-loop", label: "单个循环", icon: "↺" },
|
||
{ id: "shuffle", label: "随机播放", icon: "⌘" },
|
||
];
|
||
|
||
function moveItem<T extends { id: string }>(items: T[], sourceId: string, targetId: string) {
|
||
const sourceIndex = items.findIndex((item) => item.id === sourceId);
|
||
const targetIndex = items.findIndex((item) => item.id === targetId);
|
||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return items;
|
||
const next = [...items];
|
||
const [moved] = next.splice(sourceIndex, 1);
|
||
next.splice(targetIndex, 0, moved);
|
||
return next;
|
||
}
|
||
|
||
function randomOtherIndex(length: number, current: number) {
|
||
if (length <= 1) return current;
|
||
let next = current;
|
||
while (next === current) next = Math.floor(Math.random() * length);
|
||
return next;
|
||
}
|