Refactors music playback and boilerplate into AudioPlayer class

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-28 01:37:13 +00:00
co-authored by Copilot
parent dcae0b604b
commit 2b0b043d3d
2 changed files with 191 additions and 171 deletions
+172
View File
@@ -0,0 +1,172 @@
type PermissionState = 'granted' | 'denied' | 'prompt';
export type BackgroundModePlugin = {
checkMicrophonePermission: () => Promise<{ microphone: PermissionState | string }>;
requestMicrophonePermission: () => Promise<{ microphone: PermissionState | string }>;
checkNotificationsPermission: () => Promise<{ notifications: PermissionState | string }>;
requestNotificationsPermission: () => Promise<{ notifications: PermissionState | string }>;
checkBatteryOptimizations: () => Promise<{ enabled: boolean }>;
requestDisableBatteryOptimizations: () => Promise<void>;
disableWebViewOptimizations: () => Promise<void>;
enable: (options: {
title: string;
text: string;
channelName: string;
channelDescription: string;
resume: boolean;
silent: boolean;
disableWebViewOptimization: boolean;
}) => Promise<void>;
addListener: (eventName: string, listener: () => void) => Promise<void>;
};
export type CapacitorRuntime = {
Plugins?: {
BackgroundMode?: BackgroundModePlugin;
};
};
export type CapacitorWindow = Window & {
Capacitor?: CapacitorRuntime;
};
export type PlaybackState = 'stopped' | 'playing' | 'error';
export const STREAM_URL = 'https://kryz.out.airtime.pro/kryz_b';
export class AudioPlayer {
private url: string;
private onStateChange: (state: PlaybackState, status: string) => void;
private element: HTMLAudioElement | null = null;
private playbackState: PlaybackState = 'stopped';
constructor(url: string, onStateChange: (state: PlaybackState, status: string) => void) {
this.url = url;
this.onStateChange = onStateChange;
this.handleEnded = this.handleEnded.bind(this);
this.handleError = this.handleError.bind(this);
}
setElement(el: HTMLAudioElement): void {
this.element = el;
el.addEventListener('ended', this.handleEnded);
el.addEventListener('error', this.handleError);
}
private handleEnded(): void {
this.playbackState = 'stopped';
this.onStateChange('stopped', 'Stream ended.');
}
private handleError(): void {
this.playbackState = 'error';
this.onStateChange('error', 'Stream error.');
}
async initBackgroundMode(backgroundMode: BackgroundModePlugin): Promise<void> {
try {
const notificationPermission = await backgroundMode.checkNotificationsPermission();
if (notificationPermission.notifications !== 'granted') {
await backgroundMode.requestNotificationsPermission();
}
const microphonePermission = await backgroundMode.checkMicrophonePermission();
if (microphonePermission.microphone !== 'granted') {
const requestedMicrophonePermission = await backgroundMode.requestMicrophonePermission();
if (requestedMicrophonePermission.microphone !== 'granted') {
console.warn(
'BackgroundMode microphone permission denied; background playback may stop when screen is off.'
);
}
}
const batteryOptimizations = await backgroundMode.checkBatteryOptimizations();
if (batteryOptimizations.enabled) {
console.warn(
'Battery optimizations are enabled; requesting exemption for reliable background playback.'
);
await backgroundMode.requestDisableBatteryOptimizations();
}
await backgroundMode.disableWebViewOptimizations();
await backgroundMode.enable({
title: 'KRYZ-Go!',
text: 'Background mode is active',
channelName: 'kryz-go background',
channelDescription: 'Keeps kryz-go running while the app is in background',
resume: true,
silent: false,
disableWebViewOptimization: true,
});
await backgroundMode.addListener('appInBackground', () => {
console.log('kryz-go moved to background');
});
await backgroundMode.addListener('appInForeground', () => {
console.log('kryz-go returned to foreground');
});
console.log('BackgroundMode plugin enabled.');
} catch (error) {
console.error('Failed to initialize BackgroundMode plugin:', error);
}
}
private async ensureBackgroundPermissions(backgroundMode: BackgroundModePlugin): Promise<boolean> {
const microphonePermission = await backgroundMode.checkMicrophonePermission();
if (microphonePermission.microphone === 'granted') {
return true;
}
const requestedMicrophonePermission = await backgroundMode.requestMicrophonePermission();
if (requestedMicrophonePermission.microphone === 'granted') {
return true;
}
this.onStateChange(
this.playbackState,
'Enable microphone permission in Android settings for screen-off playback.'
);
console.warn('Background playback blocked: microphone permission not granted.');
return false;
}
async play(backgroundMode?: BackgroundModePlugin): Promise<void> {
if (!this.element) {
return;
}
try {
if (backgroundMode) {
const hasPermissions = await this.ensureBackgroundPermissions(backgroundMode);
if (!hasPermissions) {
return;
}
}
this.element.src = this.url;
await this.element.play();
this.playbackState = 'playing';
this.onStateChange('playing', 'Stream is playing.');
} catch (error) {
this.playbackState = 'error';
this.onStateChange('error', 'Unable to start stream.');
console.error('Failed to start stream:', error);
}
}
stop(): void {
if (!this.element) {
return;
}
this.element.pause();
this.element.removeAttribute('src');
this.element.load();
this.playbackState = 'stopped';
this.onStateChange('stopped', 'Stream is stopped.');
}
}