first commit

This commit is contained in:
2025-10-19 13:31:11 +00:00
commit 8bfc183b66
1248 changed files with 195992 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;