This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
Files
RECS-WEB-MODLE-REACT/app/media-player/MediaPlayerExperience.tsx
2026-08-27 20:15:24 +08:00

327 lines
22 KiB
TypeScript
Raw 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.

"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;
}