first commit
This commit is contained in:
209
src/component/Viewers/MusicPlayer/MusicPlayer.tsx
Executable file
209
src/component/Viewers/MusicPlayer/MusicPlayer.tsx
Executable file
@@ -0,0 +1,209 @@
|
||||
import { IconButton, Tooltip } from "@mui/material";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getFileEntityUrl } from "../../../api/api.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import SessionManager, { UserSettings } from "../../../session";
|
||||
import { getFileLinkedUri } from "../../../util";
|
||||
import MusicNote2 from "../../Icons/MusicNote2.tsx";
|
||||
import MusicNote2Play from "../../Icons/MusicNote2Play.tsx";
|
||||
import PlayerPopup from "./PlayerPopup.tsx";
|
||||
|
||||
export const LoopMode = {
|
||||
list_repeat: 0,
|
||||
single_repeat: 1,
|
||||
shuffle: 2,
|
||||
};
|
||||
|
||||
const MusicPlayer = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const playerState = useAppSelector((state) => state.globalState.musicPlayer);
|
||||
const audio = useRef<HTMLAudioElement>(null);
|
||||
const icon = useRef<HTMLButtonElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(0.2);
|
||||
const [index, setIndex] = useState<number | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [loopMode, setLoopMode] = useState(LoopMode.list_repeat);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||
const playHistory = useRef<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (playerState) {
|
||||
playHistory.current = [];
|
||||
setPlaying(true);
|
||||
setPopoverOpen(true);
|
||||
const volume = SessionManager.getWithFallback(UserSettings.MusicVolume);
|
||||
setVolume(volume);
|
||||
playIndex(playerState.startIndex, volume);
|
||||
}
|
||||
|
||||
audio.current?.addEventListener("timeupdate", timeUpdate);
|
||||
return () => {
|
||||
setPlaying(false);
|
||||
audio.current?.removeEventListener("timeupdate", timeUpdate);
|
||||
};
|
||||
}, [playerState]);
|
||||
|
||||
const playIndex = useCallback(
|
||||
async (index: number, latestVolume?: number) => {
|
||||
if (audio.current && playerState) {
|
||||
audio.current.pause();
|
||||
setIndex(index);
|
||||
try {
|
||||
const res = await dispatch(
|
||||
getFileEntityUrl({
|
||||
uris: [getFileLinkedUri(playerState.files[index])],
|
||||
entity: playerState.version,
|
||||
}),
|
||||
);
|
||||
audio.current.src = res.urls[0].url;
|
||||
audio.current.currentTime = 0;
|
||||
audio.current.play();
|
||||
audio.current.volume = latestVolume ?? volume;
|
||||
audio.current.playbackRate = playbackSpeed;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
[playerState, volume, playbackSpeed],
|
||||
);
|
||||
|
||||
const loopProceed = useCallback(
|
||||
(isNext: boolean) => {
|
||||
if (!playerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
playHistory.current.push(index ?? 0);
|
||||
|
||||
switch (loopMode) {
|
||||
case LoopMode.list_repeat:
|
||||
if (isNext) {
|
||||
playIndex(((index ?? 0) + 1) % playerState?.files.length);
|
||||
} else {
|
||||
playIndex(((index ?? 0) - 1 + playerState?.files.length) % playerState?.files.length);
|
||||
}
|
||||
break;
|
||||
case LoopMode.single_repeat:
|
||||
playIndex(index ?? 0);
|
||||
break;
|
||||
case LoopMode.shuffle:
|
||||
if (isNext) {
|
||||
const nextIndex = Math.floor(Math.random() * playerState?.files.length);
|
||||
playIndex(nextIndex);
|
||||
} else {
|
||||
playHistory.current.pop();
|
||||
playIndex(playHistory.current.pop() ?? index ?? 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[loopMode, playIndex, playerState, index],
|
||||
);
|
||||
|
||||
const onPlayEnded = useCallback(() => {
|
||||
loopProceed(true);
|
||||
}, []);
|
||||
|
||||
const timeUpdate = useCallback(() => {
|
||||
setCurrent(Math.floor(audio.current?.currentTime || 0));
|
||||
setDuration(Math.floor(audio.current?.duration || 0));
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((time: number) => {
|
||||
if (audio.current) {
|
||||
audio.current.currentTime = time;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const playingTooltip = playerState
|
||||
? `[${(index ?? 0) + 1}/${playerState.files.length}] ${playerState?.files[index ?? 0]?.name}`
|
||||
: "";
|
||||
|
||||
const onPlayerPopoverClose = useCallback(() => {
|
||||
setPopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
const onPlayerPopoverOpen = useCallback(() => {
|
||||
setPopoverOpen(true);
|
||||
}, []);
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
if (audio.current) {
|
||||
if (audio.current.paused) {
|
||||
audio.current.play();
|
||||
setPlaying(true);
|
||||
} else {
|
||||
audio.current.pause();
|
||||
setPlaying(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setVolumeLevel = useCallback((volume: number) => {
|
||||
if (audio.current) {
|
||||
audio.current.volume = volume;
|
||||
setVolume(volume);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleLoopMode = useCallback(() => {
|
||||
setLoopMode((loopMode) => (loopMode + 1) % 3);
|
||||
}, []);
|
||||
|
||||
const setLoopModeHandler = useCallback((mode: number) => {
|
||||
setLoopMode(mode);
|
||||
}, []);
|
||||
|
||||
const setPlaybackSpeedHandler = useCallback((speed: number) => {
|
||||
setPlaybackSpeed(speed);
|
||||
if (audio.current) {
|
||||
audio.current.playbackRate = speed;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<audio
|
||||
ref={audio}
|
||||
onPause={() => setPlaying(false)}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onEnded={() => loopProceed(true)}
|
||||
/>
|
||||
<Tooltip title={playingTooltip} enterDelay={0}>
|
||||
<IconButton ref={icon} onClick={onPlayerPopoverOpen} size="large">
|
||||
{playing ? <MusicNote2Play /> : <MusicNote2 />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{index !== undefined && (
|
||||
<PlayerPopup
|
||||
playIndex={playIndex}
|
||||
loopProceed={loopProceed}
|
||||
file={playerState?.files[index]}
|
||||
duration={duration}
|
||||
current={current}
|
||||
open={popoverOpen}
|
||||
setVolumeLevel={setVolumeLevel}
|
||||
volume={volume}
|
||||
onSeek={seek}
|
||||
togglePause={togglePause}
|
||||
playing={playing}
|
||||
playlist={playerState?.files}
|
||||
loopMode={loopMode}
|
||||
toggleLoopMode={toggleLoopMode}
|
||||
setLoopMode={setLoopModeHandler}
|
||||
playbackSpeed={playbackSpeed}
|
||||
setPlaybackSpeed={setPlaybackSpeedHandler}
|
||||
anchorEl={icon.current}
|
||||
onClose={onPlayerPopoverClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MusicPlayer;
|
||||
478
src/component/Viewers/MusicPlayer/PlayerPopup.tsx
Executable file
478
src/component/Viewers/MusicPlayer/PlayerPopup.tsx
Executable file
@@ -0,0 +1,478 @@
|
||||
import {
|
||||
FastForwardRounded,
|
||||
FastRewindRounded,
|
||||
PauseRounded,
|
||||
PlayArrowRounded,
|
||||
VolumeDownRounded,
|
||||
VolumeUpRounded,
|
||||
} from "@mui/icons-material";
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Popover,
|
||||
PopoverProps,
|
||||
Slider,
|
||||
Stack,
|
||||
styled,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FileResponse, Metadata } from "../../../api/explorer.ts";
|
||||
import { useMediaSession } from "../../../hooks/useMediaSession";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { loadFileThumb } from "../../../redux/thunks/file.ts";
|
||||
import SessionManager, { UserSettings } from "../../../session";
|
||||
import { FileManagerIndex } from "../../FileManager/FileManager.tsx";
|
||||
import { MediaMetaElements } from "../../FileManager/Sidebar/MediaMetaCard.tsx";
|
||||
import AppsList from "../../Icons/AppsList.tsx";
|
||||
import ArrowRepeatAll from "../../Icons/ArrowRepeatAll.tsx";
|
||||
import ArrowRepeatOne from "../../Icons/ArrowRepeatOne.tsx";
|
||||
import ArrowShuffle from "../../Icons/ArrowShuffle.tsx";
|
||||
import MusicNote1 from "../../Icons/MusicNote1.tsx";
|
||||
import { LoopMode } from "./MusicPlayer.tsx";
|
||||
import Playlist from "./Playlist.tsx";
|
||||
import RepeatModePopover from "./RepeatModePopover.tsx";
|
||||
|
||||
const WallPaper = styled("div")({
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
top: 0,
|
||||
left: 0,
|
||||
overflow: "hidden",
|
||||
background: "linear-gradient(rgb(255, 38, 142) 0%, rgb(255, 105, 79) 100%)",
|
||||
transition: "all 500ms cubic-bezier(0.175, 0.885, 0.32, 1.275) 0s",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
width: "140%",
|
||||
height: "140%",
|
||||
position: "absolute",
|
||||
top: "-40%",
|
||||
right: "-50%",
|
||||
background: "radial-gradient(at center center, rgb(62, 79, 249) 0%, rgba(62, 79, 249, 0) 64%)",
|
||||
},
|
||||
"&::after": {
|
||||
content: '""',
|
||||
width: "140%",
|
||||
height: "140%",
|
||||
position: "absolute",
|
||||
bottom: "-50%",
|
||||
left: "-30%",
|
||||
background: "radial-gradient(at center center, rgb(247, 237, 225) 0%, rgba(247, 237, 225, 0) 70%)",
|
||||
transform: "rotate(30deg)",
|
||||
},
|
||||
});
|
||||
|
||||
const Widget = styled("div")(({ theme }) => ({
|
||||
padding: 16,
|
||||
width: 343,
|
||||
maxWidth: "100%",
|
||||
margin: "auto",
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
backgroundColor: theme.palette.mode === "dark" ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.4)",
|
||||
backdropFilter: "blur(40px)",
|
||||
}));
|
||||
|
||||
const CoverImage = styled("div")({
|
||||
width: 100,
|
||||
height: 100,
|
||||
objectFit: "cover",
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.08)",
|
||||
"& > img": {
|
||||
width: "100%",
|
||||
},
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
});
|
||||
|
||||
const TinyText = styled(Typography)({
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.38,
|
||||
fontWeight: 500,
|
||||
letterSpacing: 0.2,
|
||||
});
|
||||
|
||||
// Scrolling text component for long text
|
||||
const ScrollingText = ({ children, text, ...props }: { children: React.ReactNode; text: string }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [shouldScroll, setShouldScroll] = useState(false);
|
||||
const [animationDuration, setAnimationDuration] = useState(15);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
if (containerRef.current && contentRef.current) {
|
||||
const isOverflowing = contentRef.current.scrollWidth > containerRef.current.clientWidth;
|
||||
setShouldScroll(isOverflowing);
|
||||
|
||||
// Calculate animation duration based on text length
|
||||
if (isOverflowing) {
|
||||
const textLength = contentRef.current.scrollWidth;
|
||||
// Adjust speed based on text length (faster for longer text)
|
||||
const calculatedDuration = Math.max(5, Math.min(10, textLength / 15));
|
||||
setAnimationDuration(calculatedDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setShouldScroll(false);
|
||||
setTimeout(() => {
|
||||
checkOverflow();
|
||||
}, 1000);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{shouldScroll ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
animation: `marquee ${animationDuration}s linear infinite`,
|
||||
"@keyframes marquee": {
|
||||
"0%": { transform: "translateX(0%)" },
|
||||
"100%": { transform: "translateX(-100%)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box ref={contentRef} sx={{ whiteSpace: "nowrap", paddingRight: "50px" }}>
|
||||
{children}
|
||||
</Box>
|
||||
<Box sx={{ whiteSpace: "nowrap", paddingRight: "50px" }}>{children}</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box ref={contentRef}>{children}</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export interface PlayerPopupProps extends PopoverProps {
|
||||
file?: FileResponse;
|
||||
playlist?: FileResponse[];
|
||||
duration: number;
|
||||
current: number;
|
||||
onSeek: (time: number) => void;
|
||||
playing: boolean;
|
||||
togglePause: () => void;
|
||||
setVolumeLevel: (volume: number) => void;
|
||||
volume: number;
|
||||
loopProceed: (isNext: boolean) => void;
|
||||
loopMode: number;
|
||||
toggleLoopMode: () => void;
|
||||
setLoopMode: (mode: number) => void;
|
||||
playbackSpeed: number;
|
||||
setPlaybackSpeed: (speed: number) => void;
|
||||
playIndex: (index: number, volume?: number) => void;
|
||||
}
|
||||
|
||||
const isIOS = /iPad|iPhone/.test(navigator.userAgent);
|
||||
|
||||
export const PlayerPopup = ({
|
||||
file,
|
||||
duration,
|
||||
current,
|
||||
onSeek,
|
||||
playing,
|
||||
togglePause,
|
||||
volume,
|
||||
setVolumeLevel,
|
||||
loopMode,
|
||||
loopProceed,
|
||||
toggleLoopMode,
|
||||
setLoopMode,
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
playlist,
|
||||
playIndex,
|
||||
...rest
|
||||
}: PlayerPopupProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const dispatch = useAppDispatch();
|
||||
const [thumbSrc, setThumbSrc] = useState<string | null>(null);
|
||||
const [thumbBgLoaded, setThumbBgLoaded] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const seeking = useRef(false);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [repeatAnchorEl, setRepeatAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
function formatDuration(value: number) {
|
||||
const minute = Math.floor(value / 60);
|
||||
const secondLeft = value - minute * 60;
|
||||
return `${minute}:${secondLeft < 10 ? `0${secondLeft}` : secondLeft}`;
|
||||
}
|
||||
const mainIconColor = theme.palette.mode === "dark" ? "#fff" : "#000";
|
||||
const lightIconColor = theme.palette.mode === "dark" ? "rgba(255,255,255,0.4)" : "rgba(0,0,0,0.4)";
|
||||
|
||||
useEffect(() => {
|
||||
setThumbBgLoaded(false);
|
||||
if (file && (!file.metadata || file.metadata[Metadata.thumbDisabled] === undefined)) {
|
||||
dispatch(loadFileThumb(FileManagerIndex.main, file)).then((src) => {
|
||||
setThumbSrc(src);
|
||||
});
|
||||
} else {
|
||||
setThumbSrc(null);
|
||||
}
|
||||
}, [file, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (seeking.current) {
|
||||
return;
|
||||
}
|
||||
setProgress(current);
|
||||
}, [current]);
|
||||
|
||||
const onSeekCommit = (time: number) => {
|
||||
seeking.current = false;
|
||||
onSeek(time);
|
||||
};
|
||||
|
||||
// Initialize Media Session API
|
||||
useMediaSession({
|
||||
file,
|
||||
playing,
|
||||
duration,
|
||||
current,
|
||||
thumbSrc,
|
||||
onPlay: togglePause,
|
||||
onPause: togglePause,
|
||||
onPrevious: () => loopProceed(false),
|
||||
onNext: () => loopProceed(true),
|
||||
onSeek,
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{playlist && file && (
|
||||
<Playlist
|
||||
playIndex={playIndex}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
file={file}
|
||||
playlist={playlist}
|
||||
/>
|
||||
)}
|
||||
<RepeatModePopover
|
||||
open={Boolean(repeatAnchorEl)}
|
||||
anchorEl={repeatAnchorEl}
|
||||
onClose={() => setRepeatAnchorEl(null)}
|
||||
loopMode={loopMode}
|
||||
onLoopModeChange={setLoopMode}
|
||||
playbackSpeed={playbackSpeed}
|
||||
onPlaybackSpeedChange={setPlaybackSpeed}
|
||||
/>
|
||||
<Widget>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<CoverImage>
|
||||
{!thumbSrc && <MusicNote1 fontSize={"large"} />}
|
||||
{thumbSrc && <img src={thumbSrc} onError={() => setThumbSrc(null)} alt="cover" />}
|
||||
</CoverImage>
|
||||
<Box sx={{ ml: 1.5, minWidth: 0, maxWidth: "210px", width: "100%" }}>
|
||||
{file && file.metadata && file.metadata[Metadata.music_artist] && (
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||
<MediaMetaElements
|
||||
element={{
|
||||
display: file.metadata[Metadata.music_artist],
|
||||
searchValue: file.metadata[Metadata.music_artist],
|
||||
searchKey: Metadata.music_artist,
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
{file && (
|
||||
<ScrollingText text={file.metadata?.[Metadata.music_title] ?? file.name}>
|
||||
<b>
|
||||
{file.metadata?.[Metadata.music_title] ? (
|
||||
<MediaMetaElements
|
||||
element={{
|
||||
display: file.metadata[Metadata.music_title],
|
||||
searchValue: file.metadata[Metadata.music_title],
|
||||
searchKey: Metadata.music_title,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
file.name
|
||||
)}
|
||||
</b>
|
||||
</ScrollingText>
|
||||
)}
|
||||
{file && file.metadata && file.metadata[Metadata.music_album] && (
|
||||
<ScrollingText text={file.metadata[Metadata.music_album]}>
|
||||
<Typography variant={"body2"} letterSpacing={-0.25}>
|
||||
<MediaMetaElements
|
||||
element={{
|
||||
display: file.metadata[Metadata.music_album],
|
||||
searchValue: file.metadata[Metadata.music_album],
|
||||
searchKey: Metadata.music_album,
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</ScrollingText>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Slider
|
||||
aria-label="time-indicator"
|
||||
size="small"
|
||||
value={progress}
|
||||
onMouseDown={() => (seeking.current = true)}
|
||||
min={0}
|
||||
step={1}
|
||||
max={duration}
|
||||
onChange={(_, value) => setProgress(value as number)}
|
||||
onChangeCommitted={(_, value) => onSeekCommit(value as number)}
|
||||
sx={{
|
||||
color: theme.palette.mode === "dark" ? "#fff" : "rgba(0,0,0,0.87)",
|
||||
height: 4,
|
||||
"& .MuiSlider-thumb": {
|
||||
width: 8,
|
||||
height: 8,
|
||||
transition: "0.3s cubic-bezier(.47,1.64,.41,.8)",
|
||||
"&::before": {
|
||||
boxShadow: "0 2px 12px 0 rgba(0,0,0,0.4)",
|
||||
},
|
||||
"&:hover, &.Mui-focusVisible": {
|
||||
boxShadow: `0px 0px 0px 8px ${
|
||||
theme.palette.mode === "dark" ? "rgb(255 255 255 / 16%)" : "rgb(0 0 0 / 16%)"
|
||||
}`,
|
||||
},
|
||||
"&.Mui-active": {
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
},
|
||||
"& .MuiSlider-rail": {
|
||||
opacity: 0.28,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mt: -2,
|
||||
}}
|
||||
>
|
||||
<TinyText>{formatDuration(current)}</TinyText>
|
||||
<TinyText>-{formatDuration(duration - current)}</TinyText>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mt: -1,
|
||||
}}
|
||||
>
|
||||
<IconButton aria-label="loop mode" onClick={(e) => setRepeatAnchorEl(e.currentTarget)}>
|
||||
{loopMode == LoopMode.list_repeat && <ArrowRepeatAll fontSize={"medium"} htmlColor={mainIconColor} />}
|
||||
{loopMode == LoopMode.single_repeat && <ArrowRepeatOne fontSize={"medium"} htmlColor={mainIconColor} />}
|
||||
{loopMode == LoopMode.shuffle && <ArrowShuffle fontSize={"medium"} htmlColor={mainIconColor} />}
|
||||
</IconButton>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<IconButton aria-label="previous song" onClick={() => loopProceed(false)}>
|
||||
<FastRewindRounded fontSize="large" htmlColor={mainIconColor} />
|
||||
</IconButton>
|
||||
<IconButton aria-label={!playing ? "play" : "pause"} onClick={togglePause}>
|
||||
{!playing ? (
|
||||
<PlayArrowRounded sx={{ fontSize: "3rem" }} htmlColor={mainIconColor} />
|
||||
) : (
|
||||
<PauseRounded sx={{ fontSize: "3rem" }} htmlColor={mainIconColor} />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton aria-label="next song" onClick={() => loopProceed(true)}>
|
||||
<FastForwardRounded fontSize="large" htmlColor={mainIconColor} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<IconButton aria-label="play list" onClick={(e) => setAnchorEl(e.currentTarget)}>
|
||||
<AppsList fontSize="medium" htmlColor={mainIconColor} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{!isIOS && (
|
||||
<Stack spacing={2} direction="row" sx={{ mb: 1, px: 1 }} alignItems="center">
|
||||
<VolumeDownRounded htmlColor={lightIconColor} />
|
||||
<Slider
|
||||
aria-label="Volume"
|
||||
value={volume}
|
||||
min={0}
|
||||
max={1}
|
||||
onChange={(_e, value) => setVolumeLevel(value as number)}
|
||||
onChangeCommitted={(_e, value) => SessionManager.set(UserSettings.MusicVolume, value as number)}
|
||||
step={0.01}
|
||||
sx={{
|
||||
color: theme.palette.mode === "dark" ? "#fff" : "rgba(0,0,0,0.87)",
|
||||
"& .MuiSlider-track": {
|
||||
border: "none",
|
||||
},
|
||||
"& .MuiSlider-thumb": {
|
||||
width: 24,
|
||||
height: 24,
|
||||
backgroundColor: "#fff",
|
||||
"&::before": {
|
||||
boxShadow: "0 4px 8px rgba(0,0,0,0.4)",
|
||||
},
|
||||
"&:hover, &.Mui-focusVisible, &.Mui-active": {
|
||||
boxShadow: "none",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<VolumeUpRounded htmlColor={lightIconColor} />
|
||||
</Stack>
|
||||
)}
|
||||
</Widget>
|
||||
{thumbSrc && (
|
||||
<Box
|
||||
component={"img"}
|
||||
onLoad={() => setThumbBgLoaded(true)}
|
||||
sx={{
|
||||
transition: "opacity 0.3s cubic-bezier(.47,1.64,.41,.8) 0s",
|
||||
opacity: thumbBgLoaded ? 1 : 0,
|
||||
position: "absolute",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
src={thumbSrc}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayerPopup;
|
||||
48
src/component/Viewers/MusicPlayer/Playlist.tsx
Executable file
48
src/component/Viewers/MusicPlayer/Playlist.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import { ListItemIcon, ListItemText, MenuProps } from "@mui/material";
|
||||
import { FileResponse } from "../../../api/explorer.ts";
|
||||
import { SquareMenu, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
|
||||
import FileIcon from "../../FileManager/Explorer/FileIcon.tsx";
|
||||
|
||||
export interface PlaylistProps extends MenuProps {
|
||||
file: FileResponse;
|
||||
playlist: FileResponse[];
|
||||
playIndex: (index: number, volume?: number) => void;
|
||||
}
|
||||
|
||||
const Playlist = ({ file, playlist, playIndex, onClose, ...rest }: PlaylistProps) => {
|
||||
return (
|
||||
<SquareMenu
|
||||
MenuListProps={{
|
||||
dense: true,
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
onClose={onClose}
|
||||
{...rest}
|
||||
>
|
||||
{playlist.map((item, index) => (
|
||||
<SquareMenuItem key={item.id} onClick={() => playIndex(index)} selected={item.path == file.path}>
|
||||
<ListItemIcon>
|
||||
<FileIcon
|
||||
sx={{ px: 0, py: 0, height: "20px" }}
|
||||
file={item}
|
||||
variant={"small"}
|
||||
iconProps={{
|
||||
fontSize: "small",
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText>{item.name}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</SquareMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playlist;
|
||||
145
src/component/Viewers/MusicPlayer/RepeatModePopover.tsx
Executable file
145
src/component/Viewers/MusicPlayer/RepeatModePopover.tsx
Executable file
@@ -0,0 +1,145 @@
|
||||
import { Box, Divider, Popover, ToggleButton, ToggleButtonGroup, Typography, styled } from "@mui/material";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowRepeatAll from "../../Icons/ArrowRepeatAll.tsx";
|
||||
import ArrowRepeatOne from "../../Icons/ArrowRepeatOne.tsx";
|
||||
import ArrowShuffle from "../../Icons/ArrowShuffle.tsx";
|
||||
import { LoopMode } from "./MusicPlayer.tsx";
|
||||
|
||||
interface RepeatModePopoverProps {
|
||||
open?: boolean;
|
||||
anchorEl?: HTMLElement | null;
|
||||
onClose?: () => void;
|
||||
loopMode: number;
|
||||
onLoopModeChange: (mode: number) => void;
|
||||
playbackSpeed: number;
|
||||
onPlaybackSpeedChange: (speed: number) => void;
|
||||
}
|
||||
|
||||
const NoWrapToggleButton = styled(ToggleButton)({
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
export const RepeatModePopover = ({
|
||||
open,
|
||||
anchorEl,
|
||||
onClose,
|
||||
loopMode,
|
||||
onLoopModeChange,
|
||||
playbackSpeed,
|
||||
onPlaybackSpeedChange,
|
||||
}: RepeatModePopoverProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentLoopMode = useMemo(() => {
|
||||
switch (loopMode) {
|
||||
case LoopMode.list_repeat:
|
||||
return "list_repeat";
|
||||
case LoopMode.single_repeat:
|
||||
return "single_repeat";
|
||||
case LoopMode.shuffle:
|
||||
return "shuffle";
|
||||
default:
|
||||
return "list_repeat";
|
||||
}
|
||||
}, [loopMode]);
|
||||
|
||||
const currentSpeed = useMemo(() => {
|
||||
return playbackSpeed.toString();
|
||||
}, [playbackSpeed]);
|
||||
|
||||
const handleLoopModeChange = (_event: React.MouseEvent<HTMLElement>, newMode: string) => {
|
||||
if (!newMode) return;
|
||||
|
||||
let newLoopMode: number;
|
||||
switch (newMode) {
|
||||
case "list_repeat":
|
||||
newLoopMode = LoopMode.list_repeat;
|
||||
break;
|
||||
case "single_repeat":
|
||||
newLoopMode = LoopMode.single_repeat;
|
||||
break;
|
||||
case "shuffle":
|
||||
newLoopMode = LoopMode.shuffle;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
onLoopModeChange(newLoopMode);
|
||||
};
|
||||
|
||||
const handleSpeedChange = (_event: React.MouseEvent<HTMLElement>, newSpeed: string) => {
|
||||
if (!newSpeed) return;
|
||||
const speed = parseFloat(newSpeed);
|
||||
if (!isNaN(speed)) {
|
||||
onPlaybackSpeedChange(speed);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!!open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "center",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, minWidth: 300 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
{t("fileManager.repeatMode")}
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
color="primary"
|
||||
value={currentLoopMode}
|
||||
exclusive
|
||||
onChange={handleLoopModeChange}
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<NoWrapToggleButton value="list_repeat">
|
||||
<ArrowRepeatAll fontSize="small" sx={{ mr: 1 }} />
|
||||
{t("fileManager.listRepeat")}
|
||||
</NoWrapToggleButton>
|
||||
<NoWrapToggleButton value="single_repeat">
|
||||
<ArrowRepeatOne fontSize="small" sx={{ mr: 1 }} />
|
||||
{t("fileManager.singleRepeat")}
|
||||
</NoWrapToggleButton>
|
||||
<NoWrapToggleButton value="shuffle">
|
||||
<ArrowShuffle fontSize="small" sx={{ mr: 1 }} />
|
||||
{t("fileManager.shuffle")}
|
||||
</NoWrapToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
{t("fileManager.playbackSpeed")}
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
color="primary"
|
||||
value={currentSpeed}
|
||||
exclusive
|
||||
onChange={handleSpeedChange}
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
<ToggleButton value="0.5">0.5×</ToggleButton>
|
||||
<ToggleButton value="0.75">0.75×</ToggleButton>
|
||||
<ToggleButton value="1">1×</ToggleButton>
|
||||
<ToggleButton value="1.25">1.25×</ToggleButton>
|
||||
<ToggleButton value="1.5">1.5×</ToggleButton>
|
||||
<ToggleButton value="2">2×</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default RepeatModePopover;
|
||||
Reference in New Issue
Block a user