From 00005a34387c07d5cc90aaeb022f784629146a05 Mon Sep 17 00:00:00 2001 From: Noman Date: Sun, 6 Apr 2025 23:59:06 -0400 Subject: [PATCH 1/4] Add MediaSession stuff --- src/js/App.tsx | 28 ++--- src/js/audio/AudioContext.tsx | 6 +- src/js/audio/AudioContextProvider.tsx | 150 ++++++++++++++++++-------- src/js/audio/AudioControls.tsx | 14 ++- src/js/audio/audioUtils.ts | 13 +++ 5 files changed, 149 insertions(+), 62 deletions(-) create mode 100644 src/js/audio/audioUtils.ts diff --git a/src/js/App.tsx b/src/js/App.tsx index e244117..c683ad2 100644 --- a/src/js/App.tsx +++ b/src/js/App.tsx @@ -54,21 +54,21 @@ export const App = () => { return ( - - <> -
-
-

transcript.fish

- - - - + + + <> +
+
+

transcript.fish

+ + + - - -
- - + +
+ + + ); diff --git a/src/js/audio/AudioContext.tsx b/src/js/audio/AudioContext.tsx index 3002ea3..efadf70 100644 --- a/src/js/audio/AudioContext.tsx +++ b/src/js/audio/AudioContext.tsx @@ -2,7 +2,8 @@ import { Ref, createContext } from 'react'; export const AudioContext = createContext<{ isPlaying: (episodeNum: number) => boolean; - playPause: (episodeNum: number) => void; + play: (episodeNum: number) => void; + pause: () => void; audioRef?: Ref; playingEpisode?: number; currentTime: number; @@ -10,7 +11,8 @@ export const AudioContext = createContext<{ ended: boolean; }>({ isPlaying: () => false, - playPause: () => undefined, + play: () => undefined, + pause: () => undefined, audioRef: null, playingEpisode: undefined, currentTime: 0, diff --git a/src/js/audio/AudioContextProvider.tsx b/src/js/audio/AudioContextProvider.tsx index c43c856..e0a6d72 100644 --- a/src/js/audio/AudioContextProvider.tsx +++ b/src/js/audio/AudioContextProvider.tsx @@ -1,85 +1,151 @@ -import { ReactElement, useEffect, useRef, useState } from 'react'; +import { ReactElement, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { AudioPlayer } from './AudioPlayer'; import { AudioContext } from './AudioContext'; +import { DatabaseContext } from '../database/DatabaseContext'; +import { setMetadata } from './audioUtils'; +import { FiltersContext } from '../filters/FiltersContext'; export const AudioContextProvider = ({ children }: { children: ReactElement }) => { - const audioRef = useRef(null); + const { episodes } = useContext(DatabaseContext); + const { getFilteredEpisodes } = useContext(FiltersContext); const [playingEpisode, setPlayingEpisode] = useState(); const [ended, setEnded] = useState(false); const [playing, setPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); - const playPause = (episodeNum: number) => { - setPlaying(p => { - if (episodeNum === playingEpisode) { - return !p; - } + const audioRef = useRef(null); - setPlayingEpisode(episodeNum); - return true; - }); - }; + const play = useCallback((episodeNum: number) => { + setPlaying(true); + setPlayingEpisode(episodeNum); + audioRef.current?.play(); + }, []); - const isPlaying = (episodeNum: number) => { - return playing && episodeNum === playingEpisode; - }; + const pause = useCallback(() => { + setPlaying(false); + audioRef.current?.pause(); + }, []); - const seek = (time: number) => { + const isPlaying = useCallback( + (episodeNum: number) => { + return playing && episodeNum === playingEpisode; + }, + [playing, playingEpisode] + ); + + const seek = useCallback((time: number) => { if (audioRef.current) { - audioRef.current.currentTime = time; + audioRef.current.fastSeek(time); } - }; + }, []); + + const filteredEpisodes = getFilteredEpisodes(episodes.data); + // Add event listeners useEffect(() => { const audio = audioRef.current; if (!audio || !playingEpisode) { return; } + if (!filteredEpisodes) { + return; + } + audio.playbackRate = 1; audio.preservesPitch = true; + const episodeIdx = filteredEpisodes?.findIndex(({ episode }) => episode === playingEpisode); + + navigator.mediaSession.setActionHandler('play', () => play(playingEpisode)); + navigator.mediaSession.setActionHandler('pause', pause); + + // Show back button on everything but the first episode + if (episodeIdx > 0) { + navigator.mediaSession.setActionHandler('previoustrack', () => { + if (typeof episodeIdx === 'number') { + setPlayingEpisode(filteredEpisodes[episodeIdx - 1].episode); + setCurrentTime(0); + setPlaying(true); + audio.currentTime = 0; + } + }); + } else { + navigator.mediaSession.setActionHandler('previoustrack', null); + } + + // Show foward button on everything but the last episode + if (episodeIdx < filteredEpisodes.length - 1) { + navigator.mediaSession.setActionHandler('nexttrack', () => { + if (typeof episodeIdx === 'number') { + setPlayingEpisode(filteredEpisodes[episodeIdx + 1].episode); + setCurrentTime(0); + setPlaying(true); + audio.currentTime = 0; + } + }); + } else { + navigator.mediaSession.setActionHandler('nexttrack', null); + } + + navigator.mediaSession.setActionHandler('seekbackward', () => { + setCurrentTime(t => { + const seekTime = t - 10_000; + audio.fastSeek(seekTime); + return seekTime; + }); + }); + + navigator.mediaSession.setActionHandler('seekforward', () => { + setCurrentTime(t => { + const seekTime = t + 10_000; + audio.fastSeek(seekTime); + return seekTime; + }); + }); + + navigator.mediaSession.setActionHandler('seekto', ({ seekTime }) => { + if (seekTime) { + setCurrentTime(seekTime); + audio.fastSeek(seekTime); + } + }); + const handleTimeupdate = (event: Event) => { const { currentTime } = event.target as HTMLAudioElement; setCurrentTime(currentTime); setEnded(false); }; - const handlePlay = () => setPlaying(true); - const handlePause = () => setPlaying(false); - const handleEnded = () => setEnded(true); - audio?.addEventListener('timeupdate', handleTimeupdate); - audio?.addEventListener('play', handlePlay); - audio?.addEventListener('pause', handlePause); - audio?.addEventListener('ended', handleEnded); - audio?.addEventListener('seeked', handleTimeupdate); + const handleEnded = () => { + setEnded(true); + setPlaying(false); + }; + + audio.addEventListener('timeupdate', handleTimeupdate); + audio.addEventListener('seeked', handleTimeupdate); + audio.addEventListener('ended', handleEnded); return () => { - audio?.removeEventListener('timeupdate', handleTimeupdate); - audio?.removeEventListener('play', handlePlay); - audio?.removeEventListener('pause', handlePause); - audio?.removeEventListener('ended', handleEnded); - audio?.removeEventListener('seeked', handleTimeupdate); + audio.removeEventListener('timeupdate', handleTimeupdate); + audio.removeEventListener('seeked', handleTimeupdate); + audio.removeEventListener('ended', handleEnded); }; - }, [playingEpisode]); + }, [playingEpisode, play, pause, filteredEpisodes, getFilteredEpisodes]); + // Update MediaSession metadata when episode is changed useEffect(() => { - const audio = audioRef.current; - if (!audio || !playingEpisode) { - return; - } - - if (playing) { - audio.play(); - } else { - audio.pause(); + const episode = filteredEpisodes?.find(({ episode }) => episode === playingEpisode); + if (episode) { + setMetadata(episode); } - }, [playingEpisode, playing]); + }, [playingEpisode, filteredEpisodes]); return ( { - const { isPlaying, playPause, currentTime, playingEpisode, seek, ended } = + const { isPlaying, play, pause, currentTime, playingEpisode, seek, ended } = useContext(AudioContext); const handleSkipBack: MouseEventHandler = ({ clientX, currentTarget }) => { @@ -160,9 +160,15 @@ export const AudioControls = ({ episodeNum, duration }: AudioControlsProps) => { return ( - + {isPlaying(episodeNum) ? ( + + ) : ( + + )} {playingEpisode === episodeNum ? ( diff --git a/src/js/audio/audioUtils.ts b/src/js/audio/audioUtils.ts new file mode 100644 index 0000000..6ba19b2 --- /dev/null +++ b/src/js/audio/audioUtils.ts @@ -0,0 +1,13 @@ +import { Episode } from '../types'; +import { formatDate } from '../utils'; + +export const setMetadata = (episode: Episode) => { + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: `${episode?.episode}: ${episode?.title}`, + artist: 'No Such Thing As A Fish', + album: episode?.pubDate ? formatDate(episode.pubDate) : undefined, + artwork: [{ src: episode?.image || '', sizes: '512x512', type: 'image/png' }], + }); + } +}; From cef0caa5f3092a65884979a90276238a22dcaaed Mon Sep 17 00:00:00 2001 From: Noman Date: Wed, 9 Apr 2025 21:24:55 -0400 Subject: [PATCH 2/4] Make native audio player work --- src/js/EpisodeSearch.tsx | 37 +++- src/js/audio/AudioContextProvider.tsx | 226 ++++++++++++++-------- src/js/audio/AudioPlayer.tsx | 10 +- src/js/audio/audioUtils.ts | 16 +- src/js/filters/FiltersContext.tsx | 9 +- src/js/filters/FiltersContextProvider.tsx | 112 ++++++----- 6 files changed, 260 insertions(+), 150 deletions(-) diff --git a/src/js/EpisodeSearch.tsx b/src/js/EpisodeSearch.tsx index 0027d2f..ed5d234 100644 --- a/src/js/EpisodeSearch.tsx +++ b/src/js/EpisodeSearch.tsx @@ -60,7 +60,6 @@ export const EpisodeSearch = () => { const { episodes: { - data: episodes, search, error: episodesError, loading: episodesLoading, @@ -68,8 +67,13 @@ export const EpisodeSearch = () => { }, } = useContext(DatabaseContext); - const { getFilteredEpisodes, episodeTypeFilters, presenterFilters, searchFilters, venueFilters } = - useContext(FiltersContext); + const { + filteredEpisodes, + episodeTypeFilters, + presenterFilters, + searchFilters, + venueFilters, + } = useContext(FiltersContext); useEffect(() => { search(searchTerm, searchFilters); @@ -77,7 +81,13 @@ export const EpisodeSearch = () => { useEffect(() => { setPage(0); - }, [episodeTypeFilters, presenterFilters, searchTerm, searchFilters, venueFilters]); + }, [ + episodeTypeFilters, + presenterFilters, + searchTerm, + searchFilters, + venueFilters, + ]); const handleSubmit = useCallback((e: FormEvent) => { preventDefault(e); @@ -93,8 +103,6 @@ export const EpisodeSearch = () => { setExpanded(e => !e); }, []); - const filteredEpisodes = getFilteredEpisodes(episodes); - if (!filteredEpisodes) { return null; } @@ -105,7 +113,10 @@ export const EpisodeSearch = () => { return ( - + {!!totalEpisodes && ( @@ -115,7 +126,11 @@ export const EpisodeSearch = () => { {!episodesLoading && !episodesError && ( - + )} )} @@ -139,7 +154,11 @@ export const EpisodeSearch = () => { }} /> {totalPages > 1 && !episodesLoading ? ( - + ) : ( )} diff --git a/src/js/audio/AudioContextProvider.tsx b/src/js/audio/AudioContextProvider.tsx index e0a6d72..535c1f3 100644 --- a/src/js/audio/AudioContextProvider.tsx +++ b/src/js/audio/AudioContextProvider.tsx @@ -1,115 +1,73 @@ -import { ReactElement, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { + ReactElement, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import { AudioPlayer } from './AudioPlayer'; import { AudioContext } from './AudioContext'; -import { DatabaseContext } from '../database/DatabaseContext'; -import { setMetadata } from './audioUtils'; +import { setMetadata, setPositionState } from './audioUtils'; import { FiltersContext } from '../filters/FiltersContext'; -export const AudioContextProvider = ({ children }: { children: ReactElement }) => { - const { episodes } = useContext(DatabaseContext); - const { getFilteredEpisodes } = useContext(FiltersContext); - const [playingEpisode, setPlayingEpisode] = useState(); +type Handler = (details: MediaSessionActionDetails) => void; + +type Handlers = [MediaSessionAction, Handler | null][]; + +export const AudioContextProvider = ({ + children, +}: { + children: ReactElement; +}) => { + const { filteredEpisodes } = useContext(FiltersContext); + const [playingEpisode, setPlayingEpisode] = useState( + filteredEpisodes?.[0]?.episode + ); const [ended, setEnded] = useState(false); const [playing, setPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const audioRef = useRef(null); const play = useCallback((episodeNum: number) => { - setPlaying(true); - setPlayingEpisode(episodeNum); - audioRef.current?.play(); + if (audioRef.current) { + setPlaying(true); + setPlayingEpisode(episodeNum); + setTimeout(() => { + if (audioRef.current) { + audioRef.current.play(); + setPositionState(audioRef.current); + } + }, 0); + } }, []); const pause = useCallback(() => { - setPlaying(false); - audioRef.current?.pause(); + if (audioRef.current) { + setPlaying(false); + audioRef.current.pause(); + } }, []); const isPlaying = useCallback( - (episodeNum: number) => { - return playing && episodeNum === playingEpisode; - }, + (episodeNum: number) => playing && episodeNum === playingEpisode, [playing, playingEpisode] ); const seek = useCallback((time: number) => { if (audioRef.current) { - audioRef.current.fastSeek(time); + audioRef.current.currentTime = time; + setPositionState(audioRef.current); } }, []); - const filteredEpisodes = getFilteredEpisodes(episodes.data); - - // Add event listeners + // Add event listeners on audio element useEffect(() => { const audio = audioRef.current; - if (!audio || !playingEpisode) { - return; - } - - if (!filteredEpisodes) { + if (!audio) { return; } - audio.playbackRate = 1; - audio.preservesPitch = true; - - const episodeIdx = filteredEpisodes?.findIndex(({ episode }) => episode === playingEpisode); - - navigator.mediaSession.setActionHandler('play', () => play(playingEpisode)); - navigator.mediaSession.setActionHandler('pause', pause); - - // Show back button on everything but the first episode - if (episodeIdx > 0) { - navigator.mediaSession.setActionHandler('previoustrack', () => { - if (typeof episodeIdx === 'number') { - setPlayingEpisode(filteredEpisodes[episodeIdx - 1].episode); - setCurrentTime(0); - setPlaying(true); - audio.currentTime = 0; - } - }); - } else { - navigator.mediaSession.setActionHandler('previoustrack', null); - } - - // Show foward button on everything but the last episode - if (episodeIdx < filteredEpisodes.length - 1) { - navigator.mediaSession.setActionHandler('nexttrack', () => { - if (typeof episodeIdx === 'number') { - setPlayingEpisode(filteredEpisodes[episodeIdx + 1].episode); - setCurrentTime(0); - setPlaying(true); - audio.currentTime = 0; - } - }); - } else { - navigator.mediaSession.setActionHandler('nexttrack', null); - } - - navigator.mediaSession.setActionHandler('seekbackward', () => { - setCurrentTime(t => { - const seekTime = t - 10_000; - audio.fastSeek(seekTime); - return seekTime; - }); - }); - - navigator.mediaSession.setActionHandler('seekforward', () => { - setCurrentTime(t => { - const seekTime = t + 10_000; - audio.fastSeek(seekTime); - return seekTime; - }); - }); - - navigator.mediaSession.setActionHandler('seekto', ({ seekTime }) => { - if (seekTime) { - setCurrentTime(seekTime); - audio.fastSeek(seekTime); - } - }); - const handleTimeupdate = (event: Event) => { const { currentTime } = event.target as HTMLAudioElement; setCurrentTime(currentTime); @@ -130,16 +88,112 @@ export const AudioContextProvider = ({ children }: { children: ReactElement }) = audio.removeEventListener('seeked', handleTimeupdate); audio.removeEventListener('ended', handleEnded); }; - }, [playingEpisode, play, pause, filteredEpisodes, getFilteredEpisodes]); + }, []); // Update MediaSession metadata when episode is changed useEffect(() => { - const episode = filteredEpisodes?.find(({ episode }) => episode === playingEpisode); + const episode = filteredEpisodes?.find( + ({ episode }) => episode === playingEpisode + ); if (episode) { setMetadata(episode); } }, [playingEpisode, filteredEpisodes]); + // Add MediaSession event handlers + useEffect(() => { + if (!audioRef.current || !playingEpisode || !filteredEpisodes) { + return; + } + + audioRef.current.playbackRate = 1; + audioRef.current.preservesPitch = true; + + const episodeIdx = filteredEpisodes?.findIndex( + ({ episode }) => episode === playingEpisode + ); + + const handlers: Handlers = [ + ['play', () => play(playingEpisode)], + ['pause', pause], + [ + 'previoustrack', + episodeIdx > 0 + ? () => { + if (audioRef.current) { + setPlayingEpisode(filteredEpisodes[episodeIdx - 1].episode); + setCurrentTime(0); + setPlaying(true); + audioRef.current.currentTime = 0; + setPositionState(audioRef.current); + } + } + : null, + ], + [ + 'nexttrack', + episodeIdx < filteredEpisodes.length - 1 + ? () => { + if (audioRef.current) { + setPlayingEpisode(filteredEpisodes[episodeIdx + 1].episode); + setCurrentTime(0); + setPlaying(true); + audioRef.current.currentTime = 0; + setPositionState(audioRef.current); + } + } + : null, + ], + [ + 'seekbackward', + ({ seekOffset = 10 }) => { + if (audioRef.current) { + audioRef.current.currentTime = + audioRef.current.currentTime - seekOffset; + setCurrentTime(time => time - seekOffset); + setPositionState(audioRef.current); + } + }, + ], + [ + 'seekforward', + ({ seekOffset = 10 }) => { + if (audioRef.current) { + audioRef.current.currentTime = + audioRef.current.currentTime + seekOffset; + setCurrentTime(time => time + seekOffset); + setPositionState(audioRef.current); + } + }, + ], + [ + 'seekto', + ({ seekTime, fastSeek }) => { + if (!seekTime || !audioRef.current) { + return; + } + + if (fastSeek && 'fastSeek' in audioRef.current) { + audioRef.current.fastSeek(seekTime); + } else { + audioRef.current.currentTime = seekTime; + } + + setCurrentTime(seekTime); + setPositionState(audioRef.current); + }, + ], + ]; + + for (const [event, handler] of handlers) { + try { + navigator.mediaSession.setActionHandler(event, handler); + } catch (e) { + console.warn(`MediaSession event '${event}' not supported.`); + } + } + }, [playingEpisode, play, pause, filteredEpisodes]); + return ( - {playingEpisode && } + {children} ); diff --git a/src/js/audio/AudioPlayer.tsx b/src/js/audio/AudioPlayer.tsx index 5f8d411..8e29be5 100644 --- a/src/js/audio/AudioPlayer.tsx +++ b/src/js/audio/AudioPlayer.tsx @@ -6,7 +6,13 @@ export const AudioPlayer = ({ episodeNum, }: { audioRef: Ref; - episodeNum: number; + episodeNum?: number; }) => { - return