diff --git a/README.md b/README.md index c837afbf..bb1aa5d1 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ 1. 📓 Notepad for quick notes. 1. 🍅 Pomodoro timer. 1. ✅ Simple to-do list (soon). +1. ⏯️ Media controls. 1. ⌨️ Keyboard shortcuts for everything. 1. 🥷 Privacy focused: no data collection. 1. 💰 Completely free, open-source, and self-hostable. diff --git a/public/logo-dark.png b/public/logo-dark.png new file mode 100644 index 00000000..5e10b4d5 Binary files /dev/null and b/public/logo-dark.png differ diff --git a/public/logo-light.png b/public/logo-light.png new file mode 100644 index 00000000..5c902f7c Binary files /dev/null and b/public/logo-light.png differ diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index e0eee08d..c5f890d4 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -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 ( +
diff --git a/src/components/media-controls/index.ts b/src/components/media-controls/index.ts new file mode 100644 index 00000000..fc42fd2f --- /dev/null +++ b/src/components/media-controls/index.ts @@ -0,0 +1 @@ +export { MediaControls } from './media-controls'; diff --git a/src/components/media-controls/media-controls.tsx b/src/components/media-controls/media-controls.tsx new file mode 100644 index 00000000..60cf4779 --- /dev/null +++ b/src/components/media-controls/media-controls.tsx @@ -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 ; +} diff --git a/src/components/media-controls/media-session-track.tsx b/src/components/media-controls/media-session-track.tsx new file mode 100644 index 00000000..6a29ab1b --- /dev/null +++ b/src/components/media-controls/media-session-track.tsx @@ -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(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