mirror of
https://github.com/cloudreve/frontend.git
synced 2025-12-25 19:52:48 +00:00
feat(music player): media session API & playback speed control (cloudreve/cloudreve#2629)
This commit is contained in:
parent
778518ed9f
commit
a710dbb2f4
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -347,6 +347,11 @@
|
|||
"subtitle": "字幕選択",
|
||||
"playlist": "プレイリスト",
|
||||
"openInExternalPlayer": "外部プレーヤーで開く",
|
||||
"repeatMode": "リピートモード",
|
||||
"listRepeat": "リストリピート",
|
||||
"singleRepeat": "シングルリピート",
|
||||
"shuffle": "シャッフル",
|
||||
"playbackSpeed": "再生速度",
|
||||
"searchResult": "検索結果",
|
||||
"preparingBathDownload": "ダウンロード準備中...",
|
||||
"preparingDownload": "ダウンロード準備中...",
|
||||
|
|
|
|||
|
|
@ -347,6 +347,11 @@
|
|||
"subtitle": "选择字幕",
|
||||
"playlist": "播放列表",
|
||||
"openInExternalPlayer": "用外部播放器打开",
|
||||
"repeatMode": "循环模式",
|
||||
"listRepeat": "列表循环",
|
||||
"singleRepeat": "单曲循环",
|
||||
"shuffle": "随机播放",
|
||||
"playbackSpeed": "播放速度",
|
||||
"searchResult": "搜索结果",
|
||||
"preparingBathDownload": "正在准备打包下载...",
|
||||
"preparingDownload": "正在准备下载...",
|
||||
|
|
|
|||
|
|
@ -347,6 +347,11 @@
|
|||
"subtitle": "選擇字幕",
|
||||
"playlist": "播放列表",
|
||||
"openInExternalPlayer": "用外部播放器開啟",
|
||||
"repeatMode": "循環模式",
|
||||
"listRepeat": "清單循環",
|
||||
"singleRepeat": "單曲循環",
|
||||
"shuffle": "隨機播放",
|
||||
"playbackSpeed": "播放速度",
|
||||
"searchResult": "搜尋結果",
|
||||
"preparingBathDownload": "正在準備打包下載...",
|
||||
"preparingDownload": "正在準備下載...",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue