Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bbcc7d537 | ||
|
|
2b0b043d3d | ||
|
|
dcae0b604b |
@@ -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
|
||||||
|
```
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
: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.playpause {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
font-size: clamp(2rem, 25vw, 6em);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { AudioPlayer, CapacitorWindow, STREAM_URL } from './AudioPlayer';
|
||||||
|
|
||||||
|
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="page-shell">
|
||||||
|
<section className="player-card" aria-label="KRYZ live stream player">
|
||||||
|
<p className="eyebrow">KRYZ Go!</p>
|
||||||
|
<h1>Live Radio</h1>
|
||||||
|
<p className="status" aria-live="polite">
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="controls">
|
||||||
|
<button type="button" className='playpause' onClick={() => void player.play(backgroundMode)} disabled={isPlaying}>
|
||||||
|
▶️
|
||||||
|
</button>
|
||||||
|
<button type="button" className='playpause' onClick={() => player.stop()} disabled={!isPlaying}>
|
||||||
|
⏹️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio ref={audioRef} preload="none" />
|
||||||
|
</section>
|
||||||
|
</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: {
|
||||||
|
|||||||
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