mirror of
https://github.com/remvze/moodist.git
synced 2025-12-18 09:24:14 +00:00
feat: media session support
This commit is contained in:
parent
f526f97908
commit
18ed2e6f05
16 changed files with 361 additions and 0 deletions
|
|
@ -24,6 +24,7 @@
|
||||||
1. 📓 Notepad for quick notes.
|
1. 📓 Notepad for quick notes.
|
||||||
1. 🍅 Pomodoro timer.
|
1. 🍅 Pomodoro timer.
|
||||||
1. ✅ Simple to-do list (soon).
|
1. ✅ Simple to-do list (soon).
|
||||||
|
1. ⏯️ Media controls.
|
||||||
1. ⌨️ Keyboard shortcuts for everything.
|
1. ⌨️ Keyboard shortcuts for everything.
|
||||||
1. 🥷 Privacy focused: no data collection.
|
1. 🥷 Privacy focused: no data collection.
|
||||||
1. 💰 Completely free, open-source, and self-hostable.
|
1. 💰 Completely free, open-source, and self-hostable.
|
||||||
|
|
|
||||||
BIN
public/logo-dark.png
Normal file
BIN
public/logo-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
public/logo-light.png
Normal file
BIN
public/logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -12,6 +12,7 @@ import { Categories } from '@/components/categories';
|
||||||
import { SharedModal } from '@/components/modals/shared';
|
import { SharedModal } from '@/components/modals/shared';
|
||||||
import { Toolbar } from '@/components/toolbar';
|
import { Toolbar } from '@/components/toolbar';
|
||||||
import { SnackbarProvider } from '@/contexts/snackbar';
|
import { SnackbarProvider } from '@/contexts/snackbar';
|
||||||
|
import { MediaControls } from '@/components/media-controls';
|
||||||
|
|
||||||
import { sounds } from '@/data/sounds';
|
import { sounds } from '@/data/sounds';
|
||||||
import { FADE_OUT } from '@/constants/events';
|
import { FADE_OUT } from '@/constants/events';
|
||||||
|
|
@ -88,6 +89,7 @@ export function App() {
|
||||||
return (
|
return (
|
||||||
<SnackbarProvider>
|
<SnackbarProvider>
|
||||||
<StoreConsumer>
|
<StoreConsumer>
|
||||||
|
<MediaControls />
|
||||||
<Container>
|
<Container>
|
||||||
<div id="app" />
|
<div id="app" />
|
||||||
<Buttons />
|
<Buttons />
|
||||||
|
|
|
||||||
1
src/components/media-controls/index.ts
Normal file
1
src/components/media-controls/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { MediaControls } from './media-controls';
|
||||||
13
src/components/media-controls/media-controls.tsx
Normal file
13
src/components/media-controls/media-controls.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
104
src/components/media-controls/media-session-track.tsx
Normal file
104
src/components/media-controls/media-session-track.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
20
src/components/menu/items/media-controls.tsx
Normal file
20
src/components/menu/items/media-controls.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,10 +29,17 @@ import { useSoundStore } from '@/stores/sound';
|
||||||
import styles from './menu.module.css';
|
import styles from './menu.module.css';
|
||||||
import { useCloseListener } from '@/hooks/use-close-listener';
|
import { useCloseListener } from '@/hooks/use-close-listener';
|
||||||
import { closeModals } from '@/lib/modal';
|
import { closeModals } from '@/lib/modal';
|
||||||
|
import { MediaControls } from './items/media-controls';
|
||||||
|
import { useMediaSessionStore } from '@/stores/media-session';
|
||||||
|
|
||||||
export function Menu() {
|
export function Menu() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
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 noSelected = useSoundStore(state => state.noSelected());
|
||||||
|
|
||||||
const initial = useMemo(
|
const initial = useMemo(
|
||||||
|
|
@ -108,6 +115,12 @@ export function Menu() {
|
||||||
>
|
>
|
||||||
<PresetsItem open={() => open('presets')} />
|
<PresetsItem open={() => open('presets')} />
|
||||||
<ShareItem open={() => open('shareLink')} />
|
<ShareItem open={() => open('shareLink')} />
|
||||||
|
{isMediaSessionSupported ? (
|
||||||
|
<MediaControls
|
||||||
|
active={mediaControlsEnabled}
|
||||||
|
onClick={toggleMediaControls}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<ShuffleItem />
|
<ShuffleItem />
|
||||||
<SleepTimerItem open={() => open('sleepTimer')} />
|
<SleepTimerItem open={() => open('sleepTimer')} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useEffect } from 'react';
|
||||||
import { useSoundStore } from '@/stores/sound';
|
import { useSoundStore } from '@/stores/sound';
|
||||||
import { useNoteStore } from '@/stores/note';
|
import { useNoteStore } from '@/stores/note';
|
||||||
import { usePresetStore } from '@/stores/preset';
|
import { usePresetStore } from '@/stores/preset';
|
||||||
|
import { useMediaSessionStore } from '@/stores/media-session';
|
||||||
|
|
||||||
interface StoreConsumerProps {
|
interface StoreConsumerProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -13,6 +14,7 @@ export function StoreConsumer({ children }: StoreConsumerProps) {
|
||||||
useSoundStore.persist.rehydrate();
|
useSoundStore.persist.rehydrate();
|
||||||
useNoteStore.persist.rehydrate();
|
useNoteStore.persist.rehydrate();
|
||||||
usePresetStore.persist.rehydrate();
|
usePresetStore.persist.rehydrate();
|
||||||
|
useMediaSessionStore.persist.rehydrate();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|
|
||||||
16
src/helpers/browser-detect.ts
Normal file
16
src/helpers/browser-detect.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export class BrowserDetect {
|
||||||
|
private static _isSafari: boolean | undefined;
|
||||||
|
|
||||||
|
public static isSafari(): boolean {
|
||||||
|
if (typeof BrowserDetect._isSafari !== 'undefined') {
|
||||||
|
return BrowserDetect._isSafari;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source: https://github.com/goldfire/howler.js/blob/v2.2.4/src/howler.core.js#L270
|
||||||
|
BrowserDetect._isSafari =
|
||||||
|
navigator.userAgent.indexOf('Safari') !== -1 &&
|
||||||
|
navigator.userAgent.indexOf('Chrome') === -1;
|
||||||
|
|
||||||
|
return BrowserDetect._isSafari;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/helpers/sound.ts
Normal file
82
src/helpers/sound.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
function blobToDataURL(blob: Blob) {
|
||||||
|
return new Promise<string>(resolve => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result !== 'string') return;
|
||||||
|
resolve(reader.result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeString(view: DataView, offset: number, string: string) {
|
||||||
|
for (let i = 0; i < string.length; i++) {
|
||||||
|
view.setUint8(offset + i, string.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeWAV(audioBuffer: AudioBuffer) {
|
||||||
|
const numChannels = audioBuffer.numberOfChannels;
|
||||||
|
const sampleRate = audioBuffer.sampleRate;
|
||||||
|
const length = audioBuffer.length * numChannels * 2 + 44; // Header + PCM data
|
||||||
|
const wavBuffer = new ArrayBuffer(length);
|
||||||
|
const view = new DataView(wavBuffer);
|
||||||
|
|
||||||
|
// WAV file header
|
||||||
|
writeString(view, 0, 'RIFF');
|
||||||
|
// File size - 8
|
||||||
|
view.setUint32(4, 36 + audioBuffer.length * numChannels * 2, true);
|
||||||
|
writeString(view, 8, 'WAVE');
|
||||||
|
writeString(view, 12, 'fmt ');
|
||||||
|
// Subchunk1Size
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
// Audio format (PCM)
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
// NumChannels
|
||||||
|
view.setUint16(22, numChannels, true);
|
||||||
|
// SampleRate
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
// ByteRate
|
||||||
|
view.setUint32(28, sampleRate * numChannels * 2, true);
|
||||||
|
// BlockAlign
|
||||||
|
view.setUint16(32, numChannels * 2, true);
|
||||||
|
// BitsPerSample
|
||||||
|
view.setUint16(34, 16, true);
|
||||||
|
writeString(view, 36, 'data');
|
||||||
|
// Subchunk2Size
|
||||||
|
view.setUint32(40, audioBuffer.length * numChannels * 2, true);
|
||||||
|
|
||||||
|
// Write interleaved PCM samples
|
||||||
|
let offset = 44;
|
||||||
|
|
||||||
|
for (let i = 0; i < audioBuffer.length; i++) {
|
||||||
|
for (let channel = 0; channel < numChannels; channel++) {
|
||||||
|
const sample = audioBuffer.getChannelData(channel)[i];
|
||||||
|
const clampedSample = Math.max(-1, Math.min(1, sample));
|
||||||
|
view.setInt16(offset, clampedSample * 0x7fff, true);
|
||||||
|
offset += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return wavBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSilenceDataURL(seconds: number = 60) {
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
|
||||||
|
const sampleRate = 44100;
|
||||||
|
const length = sampleRate * seconds;
|
||||||
|
const buffer = audioContext.createBuffer(1, length, sampleRate);
|
||||||
|
const channelData = buffer.getChannelData(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - Firefox ignores audio for Media Session without any actual sound in the beginning.
|
||||||
|
* - Add a small value to the end to prevent clipping.
|
||||||
|
*/
|
||||||
|
channelData[0] = 0.001;
|
||||||
|
channelData[channelData.length - 1] = 0.001;
|
||||||
|
|
||||||
|
return await blobToDataURL(
|
||||||
|
new Blob([encodeWAV(buffer)], { type: 'audio/wav' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/hooks/use-dark-theme.ts
Normal file
27
src/hooks/use-dark-theme.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSSR } from './use-ssr';
|
||||||
|
|
||||||
|
const themeMatch = '(prefers-color-scheme: dark)';
|
||||||
|
|
||||||
|
export function useDarkTheme() {
|
||||||
|
const { isBrowser } = useSSR();
|
||||||
|
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
|
const themeMediaQuery = window.matchMedia(themeMatch);
|
||||||
|
|
||||||
|
function handleThemeChange(event: MediaQueryListEvent) {
|
||||||
|
setIsDarkTheme(event.matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
themeMediaQuery.addEventListener('change', handleThemeChange);
|
||||||
|
setIsDarkTheme(themeMediaQuery.matches);
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
themeMediaQuery.removeEventListener('change', handleThemeChange);
|
||||||
|
}, [isBrowser]);
|
||||||
|
|
||||||
|
return isDarkTheme;
|
||||||
|
}
|
||||||
33
src/stores/media-session/index.ts
Normal file
33
src/stores/media-session/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||||
|
import merge from 'deepmerge';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createActions,
|
||||||
|
type MediaControlsActions,
|
||||||
|
} from './media-session.actions';
|
||||||
|
import { createState, type MediaControlsState } from './media-session.state';
|
||||||
|
|
||||||
|
export const useMediaSessionStore = create<
|
||||||
|
MediaControlsState & MediaControlsActions
|
||||||
|
>()(
|
||||||
|
persist(
|
||||||
|
(...a) => ({
|
||||||
|
...createState(...a),
|
||||||
|
...createActions(...a),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
merge: (persisted, current) =>
|
||||||
|
merge(
|
||||||
|
current,
|
||||||
|
// @ts-ignore
|
||||||
|
persisted,
|
||||||
|
),
|
||||||
|
name: 'moodist-media-session',
|
||||||
|
partialize: state => ({ enabled: state.enabled }),
|
||||||
|
skipHydration: true,
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
version: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
29
src/stores/media-session/media-session.actions.ts
Normal file
29
src/stores/media-session/media-session.actions.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { StateCreator } from 'zustand';
|
||||||
|
import type { MediaControlsState } from './media-session.state';
|
||||||
|
|
||||||
|
export interface MediaControlsActions {
|
||||||
|
disable: () => void;
|
||||||
|
enable: () => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createActions: StateCreator<
|
||||||
|
MediaControlsActions & MediaControlsState,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
MediaControlsActions
|
||||||
|
> = (set, get) => {
|
||||||
|
return {
|
||||||
|
disable() {
|
||||||
|
set({ enabled: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
enable() {
|
||||||
|
set({ enabled: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
set({ enabled: !get().enabled });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
18
src/stores/media-session/media-session.state.ts
Normal file
18
src/stores/media-session/media-session.state.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
|
import type { MediaControlsActions } from './media-session.actions';
|
||||||
|
|
||||||
|
export interface MediaControlsState {
|
||||||
|
enabled: boolean;
|
||||||
|
isSupported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createState: StateCreator<
|
||||||
|
MediaControlsState & MediaControlsActions,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
MediaControlsState
|
||||||
|
> = () => ({
|
||||||
|
enabled: false,
|
||||||
|
isSupported: 'mediaSession' in navigator,
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue