import { getActions, getGlobal } from '../global'; import { requestNextMutation } from '../lib/fasterdom/fasterdom'; import { AudioOrigin, GlobalSearchContent } from '../types'; import type { ApiMessage } from '../api/types'; import { IS_SAFARI } from './windowEnvironment'; import safePlay from './safePlay'; import { patchSafariProgressiveAudio, isSafariPatchInProgress } from './patchSafariProgressiveAudio'; import type { MessageKey } from '../global/helpers'; import { getMessageKey, parseMessageKey } from '../global/helpers'; import { selectCurrentMessageList, selectTabState } from '../global/selectors'; type Handler = (eventName: string, e: Event) => void; export type TrackId = `${MessageKey}-${number}`; export interface Track { audio: HTMLAudioElement; proxy: HTMLAudioElement; type: 'voice' | 'audio'; handlers: Handler[]; onForcePlay?: NoneToVoidFunction; onTrackChange?: NoneToVoidFunction; } const tracks = new Map(); let voiceQueue: TrackId[] = []; let musicQueue: TrackId[] = []; let currentTrackId: TrackId | undefined; function createAudio( trackId: TrackId, type: Track['type'], onForcePlay?: NoneToVoidFunction, onTrackChange?: NoneToVoidFunction, ): Track { const audio = new Audio(); function handleEvent(eventName: string) { return (e: Event) => { if (!tracks.has(trackId)) { return; } if (isSafariPatchInProgress(audio)) { return; } tracks.get(trackId)!.handlers.forEach((handler) => { handler(eventName, e); }); }; } audio.addEventListener('timeupdate', handleEvent('onTimeUpdate')); audio.addEventListener('play', handleEvent('onPlay')); audio.addEventListener('pause', handleEvent('onPause')); audio.addEventListener('loadstart', handleEvent('onLoadStart')); audio.addEventListener('loadeddata', handleEvent('onLoadedData')); audio.addEventListener('playing', handleEvent('onPlaying')); audio.addEventListener('ended', () => { if (!tracks.has(trackId)) { return; } if (isSafariPatchInProgress(audio)) { return; } playNext(trackId); }); return { audio, type, proxy: new Proxy(audio, { get: (target, key: keyof HTMLAudioElement) => target[key], }), handlers: [], onForcePlay, onTrackChange, }; } function playNext(trackId: TrackId, isReverseOrder?: boolean) { const currentTrack = currentTrackId && tracks.get(currentTrackId); if (currentTrack) { currentTrack.audio.pause(); currentTrack.audio.currentTime = 0; if (currentTrack.onTrackChange) currentTrack.onTrackChange(); } const origin = selectTabState(getGlobal()).audioPlayer.origin || AudioOrigin.Inline; const nextTrackId = findNextInQueue(trackId, origin, isReverseOrder); if (!nextTrackId) { return; } if (!tracks.has(nextTrackId)) { // A bit hacky way to continue playlist when switching chat getActions().openAudioPlayer(parseMessageKey(splitTrackId(nextTrackId).messageKey)); return; } const nextTrack = tracks.get(nextTrackId)!; if (nextTrack.onForcePlay) { nextTrack.onForcePlay(); } currentTrackId = nextTrackId; if (nextTrack.audio.src) { safePlay(nextTrack.audio); } } export function stopCurrentAudio() { const currentTrack = currentTrackId && tracks.get(currentTrackId); if (currentTrack) { currentTrack.audio.pause(); } } export function register( trackId: TrackId, trackType: Track['type'], handler: Handler, onForcePlay?: NoneToVoidFunction, onTrackChange?: NoneToVoidFunction, ) { if (!tracks.has(trackId)) { const track = createAudio(trackId, trackType, onForcePlay, onTrackChange); tracks.set(trackId, track); addTrackToQueue(track, trackId); } const { audio, proxy, handlers } = tracks.get(trackId)!; handlers.push(handler); return { play(src: string) { if (!audio.paused) return; const currentTrack = currentTrackId && tracks.get(currentTrackId); if (currentTrack && currentTrackId !== trackId) { currentTrack.audio.pause(); currentTrack.audio.currentTime = 0; if (isSafariPatchInProgress(currentTrack.audio)) { currentTrack.audio.dataset.preventPlayAfterPatch = 'true'; } if (currentTrack.onTrackChange) currentTrack.onTrackChange(); } currentTrackId = trackId; if (!audio.src) { audio.src = src; audio.preload = 'auto'; if (src.includes('/progressive/') && IS_SAFARI) { delete audio.dataset.preventPlayAfterPatch; patchSafariProgressiveAudio(audio); } } safePlay(audio); cleanUpQueue(trackType, trackId); }, pause() { if (currentTrackId === trackId) { audio.pause(); } }, stop() { if (currentTrackId === trackId) { // Hack, reset `src` to remove default media session notification const prevSrc = audio.src; audio.pause(); // `onPause` not called otherwise, but required to sync UI requestNextMutation(() => { audio.src = ''; audio.src = prevSrc; }); } }, setCurrentTime(time: number) { if (currentTrackId === trackId) { if (audio.fastSeek) { audio.fastSeek(time); } else { audio.currentTime = time; } } }, setVolume(volume: number) { if (currentTrackId === trackId) { audio.volume = volume; audio.muted = false; } }, setPlaybackRate(rate: number) { if (currentTrackId === trackId) { audio.playbackRate = rate; } }, toggleMuted(muted?: boolean) { if (muted === undefined) { audio.muted = !audio.muted; } else { audio.muted = muted; } }, proxy, requestNextTrack() { playNext(trackId); }, isLast() { return !findNextInQueue(trackId, selectTabState(getGlobal()).audioPlayer.origin); }, isFirst() { return !findNextInQueue(trackId, selectTabState(getGlobal()).audioPlayer.origin, true); }, requestPreviousTrack() { playNext(trackId, true); }, destroy(shouldRemoveFromQueue = false) { const track = tracks.get(trackId); if (!track) { return; } track.handlers = track.handlers.filter((h) => h !== handler); if (!track.handlers.length) { track.audio.pause(); tracks.delete(trackId); if (shouldRemoveFromQueue) { removeFromQueue(track, trackId); } if (trackId === currentTrackId) { currentTrackId = undefined; } } }, }; } function getTrackQueue(track: Track) { switch (track.type) { case 'audio': return musicQueue; case 'voice': return voiceQueue; default: return undefined; } } function addTrackToQueue(track: Track, trackId: TrackId) { if (track.type === 'audio' && !musicQueue.includes(trackId)) { musicQueue.push(trackId); musicQueue.sort(trackIdComparator); } if (track.type === 'voice' && !voiceQueue.includes(trackId)) { voiceQueue.push(trackId); voiceQueue.sort(trackIdComparator); } } function removeFromQueue(track: Track, trackId: TrackId) { const trackIdFilter = (el: TrackId) => el !== trackId; if (track.type === 'audio') { musicQueue = musicQueue.filter(trackIdFilter); } if (track.type === 'voice') { voiceQueue = voiceQueue.filter(trackIdFilter); } } function cleanUpQueue(type: Track['type'], trackId: TrackId) { if (selectTabState(getGlobal()).globalSearch.currentContent === GlobalSearchContent.Music) return; const { chatId } = parseMessageKey(splitTrackId(trackId).messageKey); const openedChatId = selectCurrentMessageList(getGlobal())?.chatId; const queueFilter = (id: string) => ( id.startsWith(`msg${chatId}`) || (openedChatId && id.startsWith(`msg${openedChatId}`)) ); if (type === 'audio') { musicQueue = musicQueue.filter(queueFilter); } if (type === 'voice') { voiceQueue = voiceQueue.filter(queueFilter); } } function findNextInQueue(currentId: TrackId, origin = AudioOrigin.Inline, isReverseOrder?: boolean) { const track = tracks.get(currentId)!; const queue = getTrackQueue(track); if (!queue) return undefined; if (origin === AudioOrigin.Search) { const index = queue.indexOf(currentId); if (index < 0) return undefined; const direction = isReverseOrder ? -1 : 1; return queue[index + direction]; } const { chatId } = parseMessageKey(splitTrackId(currentId).messageKey); const chatAudio = queue.filter((id) => id.startsWith(`msg${chatId}`)); const index = chatAudio.indexOf(currentId); if (index < 0) return undefined; let direction = origin === AudioOrigin.Inline ? -1 : 1; if (isReverseOrder) direction *= -1; return chatAudio[index + direction]; } export function makeTrackId(message: ApiMessage): TrackId { return `${getMessageKey(message)}-${message.date}`; } function splitTrackId(trackId: TrackId) { const messageKey = trackId.match(/^msg(-?\d+)-(\d+)/)![0] as MessageKey; const date = Number(trackId.split('-').pop()); return { messageKey, date, }; } // Descending order by date function trackIdComparator(one?: TrackId, two?: TrackId) { if (!one || !two) return 0; const { date: dateOne, messageKey: keyOne } = splitTrackId(one); const { date: dateTwo, messageKey: keyTwo } = splitTrackId(two); const diff = dateTwo - dateOne; return diff === 0 ? keyTwo.localeCompare(keyOne) : diff; }