479 lines
15 KiB
TypeScript
Executable File
479 lines
15 KiB
TypeScript
Executable File
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;
|