diff --git a/README.md b/README.md
index c4b3a6f..37e6baa 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,33 @@ To change the default window size, edit `src-tauri/tauri.conf.json`:
* **WebView2 Error (Windows)**: If the app doesn't start on Windows, ensure the [Microsoft Edge WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) is installed.
* **Build Failures**: Try running `cargo update` inside the `src-tauri` folder to update Rust dependencies.
+## FFmpeg (Optional) for Native Playback
+
+Local/native playback uses an external **FFmpeg** binary to decode radio streams.
+
+### How the app finds FFmpeg
+
+At runtime it searches in this order:
+
+1. `RADIOPLAYER_FFMPEG` environment variable (absolute or relative path)
+2. Next to the application executable (Windows: `ffmpeg.exe`, macOS/Linux: `ffmpeg`)
+3. Common bundle resource folders relative to the executable:
+ - `resources/ffmpeg(.exe)`
+ - `Resources/ffmpeg(.exe)`
+ - `../resources/ffmpeg(.exe)`
+ - `../Resources/ffmpeg(.exe)`
+4. Your system `PATH`
+
+### Optional: download FFmpeg automatically (Windows)
+
+This is **opt-in** (it is not run automatically during build/run). It downloads a prebuilt FFmpeg zip and extracts `ffmpeg.exe` into `tools/ffmpeg/bin/ffmpeg.exe`.
+
+```bash
+npm run ffmpeg:download
+```
+
+Then run `npm run dev:native` (or `npm run build`) to copy FFmpeg into `src-tauri/resources/` for bundling.
+
## License
[Add License Information Here]
diff --git a/android/app/src/main/assets/index.html b/android/app/src/main/assets/index.html
index 6a118f4..822cfaa 100644
--- a/android/app/src/main/assets/index.html
+++ b/android/app/src/main/assets/index.html
@@ -27,7 +27,9 @@
Radio1 Player
- Ready
+
+ Ready
+ FFMPEG
diff --git a/android/app/src/main/assets/main.js b/android/app/src/main/assets/main.js
index 9056c68..d40faf7 100644
--- a/android/app/src/main/assets/main.js
+++ b/android/app/src/main/assets/main.js
@@ -15,6 +15,7 @@ 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 engineBadgeEl = document.getElementById('engine-badge');
const playBtn = document.getElementById('play-btn');
const iconPlay = document.getElementById('icon-play');
const iconStop = document.getElementById('icon-stop');
@@ -34,6 +35,34 @@ async function init() {
await loadStations();
setupEventListeners();
updateUI();
+ updateEngineBadge();
+}
+
+function updateEngineBadge() {
+ if (!engineBadgeEl) return;
+
+ const kind = currentMode === 'cast' ? 'cast' : 'ffmpeg';
+ const label = kind === 'cast' ? 'CAST' : 'FFMPEG';
+ const title = kind === 'cast' ? 'Google Cast playback' : 'Native playback (FFmpeg)';
+
+ const iconSvg = kind === 'cast'
+ ? ``
+ : ``;
+
+ engineBadgeEl.innerHTML = `${iconSvg}${label}`;
+ engineBadgeEl.title = title;
+ engineBadgeEl.classList.remove('engine-ffmpeg', 'engine-cast', 'engine-html');
+ engineBadgeEl.classList.add(`engine-${kind}`);
}
async function loadStations() {
@@ -239,6 +268,8 @@ function updateUI() {
statusDotEl.style.backgroundColor = 'var(--text-muted)';
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream';
}
+
+ updateEngineBadge();
}
function handleVolumeInput() {
diff --git a/android/app/src/main/assets/styles.css b/android/app/src/main/assets/styles.css
index 6ee55ec..43c0d02 100644
--- a/android/app/src/main/assets/styles.css
+++ b/android/app/src/main/assets/styles.css
@@ -143,6 +143,31 @@ header {
gap: 6px;
}
+.engine-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.68rem;
+ letter-spacing: 0.6px;
+ text-transform: uppercase;
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px solid rgba(255,255,255,0.14);
+ background: rgba(255,255,255,0.06);
+ color: var(--text-main);
+ opacity: 0.9;
+}
+
+.engine-badge svg {
+ width: 12px;
+ height: 12px;
+ display: block;
+}
+
+.engine-ffmpeg { border-color: rgba(125,255,179,0.30); }
+.engine-cast { border-color: rgba(223,166,255,0.35); }
+.engine-html { border-color: rgba(255,255,255,0.22); }
+
.status-dot {
width: 6px;
height: 6px;
diff --git a/package.json b/package.json
index 95db625..87a906b 100644
--- a/package.json
+++ b/package.json
@@ -5,8 +5,10 @@
"type": "module",
"scripts": {
"dev": "tauri dev",
- "build": "node tools/copy-binaries.js && tauri build && node tools/post-build-rcedit.js",
- "tauri": "node tools/copy-binaries.js && tauri"
+ "dev:native": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri dev",
+ "ffmpeg:download": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/download-ffmpeg.ps1",
+ "build": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri build && node tools/post-build-rcedit.js",
+ "tauri": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
diff --git a/scripts/download-ffmpeg.ps1 b/scripts/download-ffmpeg.ps1
new file mode 100644
index 0000000..7cfd500
--- /dev/null
+++ b/scripts/download-ffmpeg.ps1
@@ -0,0 +1,71 @@
+param(
+ [string]$Url = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip",
+ [string]$OutDir = "tools/ffmpeg/bin",
+ [switch]$DryRun
+)
+
+$ErrorActionPreference = "Stop"
+
+$isWindows = $env:OS -eq 'Windows_NT'
+if (-not $isWindows) {
+ Write-Host "This script is intended for Windows (ffmpeg.exe)." -ForegroundColor Yellow
+ exit 1
+}
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$outDirAbs = (Resolve-Path (Join-Path $repoRoot $OutDir) -ErrorAction SilentlyContinue)
+if (-not $outDirAbs) {
+ $outDirAbs = Join-Path $repoRoot $OutDir
+ New-Item -ItemType Directory -Force -Path $outDirAbs | Out-Null
+} else {
+ $outDirAbs = $outDirAbs.Path
+}
+
+$ffmpegDest = Join-Path $outDirAbs "ffmpeg.exe"
+
+# If already present, do nothing.
+if (Test-Path $ffmpegDest) {
+ Write-Host "FFmpeg already present: $ffmpegDest"
+ exit 0
+}
+
+if ($DryRun) {
+ Write-Host "Dry run:" -ForegroundColor Cyan
+ Write-Host " Would download: $Url"
+ Write-Host " Would install to: $ffmpegDest"
+ exit 0
+}
+
+Write-Host "About to download a prebuilt FFmpeg package:" -ForegroundColor Cyan
+Write-Host " $Url"
+Write-Host "You are responsible for reviewing the FFmpeg license/compliance for your use case." -ForegroundColor Yellow
+
+$tempRoot = Join-Path $env:TEMP ("radioplayer-ffmpeg-" + [guid]::NewGuid().ToString("N"))
+$zipPath = Join-Path $tempRoot "ffmpeg.zip"
+$extractDir = Join-Path $tempRoot "extract"
+
+New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null
+New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
+
+try {
+ Write-Host "Downloading..." -ForegroundColor Cyan
+ Invoke-WebRequest -Uri $Url -OutFile $zipPath -UseBasicParsing
+
+ Write-Host "Extracting..." -ForegroundColor Cyan
+ Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force
+
+ $candidate = Get-ChildItem -Path $extractDir -Recurse -Filter "ffmpeg.exe" | Where-Object {
+ $_.FullName -match "\\bin\\ffmpeg\.exe$"
+ } | Select-Object -First 1
+
+ if (-not $candidate) {
+ throw "Could not find ffmpeg.exe under extracted content. The archive layout may have changed."
+ }
+
+ Copy-Item -Force -Path $candidate.FullName -Destination $ffmpegDest
+
+ Write-Host "Installed FFmpeg to: $ffmpegDest" -ForegroundColor Green
+ Write-Host "Next: run 'node tools/copy-ffmpeg.js' (or 'npm run dev:native' / 'npm run build') to bundle it into src-tauri/resources/." -ForegroundColor Green
+} finally {
+ try { Remove-Item -Recurse -Force -Path $tempRoot -ErrorAction SilentlyContinue } catch {}
+}
diff --git a/src-tauri/resources/.gitkeep b/src-tauri/resources/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src-tauri/resources/ffmpeg.exe b/src-tauri/resources/ffmpeg.exe
new file mode 100644
index 0000000..bb2dbeb
Binary files /dev/null and b/src-tauri/resources/ffmpeg.exe differ
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 5668d73..dd9dfae 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -64,6 +64,17 @@ async fn player_set_volume(
#[tauri::command]
async fn player_play(player: State<'_, PlayerRuntime>, url: String) -> Result<(), String> {
+ // Fail fast if audio output or ffmpeg is not available.
+ // This keeps UX predictable: JS can show an error without flipping to "playing".
+ if let Err(e) = player::preflight_check() {
+ {
+ let mut s = player.shared.state.lock().unwrap();
+ s.status = player::PlayerStatus::Error;
+ s.error = Some(e.clone());
+ }
+ return Err(e);
+ }
+
{
let mut s = player.shared.state.lock().unwrap();
s.error = None;
diff --git a/src-tauri/src/player.rs b/src-tauri/src/player.rs
index 7e8d1a0..3b5a89f 100644
--- a/src-tauri/src/player.rs
+++ b/src-tauri/src/player.rs
@@ -118,9 +118,20 @@ fn ffmpeg_command() -> OsString {
let local_name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" };
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
- let candidate = dir.join(local_name);
- if candidate.exists() {
- return candidate.into_os_string();
+ // Common locations depending on bundler/platform.
+ let candidates = [
+ dir.join(local_name),
+ // Some packagers place resources in a sibling folder.
+ dir.join("resources").join(local_name),
+ dir.join("Resources").join(local_name),
+ // Or one level above.
+ dir.join("..").join("resources").join(local_name),
+ dir.join("..").join("Resources").join(local_name),
+ ];
+ for candidate in candidates {
+ if candidate.exists() {
+ return candidate.into_os_string();
+ }
}
}
}
@@ -128,6 +139,36 @@ fn ffmpeg_command() -> OsString {
OsString::from(local_name)
}
+pub fn preflight_check() -> Result<(), String> {
+ // Ensure we have an output device up-front so UI gets a synchronous error.
+ let host = cpal::default_host();
+ let device = host
+ .default_output_device()
+ .ok_or_else(|| "No default audio output device".to_string())?;
+ let _ = device
+ .default_output_config()
+ .map_err(|e| format!("Failed to get output config: {e}"))?;
+
+ // Ensure ffmpeg can be executed.
+ let ffmpeg = ffmpeg_command();
+ let status = Command::new(&ffmpeg)
+ .arg("-version")
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .map_err(|e| {
+ let ffmpeg_disp = ffmpeg.to_string_lossy();
+ format!(
+ "FFmpeg not available ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
+ )
+ })?;
+ if !status.success() {
+ return Err("FFmpeg exists but returned non-zero for -version".to_string());
+ }
+
+ Ok(())
+}
+
struct Pipeline {
stop_flag: Arc,
volume_bits: Arc,
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index d62a861..702e58c 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -28,6 +28,9 @@
"externalBin": [
"binaries/RadioPlayer"
],
+ "resources": [
+ "resources/*"
+ ],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
diff --git a/src/index.html b/src/index.html
index d4580c6..7b4e53a 100644
--- a/src/index.html
+++ b/src/index.html
@@ -96,6 +96,9 @@
+
+ 1
+
@@ -116,6 +119,7 @@
+ FFMPEG
diff --git a/src/main.js b/src/main.js
index b7a5cc5..1dbac9d 100644
--- a/src/main.js
+++ b/src/main.js
@@ -20,6 +20,7 @@ const nowArtistEl = document.getElementById('now-artist');
const nowTitleEl = document.getElementById('now-title');
const statusTextEl = document.getElementById('status-text');
const statusDotEl = document.querySelector('.status-dot');
+const engineBadgeEl = document.getElementById('engine-badge');
const playBtn = document.getElementById('play-btn');
const iconPlay = document.getElementById('icon-play');
const iconStop = document.getElementById('icon-stop');
@@ -35,6 +36,8 @@ const coverflowStageEl = document.getElementById('artwork-coverflow-stage');
const coverflowPrevBtn = document.getElementById('artwork-prev');
const coverflowNextBtn = document.getElementById('artwork-next');
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
+const logoTextEl = document.querySelector('.station-logo-text');
+const logoImgEl = document.getElementById('station-logo-img');
// Global error handlers to avoid silent white screen and show errors in UI
window.addEventListener('error', (ev) => {
try {
@@ -69,12 +72,43 @@ async function init() {
setupEventListeners();
ensureArtworkPointerFallback();
updateUI();
+ updateEngineBadge();
} catch (e) {
console.error('Error during init', e);
if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e && e.message ? e.message : String(e));
}
}
+function updateEngineBadge() {
+ if (!engineBadgeEl) return;
+
+ // In this app:
+ // - Local playback uses the native backend (FFmpeg decode + CPAL output).
+ // - Cast mode plays via Chromecast.
+ const kind = currentMode === 'cast' ? 'cast' : 'ffmpeg';
+ const label = kind === 'cast' ? 'CAST' : 'FFMPEG';
+ const title = kind === 'cast' ? 'Google Cast playback' : 'Native playback (FFmpeg)';
+
+ const iconSvg = kind === 'cast'
+ ? ``
+ : ``;
+
+ engineBadgeEl.innerHTML = `${iconSvg}${label}`;
+ engineBadgeEl.title = title;
+ engineBadgeEl.classList.remove('engine-ffmpeg', 'engine-cast', 'engine-html');
+ engineBadgeEl.classList.add(`engine-${kind}`);
+}
+
// Volume persistence
function saveVolumeToStorage(val) {
try {
@@ -134,6 +168,15 @@ function startLocalPlayerStatePolling() {
} else if (status === 'error') {
statusTextEl.textContent = st.error ? `Error: ${st.error}` : 'Error';
statusDotEl.style.backgroundColor = 'var(--danger)';
+
+ // Backend is no longer playing; reflect that in UX.
+ isPlaying = false;
+ stopLocalPlayerStatePolling();
+ updateUI();
+ } else if (status === 'stopped' || status === 'idle') {
+ isPlaying = false;
+ stopLocalPlayerStatePolling();
+ updateUI();
} else {
// idle/stopped: keep UI consistent with our isPlaying flag
}
@@ -884,6 +927,44 @@ function loadStation(index) {
if (nowArtistEl) nowArtistEl.textContent = '';
if (nowTitleEl) nowTitleEl.textContent = '';
+ // Update main artwork logo (best-effort). Many station logo URLs are http; try https first.
+ try {
+ if (logoTextEl && station && station.name) {
+ const numberMatch = station.name.match(/\d+/);
+ logoTextEl.textContent = numberMatch ? numberMatch[0] : station.name.charAt(0).toUpperCase();
+ }
+
+ const rawLogo = (station && (station.logo || (station.raw && (station.raw.logo || station.raw.poster)))) || '';
+ const logoUrl = rawLogo && rawLogo.startsWith('http://') ? ('https://' + rawLogo.slice('http://'.length)) : rawLogo;
+
+ if (logoImgEl) {
+ logoImgEl.onload = null;
+ logoImgEl.onerror = null;
+
+ if (logoUrl) {
+ logoImgEl.onload = () => {
+ logoImgEl.classList.remove('hidden');
+ if (logoTextEl) logoTextEl.classList.add('hidden');
+ };
+ logoImgEl.onerror = () => {
+ logoImgEl.classList.add('hidden');
+ if (logoTextEl) logoTextEl.classList.remove('hidden');
+ };
+
+ logoImgEl.src = logoUrl;
+ // Show fallback until load completes.
+ logoImgEl.classList.add('hidden');
+ if (logoTextEl) logoTextEl.classList.remove('hidden');
+ } else {
+ logoImgEl.src = '';
+ logoImgEl.classList.add('hidden');
+ if (logoTextEl) logoTextEl.classList.remove('hidden');
+ }
+ }
+ } catch (e) {
+ // non-fatal
+ }
+
// Sync coverflow transforms (if present)
try { updateCoverflowTransforms(); } catch (e) {}
// When loading a station, ensure only this station's poller runs
@@ -1021,6 +1102,8 @@ function updateUI() {
statusDotEl.style.backgroundColor = 'var(--text-muted)';
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream';
}
+
+ updateEngineBadge();
}
function handleVolumeInput() {
@@ -1105,13 +1188,29 @@ async function selectCastDevice(deviceName) {
window.addEventListener('DOMContentLoaded', init);
-// Register Service Worker for PWA installation (non-disruptive)
+// Service worker is useful for the PWA, but it can cause confusing caching during
+// Tauri development because it may serve an older cached `index.html`.
+const runningInTauri = !!(window.__TAURI__ && window.__TAURI__.core);
if ('serviceWorker' in navigator) {
- window.addEventListener('load', () => {
- navigator.serviceWorker.register('sw.js')
- .then((reg) => console.log('ServiceWorker registered:', reg.scope))
- .catch((err) => console.debug('ServiceWorker registration failed:', err));
- });
+ if (runningInTauri) {
+ // Best-effort cleanup so the desktop app always reflects local file changes.
+ navigator.serviceWorker.getRegistrations()
+ .then((regs) => Promise.all(regs.map((r) => r.unregister())))
+ .catch(() => {});
+
+ if ('caches' in window) {
+ caches.keys()
+ .then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
+ .catch(() => {});
+ }
+ } else {
+ // Register Service Worker for PWA installation (non-disruptive)
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('sw.js')
+ .then((reg) => console.log('ServiceWorker registered:', reg.scope))
+ .catch((err) => console.debug('ServiceWorker registration failed:', err));
+ });
+ }
}
// Open overlay and show list of stations (used by menu/hamburger)
diff --git a/src/styles.css b/src/styles.css
index e29c590..79b8cca 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -211,6 +211,31 @@ body {
gap: 8px;
}
+.engine-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.72rem;
+ letter-spacing: 0.6px;
+ text-transform: uppercase;
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px solid rgba(255,255,255,0.12);
+ background: rgba(255,255,255,0.06);
+ color: var(--text-main);
+ opacity: 0.9;
+}
+
+.engine-badge svg {
+ width: 12px;
+ height: 12px;
+ display: block;
+}
+
+.engine-ffmpeg { border-color: rgba(125,255,179,0.30); box-shadow: 0 0 10px rgba(125,255,179,0.12); }
+.engine-cast { border-color: rgba(223,166,255,0.35); box-shadow: 0 0 10px rgba(223,166,255,0.12); }
+.engine-html { border-color: rgba(255,255,255,0.22); }
+
.status-dot {
width: 6px;
height: 6px;
@@ -341,6 +366,7 @@ body {
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
position: relative;
z-index: 3;
+ margin-left:1rem;
}
/* Logo blobs container sits behind logo but inside artwork placeholder */
diff --git a/src/sw.js b/src/sw.js
index fa201a5..b8362ea 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = 'radiocast-core-v1';
+const CACHE_NAME = 'radiocast-core-v2';
const CORE_ASSETS = [
'.',
'index.html',
@@ -11,6 +11,8 @@ const CORE_ASSETS = [
];
self.addEventListener('install', (event) => {
+ // Activate updated SW as soon as it's installed.
+ self.skipWaiting();
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(CORE_ASSETS))
);
@@ -18,9 +20,12 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => {
event.waitUntil(
- caches.keys().then((keys) => Promise.all(
- keys.map((k) => { if (k !== CACHE_NAME) return caches.delete(k); return null; })
- ))
+ Promise.all([
+ self.clients.claim(),
+ caches.keys().then((keys) => Promise.all(
+ keys.map((k) => { if (k !== CACHE_NAME) return caches.delete(k); return null; })
+ )),
+ ])
);
});
diff --git a/tools/copy-ffmpeg.js b/tools/copy-ffmpeg.js
new file mode 100644
index 0000000..576bca9
--- /dev/null
+++ b/tools/copy-ffmpeg.js
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+import fs from 'fs';
+import path from 'path';
+
+const repoRoot = process.cwd();
+const tauriDir = path.join(repoRoot, 'src-tauri');
+const resourcesDir = path.join(tauriDir, 'resources');
+
+function platformBinName() {
+ return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
+}
+
+function exists(p) {
+ try { return fs.existsSync(p); } catch { return false; }
+}
+
+function ensureDir(p) {
+ if (!exists(p)) fs.mkdirSync(p, { recursive: true });
+}
+
+// Source lookup order:
+// 1) RADIOPLAYER_FFMPEG (absolute or relative)
+// 2) tools/ffmpeg/ffmpeg(.exe)
+// 3) tools/ffmpeg/bin/ffmpeg(.exe)
+function resolveSource() {
+ const env = process.env.RADIOPLAYER_FFMPEG;
+ if (env && String(env).trim().length > 0) {
+ const p = path.isAbsolute(env) ? env : path.join(repoRoot, env);
+ if (exists(p)) return p;
+ console.warn(`RADIOPLAYER_FFMPEG set but not found: ${p}`);
+ }
+
+ const name = platformBinName();
+ const candidates = [
+ path.join(repoRoot, 'tools', 'ffmpeg', name),
+ path.join(repoRoot, 'tools', 'ffmpeg', 'bin', name),
+ ];
+
+ return candidates.find(exists) || null;
+}
+
+function main() {
+ const name = platformBinName();
+ const src = resolveSource();
+ if (!src) {
+ console.log('FFmpeg not provided; skipping copy (set RADIOPLAYER_FFMPEG or place it under tools/ffmpeg/).');
+ process.exit(0);
+ }
+
+ ensureDir(resourcesDir);
+ const dst = path.join(resourcesDir, name);
+
+ try {
+ fs.copyFileSync(src, dst);
+ // Best-effort: ensure executable bit on unix-like platforms.
+ if (process.platform !== 'win32') {
+ try { fs.chmodSync(dst, 0o755); } catch {}
+ }
+ console.log(`Copied FFmpeg into bundle resources: ${src} -> ${dst}`);
+ } catch (e) {
+ console.error('Failed to copy FFmpeg:', e);
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/tools/ffmpeg/README.md b/tools/ffmpeg/README.md
new file mode 100644
index 0000000..8f88321
--- /dev/null
+++ b/tools/ffmpeg/README.md
@@ -0,0 +1,45 @@
+# FFmpeg (Optional) for Native Playback
+
+The native player uses an external **FFmpeg** binary to decode radio streams.
+
+## Why this exists
+
+- The app intentionally does **not** download or embed FFmpeg automatically.
+- You provide FFmpeg yourself (license/compliance-friendly).
+
+## How the app finds FFmpeg
+
+At runtime it searches in this order:
+
+1. `RADIOPLAYER_FFMPEG` environment variable (absolute or relative path)
+2. Next to the application executable (Windows: `ffmpeg.exe`, macOS/Linux: `ffmpeg`)
+3. Common bundle resource folders relative to the executable:
+ - `resources/ffmpeg(.exe)`
+ - `Resources/ffmpeg(.exe)`
+ - `../resources/ffmpeg(.exe)`
+ - `../Resources/ffmpeg(.exe)`
+4. Your system `PATH`
+
+## Recommended setup (Windows dev)
+
+- Put `ffmpeg.exe` somewhere stable, then set:
+
+`RADIOPLAYER_FFMPEG=C:\\path\\to\\ffmpeg.exe`
+
+Or copy `ffmpeg.exe` next to the built app binary:
+
+- `src-tauri/target/debug/ffmpeg.exe` (dev)
+- `src-tauri/target/release/ffmpeg.exe` (release)
+
+## Optional: download helper (Windows)
+
+You can also run:
+
+`npm run ffmpeg:download`
+
+This downloads a prebuilt FFmpeg zip and extracts `ffmpeg.exe` into `tools/ffmpeg/bin/ffmpeg.exe`.
+
+## Notes
+
+- The player will fail fast with a clear error if FFmpeg is missing.
+- The project already includes a copy step (`tools/copy-ffmpeg.js`) that runs before `tauri`/`build` and places FFmpeg into `src-tauri/resources/` for bundling.
\ No newline at end of file
diff --git a/tools/ffmpeg/bin/ffmpeg.exe b/tools/ffmpeg/bin/ffmpeg.exe
new file mode 100644
index 0000000..bb2dbeb
Binary files /dev/null and b/tools/ffmpeg/bin/ffmpeg.exe differ