Skip to content

Commit

Permalink
feat: Add AudioViewer & player progress bar
Browse files Browse the repository at this point in the history
  • Loading branch information
NriotHrreion committed Aug 1, 2024
1 parent 84668e8 commit 85bfb88
Show file tree
Hide file tree
Showing 11 changed files with 505 additions and 5 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
npminstall-debug.log

# local env files
.env
Expand Down
3 changes: 3 additions & 0 deletions app/(pages)/explorer/viewer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ import { storage } from "@/lib/storage";
import TextViewer from "@/components/viewers/text-viewer";
import ImageViewer from "@/components/viewers/image-viewer";
import VideoViewer from "@/components/viewers/video-viewer";
import AudioViewer from "@/components/viewers/audio-viewer";

function getViewer(type: string): typeof React.Component<ViewerProps> {
switch(type) {
case "image":
return ImageViewer;
case "audio":
return AudioViewer;
case "video":
return VideoViewer;
case "text":
Expand Down
125 changes: 125 additions & 0 deletions components/player-progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"use client";

import React, { useEffect, useState, useCallback } from "react";
import { Progress } from "@nextui-org/progress";
import { cn } from "@nextui-org/theme";

import { emitter } from "@/lib/emitter";
import { getCurrentState } from "@/lib/utils";

function secondToTime(second: number): string {
const h = Math.floor(second / 3600);
const m = Math.floor((second - h * 3600) / 60);
const s = Math.floor(second - h * 3600 - m * 60);

return `${h < 10 ? ("0" + h) : h}:${m < 10 ? ("0" + m) : m}:${s < 10 ? ("0" + s) : s}`;
}

interface PlayerProgressProps {
duration: number
current: number
}

const PlayerProgress: React.FC<PlayerProgressProps> = (props) => {
const currentTime = secondToTime(props.current);
const duration = secondToTime(props.duration);
const percent = props.current / props.duration;

const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggingValue, setDraggingValue] = useState<number>(-1);

const handleProgressBarClick = (e: React.MouseEvent) => {
var progressBar = document.getElementById("progress-bar");

if(!progressBar) return;

var x = e.clientX - progressBar.offsetLeft;
var result = x / progressBar.clientWidth * 100;

emitter.emit("viewer:audio-player-time-change", result / 100 * props.duration);
};

useEffect(() => {
const controller = new AbortController();

window.addEventListener("mousemove", async (e) => {
if(!(await getCurrentState(setIsDragging))) return;

var progressBar = document.getElementById("progress-bar");

if(!progressBar) return;

var x = e.clientX - progressBar.offsetLeft;
var result = x / progressBar.clientWidth * 100;

if(result < 0) {
setDraggingValue(0);
} else if(result > 100) {
setDraggingValue(100);
} else {
setDraggingValue(result);
}
});

return () => controller.abort();
}, []);

useEffect(() => {
const controller = new AbortController();

window.addEventListener("mouseup", async () => {
if(!(await getCurrentState(setIsDragging))) return;

setIsDragging(false);
emitter.emit("viewer:audio-player-time-change", draggingValue / 100 * props.duration);
setDraggingValue(-1);
}, { signal: controller.signal });

return () => controller.abort();
}, [draggingValue]);

return (
<div className="flex flex-col gap-1">
<div className="flex justify-between *:text-sm *:text-default-400">
<span>{currentTime}</span>
<span>{duration}</span>
</div>

<div className="relative h-3 flex items-center" id="progress-bar">
<Progress
classNames={{
indicator: "bg-default-800"
}}
size="sm"
value={isDragging ? draggingValue : (percent * 100)}
color="default"
disableAnimation
onClick={(e) => handleProgressBarClick(e)}
aria-label="播放器进度条"/>

<div
className="h-3 absolute top-0 left-[-3px] flex flex-col items-center box-border"
style={{ left: "calc("+ (isDragging ? draggingValue : (percent * 100)) +"% - 3px)" }}>
<div
role="slider"
className="w-2 h-2 hover:w-3 hover:h-3 transition-all mt-[0.125rem] hover:mt-0 bg-default-800 rounded-full"
onMouseDown={(e) => {
setIsDragging(true);
setDraggingValue(percent * 100);

/** @see https://stackoverflow.com/questions/9506041/events-mouseup-not-firing-after-mousemove */
e.preventDefault();
}}
tabIndex={-1}
aria-valuenow={percent}/>

<div className={cn((isDragging ? "block" : "hidden"), "absolute top-3")}>
<span className="font-semibold">{secondToTime(draggingValue / 100 * props.duration)}</span>
</div>
</div>
</div>
</div>
);
}

export default PlayerProgress;
190 changes: 190 additions & 0 deletions components/viewers/audio-viewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"use client";

import React from "react";
import { type IAudioMetadata, parseBlob } from "music-metadata";
import { ReactSVG } from "react-svg";

import PlayerProgress from "../player-progress";

import Viewer, { ViewerProps } from ".";

import PlayIcon from "@/styles/icons/play.svg";
import PauseIcon from "@/styles/icons/pause.svg";
import { emitter } from "@/lib/emitter";

interface AudioViewerProps extends ViewerProps {}

interface AudioViewerState {
isLoading: boolean
value: string
metadata: IAudioMetadata | null

paused: boolean
duration: number
currentTime: number
}

export default class AudioViewer extends Viewer<AudioViewerProps, AudioViewerState> {
private audioRef = React.createRef<HTMLAudioElement>();
private blob: Blob | null = null;

private readonly eventController = new AbortController();

public constructor(props: AudioViewerProps) {
super(props, "音频播放器");

this.state = {
isLoading: true,
value: "", // audio url
metadata: null,
paused: true,
duration: 0,
currentTime: 0
};
}

private handlePlayButtonClick() {
if(!this.audioRef.current || this.state.isLoading) {
this.setState({ paused: true });

return;
}

if(this.audioRef.current.paused) {
this.setState({ paused: false });
this.audioRef.current.play();
} else {
this.setState({ paused: true });
this.audioRef.current.pause();
}
}

public render(): React.ReactNode {
return (
<div className="w-full h-full flex justify-center pt-44">
<div className="w-[44rem] h-72 flex gap-10">
<style>
{`
#banner:hover #player-button {
display: flex;
}
`}
</style>

<div id="banner" className="w-72 h-72 relative">
{
(this.state.metadata && this.state.metadata.common.picture)
? <img
className="w-full h-full rounded-lg"
src={
URL.createObjectURL(new Blob([this.state.metadata?.common.picture[0].data.buffer]))
}
alt="专辑封面"/>
: (
<div className="w-full h-full flex justify-center items-center bg-default-100 rounded-lg">
<span className="text-4xl font-bold text-default-400">{this.props.fileName.split(".").findLast(() => true)?.toUpperCase()}</span>
</div>
)
}

{
!this.state.isLoading && (
<button
id="player-button"
className="hidden absolute top-0 left-0 right-0 bottom-0 justify-center items-center z-10"
onClick={() => this.handlePlayButtonClick()}>
<ReactSVG
src={this.state.paused ? PlayIcon["src"] : PauseIcon["src"]}
className="w-14 h-14 [&_svg]:w-full [&_svg]:h-full"/>
</button>
)
}
</div>

<div className="flex-1 flex flex-col justify-between">
<div className="flex flex-col gap-2">
<span className="text-2xl font-semibold">
{
(this.state.metadata && this.state.metadata.common.title)
? this.state.metadata.common.title
: this.props.fileName
}
</span>
<span className="text-md text-default-400">
{
((this.state.metadata && this.state.metadata.common.artists) ? this.state.metadata.common.artists?.join(", ") : "未知艺术家")
+" - "
+ ((this.state.metadata && this.state.metadata.common.album) ? this.state.metadata.common.album : "未知专辑")
}
</span>
<span className="text-md text-default-400">
{
((this.state.metadata && this.state.metadata.common.year) ? this.state.metadata.common.year : "未知年份")
+" "
+ ((this.state.metadata && this.state.metadata.common.genre) ? this.state.metadata.common.genre?.join(", ") : "未知流派")
}
</span>
<span className="text-sm text-default-400">{this.props.path}</span>
</div>

<div className="pb-5">
<audio
src={this.state.value}
onDurationChange={(e) => this.setState({ duration: e.currentTarget.duration })}
onTimeUpdate={(e) => this.setState({ currentTime: e.currentTarget.currentTime })}
onEnded={() => this.setState({ paused: true })}
ref={this.audioRef}>
<track kind="captions"/>
</audio>
<PlayerProgress
duration={this.state.duration}
current={this.state.currentTime}/>
</div>
</div>
</div>
</div>
);
}

public async componentDidMount() {
this.blob = new Blob([Buffer.from(await this.loadFile())]);
this.setState({ isLoading: false });

const metadata = await parseBlob(this.blob);

if(!metadata) return;
this.setState({ metadata });

this.setState({ value: URL.createObjectURL(this.blob) });
this.initEvents();
}

public componentWillUnmount() {
if(!this.blob) return;

URL.revokeObjectURL(this.state.value);
this.blob = null;
this.setState({ value: "" });

this.eventController.abort();
emitter.removeAllListeners("viewer:audio-player-time-change");
}

private initEvents() {
document.body.addEventListener("keydown", (e) => {
if(e.code === "Space") {
this.handlePlayButtonClick();

/** @see https://www.codeproject.com/Questions/1044876/one-enter-keypress-runs-event-handler-twice */
e.stopImmediatePropagation();
}
}, { signal: this.eventController.signal });

emitter.on("viewer:audio-player-time-change", (time: number) => {
if(!this.audioRef.current) return;

this.audioRef.current.currentTime = time;
this.setState({ currentTime: time });
});
}
}
5 changes: 5 additions & 0 deletions components/viewers/image-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,9 @@ export default class ImageViewer extends Viewer<ImageViewerProps, ImageViewerSta
public async componentDidMount() {
this.setState({ value: URL.createObjectURL(new Blob([Buffer.from(await this.loadFile())])) });
}

public componentWillUnmount() {
URL.revokeObjectURL(this.state.value);
this.setState({ value: "" });
}
}
5 changes: 5 additions & 0 deletions components/viewers/video-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,9 @@ export default class VideoViewer extends Viewer<VideoViewerProps, VideoViewerSta
/** @todo */
// this.setState({ value: URL.createObjectURL(new Blob([Buffer.from(await this.loadFile())])) });
}

public componentWillUnmount() {
URL.revokeObjectURL(this.state.value);
this.setState({ value: "" });
}
}
Loading

0 comments on commit 85bfb88

Please sign in to comment.