Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: media session support #54

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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