Update WBBR frontend template collection
This commit is contained in:
24
app/media-player/MediaLyrics.module.css
Normal file
24
app/media-player/MediaLyrics.module.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.musicHero{grid-template-columns:240px minmax(360px,630px) minmax(270px,330px);gap:38px}
|
||||
.lyricsPanel{position:relative;z-index:2;height:310px;display:flex;overflow:hidden;flex-direction:column;border:1px solid rgba(255,255,255,.1);border-radius:13px;background:rgba(8,11,20,.48);box-shadow:0 18px 45px rgba(0,0,0,.24);backdrop-filter:blur(15px)}
|
||||
.lyricsPanel>header{height:52px;display:flex;align-items:center;justify-content:space-between;padding:0 13px;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||
.lyricsPanel>header>div:first-child{display:flex;flex-direction:column;gap:2px}
|
||||
.lyricsPanel>header span{color:#8e72f4;font-size:7px;font-weight:800;letter-spacing:1.5px}
|
||||
.lyricsPanel>header strong{font-size:10px}
|
||||
.lyricVolume{display:flex;align-items:center;gap:4px}
|
||||
.lyricVolume button{width:25px;height:25px;border:0;border-radius:5px;background:transparent;color:#aeb4c3;cursor:pointer;font-size:9px}
|
||||
.lyricVolume input{width:58px;height:3px;accent-color:#8b6aff}
|
||||
.lyricsScroll{position:relative;flex:1;overflow-y:auto;padding:92px 12px;scrollbar-width:none;mask-image:linear-gradient(transparent,#000 18%,#000 82%,transparent)}
|
||||
.lyricsScroll::-webkit-scrollbar{display:none}
|
||||
.lyricsScroll button{width:100%;display:flex;align-items:center;flex-direction:column;gap:3px;padding:8px 5px;border:0;background:transparent;color:#626a7d;cursor:pointer;text-align:center;transition:color .22s,transform .22s,opacity .22s;opacity:.58}
|
||||
.lyricsScroll button:hover{color:#c4cada;opacity:.9}
|
||||
.lyricsScroll button strong{font-size:9px;font-weight:600}
|
||||
.lyricsScroll button small{color:#51596b;font-size:6px}
|
||||
.lyricsScroll .lyricActive{color:#fff;opacity:1;transform:scale(1.05)}
|
||||
.lyricsScroll .lyricActive strong{font-size:11px;text-shadow:0 0 15px rgba(139,106,255,.45)}
|
||||
.lyricsScroll .lyricActive small{color:#9c8bd9}
|
||||
.lyricsEmpty{display:grid;flex:1;place-content:center;justify-items:center;padding:20px;color:#626a7c;text-align:center}
|
||||
.lyricsEmpty>span{width:36px;height:36px;display:grid;place-items:center;border:1px solid #343a4c;border-radius:50%;font-size:13px}
|
||||
.lyricsEmpty strong{margin-top:10px;color:#9da4b4;font-size:9px}
|
||||
.lyricsEmpty small{max-width:190px;margin-top:5px;font-size:7px;line-height:1.6}
|
||||
@media(max-width:1050px){.musicHero{grid-template-columns:200px 1fr}.lyricsPanel{grid-column:1/-1;width:min(760px,100%);height:240px;margin:0 auto}.lyricsScroll{padding-top:65px;padding-bottom:65px}}
|
||||
@media(max-width:720px){.musicHero{grid-template-columns:1fr}.lyricsPanel{height:235px}.lyricVolume input{width:48px}}
|
||||
1
app/media-player/MediaPlayer.module.css
Normal file
1
app/media-player/MediaPlayer.module.css
Normal file
File diff suppressed because one or more lines are too long
326
app/media-player/MediaPlayerExperience.tsx
Normal file
326
app/media-player/MediaPlayerExperience.tsx
Normal 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;
|
||||
}
|
||||
17
app/media-player/MediaPlaylist.module.css
Normal file
17
app/media-player/MediaPlaylist.module.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.playlistActions{display:flex;align-items:flex-end;gap:8px}
|
||||
.playlistActions label{display:flex;flex-direction:column;gap:4px;color:#656d80;font-size:7px}
|
||||
.playlistActions select{height:32px;padding:0 27px 0 9px;border:1px solid #2b3140;border-radius:6px;background:#121722;color:#b6bdcc;cursor:pointer;font-size:8px}
|
||||
.playlistActions option{background:#121722}
|
||||
.playlistActions>button{height:32px;padding:0 11px;border:1px solid #2b3140;border-radius:6px;background:#121722;color:#858ea1;cursor:pointer;font-size:8px}
|
||||
.playlistActions>button:hover{border-color:#6e5acd;color:#fff}
|
||||
.videoMode{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;margin-top:7px}
|
||||
.videoMode i{color:#626a7d;font-size:7px;font-style:normal}
|
||||
.videoMode select{height:28px;padding:0 7px;border:1px solid #2b3140;border-radius:5px;background:#121722;color:#aeb5c5;font-size:7px}
|
||||
.videoMode option{background:#121722}
|
||||
.draggableVideo{grid-template-columns:15px 92px 1fr!important;cursor:grab!important}
|
||||
.draggableTrack{grid-template-columns:18px 35px 42px minmax(180px,1fr) minmax(140px,.7fr) 55px!important;cursor:grab!important}
|
||||
.draggableVideo:active,.draggableTrack:active{cursor:grabbing!important}
|
||||
.dragHandle{align-self:center;color:#4f576b;font-size:10px;font-style:normal;letter-spacing:-3px;text-align:center}
|
||||
.draggingItem{opacity:.42!important}
|
||||
.dropTarget{box-shadow:inset 0 2px 0 #8b6aff;background:rgba(139,106,255,.1)!important}
|
||||
@media(max-width:720px){.playlistActions{align-items:stretch;flex-direction:column}.playlistActions label{flex-direction:row;align-items:center;justify-content:space-between}.draggableTrack{grid-template-columns:16px 28px 38px 1fr 44px!important}.draggableTrack>span:nth-of-type(2){display:none}.draggableVideo{grid-template-columns:15px 84px 1fr!important}}
|
||||
5
app/media-player/page.tsx
Normal file
5
app/media-player/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import MediaPlayerExperience from "./MediaPlayerExperience";
|
||||
|
||||
export default function VideoPlayerPage() {
|
||||
return <MediaPlayerExperience mode="video" />;
|
||||
}
|
||||
Reference in New Issue
Block a user