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