Skip to content

Commit

Permalink
feat: media session support
Browse files Browse the repository at this point in the history
  • Loading branch information
underoot committed Jan 18, 2025
1 parent f526f97 commit 18ed2e6
Show file tree
Hide file tree
Showing 16 changed files with 361 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file added public/logo-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/logo-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/components/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -88,6 +89,7 @@ export function App() {
return (
<SnackbarProvider>
<StoreConsumer>
<MediaControls />
<Container>
<div id="app" />
<Buttons />
Expand Down
1 change: 1 addition & 0 deletions src/components/media-controls/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MediaControls } from './media-controls';
13 changes: 13 additions & 0 deletions src/components/media-controls/media-controls.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 104 additions & 0 deletions src/components/media-controls/media-session-track.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 20 additions & 0 deletions src/components/menu/items/media-controls.tsx
Original file line number Diff line number Diff line change
@@ -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}
/>
);
}
13 changes: 13 additions & 0 deletions src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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')} />

Expand Down
2 changes: 2 additions & 0 deletions src/components/store-consumer/store-consumer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,6 +14,7 @@ export function StoreConsumer({ children }: StoreConsumerProps) {
useSoundStore.persist.rehydrate();
useNoteStore.persist.rehydrate();
usePresetStore.persist.rehydrate();
useMediaSessionStore.persist.rehydrate();
}, []);

return <>{children}</>;
Expand Down
16 changes: 16 additions & 0 deletions src/helpers/browser-detect.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 82 additions & 0 deletions src/helpers/sound.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 27 additions & 0 deletions src/hooks/use-dark-theme.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 33 additions & 0 deletions src/stores/media-session/index.ts
Original file line number Diff line number Diff line change
@@ -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,
},
),
);
Loading

0 comments on commit 18ed2e6

Please sign in to comment.