feat(music player): media session API & playback speed control (cloudreve/cloudreve#2629)

This commit is contained in:
Aaron Liu 2025-08-21 13:56:05 +08:00
parent 778518ed9f
commit a710dbb2f4
8 changed files with 423 additions and 12 deletions

View File

@ -347,6 +347,11 @@
"subtitle": "Subtitles",
"playlist": "Playlist",
"openInExternalPlayer": "Open in external player",
"repeatMode": "Repeat Mode",
"listRepeat": "List Repeat",
"singleRepeat": "Single Repeat",
"shuffle": "Shuffle",
"playbackSpeed": "Playback Speed",
"searchResult": "Search Results",
"preparingBathDownload": "Preparing batch download...",
"preparingDownload": "Preparing to download...",

View File

@ -347,6 +347,11 @@
"subtitle": "字幕選択",
"playlist": "プレイリスト",
"openInExternalPlayer": "外部プレーヤーで開く",
"repeatMode": "リピートモード",
"listRepeat": "リストリピート",
"singleRepeat": "シングルリピート",
"shuffle": "シャッフル",
"playbackSpeed": "再生速度",
"searchResult": "検索結果",
"preparingBathDownload": "ダウンロード準備中...",
"preparingDownload": "ダウンロード準備中...",

View File

@ -347,6 +347,11 @@
"subtitle": "选择字幕",
"playlist": "播放列表",
"openInExternalPlayer": "用外部播放器打开",
"repeatMode": "循环模式",
"listRepeat": "列表循环",
"singleRepeat": "单曲循环",
"shuffle": "随机播放",
"playbackSpeed": "播放速度",
"searchResult": "搜索结果",
"preparingBathDownload": "正在准备打包下载...",
"preparingDownload": "正在准备下载...",

View File

@ -347,6 +347,11 @@
"subtitle": "選擇字幕",
"playlist": "播放列表",
"openInExternalPlayer": "用外部播放器開啟",
"repeatMode": "循環模式",
"listRepeat": "清單循環",
"singleRepeat": "單曲循環",
"shuffle": "隨機播放",
"playbackSpeed": "播放速度",
"searchResult": "搜尋結果",
"preparingBathDownload": "正在準備打包下載...",
"preparingDownload": "正在準備下載...",

View File

@ -1,12 +1,12 @@
import { IconButton, Tooltip } from "@mui/material";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import MusicNote2Play from "../../Icons/MusicNote2Play.tsx";
import { getFileEntityUrl } from "../../../api/api.ts";
import { getFileLinkedUri } from "../../../util";
import PlayerPopup from "./PlayerPopup.tsx";
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,
@ -27,6 +27,7 @@ const MusicPlayer = () => {
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(() => {
@ -62,12 +63,13 @@ const MusicPlayer = () => {
audio.current.currentTime = 0;
audio.current.play();
audio.current.volume = latestVolume ?? volume;
audio.current.playbackRate = playbackSpeed;
} catch (e) {
console.error(e);
}
}
},
[playerState, volume],
[playerState, volume, playbackSpeed],
);
const loopProceed = useCallback(
@ -153,6 +155,17 @@ const MusicPlayer = () => {
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
@ -182,6 +195,9 @@ const MusicPlayer = () => {
playlist={playerState?.files}
loopMode={loopMode}
toggleLoopMode={toggleLoopMode}
setLoopMode={setLoopModeHandler}
playbackSpeed={playbackSpeed}
setPlaybackSpeed={setPlaybackSpeedHandler}
anchorEl={icon.current}
onClose={onPlayerPopoverClose}
/>

View File

@ -20,6 +20,7 @@ import {
} 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";
@ -32,6 +33,7 @@ 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",
@ -173,6 +175,9 @@ export interface PlayerPopupProps extends PopoverProps {
loopProceed: (isNext: boolean) => void;
loopMode: number;
toggleLoopMode: () => void;
setLoopMode: (mode: number) => void;
playbackSpeed: number;
setPlaybackSpeed: (speed: number) => void;
playIndex: (index: number, volume?: number) => void;
}
@ -190,6 +195,9 @@ export const PlayerPopup = ({
loopMode,
loopProceed,
toggleLoopMode,
setLoopMode,
playbackSpeed,
setPlaybackSpeed,
playlist,
playIndex,
...rest
@ -202,6 +210,7 @@ export const PlayerPopup = ({
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);
@ -213,11 +222,6 @@ export const PlayerPopup = ({
useEffect(() => {
setThumbBgLoaded(false);
if (file && "mediaSession" in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: file.metadata?.[Metadata.music_title] ?? file.name,
});
}
if (file && (!file.metadata || file.metadata[Metadata.thumbDisabled] === undefined)) {
dispatch(loadFileThumb(FileManagerIndex.main, file)).then((src) => {
setThumbSrc(src);
@ -225,7 +229,7 @@ export const PlayerPopup = ({
} else {
setThumbSrc(null);
}
}, [file]);
}, [file, dispatch]);
useEffect(() => {
if (seeking.current) {
@ -239,6 +243,20 @@ export const PlayerPopup = ({
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={{
@ -261,6 +279,15 @@ export const PlayerPopup = ({
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>
@ -365,7 +392,7 @@ export const PlayerPopup = ({
mt: -1,
}}
>
<IconButton aria-label="loop mode" onClick={toggleLoopMode}>
<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} />}

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;

View File

@ -0,0 +1,203 @@
import { useCallback, useEffect } from "react";
import { FileResponse, Metadata } from "../api/explorer";
interface MediaSessionConfig {
file?: FileResponse;
playing: boolean;
duration: number;
current: number;
thumbSrc?: string | null;
onPlay: () => void;
onPause: () => void;
onPrevious: () => void;
onNext: () => void;
onSeek?: (time: number) => void;
}
export const useMediaSession = ({
file,
playing,
duration,
current,
thumbSrc,
onPlay,
onPause,
onPrevious,
onNext,
onSeek,
}: MediaSessionConfig) => {
// Update media session metadata
const updateMetadata = useCallback(() => {
if (!("mediaSession" in navigator) || !file) {
return;
}
const title = file.metadata?.[Metadata.music_title] ?? file.name;
const artist = file.metadata?.[Metadata.music_artist] ?? "";
const album = file.metadata?.[Metadata.music_album] ?? "";
// Prepare artwork array
const artwork: MediaImage[] = [];
if (thumbSrc) {
// Add multiple sizes for better compatibility
artwork.push(
{ src: thumbSrc, sizes: "96x96", type: "image/jpeg" },
{ src: thumbSrc, sizes: "128x128", type: "image/jpeg" },
{ src: thumbSrc, sizes: "192x192", type: "image/jpeg" },
{ src: thumbSrc, sizes: "256x256", type: "image/jpeg" },
{ src: thumbSrc, sizes: "384x384", type: "image/jpeg" },
{ src: thumbSrc, sizes: "512x512", type: "image/jpeg" },
);
}
navigator.mediaSession.metadata = new MediaMetadata({
title,
artist,
album,
artwork,
});
}, [file, thumbSrc]);
// Update playback state
const updatePlaybackState = useCallback(() => {
if (!("mediaSession" in navigator)) {
return;
}
navigator.mediaSession.playbackState = playing ? "playing" : "paused";
}, [playing]);
// Update position state
const updatePositionState = useCallback(() => {
if (!("mediaSession" in navigator) || !duration || duration <= 0) {
return;
}
try {
navigator.mediaSession.setPositionState({
duration,
playbackRate: 1,
position: current,
});
} catch (error) {
// Some browsers may not support position state
console.debug("Media Session position state not supported:", error);
}
}, [duration, current]);
// Set up action handlers
const setupActionHandlers = useCallback(() => {
if (!("mediaSession" in navigator)) {
return;
}
// Play action
navigator.mediaSession.setActionHandler("play", () => {
onPlay();
});
// Pause action
navigator.mediaSession.setActionHandler("pause", () => {
onPause();
});
// Previous track action
navigator.mediaSession.setActionHandler("previoustrack", () => {
onPrevious();
});
// Next track action
navigator.mediaSession.setActionHandler("nexttrack", () => {
onNext();
});
// Seek backward action
navigator.mediaSession.setActionHandler("seekbackward", (details) => {
if (onSeek) {
const seekTime = Math.max(0, current - (details.seekOffset || 10));
onSeek(seekTime);
}
});
// Seek forward action
navigator.mediaSession.setActionHandler("seekforward", (details) => {
if (onSeek) {
const seekTime = Math.min(duration, current + (details.seekOffset || 10));
onSeek(seekTime);
}
});
// Seek to action (for scrubbing)
navigator.mediaSession.setActionHandler("seekto", (details) => {
if (onSeek && details.seekTime !== undefined) {
onSeek(details.seekTime);
}
});
// Stop action
navigator.mediaSession.setActionHandler("stop", () => {
onPause();
});
}, [onPlay, onPause, onPrevious, onNext, onSeek, current, duration]);
// Clean up action handlers
const cleanupActionHandlers = useCallback(() => {
if (!("mediaSession" in navigator)) {
return;
}
const actions: MediaSessionAction[] = [
"play",
"pause",
"previoustrack",
"nexttrack",
"seekbackward",
"seekforward",
"seekto",
"stop",
];
actions.forEach((action) => {
try {
navigator.mediaSession.setActionHandler(action, null);
} catch (error) {
// Some browsers may not support all actions
console.debug(`Media Session action ${action} not supported:`, error);
}
});
}, []);
// Initialize media session when component mounts
useEffect(() => {
setupActionHandlers();
// Cleanup on unmount
return () => {
cleanupActionHandlers();
};
}, [setupActionHandlers, cleanupActionHandlers]);
// Update metadata when file or thumbnail changes
useEffect(() => {
updateMetadata();
}, [updateMetadata]);
// Update playback state when playing status changes
useEffect(() => {
updatePlaybackState();
}, [updatePlaybackState]);
// Update position state when duration or current position changes
useEffect(() => {
updatePositionState();
}, [updatePositionState]);
// Return utility functions for manual control if needed
return {
updateMetadata,
updatePlaybackState,
updatePositionState,
setupActionHandlers,
cleanupActionHandlers,
};
};