diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1ade0ad..3582058 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,12 +6,10 @@ "customizations": { "vscode": { "extensions": [ - "vscjava.vscode-java-pack", "ms-vscode.vscode-typescript-next" ], "settings": { - "terminal.integrated.defaultProfile.linux": "bash", - "java.home": "/usr/lib/jvm/java-17-openjdk-amd64" + "terminal.integrated.defaultProfile.linux": "bash" } } }, diff --git a/README.md b/README.md index d1aa83d..070be9a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,48 @@ # kryz-go -KRYZ Go! Mobile radio app \ No newline at end of file +KRYZ Go! mobile radio app built with Next.js + React and packaged with Capacitor. + +## Stack + +- Next.js (App Router, static export) +- React +- Capacitor (Android and iOS) +- `@anuradev/capacitor-background-mode` for improved background playback reliability + +## Install + +```bash +npm install +``` + +## Develop Frontend + +```bash +npm run dev +``` + +## Build Web Assets For Capacitor + +```bash +npm run build +``` + +This exports static files into `out/`, which is the Capacitor `webDir`. + +## Sync Native Projects + +```bash +npm run sync +``` + +## Run Android + +```bash +npm run android +``` + +## Open Android Studio + +```bash +npm run open:android +``` \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 340e7df..c7fa7a0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ 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_a'; + +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/components/RadioControls.tsx b/app/components/RadioControls.tsx new file mode 100644 index 0000000..c54f5ce --- /dev/null +++ b/app/components/RadioControls.tsx @@ -0,0 +1,36 @@ +type RadioControlsProps = { + status: string; + isPlaying: boolean; + onPlay: () => void | Promise; + onStop: () => void; +}; + +export function RadioControls({ status, isPlaying, onPlay, onStop }: RadioControlsProps) { + return ( +
+

+ {status} +

+
+ + +
+
+ ); +} diff --git a/app/components/WebsitePanel.tsx b/app/components/WebsitePanel.tsx new file mode 100644 index 0000000..4cb0906 --- /dev/null +++ b/app/components/WebsitePanel.tsx @@ -0,0 +1,27 @@ +type WebsitePanelProps = { + label: string; + url: string; +}; + +export function WebsitePanel({ label, url }: WebsitePanelProps) { + return ( +
+
+

{label}

+

+ {url} +

+
+
+