Update WBBR frontend template collection

This commit is contained in:
Codex
2026-08-27 20:15:24 +08:00
parent bd4d54a110
commit e444605e5d
85 changed files with 6322 additions and 115 deletions

View File

@@ -0,0 +1,326 @@
"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;
}