From dcae0b604b526c58f2add7ac52323d35687034f2 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Tue, 28 Apr 2026 01:03:44 +0000 Subject: [PATCH 1/7] Transitions UI to react.js Co-authored-by: Copilot --- README.md | 47 +++++++++- app/globals.css | 118 ++++++++++++++++++++++++ app/layout.tsx | 25 ++++++ app/page.tsx | 215 ++++++++++++++++++++++++++++++++++++++++++++ capacitor.config.ts | 2 +- next-env.d.ts | 6 ++ next.config.ts | 11 +++ package.json | 15 +++- tsconfig.json | 36 ++++++++ www/css/style.css | 61 ------------- www/index.html | 25 ------ www/js/app.js | 133 --------------------------- 12 files changed, 469 insertions(+), 225 deletions(-) create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 tsconfig.json delete mode 100644 www/css/style.css delete mode 100644 www/index.html delete mode 100644 www/js/app.js 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/app/globals.css b/app/globals.css new file mode 100644 index 0000000..578df61 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,118 @@ +:root { + --bg-1: #f8f2e6; + --bg-2: #dce9f4; + --card: #fff9f0; + --ink: #1f2633; + --muted: #586173; + --primary: #0a5f94; + --primary-press: #084d79; + --disabled: #9fa9b8; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + color: var(--ink); + font-family: 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif; + background: + radial-gradient(circle at 16% 18%, rgba(255, 255, 255, 0.85) 0%, rgba(255, 255, 255, 0) 44%), + linear-gradient(140deg, var(--bg-1), var(--bg-2)); +} + +.page-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1.25rem; +} + +.player-card { + width: min(30rem, 100%); + border-radius: 1.25rem; + background: var(--card); + box-shadow: + 0 1.5rem 3rem rgba(18, 50, 73, 0.17), + inset 0 0.1rem 0.25rem rgba(255, 255, 255, 0.6); + padding: 1.4rem; + text-align: center; + animation: card-enter 420ms ease-out; +} + +.eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.14em; + font-weight: 700; + font-size: 0.72rem; + color: var(--muted); +} + +h1 { + margin: 0.4rem 0 0.6rem; + font-size: clamp(2rem, 8vw, 2.8rem); + line-height: 1.1; +} + +.status { + margin: 0 0 1.2rem; + color: var(--muted); + font-size: 1rem; + min-height: 1.3rem; +} + +.controls { + display: grid; + gap: 0.75rem; + grid-template-columns: 1fr 1fr; +} + +button { + appearance: none; + border: none; + border-radius: 0.7rem; + background: var(--primary); + color: #ffffff; + font-size: 1rem; + font-weight: 700; + padding: 0.78rem 1rem; + cursor: pointer; + transition: transform 120ms ease, background 120ms ease; +} + +button:hover:not(:disabled) { + background: var(--primary-press); +} + +button:active:not(:disabled) { + transform: translateY(1px); +} + +button:disabled { + cursor: not-allowed; + background: var(--disabled); +} + +@keyframes card-enter { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 560px) { + .controls { + grid-template-columns: 1fr; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..a220118 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'kryz-go', + description: 'KRYZ Go! mobile radio app', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..728b4d2 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,215 @@ +'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'; + +export default function HomePage() { + const audioRef = useRef(null); + const [status, setStatus] = useState('Stream is stopped.'); + const [isPlaying, setIsPlaying] = useState(false); + + const backgroundMode = useMemo(() => { + if (typeof window === 'undefined') { + return undefined; + } + return (window as CapacitorWindow).Capacitor?.Plugins?.BackgroundMode; + }, []); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) { + return; + } + + 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(() => { + 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.'); + }; + + return ( +
+
+

KRYZ Go!

+

Live Radio

+

+ {status} +

+ +
+ + +
+ +
+
+ ); +} diff --git a/capacitor.config.ts b/capacitor.config.ts index f33194a..f2164a9 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -3,7 +3,7 @@ import { CapacitorConfig } from '@capacitor/cli'; const config: CapacitorConfig = { appId: 'com.kryzgo.app', appName: 'kryz-go', - webDir: 'www', + webDir: 'out', android: { adjustMarginsForEdgeToEdge: 'force', buildOptions: { diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..4597eb8 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,11 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + output: 'export', + trailingSlash: true, + images: { + unoptimized: true, + }, +}; + +export default nextConfig; diff --git a/package.json b/package.json index 5add92f..ce6a37b 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,26 @@ "version": "1.0.0", "description": "Capacitor mobile app", "scripts": { - "build": "echo 'Add your web build command here'", - "sync": "cap sync", - "android": "cap run android", + "dev": "next dev", + "build": "next build", + "sync": "npm run build && cap sync", + "android": "npm run sync && cap run android", "open:android": "cap open android" }, "dependencies": { "@anuradev/capacitor-background-mode": "^7.2.1", "@capacitor/android": "^7.0.0", "@capacitor/core": "^7.0.0", - "@capacitor/ios": "^7.0.0" + "@capacitor/ios": "^7.0.0", + "next": "^16.2.4", + "react": "^19.2.5", + "react-dom": "^19.2.5" }, "devDependencies": { "@capacitor/cli": "^7.0.0", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "typescript": "^6.0.3" } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..79b1981 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/www/css/style.css b/www/css/style.css deleted file mode 100644 index 96501e1..0000000 --- a/www/css/style.css +++ /dev/null @@ -1,61 +0,0 @@ -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #ffffff; - color: #1a1a1a; -} - -#app { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 1rem; - min-height: 100vh; - padding: 1rem; - text-align: center; -} - -h1 { - font-size: 2rem; -} - -#stream-status { - font-size: 1rem; -} - -.controls { - display: flex; - gap: 0.75rem; -} - -button { - border: none; - border-radius: 0.5rem; - padding: 0.75rem 1rem; - font-size: 1rem; - font-weight: 600; - color: #ffffff; - background: #1456a7; -} - -button:disabled { - background: #9da8b3; - color: #f5f5f5; -} - -@media (max-width: 480px) { - .controls { - width: 100%; - flex-direction: column; - } - - button { - width: 100%; - } -} diff --git a/www/index.html b/www/index.html deleted file mode 100644 index da4e8bd..0000000 --- a/www/index.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - kryz-go - - - -
-

kryz-go

-

Stream is stopped.

-
- - -
- -
- - - diff --git a/www/js/app.js b/www/js/app.js deleted file mode 100644 index 1f6a506..0000000 --- a/www/js/app.js +++ /dev/null @@ -1,133 +0,0 @@ -// kryz-go app entry point -window.addEventListener('DOMContentLoaded', () => { - console.log('kryz-go loaded'); - initializeStreamPlayer(); - initializeBackgroundMode(); -}); - -function initializeStreamPlayer() { - const streamUrl = 'https://kryz.out.airtime.pro/kryz_b'; - const audio = document.getElementById('stream-player'); - const startButton = document.getElementById('start-stream'); - const stopButton = document.getElementById('stop-stream'); - const status = document.getElementById('stream-status'); - - if (!audio || !startButton || !stopButton || !status) { - console.error('Stream player controls are missing from the page.'); - return; - } - - const setStatus = (isPlaying, text) => { - startButton.disabled = isPlaying; - stopButton.disabled = !isPlaying; - status.textContent = text; - }; - - const ensureBackgroundPlaybackPermissions = async () => { - const backgroundMode = window.Capacitor?.Plugins?.BackgroundMode; - - 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(false, 'Enable microphone permission in Android settings for screen-off playback.'); - console.warn('Background playback blocked: microphone permission not granted.'); - return false; - }; - - const startStream = async () => { - try { - const hasBackgroundPermissions = await ensureBackgroundPlaybackPermissions(); - if (!hasBackgroundPermissions) { - return; - } - - audio.src = streamUrl; - await audio.play(); - setStatus(true, 'Stream is playing.'); - } catch (error) { - setStatus(false, 'Unable to start stream.'); - console.error('Failed to start stream:', error); - } - }; - - const stopStream = () => { - audio.pause(); - audio.removeAttribute('src'); - audio.load(); - setStatus(false, 'Stream is stopped.'); - }; - - startButton.addEventListener('click', startStream); - stopButton.addEventListener('click', stopStream); - audio.addEventListener('ended', () => { - setStatus(false, 'Stream ended.'); - }); - audio.addEventListener('error', () => { - setStatus(false, 'Stream error.'); - }); -} - -async function initializeBackgroundMode() { - const backgroundMode = window.Capacitor?.Plugins?.BackgroundMode; - - if (!backgroundMode) { - console.warn('BackgroundMode plugin is not available in this runtime.'); - return; - } - - 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); - } -} From 2b0b043d3de6b612f59bb0d61d1430ece6e71d46 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Tue, 28 Apr 2026 01:37:13 +0000 Subject: [PATCH 2/7] 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() {

- -
From 4bbcc7d537ba43bb0ba597534b91ab6aaf20e061 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Tue, 28 Apr 2026 02:22:25 +0000 Subject: [PATCH 3/7] Replaces words with emoji. Formatting enhancements for play/pause buttons. Switched to lower fidelity audio stream for better performance. --- app/AudioPlayer.ts | 2 +- app/globals.css | 7 +++++++ app/page.tsx | 8 ++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/AudioPlayer.ts b/app/AudioPlayer.ts index e453823..201975e 100644 --- a/app/AudioPlayer.ts +++ b/app/AudioPlayer.ts @@ -32,7 +32,7 @@ export type CapacitorWindow = Window & { export type PlaybackState = 'stopped' | 'playing' | 'error'; -export const STREAM_URL = 'https://kryz.out.airtime.pro/kryz_b'; +export const STREAM_URL = 'https://kryz.out.airtime.pro/kryz_a'; export class AudioPlayer { private url: string; diff --git a/app/globals.css b/app/globals.css index 578df61..63cd5b2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -87,6 +87,13 @@ button { transition: transform 120ms ease, background 120ms ease; } +button.playpause { + width: 100%; + aspect-ratio: 1; + font-size: clamp(2rem, 25vw, 6em); + padding: 0; +} + button:hover:not(:disabled) { background: var(--primary-press); } diff --git a/app/page.tsx b/app/page.tsx index 7fd79d1..111066b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -48,11 +48,11 @@ export default function HomePage() {

- -
From eab0abf800697e35f6e20535923f657713fb69db Mon Sep 17 00:00:00 2001 From: kfj001 Date: Tue, 28 Apr 2026 02:41:47 +0000 Subject: [PATCH 4/7] New redesign Co-authored-by: Copilot --- app/components/RadioControls.tsx | 36 +++++++ app/components/WebsitePanel.tsx | 27 ++++++ app/config/sites.ts | 15 +++ app/globals.css | 161 +++++++++++++++++++++---------- app/layout.tsx | 2 +- app/page.tsx | 35 ++++--- next-env.d.ts | 2 +- 7 files changed, 208 insertions(+), 70 deletions(-) create mode 100644 app/components/RadioControls.tsx create mode 100644 app/components/WebsitePanel.tsx create mode 100644 app/config/sites.ts 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} +

+
+
+