Files
leonpan-assets/src/component/Viewers/MusicPlayer/PlayerPopup.tsx
2025-10-19 13:31:11 +00:00

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;