Moves media-session feature into dedicated service
This commit is contained in:
parent
2c6c1e8b69
commit
33c0b02973
|
@ -132,34 +132,6 @@ export const Controls = () => {
|
|||
const [initialized, setInitialized] = useState(false);
|
||||
const navigator = window.navigator as any;
|
||||
|
||||
const fakeAudioRef = useCallback(fakeAudio => {
|
||||
if (navigator.mediaSession) {
|
||||
navigator.mediaSession.setActionHandler("play", null);
|
||||
navigator.mediaSession.setActionHandler("previoustrack", null);
|
||||
navigator.mediaSession.setActionHandler("nexttrack", null);
|
||||
navigator.mediaSession.setActionHandler("pause", null);
|
||||
navigator.mediaSession.metadata = null;
|
||||
if (!initialized) {
|
||||
setInitialized(true);
|
||||
fakeAudio?.play();
|
||||
if (deviceStatus?.state !== "playing") {
|
||||
setTimeout(() => fakeAudio?.pause(), 5000);
|
||||
}
|
||||
}
|
||||
navigator.mediaSession.setActionHandler("previoustrack", handlePrev);
|
||||
navigator.mediaSession.setActionHandler("nexttrack", handleNext);
|
||||
navigator.mediaSession.setActionHandler("pause", () => {
|
||||
handlePause();
|
||||
fakeAudio?.pause();
|
||||
});
|
||||
navigator.mediaSession.setActionHandler("play", () => {
|
||||
handlePlay();
|
||||
fakeAudio?.play();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
let message = ``;
|
||||
let trackIndex = deviceStatus?.track ?? null;
|
||||
let deviceState = deviceStatus?.state ?? null;
|
||||
|
@ -174,19 +146,7 @@ export const Controls = () => {
|
|||
message = `BLANKDISC`;
|
||||
} else if (deviceStatus && deviceStatus.track !== null && tracks[deviceStatus.track]) {
|
||||
let title = tracks[deviceStatus.track].fullWidthTitle || tracks[deviceStatus.track].title;
|
||||
message =
|
||||
(deviceStatus.track + 1).toString().padStart(3, '0') +
|
||||
(title ? ' - ' + title : '');
|
||||
|
||||
if (navigator.mediaSession) {
|
||||
//@ts-ignore
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title,
|
||||
album: disc?.fullWidthTitle || disc?.title,
|
||||
artwork: []
|
||||
});
|
||||
navigator.mediaSession.playbackState = deviceStatus?.state === "playing" ? "playing" : "paused";
|
||||
}
|
||||
message = (deviceStatus.track + 1).toString().padStart(3, '0') + (title ? ' - ' + title : '');
|
||||
}
|
||||
|
||||
const [lcdScroll, setLcdScroll] = useState(0);
|
||||
|
@ -264,7 +224,6 @@ export const Controls = () => {
|
|||
|
||||
return (
|
||||
<Box className={classes.container}>
|
||||
<audio ref={fakeAudioRef} loop src="10-seconds-of-silence.mp3"/>
|
||||
<IconButton aria-label="prev" onClick={handlePrev} className={classes.button}>
|
||||
<SkipPreviousIcon />
|
||||
</IconButton>
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
export function createEmptyWave(time: number, sampleRate = 22050) {
|
||||
const numOfChan = 1;
|
||||
const depthInBytes = 2;
|
||||
const length = time * sampleRate * numOfChan * depthInBytes + 44;
|
||||
const buffer = new ArrayBuffer(length);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
let pos = 0;
|
||||
function setUint16(data: number) {
|
||||
view.setUint16(pos, data, true);
|
||||
pos += 2;
|
||||
}
|
||||
|
||||
function setUint32(data: number) {
|
||||
view.setUint32(pos, data, true);
|
||||
pos += 4;
|
||||
}
|
||||
|
||||
// write WAVE header
|
||||
setUint32(0x46464952);
|
||||
setUint32(length - 8);
|
||||
setUint32(0x45564157);
|
||||
|
||||
setUint32(0x20746d66);
|
||||
setUint32(16);
|
||||
setUint16(1);
|
||||
setUint16(numOfChan);
|
||||
setUint32(sampleRate);
|
||||
setUint32(sampleRate * numOfChan * depthInBytes);
|
||||
setUint16(numOfChan * depthInBytes);
|
||||
setUint16(depthInBytes * 8);
|
||||
|
||||
setUint32(0x61746164);
|
||||
setUint32(length - pos - 4);
|
||||
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
}
|
|
@ -18,11 +18,13 @@ import './fonts/fonts.css';
|
|||
|
||||
import { FFMpegAudioExportService } from './services/audio-export';
|
||||
import { MediaRecorderService } from './services/mediarecorder';
|
||||
import { BrowserMediaSessionService } from './services/media-session';
|
||||
|
||||
serviceRegistry.netmdService = (window as any).native?.interface || new NetMDUSBService({ debug: true });
|
||||
// serviceRegistry.netmdService = new NetMDMockService(); // Uncomment to work without a device attached
|
||||
serviceRegistry.audioExportService = new FFMpegAudioExportService();
|
||||
serviceRegistry.mediaRecorderService = new MediaRecorderService();
|
||||
serviceRegistry.mediaSessionService = new BrowserMediaSessionService(store);
|
||||
|
||||
(function setupEventHandlers() {
|
||||
window.addEventListener('beforeunload', ev => {
|
||||
|
|
|
@ -17,13 +17,14 @@ import {
|
|||
askNotificationPermission,
|
||||
getGroupedTracks,
|
||||
getHalfWidthTitleLength,
|
||||
timeToSeekArgs,
|
||||
} from '../utils';
|
||||
import * as mm from 'music-metadata-browser';
|
||||
import { TitleFormatType, UploadFormat } from './convert-dialog-feature';
|
||||
import NotificationCompleteIconUrl from '../images/record-complete-notification-icon.png';
|
||||
import { assertNumber } from 'netmd-js/dist/utils';
|
||||
|
||||
export function control(action: 'play' | 'stop' | 'next' | 'prev' | 'goto' | 'pause', params?: unknown) {
|
||||
export function control(action: 'play' | 'stop' | 'next' | 'prev' | 'goto' | 'pause' | 'seek', params?: unknown) {
|
||||
return async function(dispatch: AppDispatch, getState: () => RootState) {
|
||||
switch (action) {
|
||||
case 'play':
|
||||
|
@ -41,10 +42,22 @@ export function control(action: 'play' | 'stop' | 'next' | 'prev' | 'goto' | 'pa
|
|||
case 'pause':
|
||||
await serviceRegistry.netmdService!.pause();
|
||||
break;
|
||||
case 'goto':
|
||||
case 'goto': {
|
||||
const trackNumber = assertNumber(params, 'Invalid track number for "goto" command');
|
||||
await serviceRegistry.netmdService!.gotoTrack(trackNumber);
|
||||
break;
|
||||
}
|
||||
case 'seek': {
|
||||
if (!(params instanceof Object)) {
|
||||
throw new Error('"seek" command has wrong params');
|
||||
}
|
||||
const typedParams: { trackNumber: number; time: number } = params as any;
|
||||
const trackNumber = assertNumber(typedParams.trackNumber, 'Invalid track number for "seek" command');
|
||||
const time = assertNumber(typedParams.time, 'Invalid time for "seek" command');
|
||||
const timeArgs = timeToSeekArgs(time);
|
||||
await serviceRegistry.netmdService!.gotoTime(trackNumber, timeArgs[0], timeArgs[1], timeArgs[2], timeArgs[3]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// CAVEAT: change-track might take a up to a few seconds to complete.
|
||||
// We wait 500ms and let the monitor do further updates
|
||||
|
@ -184,6 +197,7 @@ export function pair() {
|
|||
return async function(dispatch: AppDispatch, getState: () => RootState) {
|
||||
dispatch(appStateActions.setPairingFailed(false));
|
||||
|
||||
serviceRegistry.mediaSessionService?.init(); // no need to await
|
||||
await serviceRegistry.audioExportService!.init();
|
||||
|
||||
try {
|
||||
|
|
|
@ -50,5 +50,8 @@ export const store = configureStore({
|
|||
|
||||
const initialState = Object.freeze(store.getState());
|
||||
|
||||
export type AppStore = typeof store;
|
||||
export type AppSubscribe = typeof store.subscribe;
|
||||
export type AppGetState = typeof store.getState;
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
|
|
@ -0,0 +1,146 @@
|
|||
import { debounce, DisplayTrack, getSortedTracks, sleep } from '../utils';
|
||||
import { createEmptyWave } from '../create-empty-wave';
|
||||
import { control } from '../redux/actions';
|
||||
import { AppStore, AppSubscribe, AppGetState } from '../redux/store';
|
||||
import { Dispatch } from '@reduxjs/toolkit';
|
||||
|
||||
export interface MediaSessionService {
|
||||
init(): Promise<void>;
|
||||
}
|
||||
|
||||
export class BrowserMediaSessionService {
|
||||
private initialized = false;
|
||||
private audioEl?: HTMLAudioElement;
|
||||
|
||||
private dispatch: Dispatch<any>; // CAVEAT: AppDispatch type doesn't have an overload for redux thunk actions
|
||||
private subscribe: AppSubscribe;
|
||||
private getState: AppGetState;
|
||||
|
||||
constructor(appStore: AppStore) {
|
||||
this.dispatch = appStore.dispatch.bind(appStore) as Dispatch<any>;
|
||||
this.subscribe = appStore.subscribe.bind(appStore);
|
||||
this.getState = appStore.getState.bind(appStore);
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initialized || !navigator.mediaSession) {
|
||||
return;
|
||||
}
|
||||
this.initialized = true;
|
||||
|
||||
// Audio el
|
||||
const audioEl = document.createElement('audio');
|
||||
audioEl.id = 'browser-media-session-helper';
|
||||
document.body.appendChild(audioEl);
|
||||
|
||||
audioEl.setAttribute('loop', 'true');
|
||||
audioEl.src = URL.createObjectURL(createEmptyWave(6));
|
||||
audioEl.volume = 0;
|
||||
|
||||
this.audioEl = audioEl;
|
||||
|
||||
// Blocks media session events during initialization
|
||||
navigator.mediaSession.setActionHandler('play', null);
|
||||
navigator.mediaSession.setActionHandler('previoustrack', null);
|
||||
navigator.mediaSession.setActionHandler('nexttrack', null);
|
||||
navigator.mediaSession.setActionHandler('pause', null);
|
||||
navigator.mediaSession.setActionHandler('seekto', null);
|
||||
navigator.mediaSession.metadata = null;
|
||||
|
||||
audioEl.play();
|
||||
await sleep(5000); // CAVEAT: 5secs is the minimum playing time for media info to show up
|
||||
audioEl.pause();
|
||||
|
||||
if (this.getState().main.deviceStatus?.state === 'playing') {
|
||||
// restore current state
|
||||
audioEl.play();
|
||||
}
|
||||
|
||||
console.log('MediaSession ready');
|
||||
|
||||
// Set mediaSession event handlers
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
this.dispatch(control('prev'));
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
this.dispatch(control('next'));
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
audioEl.pause();
|
||||
this.dispatch(control('pause'));
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
audioEl.play();
|
||||
this.dispatch(control('play'));
|
||||
});
|
||||
|
||||
const debouncedSeek = debounce((time: number, trackNumber: number) => {
|
||||
this.dispatch(control('seek', { time, trackNumber }));
|
||||
audioEl.currentTime = time;
|
||||
}, 100);
|
||||
|
||||
navigator.mediaSession.setActionHandler('seekto', details => {
|
||||
const trackNumber = this.getState().main.deviceStatus?.track ?? -1;
|
||||
if (trackNumber === -1 || details.seekTime === null) {
|
||||
return; // can't seek without knowing the track number or the seek time
|
||||
}
|
||||
debouncedSeek(details.seekTime, trackNumber);
|
||||
});
|
||||
|
||||
this.subscribe(() => {
|
||||
this.syncState();
|
||||
});
|
||||
}
|
||||
|
||||
syncState() {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioEl = this.audioEl!;
|
||||
const {
|
||||
main: { deviceStatus, disc },
|
||||
} = this.getState();
|
||||
|
||||
const isPlaying = deviceStatus?.state === 'playing';
|
||||
const currentDiscTitle = disc?.title;
|
||||
const currentTrackIndex = deviceStatus?.track ?? -1;
|
||||
const allTracks = getSortedTracks(disc);
|
||||
const currentTrack: DisplayTrack | undefined = allTracks[currentTrackIndex];
|
||||
const currentTrackTitle = currentTrack ? currentTrack.fullWidthTitle || currentTrack?.title : '';
|
||||
const currentTrackDurationInSecs = Math.round(currentTrack?.durationInSecs ?? -1);
|
||||
|
||||
const oldTrackTitle = navigator.mediaSession.metadata?.title;
|
||||
const oldDiscTitle = navigator.mediaSession.metadata?.album;
|
||||
|
||||
// Sync MmediaSession
|
||||
if (isPlaying && navigator.mediaSession.playbackState !== 'playing') {
|
||||
navigator.mediaSession.playbackState = 'playing';
|
||||
} else if (!isPlaying && navigator.mediaSession.playbackState !== 'paused') {
|
||||
navigator.mediaSession.playbackState = 'paused';
|
||||
}
|
||||
|
||||
// Sync MediaMetadata
|
||||
if (oldTrackTitle !== currentTrackTitle || oldDiscTitle !== currentDiscTitle) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: currentTrackTitle,
|
||||
album: currentDiscTitle,
|
||||
artwork: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Sync audio duration.
|
||||
// CAVEAT: replacing the src may change the audioEl paused state.
|
||||
if (audioEl && audioEl.duration !== currentTrackDurationInSecs && isPlaying) {
|
||||
URL.revokeObjectURL(audioEl.src ?? '');
|
||||
audioEl.src = URL.createObjectURL(createEmptyWave(Math.max(currentTrackDurationInSecs, 1)));
|
||||
}
|
||||
|
||||
// Sync <audio> state
|
||||
if (isPlaying && audioEl.paused) {
|
||||
audioEl.play();
|
||||
} else if (!isPlaying && !audioEl.paused) {
|
||||
audioEl.pause();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ class NetMDMockService implements NetMDService {
|
|||
public _discCapacity: number = 80 * 60 * 512;
|
||||
public _tracks: Track[] = [
|
||||
{
|
||||
duration: 5 * 60 * 512,
|
||||
duration: 3 * 60 * 512,
|
||||
encoding: Encoding.sp,
|
||||
index: 0,
|
||||
channel: Channels.stereo,
|
||||
|
@ -360,6 +360,11 @@ class NetMDMockService implements NetMDService {
|
|||
await sleep(500);
|
||||
}
|
||||
|
||||
async gotoTime(index: number, hour = 0, minute = 0, second = 0, frame = 0) {
|
||||
this._status.track = index;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
async getPosition() {
|
||||
if (this._status.track === null || this._status.time === null) {
|
||||
return null;
|
||||
|
|
|
@ -58,6 +58,7 @@ export interface NetMDService {
|
|||
next(): Promise<void>;
|
||||
prev(): Promise<void>;
|
||||
gotoTrack(index: number): Promise<void>;
|
||||
gotoTime(index: number, hour: number, minute: number, second: number, frame: number): Promise<void>;
|
||||
getPosition(): Promise<number[] | null>;
|
||||
}
|
||||
|
||||
|
@ -384,6 +385,11 @@ export class NetMDUSBService implements NetMDService {
|
|||
await this.netmdInterface!.gotoTrack(index);
|
||||
}
|
||||
|
||||
@asyncMutex
|
||||
async gotoTime(index: number, h: number, m: number, s: number, f: number) {
|
||||
await this.netmdInterface!.gotoTime(index, h, m, s, f);
|
||||
}
|
||||
|
||||
@asyncMutex
|
||||
async getPosition() {
|
||||
return await this.netmdInterface!.getPosition();
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { NetMDService } from './netmd';
|
||||
import { AudioExportService } from './audio-export';
|
||||
import { MediaRecorderService } from './mediarecorder';
|
||||
import { MediaSessionService } from './media-session';
|
||||
|
||||
interface ServiceRegistry {
|
||||
netmdService?: NetMDService;
|
||||
audioExportService?: AudioExportService;
|
||||
mediaRecorderService?: MediaRecorderService;
|
||||
mediaSessionService?: MediaSessionService;
|
||||
}
|
||||
|
||||
const ServiceRegistry: ServiceRegistry = {};
|
||||
|
|
27
src/utils.ts
27
src/utils.ts
|
@ -12,6 +12,17 @@ export function sleep(ms: number) {
|
|||
});
|
||||
}
|
||||
|
||||
export function debounce<T extends Function>(func: T, timeout = 300): T {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const debouncedFn = (...args: any) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
func(...args);
|
||||
}, timeout);
|
||||
};
|
||||
return (debouncedFn as any) as T;
|
||||
}
|
||||
|
||||
export async function sleepWithProgressCallback(ms: number, cb: (perc: number) => void) {
|
||||
let elapsedSecs = 1;
|
||||
let interval = setInterval(() => {
|
||||
|
@ -84,6 +95,20 @@ export function framesToSec(frames: number) {
|
|||
return frames / 512;
|
||||
}
|
||||
|
||||
export function timeToSeekArgs(timeInSecs: number): number[] {
|
||||
let value = Math.round(timeInSecs); // ignore frames
|
||||
|
||||
let s = value % 60;
|
||||
value = (value - s) / 60; // min
|
||||
|
||||
let m = value % 60;
|
||||
value = (value - m) / 60; // hour
|
||||
|
||||
let h = value;
|
||||
|
||||
return [h, m, s, 0];
|
||||
}
|
||||
|
||||
export function sanitizeTitle(title: string) {
|
||||
return title.normalize('NFD').replace(/[^\x00-\x7F]/g, '');
|
||||
}
|
||||
|
@ -197,6 +222,7 @@ export type DisplayTrack = {
|
|||
group: string | null;
|
||||
duration: string;
|
||||
encoding: string;
|
||||
durationInSecs: number;
|
||||
};
|
||||
|
||||
export function getSortedTracks(disc: Disc | null) {
|
||||
|
@ -211,6 +237,7 @@ export function getSortedTracks(disc: Disc | null) {
|
|||
group: group.title ?? null,
|
||||
encoding: EncodingName[track.encoding],
|
||||
duration: formatTimeFromFrames(track.duration, false),
|
||||
durationInSecs: track.duration / 512, // CAVEAT: 1s = 512 frames
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue