andorid app
11
android/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
This folder is not a full Android Studio project.
|
||||||
|
|
||||||
|
The buildable Android Studio/Gradle project is generated by Tauri at:
|
||||||
|
|
||||||
|
- src-tauri/gen/android
|
||||||
|
|
||||||
|
If you haven't generated it yet, run from the repo root:
|
||||||
|
|
||||||
|
- .\node_modules\.bin\tauri.cmd android init --ci
|
||||||
|
|
||||||
|
Then open `src-tauri/gen/android` in Android Studio and build the APK/AAB.
|
||||||
BIN
android/app/src/main/assets/assets/appIcon.png
Normal file
|
After Width: | Height: | Size: 682 KiB |
BIN
android/app/src/main/assets/assets/favicon_io.zip
Normal file
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 290 KiB |
BIN
android/app/src/main/assets/assets/favicon_io/app-icon.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 49 KiB |
BIN
android/app/src/main/assets/assets/favicon_io/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 859 B |
BIN
android/app/src/main/assets/assets/favicon_io/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
android/app/src/main/assets/assets/favicon_io/icon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
1
android/app/src/main/assets/assets/javascript.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
4
android/app/src/main/assets/assets/tauri.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="206" height="231" viewBox="0 0 206 231">
|
||||||
|
<!-- Wrapper SVG that embeds the PNG app icon so existing references to tauri.svg render the PNG -->
|
||||||
|
<image href="appIcon.png" width="206" height="231" preserveAspectRatio="xMidYMid slice" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
158
android/app/src/main/assets/index.html
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Radio1 Player</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<script src="main.js" defer type="module"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="bg-shape shape-1"></div>
|
||||||
|
<div class="bg-shape shape-2"></div>
|
||||||
|
|
||||||
|
<main class="glass-card">
|
||||||
|
<header data-tauri-drag-region>
|
||||||
|
<button id="menu-btn" class="icon-btn" aria-label="Menu">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="header-info" data-tauri-drag-region>
|
||||||
|
<span class="app-title">Radio1 Player</span>
|
||||||
|
<span class="status-indicator" id="status-indicator">
|
||||||
|
<span class="status-dot"></span> <span id="status-text">Ready</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-buttons">
|
||||||
|
<button id="cast-toggle-btn" class="icon-btn" aria-label="Cast">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a14 14 0 0 1 14 14h-2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="close-btn" class="icon-btn close-btn" aria-label="Close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="artwork-section">
|
||||||
|
<div class="artwork-container">
|
||||||
|
<div class="artwork-placeholder">
|
||||||
|
<!-- Gooey SVG filter for fluid blob blending -->
|
||||||
|
<svg width="0" height="0" style="position:absolute">
|
||||||
|
<defs>
|
||||||
|
<filter id="goo">
|
||||||
|
<!-- increased blur for smoother, more transparent blending -->
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="18" result="blur" />
|
||||||
|
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7" result="goo" />
|
||||||
|
<feBlend in="SourceGraphic" in2="goo" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="logo-blobs" aria-hidden="true">
|
||||||
|
<span class="blob b1"></span>
|
||||||
|
<span class="blob b2"></span>
|
||||||
|
<span class="blob b3"></span>
|
||||||
|
<span class="blob b4"></span>
|
||||||
|
<span class="blob b5"></span>
|
||||||
|
<span class="blob b6"></span>
|
||||||
|
<span class="blob b7"></span>
|
||||||
|
<span class="blob b8"></span>
|
||||||
|
<span class="blob b9"></span>
|
||||||
|
<span class="blob b10"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
|
||||||
|
<span class="station-logo-text">1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="track-info">
|
||||||
|
<h2 id="station-name">Radio 1 MB</h2>
|
||||||
|
<p id="station-subtitle">Live Stream</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Visual Progress Bar (Live) -->
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
<div class="progress-handle"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="controls-section">
|
||||||
|
<button id="prev-btn" class="control-btn secondary" aria-label="Previous Station">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="play-btn" class="control-btn primary" aria-label="Play">
|
||||||
|
<div class="icon-container">
|
||||||
|
<!-- Play Icon -->
|
||||||
|
<svg id="icon-play" width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
<!-- Stop/Pause Icon (Hidden by default) -->
|
||||||
|
<svg id="icon-stop" class="hidden" width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 6h12v12H6z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="next-btn" class="control-btn secondary" aria-label="Next Station">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="volume-section">
|
||||||
|
<button id="mute-btn" class="icon-btn small">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="slider-container">
|
||||||
|
<input type="range" id="volume-slider" min="0" max="100" value="50">
|
||||||
|
</div>
|
||||||
|
<span id="volume-value">50%</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Hidden Cast Overlay (Beautified) -->
|
||||||
|
<div id="cast-overlay" class="overlay hidden" aria-hidden="true" data-tauri-drag-region>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="deviceTitle">
|
||||||
|
<h2 id="deviceTitle">Choose</h2>
|
||||||
|
|
||||||
|
<ul id="device-list" class="device-list">
|
||||||
|
<!-- Render device items here -->
|
||||||
|
<li class="device">
|
||||||
|
<div class="device-main">Scanning...</div>
|
||||||
|
<div class="device-sub">Searching for speakers</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button id="close-overlay" class="btn cancel" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
355
android/app/src/main/assets/main.js
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
const { getCurrentWindow } = window.__TAURI__.window;
|
||||||
|
|
||||||
|
// State
|
||||||
|
let stations = [];
|
||||||
|
let currentIndex = 0;
|
||||||
|
let isPlaying = false;
|
||||||
|
let currentMode = 'local'; // 'local' | 'cast'
|
||||||
|
let currentCastDevice = null;
|
||||||
|
const audio = new Audio();
|
||||||
|
|
||||||
|
// UI Elements
|
||||||
|
const stationNameEl = document.getElementById('station-name');
|
||||||
|
const stationSubtitleEl = document.getElementById('station-subtitle');
|
||||||
|
const statusTextEl = document.getElementById('status-text');
|
||||||
|
const statusDotEl = document.querySelector('.status-dot');
|
||||||
|
const playBtn = document.getElementById('play-btn');
|
||||||
|
const iconPlay = document.getElementById('icon-play');
|
||||||
|
const iconStop = document.getElementById('icon-stop');
|
||||||
|
const prevBtn = document.getElementById('prev-btn');
|
||||||
|
const nextBtn = document.getElementById('next-btn');
|
||||||
|
const volumeSlider = document.getElementById('volume-slider');
|
||||||
|
const volumeValue = document.getElementById('volume-value');
|
||||||
|
const castBtn = document.getElementById('cast-toggle-btn');
|
||||||
|
const castOverlay = document.getElementById('cast-overlay');
|
||||||
|
const closeOverlayBtn = document.getElementById('close-overlay');
|
||||||
|
const deviceListEl = document.getElementById('device-list');
|
||||||
|
const logoTextEl = document.querySelector('.station-logo-text');
|
||||||
|
const logoImgEl = document.getElementById('station-logo-img');
|
||||||
|
|
||||||
|
// Init
|
||||||
|
async function init() {
|
||||||
|
await loadStations();
|
||||||
|
setupEventListeners();
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStations() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('stations.json');
|
||||||
|
const raw = await resp.json();
|
||||||
|
|
||||||
|
// Normalize station objects so the rest of the app can rely on `name` and `url`.
|
||||||
|
stations = raw
|
||||||
|
.map((s) => {
|
||||||
|
// If already in the old format, keep as-is
|
||||||
|
if (s.name && s.url) return s;
|
||||||
|
|
||||||
|
const name = s.title || s.id || s.name || 'Unknown';
|
||||||
|
// Prefer liveAudio, fall back to liveVideo or any common fields
|
||||||
|
const url = s.liveAudio || s.liveVideo || s.liveStream || s.url || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: s.id || name,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
logo: s.logo || s.poster || '',
|
||||||
|
enabled: typeof s.enabled === 'boolean' ? s.enabled : true,
|
||||||
|
raw: s,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
// Filter out disabled stations and those without a stream URL
|
||||||
|
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
||||||
|
|
||||||
|
if (stations.length > 0) {
|
||||||
|
currentIndex = 0;
|
||||||
|
loadStation(currentIndex);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load stations', e);
|
||||||
|
statusTextEl.textContent = 'Error loading stations';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEventListeners() {
|
||||||
|
playBtn.addEventListener('click', togglePlay);
|
||||||
|
prevBtn.addEventListener('click', playPrev);
|
||||||
|
nextBtn.addEventListener('click', playNext);
|
||||||
|
|
||||||
|
volumeSlider.addEventListener('input', handleVolumeInput);
|
||||||
|
|
||||||
|
castBtn.addEventListener('click', openCastOverlay);
|
||||||
|
closeOverlayBtn.addEventListener('click', closeCastOverlay);
|
||||||
|
|
||||||
|
// Close overlay on background click
|
||||||
|
castOverlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === castOverlay) closeCastOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
document.getElementById('close-btn').addEventListener('click', async () => {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
await appWindow.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Menu button - explicit functionality or placeholder?
|
||||||
|
// For now just log or maybe show about
|
||||||
|
document.getElementById('menu-btn').addEventListener('click', () => {
|
||||||
|
openStationsOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hotkeys?
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStation(index) {
|
||||||
|
if (index < 0 || index >= stations.length) return;
|
||||||
|
const station = stations[index];
|
||||||
|
|
||||||
|
stationNameEl.textContent = station.name;
|
||||||
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream';
|
||||||
|
|
||||||
|
// Update Logo Text (First letter or number)
|
||||||
|
// Simple heuristic: if name has a number, use it, else first letter
|
||||||
|
// If station has a logo URL, show the image; otherwise show the text fallback
|
||||||
|
if (station.logo && station.logo.length > 0) {
|
||||||
|
logoImgEl.src = station.logo;
|
||||||
|
logoImgEl.classList.remove('hidden');
|
||||||
|
logoTextEl.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
// Fallback to single-letter/logo text
|
||||||
|
logoImgEl.src = '';
|
||||||
|
logoImgEl.classList.add('hidden');
|
||||||
|
const numberMatch = station.name.match(/\d+/);
|
||||||
|
if (numberMatch) {
|
||||||
|
logoTextEl.textContent = numberMatch[0];
|
||||||
|
} else {
|
||||||
|
logoTextEl.textContent = station.name.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
logoTextEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlay() {
|
||||||
|
if (isPlaying) {
|
||||||
|
await stop();
|
||||||
|
} else {
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function play() {
|
||||||
|
const station = stations[currentIndex];
|
||||||
|
if (!station) return;
|
||||||
|
|
||||||
|
statusTextEl.textContent = 'Buffering...';
|
||||||
|
statusDotEl.style.backgroundColor = 'var(--text-muted)'; // Grey/Yellow while loading
|
||||||
|
|
||||||
|
if (currentMode === 'local') {
|
||||||
|
audio.src = station.url;
|
||||||
|
audio.volume = volumeSlider.value / 100;
|
||||||
|
try {
|
||||||
|
await audio.play();
|
||||||
|
isPlaying = true;
|
||||||
|
updateUI();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Playback failed', e);
|
||||||
|
statusTextEl.textContent = 'Error';
|
||||||
|
}
|
||||||
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
||||||
|
// Cast logic
|
||||||
|
try {
|
||||||
|
await invoke('cast_play', { deviceName: currentCastDevice, url: station.url });
|
||||||
|
isPlaying = true;
|
||||||
|
// Sync volume
|
||||||
|
const vol = volumeSlider.value / 100;
|
||||||
|
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: vol });
|
||||||
|
updateUI();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cast failed', e);
|
||||||
|
statusTextEl.textContent = 'Cast Error';
|
||||||
|
currentMode = 'local'; // Fallback
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (currentMode === 'local') {
|
||||||
|
audio.pause();
|
||||||
|
audio.src = '';
|
||||||
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
||||||
|
try {
|
||||||
|
await invoke('cast_stop', { deviceName: currentCastDevice });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying = false;
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playNext() {
|
||||||
|
if (stations.length === 0) return;
|
||||||
|
|
||||||
|
// If playing, stop first? Or seamless?
|
||||||
|
// For radio, seamless switch requires stop then play new URL
|
||||||
|
const wasPlaying = isPlaying;
|
||||||
|
|
||||||
|
if (wasPlaying) await stop();
|
||||||
|
|
||||||
|
currentIndex = (currentIndex + 1) % stations.length;
|
||||||
|
loadStation(currentIndex);
|
||||||
|
|
||||||
|
if (wasPlaying) await play();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playPrev() {
|
||||||
|
if (stations.length === 0) return;
|
||||||
|
|
||||||
|
const wasPlaying = isPlaying;
|
||||||
|
|
||||||
|
if (wasPlaying) await stop();
|
||||||
|
|
||||||
|
currentIndex = (currentIndex - 1 + stations.length) % stations.length;
|
||||||
|
loadStation(currentIndex);
|
||||||
|
|
||||||
|
if (wasPlaying) await play();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
// Play/Stop Button
|
||||||
|
if (isPlaying) {
|
||||||
|
iconPlay.classList.add('hidden');
|
||||||
|
iconStop.classList.remove('hidden');
|
||||||
|
playBtn.classList.add('playing'); // Add pulsing ring animation
|
||||||
|
statusTextEl.textContent = 'Playing';
|
||||||
|
statusDotEl.style.backgroundColor = 'var(--success)';
|
||||||
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream';
|
||||||
|
} else {
|
||||||
|
iconPlay.classList.remove('hidden');
|
||||||
|
iconStop.classList.add('hidden');
|
||||||
|
playBtn.classList.remove('playing'); // Remove pulsing ring
|
||||||
|
statusTextEl.textContent = 'Ready';
|
||||||
|
statusDotEl.style.backgroundColor = 'var(--text-muted)';
|
||||||
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVolumeInput() {
|
||||||
|
const val = volumeSlider.value;
|
||||||
|
volumeValue.textContent = `${val}%`;
|
||||||
|
const decimals = val / 100;
|
||||||
|
|
||||||
|
if (currentMode === 'local') {
|
||||||
|
audio.volume = decimals;
|
||||||
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
||||||
|
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast Logic
|
||||||
|
async function openCastOverlay() {
|
||||||
|
castOverlay.classList.remove('hidden');
|
||||||
|
castOverlay.setAttribute('aria-hidden', 'false');
|
||||||
|
deviceListEl.innerHTML = '<li class="device"><div class="device-main">Scanning...</div><div class="device-sub">Searching for speakers</div></li>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const devices = await invoke('list_cast_devices');
|
||||||
|
deviceListEl.innerHTML = '';
|
||||||
|
|
||||||
|
// Add "This Computer" option
|
||||||
|
const localLi = document.createElement('li');
|
||||||
|
localLi.className = 'device' + (currentMode === 'local' ? ' selected' : '');
|
||||||
|
localLi.innerHTML = '<div class="device-main">This Computer</div><div class="device-sub">Local Playback</div>';
|
||||||
|
localLi.onclick = () => selectCastDevice(null);
|
||||||
|
deviceListEl.appendChild(localLi);
|
||||||
|
|
||||||
|
if (devices.length > 0) {
|
||||||
|
devices.forEach(d => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'device' + (currentMode === 'cast' && currentCastDevice === d ? ' selected' : '');
|
||||||
|
li.innerHTML = `<div class="device-main">${d}</div><div class="device-sub">Google Cast Speaker</div>`;
|
||||||
|
li.onclick = () => selectCastDevice(d);
|
||||||
|
deviceListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
deviceListEl.innerHTML = `<li class="device"><div class="device-main">Error</div><div class="device-sub">${e}</div></li>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCastOverlay() {
|
||||||
|
castOverlay.classList.add('hidden');
|
||||||
|
castOverlay.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectCastDevice(deviceName) {
|
||||||
|
closeCastOverlay();
|
||||||
|
|
||||||
|
// If checking same device, do nothing
|
||||||
|
if (deviceName === currentCastDevice) return;
|
||||||
|
|
||||||
|
// If switching mode, stop current playback
|
||||||
|
if (isPlaying) {
|
||||||
|
await stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceName) {
|
||||||
|
currentMode = 'cast';
|
||||||
|
currentCastDevice = deviceName;
|
||||||
|
castBtn.style.color = 'var(--success)';
|
||||||
|
} else {
|
||||||
|
currentMode = 'local';
|
||||||
|
currentCastDevice = null;
|
||||||
|
castBtn.style.color = 'var(--text-main)';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUI();
|
||||||
|
|
||||||
|
// Auto-play if we were playing? Let's stay stopped to be safe/explicit
|
||||||
|
// Or auto-play for better UX?
|
||||||
|
// Let's prompt user to play.
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
|
// Open overlay and show list of stations (used by menu/hamburger)
|
||||||
|
function openStationsOverlay() {
|
||||||
|
castOverlay.classList.remove('hidden');
|
||||||
|
castOverlay.setAttribute('aria-hidden', 'false');
|
||||||
|
deviceListEl.innerHTML = '<li class="device"><div class="device-main">Loading...</div><div class="device-sub">Preparing stations</div></li>';
|
||||||
|
|
||||||
|
// If stations not loaded yet, show message
|
||||||
|
if (!stations || stations.length === 0) {
|
||||||
|
deviceListEl.innerHTML = '<li class="device"><div class="device-main">No stations found</div><div class="device-sub">Check your stations.json</div></li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceListEl.innerHTML = '';
|
||||||
|
|
||||||
|
stations.forEach((s, idx) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'device' + (currentIndex === idx ? ' selected' : '');
|
||||||
|
const subtitle = (s.raw && s.raw.www) ? s.raw.www : (s.id || '');
|
||||||
|
li.innerHTML = `<div class="device-main">${s.name}</div><div class="device-sub">${subtitle}</div>`;
|
||||||
|
li.onclick = async () => {
|
||||||
|
// Always switch to local playback when selecting from stations menu
|
||||||
|
currentMode = 'local';
|
||||||
|
currentCastDevice = null;
|
||||||
|
castBtn.style.color = 'var(--text-main)';
|
||||||
|
|
||||||
|
// Select and play
|
||||||
|
currentIndex = idx;
|
||||||
|
loadStation(currentIndex);
|
||||||
|
closeCastOverlay();
|
||||||
|
try {
|
||||||
|
await play();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to play station from menu', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
deviceListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
1342
android/app/src/main/assets/stations.json
Normal file
606
android/app/src/main/assets/styles.css
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
:root {
|
||||||
|
--bg-gradient: linear-gradient(135deg, #7b7fd8, #b57cf2);
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.1);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.2);
|
||||||
|
--accent: #dfa6ff;
|
||||||
|
--accent-glow: rgba(223, 166, 255, 0.5);
|
||||||
|
--text-main: #ffffff;
|
||||||
|
--text-muted: rgba(255, 255, 255, 0.7);
|
||||||
|
--danger: #cf6679;
|
||||||
|
--success: #7dffb3;
|
||||||
|
--card-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide Scrollbars */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
background: linear-gradient(-45deg, #7b7fd8, #b57cf2, #8b5cf6, #6930c3, #7b7fd8);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradientShift 12s ease-in-out infinite;
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
color: var(--text-main);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 50% 100%;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background Blobs */
|
||||||
|
.bg-shape {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(60px);
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: float 10s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-1 {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
background: #5e60ce;
|
||||||
|
top: -50px;
|
||||||
|
left: -50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-2 {
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
background: #ff6bf0;
|
||||||
|
bottom: -50px;
|
||||||
|
right: -50px;
|
||||||
|
animation-delay: -5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
100% { transform: translate(30px, 30px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
padding: 10px; /* Slight padding from window edges if desired, or 0 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
-webkit-app-region: drag; /* Draggable area */
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--success);
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background-color: var(--success);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 8px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
-webkit-app-region: no-drag; /* Buttons clickable */
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: rgba(207, 102, 121, 0.3) !important;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Artwork */
|
||||||
|
.artwork-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-container {
|
||||||
|
width: 220px;
|
||||||
|
height: 220px;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 6px; /* spacing for ring */
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.1), rgba(255,255,255,0));
|
||||||
|
box-shadow: 5px 5px 15px rgba(0,0,0,0.1), inset 1px 1px 2px rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #4ea8de, #6930c3);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 0 20px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #4ea8de, #6930c3);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 0 20px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-logo-text {
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
text-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-logo-img {
|
||||||
|
/* Fill the artwork placeholder while keeping aspect ratio and inner padding */
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
padding: 12px; /* inner spacing from rounded edges */
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo blobs container sits behind logo but inside artwork placeholder */
|
||||||
|
.logo-blobs {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
filter: url(#goo);
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
/* more transparent overall */
|
||||||
|
opacity: 0.18;
|
||||||
|
/* slightly smaller blur for subtle definition */
|
||||||
|
filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b1 { width: 110px; height: 110px; left: 8%; top: 20%; background: radial-gradient(circle at 30% 30%, #c77dff, #8b5cf6); animation: float1 6s ease-in-out infinite; }
|
||||||
|
.b2 { width: 85px; height: 85px; right: 6%; top: 10%; background: radial-gradient(circle at 30% 30%, #7bffd1, #7dffb3); animation: float2 5.5s ease-in-out infinite; }
|
||||||
|
.b3 { width: 95px; height: 95px; left: 20%; bottom: 12%; background: radial-gradient(circle at 20% 20%, #ffd07a, #ff6bf0); animation: float3 7s ease-in-out infinite; }
|
||||||
|
.b4 { width: 70px; height: 70px; right: 24%; bottom: 18%; background: radial-gradient(circle at 30% 30%, #6bd3ff, #4ea8de); animation: float4 6.5s ease-in-out infinite; }
|
||||||
|
.b5 { width: 50px; height: 50px; left: 46%; top: 36%; background: radial-gradient(circle at 40% 40%, #ffa6d6, #c77dff); animation: float5 8s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* Additional blobs */
|
||||||
|
.b6 { width: 75px; height: 75px; left: 12%; top: 48%; background: radial-gradient(circle at 30% 30%, #bde7ff, #6bd3ff); animation: float6 6.8s ease-in-out infinite; }
|
||||||
|
.b7 { width: 42px; height: 42px; right: 10%; top: 42%; background: radial-gradient(circle at 40% 40%, #ffd9b3, #ffd07a); animation: float7 7.2s ease-in-out infinite; }
|
||||||
|
.b8 { width: 70px; height: 70px; left: 34%; bottom: 8%; background: radial-gradient(circle at 30% 30%, #e3b6ff, #c77dff); animation: float8 6.4s ease-in-out infinite; }
|
||||||
|
.b9 { width: 36px; height: 36px; right: 34%; bottom: 6%; background: radial-gradient(circle at 30% 30%, #9ef7d3, #7bffd1); animation: float9 8.4s ease-in-out infinite; }
|
||||||
|
.b10 { width: 30px; height: 30px; left: 52%; bottom: 28%; background: radial-gradient(circle at 30% 30%, #ffd0f0, #ffa6d6); animation: float10 5.8s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@keyframes float1 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(12px) translateX(8px) scale(1.06); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float2 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-10px) translateX(-6px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float3 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(8px) translateX(-10px) scale(1.05); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float4 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-6px) translateX(10px) scale(1.03); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float5 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-12px) translateX(4px) scale(1.07); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float6 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-8px) translateX(6px) scale(1.05); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float7 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(10px) translateX(-6px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float8 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-6px) translateX(10px) scale(1.03); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float9 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(12px) translateX(-4px) scale(1.06); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float10 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-10px) translateX(2px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
|
||||||
|
/* Slightly darken backdrop gradient so blobs read better */
|
||||||
|
.artwork-placeholder::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(0,0,0,0.06), rgba(0,0,0,0.12));
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track Info */
|
||||||
|
.track-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar (Visual) */
|
||||||
|
.progress-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
width: 100%; /* Live always full or pulsing */
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), #fff);
|
||||||
|
border-radius: 2px;
|
||||||
|
opacity: 0.8;
|
||||||
|
box-shadow: 0 0 10px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(50%, -50%);
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 10px rgba(255,255,255,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.controls-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-main);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, opacity 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.secondary {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.primary {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.2), rgba(255,255,255,0.05));
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.2), inset 0 0 10px rgba(255,255,255,0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.primary svg {
|
||||||
|
filter: drop-shadow(0 0 5px var(--accent-glow));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playing state - pulsing glow ring */
|
||||||
|
.control-btn.primary.playing {
|
||||||
|
animation: pulse-ring 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.2),
|
||||||
|
inset 0 0 10px rgba(255,255,255,0.1),
|
||||||
|
0 0 0 0 rgba(223, 166, 255, 0.7);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.2),
|
||||||
|
inset 0 0 10px rgba(255,255,255,0.1),
|
||||||
|
0 0 0 8px rgba(223, 166, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon container prevents layout jump */
|
||||||
|
.icon-container {
|
||||||
|
position: relative;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-container svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume */
|
||||||
|
.volume-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-container {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range] {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]::-webkit-slider-runnable-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]::-webkit-slider-thumb {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin-top: -6px; /* align with track */
|
||||||
|
box-shadow: 0 0 10px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#volume-value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
width: 30px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.small {
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cast Overlay (Beautified as per layout2_plan.md) */
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(20, 10, 35, 0.45);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay:not(.hidden) {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
width: min(420px, calc(100vw - 48px));
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(30, 30, 40, 0.82);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
box-shadow: 0 30px 80px rgba(0,0,0,0.6);
|
||||||
|
color: #fff;
|
||||||
|
animation: pop 0.22s ease;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pop {
|
||||||
|
from { transform: scale(0.94); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device list */
|
||||||
|
.device-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 10px 5px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device row */
|
||||||
|
.device {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device:hover {
|
||||||
|
background: rgba(255,255,255,0.10);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device .device-main {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device .device-sub {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected device */
|
||||||
|
.device.selected {
|
||||||
|
background: linear-gradient(135deg, #c77dff, #8b5cf6);
|
||||||
|
box-shadow: 0 0 18px rgba(199,125,255,0.65);
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device.selected .device-main,
|
||||||
|
.device.selected .device-sub {
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device.selected .device-sub {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cancel button */
|
||||||
|
.btn.cancel {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: #d16b7d;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, background 0.2s;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.cancel:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
background: #e17c8d;
|
||||||
|
}
|
||||||
BIN
android/app/src/main/jniLibs/arm64-v8a/libradio_tauri_lib.so
Normal file
BIN
android/app/src/main/jniLibs/armeabi-v7a/libradio_tauri_lib.so
Normal file
206
scripts/build-android.ps1
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<#
|
||||||
|
Build helper for Android (Windows PowerShell)
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
- Checks for required commands (`npm`, `rustup`, `cargo`, `cargo-ndk`)
|
||||||
|
- Builds frontend (runs `npm run build` if `dist`/`build` not present)
|
||||||
|
- Copies frontend files from `dist` or `src` into `android/app/src/main/assets`
|
||||||
|
- Builds Rust native libs using `cargo-ndk` (if available) for `aarch64` and `armv7`
|
||||||
|
- Copies produced `.so` files into `android/app/src/main/jniLibs/*`
|
||||||
|
|
||||||
|
Note: This script prepares the Android project. To produce the APK, open `android/` in Android Studio and run Build -> Assemble, or run `gradlew assembleDebug` locally.
|
||||||
|
#>
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
|
function Check-Command($name) {
|
||||||
|
$which = Get-Command $name -ErrorAction SilentlyContinue
|
||||||
|
return $which -ne $null
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "Starting Android prep script..."
|
||||||
|
|
||||||
|
if (-not (Check-Command npm)) { Write-Warning "npm not found in PATH. Install Node.js to build frontend." }
|
||||||
|
if (-not (Check-Command rustup)) { Write-Warning "rustup not found in PATH. Install Rust toolchain." }
|
||||||
|
if (-not (Check-Command cargo)) { Write-Warning "cargo not found in PATH." }
|
||||||
|
|
||||||
|
$cargoNdkAvailable = Check-Command cargo-ndk
|
||||||
|
if (-not $cargoNdkAvailable) { Write-Warning "cargo-ndk not found. Native libs will not be built. Install via 'cargo install cargo-ndk'" }
|
||||||
|
|
||||||
|
# Determine repository root (parent of the scripts folder)
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$root = Split-Path -Parent $scriptDir
|
||||||
|
Push-Location $root
|
||||||
|
|
||||||
|
# Prefer Tauri-generated Android Studio project (tauri android init)
|
||||||
|
$androidRoot = Join-Path $root 'src-tauri\gen\android'
|
||||||
|
if (-not (Test-Path $androidRoot)) {
|
||||||
|
# Legacy fallback (non-Tauri project)
|
||||||
|
$androidRoot = Join-Path $root 'android'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Escape-LocalPropertiesPath([string]$p) {
|
||||||
|
# local.properties expects ':' escaped and backslashes doubled on Windows.
|
||||||
|
# Use plain string replacements to avoid regex escaping pitfalls.
|
||||||
|
return ($p.Replace('\', '\\').Replace(':', '\:'))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure Android SDK/NDK locations are set for Gradle (local.properties)
|
||||||
|
$sdkRoot = $env:ANDROID_SDK_ROOT
|
||||||
|
if (-not $sdkRoot) { $sdkRoot = $env:ANDROID_HOME }
|
||||||
|
if (-not $sdkRoot) { $sdkRoot = Join-Path $env:LOCALAPPDATA 'Android\Sdk' }
|
||||||
|
|
||||||
|
$ndkRoot = $env:ANDROID_NDK_ROOT
|
||||||
|
if (-not $ndkRoot) { $ndkRoot = $env:ANDROID_NDK_HOME }
|
||||||
|
if (-not $ndkRoot -and (Test-Path (Join-Path $sdkRoot 'ndk'))) {
|
||||||
|
$ndkVersions = Get-ChildItem -Path (Join-Path $sdkRoot 'ndk') -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending
|
||||||
|
if ($ndkVersions -and (@($ndkVersions)).Count -gt 0) { $ndkRoot = @($ndkVersions)[0].FullName }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path $androidRoot) {
|
||||||
|
$localPropsPath = Join-Path $androidRoot 'local.properties'
|
||||||
|
$lines = @()
|
||||||
|
if ($sdkRoot) { $lines += "sdk.dir=$(Escape-LocalPropertiesPath $sdkRoot)" }
|
||||||
|
if ($ndkRoot) { $lines += "ndk.dir=$(Escape-LocalPropertiesPath $ndkRoot)" }
|
||||||
|
if ($lines.Count -gt 0) {
|
||||||
|
Set-Content -Path $localPropsPath -Value ($lines -join "`n") -Encoding ASCII
|
||||||
|
Write-Output "Wrote Android SDK/NDK config to: $localPropsPath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build frontend (optional)
|
||||||
|
Write-Output "Preparing frontend files..."
|
||||||
|
$distDirs = @('dist','build')
|
||||||
|
$foundDist = $null
|
||||||
|
foreach ($d in $distDirs) {
|
||||||
|
if (Test-Path (Join-Path $root $d)) { $foundDist = $d; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $foundDist) {
|
||||||
|
# IMPORTANT: `npm run build` in this repo runs `tauri build`, which is a desktop bundling step.
|
||||||
|
# For Android prep we only need web assets, so we fall back to copying `src/` as assets.
|
||||||
|
Write-Warning "No dist/build output found — copying `src/` as assets (skipping `npm run build` to avoid desktop bundling)."
|
||||||
|
}
|
||||||
|
|
||||||
|
$assetsDst = Join-Path $androidRoot 'app\src\main\assets'
|
||||||
|
if (-not (Test-Path $assetsDst)) { New-Item -ItemType Directory -Path $assetsDst -Force | Out-Null }
|
||||||
|
|
||||||
|
if ($foundDist) {
|
||||||
|
Write-Output "Copying frontend from '$foundDist' to Android assets..."
|
||||||
|
robocopy (Join-Path $root $foundDist) $assetsDst /MIR | Out-Null
|
||||||
|
} else {
|
||||||
|
Write-Output "Copying raw 'src' to Android assets..."
|
||||||
|
robocopy (Join-Path $root 'src') $assetsDst /MIR | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build native libs if cargo-ndk available
|
||||||
|
if ($cargoNdkAvailable) {
|
||||||
|
Write-Output "Building Rust native libs via cargo-ndk from project root: $root"
|
||||||
|
try {
|
||||||
|
# Build from the Rust crate directory `src-tauri`
|
||||||
|
$crateDir = Join-Path $root 'src-tauri'
|
||||||
|
if (-not (Test-Path (Join-Path $crateDir 'Cargo.toml'))) {
|
||||||
|
Write-Warning "Cargo.toml not found in src-tauri; skipping native build."
|
||||||
|
} else {
|
||||||
|
# Prefer Ninja generator for CMake if available (avoids Visual Studio generator issues)
|
||||||
|
# Restore env vars at the end so we don't pollute the current PowerShell session.
|
||||||
|
$oldCmakeGenerator = $env:CMAKE_GENERATOR
|
||||||
|
$oldCmakeMakeProgram = $env:CMAKE_MAKE_PROGRAM
|
||||||
|
$ninjaCmd = Get-Command ninja -ErrorAction SilentlyContinue
|
||||||
|
if ($ninjaCmd) {
|
||||||
|
Write-Output "Ninja detected at $($ninjaCmd.Source); setting CMake generator to Ninja."
|
||||||
|
$env:CMAKE_GENERATOR = 'Ninja'
|
||||||
|
$env:CMAKE_MAKE_PROGRAM = $ninjaCmd.Source
|
||||||
|
} else {
|
||||||
|
Write-Warning "Ninja not found in PATH. Installing Ninja or adding it to PATH is strongly recommended to avoid Visual Studio CMake generator on Windows."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attempt to locate Android NDK if environment variables are not set
|
||||||
|
if (-not $env:ANDROID_NDK_ROOT -and -not $env:ANDROID_NDK_HOME) {
|
||||||
|
$candidates = @()
|
||||||
|
if ($env:ANDROID_SDK_ROOT) { $candidates += Join-Path $env:ANDROID_SDK_ROOT 'ndk' }
|
||||||
|
if ($env:ANDROID_HOME) { $candidates += Join-Path $env:ANDROID_HOME 'ndk' }
|
||||||
|
$candidates += Join-Path $env:LOCALAPPDATA 'Android\sdk\ndk'
|
||||||
|
$candidates += Join-Path $env:USERPROFILE 'AppData\Local\Android\sdk\ndk'
|
||||||
|
$candidates += 'C:\Program Files (x86)\Android\AndroidNDK'
|
||||||
|
|
||||||
|
foreach ($cand in $candidates) {
|
||||||
|
if (Test-Path $cand) {
|
||||||
|
$versions = Get-ChildItem -Path $cand -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending
|
||||||
|
if ($versions -and (@($versions)).Count -gt 0) {
|
||||||
|
$ndkPath = @($versions)[0].FullName
|
||||||
|
Write-Output "Detected Android NDK at: $ndkPath"
|
||||||
|
$env:ANDROID_NDK_ROOT = $ndkPath
|
||||||
|
$env:ANDROID_NDK = $ndkPath
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $env:ANDROID_NDK_ROOT) { Write-Warning "ANDROID_NDK_ROOT/ANDROID_NDK not set and no NDK found in common locations. Set ANDROID_NDK_ROOT to your NDK path." }
|
||||||
|
} else {
|
||||||
|
Write-Output "Using existing ANDROID_NDK_ROOT: $($env:ANDROID_NDK_ROOT)"
|
||||||
|
if (-not $env:ANDROID_NDK) { $env:ANDROID_NDK = $env:ANDROID_NDK_ROOT }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure expected external binary placeholders exist so Tauri bundling doesn't fail
|
||||||
|
$binariesDir = Join-Path $crateDir 'binaries'
|
||||||
|
if (-not (Test-Path $binariesDir)) { New-Item -ItemType Directory -Path $binariesDir -Force | Out-Null }
|
||||||
|
$placeholder1 = Join-Path $binariesDir 'RadioPlayer-aarch64-linux-android'
|
||||||
|
$placeholder2 = Join-Path $binariesDir 'RadioPlayer-armv7-linux-androideabi'
|
||||||
|
if (-not (Test-Path $placeholder1)) { New-Item -ItemType File -Path $placeholder1 -Force | Out-Null; Write-Output "Created placeholder: $placeholder1" }
|
||||||
|
if (-not (Test-Path $placeholder2)) { New-Item -ItemType File -Path $placeholder2 -Force | Out-Null; Write-Output "Created placeholder: $placeholder2" }
|
||||||
|
|
||||||
|
# If a previous build used a different CMake generator (e.g., Visual Studio), aws-lc-sys can fail with
|
||||||
|
# "Does not match the generator used previously". Clean only the aws-lc-sys CMake build dirs.
|
||||||
|
$awsLcBuildDirs = Get-ChildItem -Path (Join-Path $crateDir 'target') -Recurse -Directory -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -like 'aws-lc-sys-*' }
|
||||||
|
foreach ($d in @($awsLcBuildDirs)) {
|
||||||
|
$cmakeBuildDir = Join-Path $d.FullName 'out\build'
|
||||||
|
$cmakeCache = Join-Path $cmakeBuildDir 'CMakeCache.txt'
|
||||||
|
if (Test-Path $cmakeCache) {
|
||||||
|
Write-Output "Cleaning stale CMake cache for aws-lc-sys: $cmakeBuildDir"
|
||||||
|
Remove-Item -Path $cmakeBuildDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location $crateDir
|
||||||
|
try {
|
||||||
|
# Use API 24 to ensure libc symbols like getifaddrs/freeifaddrs are available.
|
||||||
|
# Build only the library to avoid linking the desktop binary for Android.
|
||||||
|
Write-Output "Running: cargo ndk -t arm64-v8a -t armeabi-v7a -P 24 build --release --lib (in $crateDir)"
|
||||||
|
cargo ndk -t arm64-v8a -t armeabi-v7a -P 24 build --release --lib
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
if ($null -eq $oldCmakeGenerator) { Remove-Item Env:\CMAKE_GENERATOR -ErrorAction SilentlyContinue } else { $env:CMAKE_GENERATOR = $oldCmakeGenerator }
|
||||||
|
if ($null -eq $oldCmakeMakeProgram) { Remove-Item Env:\CMAKE_MAKE_PROGRAM -ErrorAction SilentlyContinue } else { $env:CMAKE_MAKE_PROGRAM = $oldCmakeMakeProgram }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Search for produced .so files under src-tauri/target
|
||||||
|
$soFiles = Get-ChildItem -Path (Join-Path $crateDir 'target') -Recurse -Filter "*.so" -ErrorAction SilentlyContinue
|
||||||
|
if (-not $soFiles) {
|
||||||
|
Write-Warning "No .so files found after build. Check cargo-ndk output above for errors."
|
||||||
|
} else {
|
||||||
|
foreach ($f in @($soFiles)) {
|
||||||
|
$full = $f.FullName
|
||||||
|
if ($full -match 'aarch64|aarch64-linux-android|arm64-v8a') { $abi = 'arm64-v8a' }
|
||||||
|
elseif ($full -match 'armv7|armv7-linux-androideabi|armeabi-v7a') { $abi = 'armeabi-v7a' }
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
$dst = Join-Path $androidRoot "app\src\main\jniLibs\$abi"
|
||||||
|
if (-not (Test-Path $dst)) { New-Item -ItemType Directory -Path $dst -Force | Out-Null }
|
||||||
|
Copy-Item $full -Destination $dst -Force
|
||||||
|
Write-Output "Copied $($f.Name) -> $dst"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Warning "cargo-ndk build failed. Exception: $($_.Exception.Message)"
|
||||||
|
if ($_.ScriptStackTrace) { Write-Output $_.ScriptStackTrace }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning "Skipping native lib build (cargo-ndk missing)."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "Android prep complete. Open '$androidRoot' in Android Studio and build the APK (or run './gradlew assembleDebug' in that folder)."
|
||||||
|
|
||||||
|
Pop-Location
|
||||||
45
scripts/build-android.sh
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Cross-platform helper for Unix-like shells
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo "Preparing Android assets and native libs..."
|
||||||
|
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
echo "Running npm install & build"
|
||||||
|
npm install
|
||||||
|
npm run build || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
DIST_DIR="dist"
|
||||||
|
if [ ! -d "$DIST_DIR" ]; then DIST_DIR="build"; fi
|
||||||
|
if [ -d "$DIST_DIR" ]; then
|
||||||
|
echo "Copying $DIST_DIR -> android/app/src/main/assets"
|
||||||
|
mkdir -p android/app/src/main/assets
|
||||||
|
rsync -a --delete "$DIST_DIR/" android/app/src/main/assets/
|
||||||
|
else
|
||||||
|
echo "No dist/build found, copying src/ -> android assets"
|
||||||
|
mkdir -p android/app/src/main/assets
|
||||||
|
rsync -a --delete src/ android/app/src/main/assets/
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v cargo-ndk >/dev/null 2>&1; then
|
||||||
|
echo "Building native libs with cargo-ndk"
|
||||||
|
cargo-ndk -t aarch64 -t armv7 build --release || true
|
||||||
|
# copy so files
|
||||||
|
find target -type f -name "*.so" | while read -r f; do
|
||||||
|
if [[ "$f" =~ aarch64|aarch64-linux-android ]]; then abi=arm64-v8a; fi
|
||||||
|
if [[ "$f" =~ armv7|armv7-linux-androideabi ]]; then abi=armeabi-v7a; fi
|
||||||
|
if [ -n "${abi-}" ]; then
|
||||||
|
mkdir -p android/app/src/main/jniLibs/$abi
|
||||||
|
cp "$f" android/app/src/main/jniLibs/$abi/
|
||||||
|
echo "Copied $f -> android/app/src/main/jniLibs/$abi/"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "cargo-ndk not found; skipping native lib build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Prepared Android project. Open android/ in Android Studio to build the APK (or run ./gradlew assembleDebug)."
|
||||||
12
src-tauri/gen/android/.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
insert_final_newline = false
|
||||||
19
src-tauri/gen/android/.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
key.properties
|
||||||
|
|
||||||
|
/.tauri
|
||||||
|
/tauri.settings.gradle
|
||||||
6
src-tauri/gen/android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/src/main/java/si/klevze/radioPlayer/generated
|
||||||
|
/src/main/jniLibs/**/*.so
|
||||||
|
/src/main/assets/tauri.conf.json
|
||||||
|
/tauri.build.gradle.kts
|
||||||
|
/proguard-tauri.pro
|
||||||
|
/tauri.properties
|
||||||
64
src-tauri/gen/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
val tauriProperties = Properties().apply {
|
||||||
|
val propFile = file("tauri.properties")
|
||||||
|
if (propFile.exists()) {
|
||||||
|
propFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 36
|
||||||
|
namespace = "si.klevze.radioPlayer"
|
||||||
|
defaultConfig {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
applicationId = "si.klevze.radioPlayer"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||||
|
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
|
isDebuggable = true
|
||||||
|
isJniDebuggable = true
|
||||||
|
isMinifyEnabled = false
|
||||||
|
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
*fileTree(".") { include("**/*.pro") }
|
||||||
|
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||||
|
implementation("androidx.webkit:webkit:1.14.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||||
|
}
|
||||||
21
src-tauri/gen/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
37
src-tauri/gen/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!-- AndroidTV support -->
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.radio_tauri"
|
||||||
|
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:label="@string/main_activity_title"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<!-- AndroidTV support -->
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
BIN
src-tauri/gen/android/app/src/main/assets/assets/appIcon.png
Normal file
|
After Width: | Height: | Size: 682 KiB |
BIN
src-tauri/gen/android/app/src/main/assets/assets/favicon_io.zip
Normal file
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 859 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="206" height="231" viewBox="0 0 206 231">
|
||||||
|
<!-- Wrapper SVG that embeds the PNG app icon so existing references to tauri.svg render the PNG -->
|
||||||
|
<image href="appIcon.png" width="206" height="231" preserveAspectRatio="xMidYMid slice" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
158
src-tauri/gen/android/app/src/main/assets/index.html
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Radio1 Player</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<script src="main.js" defer type="module"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="bg-shape shape-1"></div>
|
||||||
|
<div class="bg-shape shape-2"></div>
|
||||||
|
|
||||||
|
<main class="glass-card">
|
||||||
|
<header data-tauri-drag-region>
|
||||||
|
<button id="menu-btn" class="icon-btn" aria-label="Menu">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="header-info" data-tauri-drag-region>
|
||||||
|
<span class="app-title">Radio1 Player</span>
|
||||||
|
<span class="status-indicator" id="status-indicator">
|
||||||
|
<span class="status-dot"></span> <span id="status-text">Ready</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-buttons">
|
||||||
|
<button id="cast-toggle-btn" class="icon-btn" aria-label="Cast">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a14 14 0 0 1 14 14h-2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="close-btn" class="icon-btn close-btn" aria-label="Close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="artwork-section">
|
||||||
|
<div class="artwork-container">
|
||||||
|
<div class="artwork-placeholder">
|
||||||
|
<!-- Gooey SVG filter for fluid blob blending -->
|
||||||
|
<svg width="0" height="0" style="position:absolute">
|
||||||
|
<defs>
|
||||||
|
<filter id="goo">
|
||||||
|
<!-- increased blur for smoother, more transparent blending -->
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="18" result="blur" />
|
||||||
|
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7" result="goo" />
|
||||||
|
<feBlend in="SourceGraphic" in2="goo" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="logo-blobs" aria-hidden="true">
|
||||||
|
<span class="blob b1"></span>
|
||||||
|
<span class="blob b2"></span>
|
||||||
|
<span class="blob b3"></span>
|
||||||
|
<span class="blob b4"></span>
|
||||||
|
<span class="blob b5"></span>
|
||||||
|
<span class="blob b6"></span>
|
||||||
|
<span class="blob b7"></span>
|
||||||
|
<span class="blob b8"></span>
|
||||||
|
<span class="blob b9"></span>
|
||||||
|
<span class="blob b10"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
|
||||||
|
<span class="station-logo-text">1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="track-info">
|
||||||
|
<h2 id="station-name">Radio 1 MB</h2>
|
||||||
|
<p id="station-subtitle">Live Stream</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Visual Progress Bar (Live) -->
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
<div class="progress-handle"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="controls-section">
|
||||||
|
<button id="prev-btn" class="control-btn secondary" aria-label="Previous Station">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="play-btn" class="control-btn primary" aria-label="Play">
|
||||||
|
<div class="icon-container">
|
||||||
|
<!-- Play Icon -->
|
||||||
|
<svg id="icon-play" width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
<!-- Stop/Pause Icon (Hidden by default) -->
|
||||||
|
<svg id="icon-stop" class="hidden" width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 6h12v12H6z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="next-btn" class="control-btn secondary" aria-label="Next Station">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="volume-section">
|
||||||
|
<button id="mute-btn" class="icon-btn small">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="slider-container">
|
||||||
|
<input type="range" id="volume-slider" min="0" max="100" value="50">
|
||||||
|
</div>
|
||||||
|
<span id="volume-value">50%</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Hidden Cast Overlay (Beautified) -->
|
||||||
|
<div id="cast-overlay" class="overlay hidden" aria-hidden="true" data-tauri-drag-region>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="deviceTitle">
|
||||||
|
<h2 id="deviceTitle">Choose</h2>
|
||||||
|
|
||||||
|
<ul id="device-list" class="device-list">
|
||||||
|
<!-- Render device items here -->
|
||||||
|
<li class="device">
|
||||||
|
<div class="device-main">Scanning...</div>
|
||||||
|
<div class="device-sub">Searching for speakers</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button id="close-overlay" class="btn cancel" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
355
src-tauri/gen/android/app/src/main/assets/main.js
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
const { getCurrentWindow } = window.__TAURI__.window;
|
||||||
|
|
||||||
|
// State
|
||||||
|
let stations = [];
|
||||||
|
let currentIndex = 0;
|
||||||
|
let isPlaying = false;
|
||||||
|
let currentMode = 'local'; // 'local' | 'cast'
|
||||||
|
let currentCastDevice = null;
|
||||||
|
const audio = new Audio();
|
||||||
|
|
||||||
|
// UI Elements
|
||||||
|
const stationNameEl = document.getElementById('station-name');
|
||||||
|
const stationSubtitleEl = document.getElementById('station-subtitle');
|
||||||
|
const statusTextEl = document.getElementById('status-text');
|
||||||
|
const statusDotEl = document.querySelector('.status-dot');
|
||||||
|
const playBtn = document.getElementById('play-btn');
|
||||||
|
const iconPlay = document.getElementById('icon-play');
|
||||||
|
const iconStop = document.getElementById('icon-stop');
|
||||||
|
const prevBtn = document.getElementById('prev-btn');
|
||||||
|
const nextBtn = document.getElementById('next-btn');
|
||||||
|
const volumeSlider = document.getElementById('volume-slider');
|
||||||
|
const volumeValue = document.getElementById('volume-value');
|
||||||
|
const castBtn = document.getElementById('cast-toggle-btn');
|
||||||
|
const castOverlay = document.getElementById('cast-overlay');
|
||||||
|
const closeOverlayBtn = document.getElementById('close-overlay');
|
||||||
|
const deviceListEl = document.getElementById('device-list');
|
||||||
|
const logoTextEl = document.querySelector('.station-logo-text');
|
||||||
|
const logoImgEl = document.getElementById('station-logo-img');
|
||||||
|
|
||||||
|
// Init
|
||||||
|
async function init() {
|
||||||
|
await loadStations();
|
||||||
|
setupEventListeners();
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStations() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('stations.json');
|
||||||
|
const raw = await resp.json();
|
||||||
|
|
||||||
|
// Normalize station objects so the rest of the app can rely on `name` and `url`.
|
||||||
|
stations = raw
|
||||||
|
.map((s) => {
|
||||||
|
// If already in the old format, keep as-is
|
||||||
|
if (s.name && s.url) return s;
|
||||||
|
|
||||||
|
const name = s.title || s.id || s.name || 'Unknown';
|
||||||
|
// Prefer liveAudio, fall back to liveVideo or any common fields
|
||||||
|
const url = s.liveAudio || s.liveVideo || s.liveStream || s.url || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: s.id || name,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
logo: s.logo || s.poster || '',
|
||||||
|
enabled: typeof s.enabled === 'boolean' ? s.enabled : true,
|
||||||
|
raw: s,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
// Filter out disabled stations and those without a stream URL
|
||||||
|
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
||||||
|
|
||||||
|
if (stations.length > 0) {
|
||||||
|
currentIndex = 0;
|
||||||
|
loadStation(currentIndex);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load stations', e);
|
||||||
|
statusTextEl.textContent = 'Error loading stations';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEventListeners() {
|
||||||
|
playBtn.addEventListener('click', togglePlay);
|
||||||
|
prevBtn.addEventListener('click', playPrev);
|
||||||
|
nextBtn.addEventListener('click', playNext);
|
||||||
|
|
||||||
|
volumeSlider.addEventListener('input', handleVolumeInput);
|
||||||
|
|
||||||
|
castBtn.addEventListener('click', openCastOverlay);
|
||||||
|
closeOverlayBtn.addEventListener('click', closeCastOverlay);
|
||||||
|
|
||||||
|
// Close overlay on background click
|
||||||
|
castOverlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === castOverlay) closeCastOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
document.getElementById('close-btn').addEventListener('click', async () => {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
await appWindow.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Menu button - explicit functionality or placeholder?
|
||||||
|
// For now just log or maybe show about
|
||||||
|
document.getElementById('menu-btn').addEventListener('click', () => {
|
||||||
|
openStationsOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hotkeys?
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStation(index) {
|
||||||
|
if (index < 0 || index >= stations.length) return;
|
||||||
|
const station = stations[index];
|
||||||
|
|
||||||
|
stationNameEl.textContent = station.name;
|
||||||
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream';
|
||||||
|
|
||||||
|
// Update Logo Text (First letter or number)
|
||||||
|
// Simple heuristic: if name has a number, use it, else first letter
|
||||||
|
// If station has a logo URL, show the image; otherwise show the text fallback
|
||||||
|
if (station.logo && station.logo.length > 0) {
|
||||||
|
logoImgEl.src = station.logo;
|
||||||
|
logoImgEl.classList.remove('hidden');
|
||||||
|
logoTextEl.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
// Fallback to single-letter/logo text
|
||||||
|
logoImgEl.src = '';
|
||||||
|
logoImgEl.classList.add('hidden');
|
||||||
|
const numberMatch = station.name.match(/\d+/);
|
||||||
|
if (numberMatch) {
|
||||||
|
logoTextEl.textContent = numberMatch[0];
|
||||||
|
} else {
|
||||||
|
logoTextEl.textContent = station.name.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
logoTextEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlay() {
|
||||||
|
if (isPlaying) {
|
||||||
|
await stop();
|
||||||
|
} else {
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function play() {
|
||||||
|
const station = stations[currentIndex];
|
||||||
|
if (!station) return;
|
||||||
|
|
||||||
|
statusTextEl.textContent = 'Buffering...';
|
||||||
|
statusDotEl.style.backgroundColor = 'var(--text-muted)'; // Grey/Yellow while loading
|
||||||
|
|
||||||
|
if (currentMode === 'local') {
|
||||||
|
audio.src = station.url;
|
||||||
|
audio.volume = volumeSlider.value / 100;
|
||||||
|
try {
|
||||||
|
await audio.play();
|
||||||
|
isPlaying = true;
|
||||||
|
updateUI();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Playback failed', e);
|
||||||
|
statusTextEl.textContent = 'Error';
|
||||||
|
}
|
||||||
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
||||||
|
// Cast logic
|
||||||
|
try {
|
||||||
|
await invoke('cast_play', { deviceName: currentCastDevice, url: station.url });
|
||||||
|
isPlaying = true;
|
||||||
|
// Sync volume
|
||||||
|
const vol = volumeSlider.value / 100;
|
||||||
|
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: vol });
|
||||||
|
updateUI();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cast failed', e);
|
||||||
|
statusTextEl.textContent = 'Cast Error';
|
||||||
|
currentMode = 'local'; // Fallback
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (currentMode === 'local') {
|
||||||
|
audio.pause();
|
||||||
|
audio.src = '';
|
||||||
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
||||||
|
try {
|
||||||
|
await invoke('cast_stop', { deviceName: currentCastDevice });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying = false;
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playNext() {
|
||||||
|
if (stations.length === 0) return;
|
||||||
|
|
||||||
|
// If playing, stop first? Or seamless?
|
||||||
|
// For radio, seamless switch requires stop then play new URL
|
||||||
|
const wasPlaying = isPlaying;
|
||||||
|
|
||||||
|
if (wasPlaying) await stop();
|
||||||
|
|
||||||
|
currentIndex = (currentIndex + 1) % stations.length;
|
||||||
|
loadStation(currentIndex);
|
||||||
|
|
||||||
|
if (wasPlaying) await play();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playPrev() {
|
||||||
|
if (stations.length === 0) return;
|
||||||
|
|
||||||
|
const wasPlaying = isPlaying;
|
||||||
|
|
||||||
|
if (wasPlaying) await stop();
|
||||||
|
|
||||||
|
currentIndex = (currentIndex - 1 + stations.length) % stations.length;
|
||||||
|
loadStation(currentIndex);
|
||||||
|
|
||||||
|
if (wasPlaying) await play();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
// Play/Stop Button
|
||||||
|
if (isPlaying) {
|
||||||
|
iconPlay.classList.add('hidden');
|
||||||
|
iconStop.classList.remove('hidden');
|
||||||
|
playBtn.classList.add('playing'); // Add pulsing ring animation
|
||||||
|
statusTextEl.textContent = 'Playing';
|
||||||
|
statusDotEl.style.backgroundColor = 'var(--success)';
|
||||||
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream';
|
||||||
|
} else {
|
||||||
|
iconPlay.classList.remove('hidden');
|
||||||
|
iconStop.classList.add('hidden');
|
||||||
|
playBtn.classList.remove('playing'); // Remove pulsing ring
|
||||||
|
statusTextEl.textContent = 'Ready';
|
||||||
|
statusDotEl.style.backgroundColor = 'var(--text-muted)';
|
||||||
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVolumeInput() {
|
||||||
|
const val = volumeSlider.value;
|
||||||
|
volumeValue.textContent = `${val}%`;
|
||||||
|
const decimals = val / 100;
|
||||||
|
|
||||||
|
if (currentMode === 'local') {
|
||||||
|
audio.volume = decimals;
|
||||||
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
||||||
|
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast Logic
|
||||||
|
async function openCastOverlay() {
|
||||||
|
castOverlay.classList.remove('hidden');
|
||||||
|
castOverlay.setAttribute('aria-hidden', 'false');
|
||||||
|
deviceListEl.innerHTML = '<li class="device"><div class="device-main">Scanning...</div><div class="device-sub">Searching for speakers</div></li>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const devices = await invoke('list_cast_devices');
|
||||||
|
deviceListEl.innerHTML = '';
|
||||||
|
|
||||||
|
// Add "This Computer" option
|
||||||
|
const localLi = document.createElement('li');
|
||||||
|
localLi.className = 'device' + (currentMode === 'local' ? ' selected' : '');
|
||||||
|
localLi.innerHTML = '<div class="device-main">This Computer</div><div class="device-sub">Local Playback</div>';
|
||||||
|
localLi.onclick = () => selectCastDevice(null);
|
||||||
|
deviceListEl.appendChild(localLi);
|
||||||
|
|
||||||
|
if (devices.length > 0) {
|
||||||
|
devices.forEach(d => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'device' + (currentMode === 'cast' && currentCastDevice === d ? ' selected' : '');
|
||||||
|
li.innerHTML = `<div class="device-main">${d}</div><div class="device-sub">Google Cast Speaker</div>`;
|
||||||
|
li.onclick = () => selectCastDevice(d);
|
||||||
|
deviceListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
deviceListEl.innerHTML = `<li class="device"><div class="device-main">Error</div><div class="device-sub">${e}</div></li>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCastOverlay() {
|
||||||
|
castOverlay.classList.add('hidden');
|
||||||
|
castOverlay.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectCastDevice(deviceName) {
|
||||||
|
closeCastOverlay();
|
||||||
|
|
||||||
|
// If checking same device, do nothing
|
||||||
|
if (deviceName === currentCastDevice) return;
|
||||||
|
|
||||||
|
// If switching mode, stop current playback
|
||||||
|
if (isPlaying) {
|
||||||
|
await stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceName) {
|
||||||
|
currentMode = 'cast';
|
||||||
|
currentCastDevice = deviceName;
|
||||||
|
castBtn.style.color = 'var(--success)';
|
||||||
|
} else {
|
||||||
|
currentMode = 'local';
|
||||||
|
currentCastDevice = null;
|
||||||
|
castBtn.style.color = 'var(--text-main)';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUI();
|
||||||
|
|
||||||
|
// Auto-play if we were playing? Let's stay stopped to be safe/explicit
|
||||||
|
// Or auto-play for better UX?
|
||||||
|
// Let's prompt user to play.
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
|
// Open overlay and show list of stations (used by menu/hamburger)
|
||||||
|
function openStationsOverlay() {
|
||||||
|
castOverlay.classList.remove('hidden');
|
||||||
|
castOverlay.setAttribute('aria-hidden', 'false');
|
||||||
|
deviceListEl.innerHTML = '<li class="device"><div class="device-main">Loading...</div><div class="device-sub">Preparing stations</div></li>';
|
||||||
|
|
||||||
|
// If stations not loaded yet, show message
|
||||||
|
if (!stations || stations.length === 0) {
|
||||||
|
deviceListEl.innerHTML = '<li class="device"><div class="device-main">No stations found</div><div class="device-sub">Check your stations.json</div></li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceListEl.innerHTML = '';
|
||||||
|
|
||||||
|
stations.forEach((s, idx) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'device' + (currentIndex === idx ? ' selected' : '');
|
||||||
|
const subtitle = (s.raw && s.raw.www) ? s.raw.www : (s.id || '');
|
||||||
|
li.innerHTML = `<div class="device-main">${s.name}</div><div class="device-sub">${subtitle}</div>`;
|
||||||
|
li.onclick = async () => {
|
||||||
|
// Always switch to local playback when selecting from stations menu
|
||||||
|
currentMode = 'local';
|
||||||
|
currentCastDevice = null;
|
||||||
|
castBtn.style.color = 'var(--text-main)';
|
||||||
|
|
||||||
|
// Select and play
|
||||||
|
currentIndex = idx;
|
||||||
|
loadStation(currentIndex);
|
||||||
|
closeCastOverlay();
|
||||||
|
try {
|
||||||
|
await play();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to play station from menu', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
deviceListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
1342
src-tauri/gen/android/app/src/main/assets/stations.json
Normal file
606
src-tauri/gen/android/app/src/main/assets/styles.css
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
:root {
|
||||||
|
--bg-gradient: linear-gradient(135deg, #7b7fd8, #b57cf2);
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.1);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.2);
|
||||||
|
--accent: #dfa6ff;
|
||||||
|
--accent-glow: rgba(223, 166, 255, 0.5);
|
||||||
|
--text-main: #ffffff;
|
||||||
|
--text-muted: rgba(255, 255, 255, 0.7);
|
||||||
|
--danger: #cf6679;
|
||||||
|
--success: #7dffb3;
|
||||||
|
--card-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide Scrollbars */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
background: linear-gradient(-45deg, #7b7fd8, #b57cf2, #8b5cf6, #6930c3, #7b7fd8);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradientShift 12s ease-in-out infinite;
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
color: var(--text-main);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 50% 100%;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background Blobs */
|
||||||
|
.bg-shape {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(60px);
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: float 10s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-1 {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
background: #5e60ce;
|
||||||
|
top: -50px;
|
||||||
|
left: -50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-2 {
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
background: #ff6bf0;
|
||||||
|
bottom: -50px;
|
||||||
|
right: -50px;
|
||||||
|
animation-delay: -5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
100% { transform: translate(30px, 30px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
padding: 10px; /* Slight padding from window edges if desired, or 0 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
-webkit-app-region: drag; /* Draggable area */
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--success);
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background-color: var(--success);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 8px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
-webkit-app-region: no-drag; /* Buttons clickable */
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: rgba(207, 102, 121, 0.3) !important;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Artwork */
|
||||||
|
.artwork-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-container {
|
||||||
|
width: 220px;
|
||||||
|
height: 220px;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 6px; /* spacing for ring */
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.1), rgba(255,255,255,0));
|
||||||
|
box-shadow: 5px 5px 15px rgba(0,0,0,0.1), inset 1px 1px 2px rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #4ea8de, #6930c3);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 0 20px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #4ea8de, #6930c3);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 0 20px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-logo-text {
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
text-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-logo-img {
|
||||||
|
/* Fill the artwork placeholder while keeping aspect ratio and inner padding */
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
padding: 12px; /* inner spacing from rounded edges */
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo blobs container sits behind logo but inside artwork placeholder */
|
||||||
|
.logo-blobs {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
filter: url(#goo);
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
/* more transparent overall */
|
||||||
|
opacity: 0.18;
|
||||||
|
/* slightly smaller blur for subtle definition */
|
||||||
|
filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b1 { width: 110px; height: 110px; left: 8%; top: 20%; background: radial-gradient(circle at 30% 30%, #c77dff, #8b5cf6); animation: float1 6s ease-in-out infinite; }
|
||||||
|
.b2 { width: 85px; height: 85px; right: 6%; top: 10%; background: radial-gradient(circle at 30% 30%, #7bffd1, #7dffb3); animation: float2 5.5s ease-in-out infinite; }
|
||||||
|
.b3 { width: 95px; height: 95px; left: 20%; bottom: 12%; background: radial-gradient(circle at 20% 20%, #ffd07a, #ff6bf0); animation: float3 7s ease-in-out infinite; }
|
||||||
|
.b4 { width: 70px; height: 70px; right: 24%; bottom: 18%; background: radial-gradient(circle at 30% 30%, #6bd3ff, #4ea8de); animation: float4 6.5s ease-in-out infinite; }
|
||||||
|
.b5 { width: 50px; height: 50px; left: 46%; top: 36%; background: radial-gradient(circle at 40% 40%, #ffa6d6, #c77dff); animation: float5 8s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* Additional blobs */
|
||||||
|
.b6 { width: 75px; height: 75px; left: 12%; top: 48%; background: radial-gradient(circle at 30% 30%, #bde7ff, #6bd3ff); animation: float6 6.8s ease-in-out infinite; }
|
||||||
|
.b7 { width: 42px; height: 42px; right: 10%; top: 42%; background: radial-gradient(circle at 40% 40%, #ffd9b3, #ffd07a); animation: float7 7.2s ease-in-out infinite; }
|
||||||
|
.b8 { width: 70px; height: 70px; left: 34%; bottom: 8%; background: radial-gradient(circle at 30% 30%, #e3b6ff, #c77dff); animation: float8 6.4s ease-in-out infinite; }
|
||||||
|
.b9 { width: 36px; height: 36px; right: 34%; bottom: 6%; background: radial-gradient(circle at 30% 30%, #9ef7d3, #7bffd1); animation: float9 8.4s ease-in-out infinite; }
|
||||||
|
.b10 { width: 30px; height: 30px; left: 52%; bottom: 28%; background: radial-gradient(circle at 30% 30%, #ffd0f0, #ffa6d6); animation: float10 5.8s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@keyframes float1 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(12px) translateX(8px) scale(1.06); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float2 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-10px) translateX(-6px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float3 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(8px) translateX(-10px) scale(1.05); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float4 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-6px) translateX(10px) scale(1.03); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float5 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-12px) translateX(4px) scale(1.07); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float6 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-8px) translateX(6px) scale(1.05); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float7 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(10px) translateX(-6px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float8 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-6px) translateX(10px) scale(1.03); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float9 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(12px) translateX(-4px) scale(1.06); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
@keyframes float10 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-10px) translateX(2px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
|
||||||
|
|
||||||
|
/* Slightly darken backdrop gradient so blobs read better */
|
||||||
|
.artwork-placeholder::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(0,0,0,0.06), rgba(0,0,0,0.12));
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track Info */
|
||||||
|
.track-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar (Visual) */
|
||||||
|
.progress-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
width: 100%; /* Live always full or pulsing */
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), #fff);
|
||||||
|
border-radius: 2px;
|
||||||
|
opacity: 0.8;
|
||||||
|
box-shadow: 0 0 10px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(50%, -50%);
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 10px rgba(255,255,255,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.controls-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-main);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, opacity 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.secondary {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.primary {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.2), rgba(255,255,255,0.05));
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.2), inset 0 0 10px rgba(255,255,255,0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.primary svg {
|
||||||
|
filter: drop-shadow(0 0 5px var(--accent-glow));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playing state - pulsing glow ring */
|
||||||
|
.control-btn.primary.playing {
|
||||||
|
animation: pulse-ring 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.2),
|
||||||
|
inset 0 0 10px rgba(255,255,255,0.1),
|
||||||
|
0 0 0 0 rgba(223, 166, 255, 0.7);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.2),
|
||||||
|
inset 0 0 10px rgba(255,255,255,0.1),
|
||||||
|
0 0 0 8px rgba(223, 166, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon container prevents layout jump */
|
||||||
|
.icon-container {
|
||||||
|
position: relative;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-container svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume */
|
||||||
|
.volume-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-container {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range] {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]::-webkit-slider-runnable-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]::-webkit-slider-thumb {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin-top: -6px; /* align with track */
|
||||||
|
box-shadow: 0 0 10px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#volume-value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
width: 30px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.small {
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cast Overlay (Beautified as per layout2_plan.md) */
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(20, 10, 35, 0.45);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay:not(.hidden) {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
width: min(420px, calc(100vw - 48px));
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(30, 30, 40, 0.82);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
box-shadow: 0 30px 80px rgba(0,0,0,0.6);
|
||||||
|
color: #fff;
|
||||||
|
animation: pop 0.22s ease;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pop {
|
||||||
|
from { transform: scale(0.94); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device list */
|
||||||
|
.device-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 10px 5px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device row */
|
||||||
|
.device {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device:hover {
|
||||||
|
background: rgba(255,255,255,0.10);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device .device-main {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device .device-sub {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected device */
|
||||||
|
.device.selected {
|
||||||
|
background: linear-gradient(135deg, #c77dff, #8b5cf6);
|
||||||
|
box-shadow: 0 0 18px rgba(199,125,255,0.65);
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device.selected .device-main,
|
||||||
|
.device.selected .device-sub {
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device.selected .device-sub {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cancel button */
|
||||||
|
.btn.cancel {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: #d16b7d;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, background 0.2s;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.cancel:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
background: #e17c8d;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package si.klevze.radioPlayer
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
val webView = findViewById<WebView>(R.id.webview)
|
||||||
|
webView.webViewClient = WebViewClient()
|
||||||
|
webView.webChromeClient = WebChromeClient()
|
||||||
|
webView.settings.javaScriptEnabled = true
|
||||||
|
webView.settings.domStorageEnabled = true
|
||||||
|
webView.settings.allowFileAccess = true
|
||||||
|
|
||||||
|
// Load the bundled web UI from assets/.
|
||||||
|
webView.loadUrl("file:///android_asset/index.html")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:id="@+id/webview"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.radio_tauri" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
10
src-tauri/gen/android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">RadioPlayer</string>
|
||||||
|
<string name="main_activity_title">RadioPlayer</string>
|
||||||
|
</resources>
|
||||||
6
src-tauri/gen/android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.radio_tauri" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
22
src-tauri/gen/android/build.gradle.kts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath("com.android.tools.build:gradle:8.11.0")
|
||||||
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register("clean").configure {
|
||||||
|
delete("build")
|
||||||
|
}
|
||||||
|
|
||||||
23
src-tauri/gen/android/buildSrc/build.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
plugins {
|
||||||
|
`kotlin-dsl`
|
||||||
|
}
|
||||||
|
|
||||||
|
gradlePlugin {
|
||||||
|
plugins {
|
||||||
|
create("pluginsForCoolKids") {
|
||||||
|
id = "rust"
|
||||||
|
implementationClass = "RustPlugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(gradleApi())
|
||||||
|
implementation("com.android.tools.build:gradle:8.11.0")
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import java.io.File
|
||||||
|
import org.apache.tools.ant.taskdefs.condition.Os
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.GradleException
|
||||||
|
import org.gradle.api.logging.LogLevel
|
||||||
|
import org.gradle.api.tasks.Input
|
||||||
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
|
||||||
|
open class BuildTask : DefaultTask() {
|
||||||
|
@Input
|
||||||
|
var rootDirRel: String? = null
|
||||||
|
@Input
|
||||||
|
var target: String? = null
|
||||||
|
@Input
|
||||||
|
var release: Boolean? = null
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun assemble() {
|
||||||
|
val executable = """node""";
|
||||||
|
try {
|
||||||
|
runTauriCli(executable)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||||
|
// Try different Windows-specific extensions
|
||||||
|
val fallbacks = listOf(
|
||||||
|
"$executable.exe",
|
||||||
|
"$executable.cmd",
|
||||||
|
"$executable.bat",
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastException: Exception = e
|
||||||
|
for (fallback in fallbacks) {
|
||||||
|
try {
|
||||||
|
runTauriCli(fallback)
|
||||||
|
return
|
||||||
|
} catch (fallbackException: Exception) {
|
||||||
|
lastException = fallbackException
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastException
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runTauriCli(executable: String) {
|
||||||
|
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
|
||||||
|
val target = target ?: throw GradleException("target cannot be null")
|
||||||
|
val release = release ?: throw GradleException("release cannot be null")
|
||||||
|
val args = listOf("tauri", "android", "android-studio-script");
|
||||||
|
|
||||||
|
project.exec {
|
||||||
|
workingDir(File(project.projectDir, rootDirRel))
|
||||||
|
executable(executable)
|
||||||
|
args(args)
|
||||||
|
if (project.logger.isEnabled(LogLevel.DEBUG)) {
|
||||||
|
args("-vv")
|
||||||
|
} else if (project.logger.isEnabled(LogLevel.INFO)) {
|
||||||
|
args("-v")
|
||||||
|
}
|
||||||
|
if (release) {
|
||||||
|
args("--release")
|
||||||
|
}
|
||||||
|
args(listOf("--target", target))
|
||||||
|
}.assertNormalExitValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
import org.gradle.kotlin.dsl.get
|
||||||
|
|
||||||
|
const val TASK_GROUP = "rust"
|
||||||
|
|
||||||
|
open class Config {
|
||||||
|
lateinit var rootDirRel: String
|
||||||
|
}
|
||||||
|
|
||||||
|
open class RustPlugin : Plugin<Project> {
|
||||||
|
private lateinit var config: Config
|
||||||
|
|
||||||
|
override fun apply(project: Project) = with(project) {
|
||||||
|
config = extensions.create("rust", Config::class.java)
|
||||||
|
|
||||||
|
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
|
||||||
|
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
|
||||||
|
|
||||||
|
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
|
||||||
|
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
|
||||||
|
|
||||||
|
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
|
||||||
|
|
||||||
|
extensions.configure<ApplicationExtension> {
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
|
flavorDimensions.add("abi")
|
||||||
|
productFlavors {
|
||||||
|
create("universal") {
|
||||||
|
dimension = "abi"
|
||||||
|
ndk {
|
||||||
|
abiFilters += abiList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaultArchList.forEachIndexed { index, arch ->
|
||||||
|
create(arch) {
|
||||||
|
dimension = "abi"
|
||||||
|
ndk {
|
||||||
|
abiFilters.add(defaultAbiList[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
for (profile in listOf("debug", "release")) {
|
||||||
|
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
|
||||||
|
val buildTask = tasks.maybeCreate(
|
||||||
|
"rustBuildUniversal$profileCapitalized",
|
||||||
|
DefaultTask::class.java
|
||||||
|
).apply {
|
||||||
|
group = TASK_GROUP
|
||||||
|
description = "Build dynamic library in $profile mode for all targets"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
|
||||||
|
|
||||||
|
for (targetPair in targetsList.withIndex()) {
|
||||||
|
val targetName = targetPair.value
|
||||||
|
val targetArch = archList[targetPair.index]
|
||||||
|
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
|
||||||
|
val targetBuildTask = project.tasks.maybeCreate(
|
||||||
|
"rustBuild$targetArchCapitalized$profileCapitalized",
|
||||||
|
BuildTask::class.java
|
||||||
|
).apply {
|
||||||
|
group = TASK_GROUP
|
||||||
|
description = "Build dynamic library in $profile mode for $targetArch"
|
||||||
|
rootDirRel = config.rootDirRel
|
||||||
|
target = targetName
|
||||||
|
release = profile == "release"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTask.dependsOn(targetBuildTask)
|
||||||
|
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
|
||||||
|
targetBuildTask
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src-tauri/gen/android/gradle.properties
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app"s APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
android.nonFinalResIds=false
|
||||||
BIN
src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
6
src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#Tue May 10 19:22:52 CST 2022
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
185
src-tauri/gen/android/gradlew
vendored
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2015 the original author or authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=`expr $i + 1`
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
0) set -- ;;
|
||||||
|
1) set -- "$args0" ;;
|
||||||
|
2) set -- "$args0" "$args1" ;;
|
||||||
|
3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=`save "$@"`
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
89
src-tauri/gen/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
3
src-tauri/gen/android/settings.gradle
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
include ':app'
|
||||||
|
|
||||||
|
rootProject.name = 'radio_tauri'
|
||||||
@@ -19,14 +19,26 @@ if (!fs.existsSync(iconPath)) {
|
|||||||
|
|
||||||
console.log('Patching EXE icon with rcedit...');
|
console.log('Patching EXE icon with rcedit...');
|
||||||
|
|
||||||
// Prefer local installed binary (node_modules/.bin/rcedit) to avoid relying on npx in some CI/envs
|
// Prefer local installed binary (node_modules/.bin) to avoid relying on npx.
|
||||||
const localBin = path.join(repoRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'rcedit.exe' : 'rcedit');
|
// On Windows, npm typically creates a .cmd shim, which Node can execute.
|
||||||
|
const binDir = path.join(repoRoot, 'node_modules', '.bin');
|
||||||
|
const localCandidates = process.platform === 'win32'
|
||||||
|
? [
|
||||||
|
path.join(binDir, 'rcedit.cmd'),
|
||||||
|
path.join(binDir, 'rcedit.exe'),
|
||||||
|
path.join(binDir, 'rcedit'),
|
||||||
|
]
|
||||||
|
: [path.join(binDir, 'rcedit')];
|
||||||
|
|
||||||
|
const localBin = localCandidates.find(p => fs.existsSync(p));
|
||||||
|
|
||||||
let cmd, args;
|
let cmd, args;
|
||||||
if (fs.existsSync(localBin)) {
|
if (localBin) {
|
||||||
cmd = localBin;
|
cmd = localBin;
|
||||||
args = [exePath, '--set-icon', iconPath];
|
args = [exePath, '--set-icon', iconPath];
|
||||||
} else {
|
} else {
|
||||||
// fallback to npx
|
// Fallback to npx. Note: Node can't execute PowerShell shims (npx.ps1), so this may fail
|
||||||
|
// in environments that only provide .ps1 launchers.
|
||||||
cmd = 'npx';
|
cmd = 'npx';
|
||||||
args = ['rcedit', exePath, '--set-icon', iconPath];
|
args = ['rcedit', exePath, '--set-icon', iconPath];
|
||||||
}
|
}
|
||||||
@@ -35,7 +47,8 @@ const res = spawnSync(cmd, args, { stdio: 'inherit' });
|
|||||||
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
console.error(`Failed to run ${cmd}:`, res.error.message);
|
console.error(`Failed to run ${cmd}:`, res.error.message);
|
||||||
console.error('Ensure rcedit is installed (npm install --save-dev rcedit) or that npx is available.');
|
console.error('Ensure rcedit is installed and available as a .cmd/.exe in node_modules/.bin (run `npm install`).');
|
||||||
|
console.error('If you rely on npx, make sure you have npx.cmd on PATH (PowerShell-only shims like npx.ps1 will not work with Node spawn).');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||