Compare commits

..
8 Commits
19 changed files with 637 additions and 232 deletions
+1 -3
View File
@@ -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"
}
}
},
+46 -1
View File
@@ -1,3 +1,48 @@
# kryz-go
KRYZ Go! Mobile radio app
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
```
+1
View File
@@ -11,6 +11,7 @@
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:screenOrientation="portrait"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
+172
View File
@@ -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_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<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.');
}
}
+36
View File
@@ -0,0 +1,36 @@
type RadioControlsProps = {
status: string;
isPlaying: boolean;
onPlay: () => void | Promise<void>;
onStop: () => void;
};
export function RadioControls({ status, isPlaying, onPlay, onStop }: RadioControlsProps) {
return (
<section className="radio-controls" aria-label="Radio playback controls">
<p className="radio-controls__status" aria-live="polite">
{status}
</p>
<div className="radio-controls__buttons">
<button
type="button"
className="playpause"
onClick={() => void onPlay()}
disabled={isPlaying}
aria-label="Start live stream"
>
</button>
<button
type="button"
className="playpause"
onClick={onStop}
disabled={!isPlaying}
aria-label="Stop live stream"
>
</button>
</div>
</section>
);
}
+27
View File
@@ -0,0 +1,27 @@
type WebsitePanelProps = {
label: string;
url: string;
};
export function WebsitePanel({ label, url }: WebsitePanelProps) {
return (
<section className="website-panel" aria-label={label}>
<header className="website-panel__header">
<p className="website-panel__label">{label}</p>
<p className="website-panel__url" title={url}>
{url}
</p>
</header>
<div className="website-panel__content">
<iframe
className="website-panel__frame"
src={url}
title={label}
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
/>
</div>
</section>
);
}
+15
View File
@@ -0,0 +1,15 @@
export type SiteConfig = {
id: string;
label: string;
url: string;
};
export const SITES: SiteConfig[] = [
{
id: 'kryz-home',
label: 'Station Website',
url: 'https://kryzradio.org/',
},
];
export const ACTIVE_SITE = SITES[0];
+187
View File
@@ -0,0 +1,187 @@
:root {
--bg-1: #f8f2e6;
--bg-2: #dce9f4;
--panel: #f9f8f3;
--panel-line: #d8d1c4;
--ink: #1f2633;
--muted: #586173;
--primary: #0a5f94;
--primary-press: #084d79;
--disabled: #9fa9b8;
--controls-height: 10svh;
--controls-safe: env(safe-area-inset-bottom);
--controls-total-height: calc(var(--controls-height) + var(--controls-safe));
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
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));
overflow: hidden;
}
.home-layout {
position: relative;
height: 100svh;
width: 100%;
padding: 1rem;
padding-bottom: calc(var(--controls-total-height) + 1rem);
overflow: hidden;
}
.website-zone {
height: 100%;
min-height: 0;
}
.website-panel {
height: 100%;
border-radius: 1rem;
border: 1px solid var(--panel-line);
background: var(--panel);
box-shadow:
0 1.2rem 2.2rem rgba(18, 50, 73, 0.12),
inset 0 0.1rem 0.2rem rgba(255, 255, 255, 0.7);
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
}
.website-panel__header {
border-bottom: 1px solid var(--panel-line);
padding: 0.75rem 1rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(247, 242, 233, 0.9));
}
.website-panel__label {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.68rem;
font-weight: 700;
color: var(--muted);
}
.website-panel__url {
margin: 0.25rem 0 0;
font-size: clamp(0.82rem, 2.2vw, 0.96rem);
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.website-panel__content {
min-height: 0;
}
.website-panel__frame {
width: 100%;
height: 100%;
border: none;
background: #ffffff;
}
.radio-controls-wrap {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: var(--controls-total-height);
padding: 0.5rem 1rem;
padding-bottom: calc(0.5rem + var(--controls-safe));
border-top: 1px solid rgba(28, 41, 62, 0.16);
background: linear-gradient(180deg, rgba(255, 250, 242, 0.95), rgba(239, 235, 225, 0.98));
backdrop-filter: blur(7px);
}
.radio-controls {
height: 100%;
max-width: 48rem;
margin: 0 auto;
display: grid;
gap: 0.55rem;
grid-template-columns: 1fr auto;
align-items: center;
}
.radio-controls__status {
margin: 0;
color: var(--muted);
font-size: clamp(0.8rem, 2.2vw, 1rem);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.radio-controls__buttons {
display: grid;
grid-auto-flow: column;
gap: 0.6rem;
}
.radio-controls 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;
}
.radio-controls button.playpause {
width: clamp(2.9rem, 9vw, 4rem);
aspect-ratio: 1 / 1;
font-size: clamp(1.15rem, 4vw, 1.7rem);
padding: 0;
}
.radio-controls button:hover:not(:disabled) {
background: var(--primary-press);
}
.radio-controls button:active:not(:disabled) {
transform: translateY(1px);
}
.radio-controls button:disabled {
cursor: not-allowed;
background: var(--disabled);
}
@media (max-width: 560px) {
.home-layout {
padding: 0.65rem;
padding-bottom: calc(var(--controls-total-height) + 0.65rem);
}
.radio-controls-wrap {
padding-left: 0.65rem;
padding-right: 0.65rem;
}
.radio-controls {
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
}
.radio-controls__buttons {
justify-content: end;
}
}
+25
View File
@@ -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 (
<html lang="en">
<head>
<meta
httpEquiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; media-src 'self' https://kryz.out.airtime.pro; frame-src 'self' https:; child-src 'self' https:"
/>
</head>
<body>{children}</body>
</html>
);
}
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { AudioPlayer, CapacitorWindow, STREAM_URL } from './AudioPlayer';
import { RadioControls } from './components/RadioControls';
import { WebsitePanel } from './components/WebsitePanel';
import { ACTIVE_SITE } from './config/sites';
export default function HomePage() {
const audioRef = useRef<HTMLAudioElement | null>(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;
}, []);
const player = useMemo(
() =>
new AudioPlayer(STREAM_URL, (state, msg) => {
setIsPlaying(state === 'playing');
setStatus(msg);
}),
[]
);
useEffect(() => {
if (audioRef.current) {
player.setElement(audioRef.current);
}
}, [player]);
useEffect(() => {
if (!backgroundMode) {
console.warn('BackgroundMode plugin is not available in this runtime.');
return;
}
void player.initBackgroundMode(backgroundMode);
}, [player, backgroundMode]);
return (
<main className="home-layout">
<section className="website-zone">
<WebsitePanel label={ACTIVE_SITE.label} url={ACTIVE_SITE.url} />
</section>
<div className="radio-controls-wrap">
<RadioControls
status={status}
isPlaying={isPlaying}
onPlay={() => player.play(backgroundMode)}
onStop={() => player.stop()}
/>
</div>
<audio ref={audioRef} preload="none" />
</main>
);
}
+1 -1
View File
@@ -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: {
-4
View File
@@ -33,15 +33,11 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'export',
trailingSlash: true,
images: {
unoptimized: true,
},
};
export default nextConfig;
+11 -4
View File
@@ -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"
}
}
+36
View File
@@ -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"
]
}
-61
View File
@@ -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%;
}
}
-25
View File
@@ -1,25 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; media-src 'self' https://kryz.out.airtime.pro"
/>
<title>kryz-go</title>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<div id="app">
<h1>kryz-go</h1>
<p id="stream-status" aria-live="polite">Stream is stopped.</p>
<div class="controls">
<button id="start-stream" type="button">Start Stream</button>
<button id="stop-stream" type="button" disabled>Stop Stream</button>
</div>
<audio id="stream-player" preload="none"></audio>
</div>
<script src="js/app.js"></script>
</body>
</html>
-133
View File
@@ -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);
}
}