forked from remvze/moodist
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
16 changed files
with
361 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { MediaControls } from './media-controls'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 />; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} />; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | ||
/> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }), | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
), | ||
); |
Oops, something went wrong.