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); - } -}