Compare commits
8
Commits
1897f8a9e7
...
aca05f71ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca05f71ff | ||
|
|
e847c667b3 | ||
|
|
bf4dd174b0 | ||
|
|
695d3ded97 | ||
|
|
eab0abf800 | ||
|
|
4bbcc7d537 | ||
|
|
2b0b043d3d | ||
|
|
dcae0b604b |
@@ -6,12 +6,10 @@
|
|||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"vscjava.vscode-java-pack",
|
|
||||||
"ms-vscode.vscode-typescript-next"
|
"ms-vscode.vscode-typescript-next"
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"terminal.integrated.defaultProfile.linux": "bash",
|
"terminal.integrated.defaultProfile.linux": "bash"
|
||||||
"java.home": "/usr/lib/jvm/java-17-openjdk-amd64"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,48 @@
|
|||||||
# kryz-go
|
# 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
|
||||||
|
```
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:label="@string/title_activity_main"
|
android:label="@string/title_activity_main"
|
||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -3,7 +3,7 @@ import { CapacitorConfig } from '@capacitor/cli';
|
|||||||
const config: CapacitorConfig = {
|
const config: CapacitorConfig = {
|
||||||
appId: 'com.kryzgo.app',
|
appId: 'com.kryzgo.app',
|
||||||
appName: 'kryz-go',
|
appName: 'kryz-go',
|
||||||
webDir: 'www',
|
webDir: 'out',
|
||||||
android: {
|
android: {
|
||||||
adjustMarginsForEdgeToEdge: 'force',
|
adjustMarginsForEdgeToEdge: 'force',
|
||||||
buildOptions: {
|
buildOptions: {
|
||||||
|
|||||||
@@ -33,15 +33,11 @@
|
|||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
Vendored
+6
@@ -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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'export',
|
||||||
|
trailingSlash: true,
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
+11
-4
@@ -3,19 +3,26 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Capacitor mobile app",
|
"description": "Capacitor mobile app",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'Add your web build command here'",
|
"dev": "next dev",
|
||||||
"sync": "cap sync",
|
"build": "next build",
|
||||||
"android": "cap run android",
|
"sync": "npm run build && cap sync",
|
||||||
|
"android": "npm run sync && cap run android",
|
||||||
"open:android": "cap open android"
|
"open:android": "cap open android"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anuradev/capacitor-background-mode": "^7.2.1",
|
"@anuradev/capacitor-background-mode": "^7.2.1",
|
||||||
"@capacitor/android": "^7.0.0",
|
"@capacitor/android": "^7.0.0",
|
||||||
"@capacitor/core": "^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": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^7.0.0",
|
"@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"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user