Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca05f71ff | ||
|
|
e847c667b3 | ||
|
|
bf4dd174b0 | ||
|
|
695d3ded97 | ||
|
|
eab0abf800 | ||
|
|
4bbcc7d537 | ||
|
|
2b0b043d3d | ||
|
|
dcae0b604b |
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
appId: 'com.kryzgo.app',
|
||||
appName: 'kryz-go',
|
||||
webDir: 'www',
|
||||
webDir: 'out',
|
||||
android: {
|
||||
adjustMarginsForEdgeToEdge: 'force',
|
||||
buildOptions: {
|
||||
|
||||
@@ -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/>
|
||||
|
||||
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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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