Transitions UI to react.js #1
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-171
@@ -1,40 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { AudioPlayer, CapacitorWindow, STREAM_URL } from './AudioPlayer';
|
||||||
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<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>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CapacitorRuntime = {
|
|
||||||
Plugins?: {
|
|
||||||
BackgroundMode?: BackgroundModePlugin;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type CapacitorWindow = Window & {
|
|
||||||
Capacitor?: CapacitorRuntime;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STREAM_URL = 'https://kryz.out.airtime.pro/kryz_b';
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
@@ -48,147 +15,28 @@ export default function HomePage() {
|
|||||||
return (window as CapacitorWindow).Capacitor?.Plugins?.BackgroundMode;
|
return (window as CapacitorWindow).Capacitor?.Plugins?.BackgroundMode;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const player = useMemo(
|
||||||
|
() =>
|
||||||
|
new AudioPlayer(STREAM_URL, (state, msg) => {
|
||||||
|
setIsPlaying(state === 'playing');
|
||||||
|
setStatus(msg);
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
if (audioRef.current) {
|
||||||
if (!audio) {
|
player.setElement(audioRef.current);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [player]);
|
||||||
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);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!backgroundMode) {
|
if (!backgroundMode) {
|
||||||
console.warn('BackgroundMode plugin is not available in this runtime.');
|
console.warn('BackgroundMode plugin is not available in this runtime.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
void player.initBackgroundMode(backgroundMode);
|
||||||
const initializeBackgroundMode = async () => {
|
}, [player, backgroundMode]);
|
||||||
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.');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-shell">
|
<main className="page-shell">
|
||||||
@@ -200,11 +48,11 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="controls">
|
<div className="controls">
|
||||||
<button type="button" onClick={startStream} disabled={isPlaying}>
|
<button type="button" onClick={() => void player.play(backgroundMode)} disabled={isPlaying}>
|
||||||
Start Stream
|
Play
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={stopStream} disabled={!isPlaying}>
|
<button type="button" onClick={() => player.stop()} disabled={!isPlaying}>
|
||||||
Stop Stream
|
Stop
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user