From 2b0b043d3de6b612f59bb0d61d1430ece6e71d46 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Tue, 28 Apr 2026 01:37:13 +0000 Subject: [PATCH] Refactors music playback and boilerplate into AudioPlayer class Co-authored-by: Copilot --- app/AudioPlayer.ts | 172 ++++++++++++++++++++++++++++++++++++++++ app/page.tsx | 190 +++++---------------------------------------- 2 files changed, 191 insertions(+), 171 deletions(-) create mode 100644 app/AudioPlayer.ts diff --git a/app/AudioPlayer.ts b/app/AudioPlayer.ts new file mode 100644 index 0000000..e453823 --- /dev/null +++ b/app/AudioPlayer.ts @@ -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; + disableWebViewOptimizations: () => Promise; + enable: (options: { + title: string; + text: string; + channelName: string; + channelDescription: string; + resume: boolean; + silent: boolean; + disableWebViewOptimization: boolean; + }) => Promise; + addListener: (eventName: string, listener: () => void) => Promise; +}; + +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 { + 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 { + 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 { + 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.'); + } +} diff --git a/app/page.tsx b/app/page.tsx index 728b4d2..7fd79d1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,40 +1,7 @@ 'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; - -type PermissionState = 'granted' | 'denied' | 'prompt'; - -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; - disableWebViewOptimizations: () => Promise; - enable: (options: { - title: string; - text: string; - channelName: string; - channelDescription: string; - resume: boolean; - silent: boolean; - disableWebViewOptimization: boolean; - }) => Promise; - addListener: (eventName: string, listener: () => void) => Promise; -}; - -type CapacitorRuntime = { - Plugins?: { - BackgroundMode?: BackgroundModePlugin; - }; -}; - -type CapacitorWindow = Window & { - Capacitor?: CapacitorRuntime; -}; - -const STREAM_URL = 'https://kryz.out.airtime.pro/kryz_b'; +import { AudioPlayer, CapacitorWindow, STREAM_URL } from './AudioPlayer'; export default function HomePage() { const audioRef = useRef(null); @@ -48,147 +15,28 @@ export default function HomePage() { return (window as CapacitorWindow).Capacitor?.Plugins?.BackgroundMode; }, []); + const player = useMemo( + () => + new AudioPlayer(STREAM_URL, (state, msg) => { + setIsPlaying(state === 'playing'); + setStatus(msg); + }), + [] + ); + useEffect(() => { - const audio = audioRef.current; - if (!audio) { - return; + if (audioRef.current) { + player.setElement(audioRef.current); } - - const handleEnded = () => { - setIsPlaying(false); - setStatus('Stream ended.'); - }; - - const handleError = () => { - setIsPlaying(false); - setStatus('Stream error.'); - }; - - audio.addEventListener('ended', handleEnded); - audio.addEventListener('error', handleError); - - return () => { - audio.removeEventListener('ended', handleEnded); - audio.removeEventListener('error', handleError); - }; - }, []); + }, [player]); useEffect(() => { if (!backgroundMode) { console.warn('BackgroundMode plugin is not available in this runtime.'); return; } - - const initializeBackgroundMode = async () => { - 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); - } - }; - - void initializeBackgroundMode(); - }, [backgroundMode]); - - const ensureBackgroundPlaybackPermissions = async () => { - if (!backgroundMode) { - return true; - } - - const microphonePermission = await backgroundMode.checkMicrophonePermission(); - if (microphonePermission.microphone === 'granted') { - return true; - } - - const requestedMicrophonePermission = await backgroundMode.requestMicrophonePermission(); - if (requestedMicrophonePermission.microphone === 'granted') { - return true; - } - - setStatus('Enable microphone permission in Android settings for screen-off playback.'); - console.warn('Background playback blocked: microphone permission not granted.'); - return false; - }; - - const startStream = async () => { - const audio = audioRef.current; - if (!audio) { - return; - } - - try { - const hasBackgroundPermissions = await ensureBackgroundPlaybackPermissions(); - if (!hasBackgroundPermissions) { - return; - } - - audio.src = STREAM_URL; - await audio.play(); - setIsPlaying(true); - setStatus('Stream is playing.'); - } catch (error) { - setIsPlaying(false); - setStatus('Unable to start stream.'); - console.error('Failed to start stream:', error); - } - }; - - const stopStream = () => { - const audio = audioRef.current; - if (!audio) { - return; - } - - audio.pause(); - audio.removeAttribute('src'); - audio.load(); - - setIsPlaying(false); - setStatus('Stream is stopped.'); - }; + void player.initBackgroundMode(backgroundMode); + }, [player, backgroundMode]); return (
@@ -200,11 +48,11 @@ export default function HomePage() {

- -