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() {
-