andorid app

This commit is contained in:
2026-01-01 10:47:57 +01:00
parent ff9209062e
commit b99d9ce524
80 changed files with 6154 additions and 5 deletions

11
android/README.md Normal file
View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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"}

View 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

View 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

View 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>

View 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);
});
}

File diff suppressed because it is too large Load Diff

View 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;
}

206
scripts/build-android.ps1 Normal file
View 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
View 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)."

View 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
View 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
View 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

View 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")
}

View 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

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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"}

View 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

View 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

View 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>

View 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);
});
}

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -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")
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
<resources>
<string name="app_name">RadioPlayer</string>
<string name="main_activity_title">RadioPlayer</string>
</resources>

View 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>

View File

@@ -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>

View 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")
}

View 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")
}

View File

@@ -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()
}
}

View File

@@ -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
)
}
}
}
}
}

View 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

Binary file not shown.

View 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
View 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
View 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

View File

@@ -0,0 +1,3 @@
include ':app'
rootProject.name = 'radio_tauri'

View File

@@ -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);
} }