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