feat: media session support

This commit is contained in:
Aleksandr Shoronov
2025-01-18 13:53:46 +02:00
parent f526f97908
commit 18ed2e6f05
16 changed files with 361 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ import { Categories } from '@/components/categories';
import { SharedModal } from '@/components/modals/shared';
import { Toolbar } from '@/components/toolbar';
import { SnackbarProvider } from '@/contexts/snackbar';
import { MediaControls } from '@/components/media-controls';
import { sounds } from '@/data/sounds';
import { FADE_OUT } from '@/constants/events';
@@ -88,6 +89,7 @@ export function App() {
return (
<SnackbarProvider>
<StoreConsumer>
<MediaControls />
<Container>
<div id="app" />
<Buttons />

View File

@@ -0,0 +1 @@
export { MediaControls } from './media-controls';

View File

@@ -0,0 +1,13 @@
import { useMediaSessionStore } from '@/stores/media-session';
import { MediaSessionTrack } from './media-session-track';
export function MediaControls() {
const mediaControlsEnabled = useMediaSessionStore(state => state.enabled);
if (!mediaControlsEnabled) {
return null;
}
return <MediaSessionTrack />;
}

View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getSilenceDataURL } from '@/helpers/sound';
import { BrowserDetect } from '@/helpers/browser-detect';
import { useSoundStore } from '@/stores/sound';
import { useSSR } from '@/hooks/use-ssr';
import { useDarkTheme } from '@/hooks/use-dark-theme';
const metadata: MediaMetadataInit = {
artist: 'Moodist',
title: 'Ambient Sounds for Focus and Calm',
};
export function MediaSessionTrack() {
const { isBrowser } = useSSR();
const isDarkTheme = useDarkTheme();
const [isGenerated, setIsGenerated] = useState(false);
const isPlaying = useSoundStore(state => state.isPlaying);
const play = useSoundStore(state => state.play);
const pause = useSoundStore(state => state.pause);
const masterAudioSoundRef = useRef<HTMLAudioElement>(null);
const artworkURL = isDarkTheme ? '/logo-dark.png' : '/logo-light.png';
const generateSilence = useCallback(async () => {
if (!masterAudioSoundRef.current) return;
masterAudioSoundRef.current.src = await getSilenceDataURL();
setIsGenerated(true);
}, []);
useEffect(() => {
if (!isBrowser || !isPlaying || !isGenerated) return;
navigator.mediaSession.metadata = new MediaMetadata({
...metadata,
artwork: [
{
sizes: '200x200',
src: artworkURL,
type: 'image/png',
},
],
});
}, [artworkURL, isBrowser, isDarkTheme, isGenerated, isPlaying]);
useEffect(() => {
generateSilence();
}, [generateSilence]);
const startMasterAudio = useCallback(async () => {
if (!masterAudioSoundRef.current) return;
if (!masterAudioSoundRef.current.paused) return;
try {
await masterAudioSoundRef.current.play();
navigator.mediaSession.playbackState = 'playing';
navigator.mediaSession.setActionHandler('play', play);
navigator.mediaSession.setActionHandler('pause', pause);
} catch {
// Do nothing
}
}, [pause, play]);
const stopMasterAudio = useCallback(() => {
if (!masterAudioSoundRef.current) return;
/**
* Otherwise in Safari we cannot play the audio again
* through the media session controls
*/
if (BrowserDetect.isSafari()) {
masterAudioSoundRef.current.load();
} else {
masterAudioSoundRef.current.pause();
}
navigator.mediaSession.playbackState = 'paused';
}, []);
useEffect(() => {
if (!isGenerated) return;
if (!masterAudioSoundRef.current) return;
if (isPlaying) {
startMasterAudio();
} else {
stopMasterAudio();
}
}, [isGenerated, isPlaying, startMasterAudio, stopMasterAudio]);
useEffect(() => {
const masterAudioSound = masterAudioSoundRef.current;
return () => {
masterAudioSound?.pause();
navigator.mediaSession.setActionHandler('play', null);
navigator.mediaSession.setActionHandler('pause', null);
navigator.mediaSession.playbackState = 'none';
};
}, []);
return <audio id="media-session-track" loop ref={masterAudioSoundRef} />;
}

View File

@@ -0,0 +1,20 @@
import { IoMdPlayCircle } from 'react-icons/io/index';
import { Item } from '../item';
export function MediaControls({
active,
onClick,
}: {
active: boolean;
onClick: () => void;
}) {
return (
<Item
active={active}
icon={<IoMdPlayCircle />}
label="Media Controls"
onClick={onClick}
/>
);
}

View File

@@ -29,10 +29,17 @@ import { useSoundStore } from '@/stores/sound';
import styles from './menu.module.css';
import { useCloseListener } from '@/hooks/use-close-listener';
import { closeModals } from '@/lib/modal';
import { MediaControls } from './items/media-controls';
import { useMediaSessionStore } from '@/stores/media-session';
export function Menu() {
const [isOpen, setIsOpen] = useState(false);
const mediaControlsEnabled = useMediaSessionStore(state => state.enabled);
const toggleMediaControls = useMediaSessionStore(state => state.toggle);
const isMediaSessionSupported = useMediaSessionStore(
state => state.isSupported,
);
const noSelected = useSoundStore(state => state.noSelected());
const initial = useMemo(
@@ -108,6 +115,12 @@ export function Menu() {
>
<PresetsItem open={() => open('presets')} />
<ShareItem open={() => open('shareLink')} />
{isMediaSessionSupported ? (
<MediaControls
active={mediaControlsEnabled}
onClick={toggleMediaControls}
/>
) : null}
<ShuffleItem />
<SleepTimerItem open={() => open('sleepTimer')} />

View File

@@ -3,6 +3,7 @@ import { useEffect } from 'react';
import { useSoundStore } from '@/stores/sound';
import { useNoteStore } from '@/stores/note';
import { usePresetStore } from '@/stores/preset';
import { useMediaSessionStore } from '@/stores/media-session';
interface StoreConsumerProps {
children: React.ReactNode;
@@ -13,6 +14,7 @@ export function StoreConsumer({ children }: StoreConsumerProps) {
useSoundStore.persist.rehydrate();
useNoteStore.persist.rehydrate();
usePresetStore.persist.rehydrate();
useMediaSessionStore.persist.rehydrate();
}, []);
return <>{children}</>;