TeaWeb/shared/js/ui/frames/side/MusicBotRenderer.tsx

450 lines
No EOL
18 KiB
TypeScript

import {Registry} from "tc-shared/events";
import {MusicPlaylistUiEvents} from "tc-shared/ui/frames/side/MusicPlaylistDefinitions";
import {DefaultThumbnail, formatPlaytime, MusicPlaylistList} from "tc-shared/ui/frames/side/MusicPlaylistRenderer";
import * as React from "react";
import {useContext, useEffect, useRef, useState} from "react";
import {
MusicBotPlayerState,
MusicBotPlayerTimestamp,
MusicBotSongInfo,
MusicBotUiEvents
} from "tc-shared/ui/frames/side/MusicBotDefinitions";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {preview_image} from "tc-shared/ui/frames/image_preview";
import {Slider} from "tc-shared/ui/react-elements/Slider";
const cssStyle = require("./MusicBotRenderer.scss");
const EventContext = React.createContext<Registry<MusicBotUiEvents>>(undefined);
const SongInfoContext = React.createContext<MusicBotSongInfo>(undefined);
const TimestampContext = React.createContext<MusicBotPlayerTimestamp & { seekOffset: number | undefined }>(undefined);
const ButtonRewind = () => (
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" xmlSpace="preserve">
<path transform="rotate(180, 256, 256)" d="M504.171,239.489l-234.667-192c-6.357-5.227-15.189-6.293-22.656-2.773c-7.424,3.541-12.181,11.051-12.181,19.285v146.987
L34.837,47.489c-6.379-5.227-15.189-6.293-22.656-2.773C4.757,48.257,0,55.767,0,64.001v384c0,8.235,4.757,15.744,12.181,19.285
c2.923,1.365,6.059,2.048,9.152,2.048c4.843,0,9.621-1.643,13.504-4.821l199.829-163.499v146.987
c0,8.235,4.757,15.744,12.181,19.285c2.923,1.365,6.059,2.048,9.152,2.048c4.843,0,9.621-1.643,13.504-4.821l234.667-192
c4.949-4.053,7.829-10.112,7.829-16.512S509.12,243.543,504.171,239.489z"/>
</svg>
);
const ButtonPlay = () => (
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" xmlSpace="preserve">
<path d="M500.203,236.907L30.869,2.24c-6.613-3.285-14.443-2.944-20.736,0.939C3.84,7.083,0,13.931,0,21.333v469.333
c0,7.403,3.84,14.251,10.133,18.155c3.413,2.112,7.296,3.179,11.2,3.179c3.264,0,6.528-0.747,9.536-2.24l469.333-234.667
C507.435,271.467,512,264.085,512,256S507.435,240.533,500.203,236.907z"/>
</svg>
);
const ButtonPause = () => (
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" xmlSpace="preserve">
<path transform='rotate(90, 256, 256)' d="M85.333,213.333h341.333C473.728,213.333,512,175.061,512,128s-38.272-85.333-85.333-85.333H85.333
C38.272,42.667,0,80.939,0,128S38.272,213.333,85.333,213.333z"/>
<path transform='rotate(90, 256, 256)' d="M426.667,298.667H85.333C38.272,298.667,0,336.939,0,384s38.272,85.333,85.333,85.333h341.333
C473.728,469.333,512,431.061,512,384S473.728,298.667,426.667,298.667z"/>
</svg>
);
const ButtonForward = () => (
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" xmlSpace="preserve">
<path d="M504.171,239.489l-234.667-192c-6.357-5.227-15.189-6.293-22.656-2.773c-7.424,3.541-12.181,11.051-12.181,19.285v146.987
L34.837,47.489c-6.379-5.227-15.189-6.293-22.656-2.773C4.757,48.257,0,55.767,0,64.001v384c0,8.235,4.757,15.744,12.181,19.285
c2.923,1.365,6.059,2.048,9.152,2.048c4.843,0,9.621-1.643,13.504-4.821l199.829-163.499v146.987
c0,8.235,4.757,15.744,12.181,19.285c2.923,1.365,6.059,2.048,9.152,2.048c4.843,0,9.621-1.643,13.504-4.821l234.667-192
c4.949-4.053,7.829-10.112,7.829-16.512S509.12,243.543,504.171,239.489z"/>
</svg>
);
const ButtonVolume = () => (
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="93.038px" height="93.038px" viewBox="0 0 93.038 93.038"
xmlSpace="preserve">
<path d="M46.547,75.521c0,1.639-0.947,3.128-2.429,3.823c-0.573,0.271-1.187,0.402-1.797,0.402c-0.966,0-1.923-0.332-2.696-0.973
l-23.098-19.14H4.225C1.892,59.635,0,57.742,0,55.409V38.576c0-2.334,1.892-4.226,4.225-4.226h12.303l23.098-19.14
c1.262-1.046,3.012-1.269,4.493-0.569c1.481,0.695,2.429,2.185,2.429,3.823L46.547,75.521L46.547,75.521z M62.784,68.919
c-0.103,0.007-0.202,0.011-0.304,0.011c-1.116,0-2.192-0.441-2.987-1.237l-0.565-0.567c-1.482-1.479-1.656-3.822-0.408-5.504
c3.164-4.266,4.834-9.323,4.834-14.628c0-5.706-1.896-11.058-5.484-15.478c-1.366-1.68-1.24-4.12,0.291-5.65l0.564-0.565
c0.844-0.844,1.975-1.304,3.199-1.231c1.192,0.06,2.305,0.621,3.061,1.545c4.977,6.09,7.606,13.484,7.606,21.38
c0,7.354-2.325,14.354-6.725,20.24C65.131,68.216,64.007,68.832,62.784,68.919z M80.252,81.976
c-0.764,0.903-1.869,1.445-3.052,1.495c-0.058,0.002-0.117,0.004-0.177,0.004c-1.119,0-2.193-0.442-2.988-1.237l-0.555-0.555
c-1.551-1.55-1.656-4.029-0.246-5.707c6.814-8.104,10.568-18.396,10.568-28.982c0-11.011-4.019-21.611-11.314-29.847
c-1.479-1.672-1.404-4.203,0.17-5.783l0.554-0.555c0.822-0.826,1.89-1.281,3.115-1.242c1.163,0.033,2.263,0.547,3.036,1.417
c8.818,9.928,13.675,22.718,13.675,36.01C93.04,59.783,88.499,72.207,80.252,81.976z"/>
</svg>
);
const SongInfoProvider = (props) => {
const events = useContext(EventContext);
const [ info, setInfo ] = useState<MusicBotSongInfo>(() => {
events.fire("query_song_info");
return { type: "none" };
});
events.reactUse("notify_song_info", event => setInfo(event.info));
return (
<SongInfoContext.Provider value={info}>
{props.children}
</SongInfoContext.Provider>
);
};
const PlayerTimestampProvider = (props) => {
const events = useContext(EventContext);
const [ timestamp, setTimestamp ] = useState<MusicBotPlayerTimestamp>(() => {
events.fire("query_player_timestamp");
return {
seekable: false,
bufferOffset: 0,
playOffset: 0,
base: 0,
total: 0,
};
});
const [ seekOffset, setSeekOffset ] = useState<number | undefined>(undefined);
events.reactUse("notify_player_timestamp", event => setTimestamp(event.timestamp), undefined, []);
events.reactUse("notify_player_seek_timestamp", event => {
if(event.applySeek && timestamp.base > 0 && typeof seekOffset === "number") {
timestamp.playOffset = seekOffset;
timestamp.bufferOffset += Date.now() - timestamp.base;
timestamp.base = Date.now();
}
setSeekOffset(event.offset);
}, undefined, [ seekOffset, timestamp ]);
return (
<TimestampContext.Provider value={Object.assign({ seekOffset: seekOffset }, timestamp)}>
{props.children}
</TimestampContext.Provider>
)
}
const Thumbnail = React.memo(() => {
const info = useContext(SongInfoContext);
let thumbnail;
switch (info.type) {
case "none":
thumbnail = <DefaultThumbnail type={"none-present"} key={"none"} />;
break;
case "loading":
thumbnail = <DefaultThumbnail type={"loading"} key={"loading"} />;
break;
case "song":
if(info.thumbnail) {
thumbnail = (
<img
draggable={false}
key={"song-thumbnail"}
src={info.thumbnail}
onClick={() => preview_image(info.thumbnail, info.thumbnail)}
alt={tr("Thumbnail")}
style={{ cursor: "pointer" }}
/>
);
} else {
thumbnail = <DefaultThumbnail type={"none-present"} key={"none"} />;
}
break;
}
return (
<div className={cssStyle.thumbnail}>
{thumbnail}
</div>
);
});
const Timestamps = () => {
const info = useContext(TimestampContext);
const [ revision, setRevision ] = useState(0);
useEffect(() => {
const id = setTimeout(() => setRevision(revision + 1), 990);
return () => clearTimeout(id);
});
let current: number;
if(info.seekable && typeof info.seekOffset === "number") {
current = info.seekOffset;
} else {
const timePassed = info.base > 0 ? Date.now() - info.base : 0;
current = info.playOffset + timePassed;
}
return (
<div className={cssStyle.timestamps}>
<div>{formatPlaytime(current)}</div>
<div>{formatPlaytime(info.total)}</div>
</div>
);
}
const Timeline = () => {
const events = useContext(EventContext);
const info = useContext(TimestampContext);
const refContainer = useRef<HTMLDivElement>();
const [ moveActive, setMoveActive ] = useState(false);
useEffect(() => {
if(!moveActive) {
return;
}
document.body.classList.add(cssStyle.bodySeek);
let currentSeekOffset;
const mouseMoveListener = (event: MouseEvent) => {
if(!refContainer.current) {
return;
}
const { x, width } = refContainer.current.getBoundingClientRect();
if(event.pageX <= x) {
events.fire("notify_player_seek_timestamp", { offset: currentSeekOffset = 0, applySeek: false });
} else if(event.pageX >= x + width) {
events.fire("notify_player_seek_timestamp", { offset: currentSeekOffset = info.total, applySeek: false });
} else {
events.fire("notify_player_seek_timestamp", { offset: currentSeekOffset = Math.floor((event.pageX - x) / width * info.total), applySeek: false });
}
};
const mouseUpListener = () => {
if(typeof currentSeekOffset === "number") {
events.fire("action_seek_to", { target: currentSeekOffset });
}
events.fire("notify_player_seek_timestamp", { offset: undefined, applySeek: true });
setMoveActive(false);
}
document.addEventListener("mousemove", mouseMoveListener);
document.addEventListener("mouseleave", mouseUpListener);
document.addEventListener("mouseup", mouseUpListener);
document.addEventListener("focusout", mouseUpListener);
return () => {
document.body.classList.remove(cssStyle.bodySeek);
document.removeEventListener("mousemove", mouseMoveListener);
document.removeEventListener("mouseleave", mouseUpListener);
document.removeEventListener("mouseup", mouseUpListener);
document.removeEventListener("focusout", mouseUpListener);
};
}, [ moveActive ]);
let current: number, buffered: number;
const timePassed = info.base > 0 ? Date.now() - info.base : 0;
if(info.seekable && typeof info.seekOffset === "number") {
current = info.seekOffset;
} else {
current = info.playOffset + timePassed;
}
buffered = info.bufferOffset + timePassed;
let widthBuffered = info.total === 0 ? 100 : (buffered / info.total) * 100;
let widthCurrent = info.total === 0 ? 100 : (current / info.total) * 100;
return (
<div className={cssStyle.timeline} ref={refContainer}>
<div className={cssStyle.indicator + " " + cssStyle.buffered} style={{ width: widthBuffered.toFixed(2) + "%"}} />
<div className={cssStyle.indicator + " " + cssStyle.playtime} style={{ width: widthCurrent.toFixed(2) + "%"}} />
<div
className={cssStyle.thumb}
style={{ marginLeft: widthCurrent.toFixed(2) + "%"}}
onMouseDown={() => info.seekable && info.total > 0 && setMoveActive(true)}
>
<div className={cssStyle.dot} />
</div>
</div>
);
}
const SongInfo = () => {
const info = useContext(SongInfoContext);
let name, nameTitle/*, description */;
switch (info.type) {
case "none":
name = <Translatable key={"no-song"}>No song selected</Translatable>;
break;
case "song":
name = info.title || info.url;
nameTitle = name;
/* description = info.description; */
break;
case "loading":
name = info.url;
nameTitle = name;
break;
}
return (
<div className={cssStyle.containerSongInfo}>
<a className={cssStyle.songName} title={nameTitle}>{name}</a>
{/* <a className={cssStyle.songDescription}>{description}</a> */}
</div>
);
}
const ControlButtons = () => {
const events = useContext(EventContext);
const [ playerState, setPlayerState ] = useState<MusicBotPlayerState>(() => {
events.fire("query_player_state");
return "paused";
});
events.reactUse("notify_player_state", event => setPlayerState(event.state));
let playButton;
if(playerState === "paused") {
playButton = (
<div className={cssStyle.controlButton} key={"play"} onClick={() => events.fire("action_player_action", { action: "play" })}>
<ButtonPlay />
</div>
);
} else {
playButton = (
<div className={cssStyle.controlButton} key={"pause"} onClick={() => events.fire("action_player_action", { action: "pause" })}>
<ButtonPause />
</div>
);
}
return (
<div className={cssStyle.controlButtons}>
<div className={cssStyle.controlButton} onClick={() => events.fire("action_player_action", { action: "rewind" })}>
<ButtonRewind />
</div>
{playButton}
<div className={cssStyle.controlButton} onClick={() => events.fire("action_player_action", { action: "forward" })}>
<ButtonForward />
</div>
</div>
);
}
const VolumeSlider = (props: { mode: "local" | "remote", }) => {
const events = useContext(EventContext);
const refSlider = useRef<Slider>();
const [ value, setValue ] = useState(() => {
events.fire("query_volume", { mode: props.mode });
return 100;
});
events.reactUse("notify_volume", event => {
if(event.mode !== props.mode) {
return;
}
setValue(event.volume * 100);
if(!refSlider.current?.state.active) {
refSlider.current?.setState({ value: event.volume * 100 });
}
});
let name;
if(props.mode === "local") {
name = <Translatable key={"local"}>Local</Translatable>;
} else {
name = <Translatable key={"remote"}>Remote</Translatable>;
}
const valueString = (value: number) => {
if(value > 100) {
return "+" + (value - 100).toFixed(0);
} else if(value == 100) {
return "±0";
} else {
return "-" + (100 - value).toFixed(0);
}
}
return (
<div className={cssStyle.containerSlider}>
<div className={cssStyle.name}>{name} (<a>{valueString(value)}</a>%):</div>
<Slider
ref={refSlider}
minValue={0}
maxValue={200}
stepSize={1}
value={value}
className={cssStyle.slider}
onInput={value => {
setValue(value);
if(props.mode === "local") {
events.fire("action_change_volume", { mode: props.mode, volume: value / 100 });
}
}}
onChange={value => {
setValue(value);
events.fire("action_change_volume", { mode: props.mode, volume: value / 100 });
}}
tooltip={value => valueString(value) + "%"}
/>
</div>
);
}
const VolumeSetting = () => {
const events = useContext(EventContext);
const [ expended, setExpended ] = useState(false);
events.reactUse("notify_bot_changed", () => setExpended(false));
return (
<div className={cssStyle.volumeOverlay + " " + (expended ? cssStyle.expended : "")}>
<div className={cssStyle.controlButton} onClick={() => setExpended(!expended)}>
<ButtonVolume />
</div>
<div className={cssStyle.content}>
<VolumeSlider mode={"local"} />
<VolumeSlider mode={"remote"} />
</div>
</div>
);
}
const MusicBotPlayer = () => {
return (
<div className={cssStyle.player}>
<SongInfoProvider>
<div className={cssStyle.containerThumbnail}>
<Thumbnail />
</div>
<SongInfo />
</SongInfoProvider>
<PlayerTimestampProvider>
<div className={cssStyle.containerTimeline}>
<Timestamps />
<Timeline />
</div>
</PlayerTimestampProvider>
<div className={cssStyle.containerControl}>
<ControlButtons />
<VolumeSetting />
</div>
</div>
);
}
export const MusicBotRenderer = (props: {
botEvents: Registry<MusicBotUiEvents>,
playlistEvents: Registry<MusicPlaylistUiEvents>
}) => {
return (
<EventContext.Provider value={props.botEvents}>
<div className={cssStyle.container} draggable={false}>
<MusicBotPlayer />
<MusicPlaylistList events={props.playlistEvents} className={cssStyle.playlist} />
</div>
</EventContext.Provider>
);
}