ffmpeg implemented
This commit is contained in:
27
README.md
27
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.
|
* **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.
|
* **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
|
## License
|
||||||
|
|
||||||
[Add License Information Here]
|
[Add License Information Here]
|
||||||
|
|||||||
@@ -27,7 +27,9 @@
|
|||||||
<div class="header-info" data-tauri-drag-region>
|
<div class="header-info" data-tauri-drag-region>
|
||||||
<span class="app-title">Radio1 Player</span>
|
<span class="app-title">Radio1 Player</span>
|
||||||
<span class="status-indicator" id="status-indicator">
|
<span class="status-indicator" id="status-indicator">
|
||||||
<span class="status-dot"></span> <span id="status-text">Ready</span>
|
<span class="status-dot"></span>
|
||||||
|
<span id="status-text">Ready</span>
|
||||||
|
<span id="engine-badge" class="engine-badge" title="Playback engine">FFMPEG</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const stationNameEl = document.getElementById('station-name');
|
|||||||
const stationSubtitleEl = document.getElementById('station-subtitle');
|
const stationSubtitleEl = document.getElementById('station-subtitle');
|
||||||
const statusTextEl = document.getElementById('status-text');
|
const statusTextEl = document.getElementById('status-text');
|
||||||
const statusDotEl = document.querySelector('.status-dot');
|
const statusDotEl = document.querySelector('.status-dot');
|
||||||
|
const engineBadgeEl = document.getElementById('engine-badge');
|
||||||
const playBtn = document.getElementById('play-btn');
|
const playBtn = document.getElementById('play-btn');
|
||||||
const iconPlay = document.getElementById('icon-play');
|
const iconPlay = document.getElementById('icon-play');
|
||||||
const iconStop = document.getElementById('icon-stop');
|
const iconStop = document.getElementById('icon-stop');
|
||||||
@@ -34,6 +35,34 @@ async function init() {
|
|||||||
await loadStations();
|
await loadStations();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
updateUI();
|
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'
|
||||||
|
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 16.1A5 5 0 0 1 5.9 20" />
|
||||||
|
<path d="M2 12.05A9 9 0 0 1 9.95 20" />
|
||||||
|
<path d="M2 8V6a14 14 0 0 1 14 14h-2" />
|
||||||
|
</svg>`
|
||||||
|
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 15V9" />
|
||||||
|
<path d="M8 19V5" />
|
||||||
|
<path d="M12 16V8" />
|
||||||
|
<path d="M16 18V6" />
|
||||||
|
<path d="M20 15V9" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
engineBadgeEl.innerHTML = `${iconSvg}<span>${label}</span>`;
|
||||||
|
engineBadgeEl.title = title;
|
||||||
|
engineBadgeEl.classList.remove('engine-ffmpeg', 'engine-cast', 'engine-html');
|
||||||
|
engineBadgeEl.classList.add(`engine-${kind}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStations() {
|
async function loadStations() {
|
||||||
@@ -239,6 +268,8 @@ function updateUI() {
|
|||||||
statusDotEl.style.backgroundColor = 'var(--text-muted)';
|
statusDotEl.style.backgroundColor = 'var(--text-muted)';
|
||||||
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream';
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateEngineBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVolumeInput() {
|
function handleVolumeInput() {
|
||||||
|
|||||||
@@ -143,6 +143,31 @@ header {
|
|||||||
gap: 6px;
|
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 {
|
.status-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
"build": "node tools/copy-binaries.js && tauri build && node tools/post-build-rcedit.js",
|
"dev:native": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri dev",
|
||||||
"tauri": "node tools/copy-binaries.js && tauri"
|
"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": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
|||||||
71
scripts/download-ffmpeg.ps1
Normal file
71
scripts/download-ffmpeg.ps1
Normal file
@@ -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 {}
|
||||||
|
}
|
||||||
0
src-tauri/resources/.gitkeep
Normal file
0
src-tauri/resources/.gitkeep
Normal file
BIN
src-tauri/resources/ffmpeg.exe
Normal file
BIN
src-tauri/resources/ffmpeg.exe
Normal file
Binary file not shown.
@@ -64,6 +64,17 @@ async fn player_set_volume(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn player_play(player: State<'_, PlayerRuntime>, url: String) -> Result<(), String> {
|
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();
|
let mut s = player.shared.state.lock().unwrap();
|
||||||
s.error = None;
|
s.error = None;
|
||||||
|
|||||||
@@ -118,16 +118,57 @@ fn ffmpeg_command() -> OsString {
|
|||||||
let local_name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" };
|
let local_name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" };
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
if let Some(dir) = exe.parent() {
|
||||||
let candidate = dir.join(local_name);
|
// 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() {
|
if candidate.exists() {
|
||||||
return candidate.into_os_string();
|
return candidate.into_os_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OsString::from(local_name)
|
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 {
|
struct Pipeline {
|
||||||
stop_flag: Arc<AtomicBool>,
|
stop_flag: Arc<AtomicBool>,
|
||||||
volume_bits: Arc<AtomicU32>,
|
volume_bits: Arc<AtomicU32>,
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
"externalBin": [
|
"externalBin": [
|
||||||
"binaries/RadioPlayer"
|
"binaries/RadioPlayer"
|
||||||
],
|
],
|
||||||
|
"resources": [
|
||||||
|
"resources/*"
|
||||||
|
],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -96,6 +96,9 @@
|
|||||||
<span class="blob b10"></span>
|
<span class="blob b10"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
|
||||||
|
<span class="station-logo-text">1</span>
|
||||||
|
|
||||||
<!-- Coverflow-style station carousel inside the artwork (drag or use arrows) -->
|
<!-- Coverflow-style station carousel inside the artwork (drag or use arrows) -->
|
||||||
<div id="artwork-coverflow" class="artwork-coverflow" aria-label="Stations">
|
<div id="artwork-coverflow" class="artwork-coverflow" aria-label="Stations">
|
||||||
<button id="artwork-prev" class="coverflow-arrow left" aria-label="Previous station">‹</button>
|
<button id="artwork-prev" class="coverflow-arrow left" aria-label="Previous station">‹</button>
|
||||||
@@ -116,6 +119,7 @@
|
|||||||
<div id="status-indicator" class="status-indicator-wrap" aria-hidden="true">
|
<div id="status-indicator" class="status-indicator-wrap" aria-hidden="true">
|
||||||
<span class="status-dot"></span>
|
<span class="status-dot"></span>
|
||||||
<span id="status-text"></span>
|
<span id="status-text"></span>
|
||||||
|
<span id="engine-badge" class="engine-badge" title="Playback engine">FFMPEG</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
101
src/main.js
101
src/main.js
@@ -20,6 +20,7 @@ const nowArtistEl = document.getElementById('now-artist');
|
|||||||
const nowTitleEl = document.getElementById('now-title');
|
const nowTitleEl = document.getElementById('now-title');
|
||||||
const statusTextEl = document.getElementById('status-text');
|
const statusTextEl = document.getElementById('status-text');
|
||||||
const statusDotEl = document.querySelector('.status-dot');
|
const statusDotEl = document.querySelector('.status-dot');
|
||||||
|
const engineBadgeEl = document.getElementById('engine-badge');
|
||||||
const playBtn = document.getElementById('play-btn');
|
const playBtn = document.getElementById('play-btn');
|
||||||
const iconPlay = document.getElementById('icon-play');
|
const iconPlay = document.getElementById('icon-play');
|
||||||
const iconStop = document.getElementById('icon-stop');
|
const iconStop = document.getElementById('icon-stop');
|
||||||
@@ -35,6 +36,8 @@ const coverflowStageEl = document.getElementById('artwork-coverflow-stage');
|
|||||||
const coverflowPrevBtn = document.getElementById('artwork-prev');
|
const coverflowPrevBtn = document.getElementById('artwork-prev');
|
||||||
const coverflowNextBtn = document.getElementById('artwork-next');
|
const coverflowNextBtn = document.getElementById('artwork-next');
|
||||||
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
|
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
|
// Global error handlers to avoid silent white screen and show errors in UI
|
||||||
window.addEventListener('error', (ev) => {
|
window.addEventListener('error', (ev) => {
|
||||||
try {
|
try {
|
||||||
@@ -69,12 +72,43 @@ async function init() {
|
|||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
ensureArtworkPointerFallback();
|
ensureArtworkPointerFallback();
|
||||||
updateUI();
|
updateUI();
|
||||||
|
updateEngineBadge();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error during init', e);
|
console.error('Error during init', e);
|
||||||
if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e && e.message ? e.message : String(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'
|
||||||
|
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 16.1A5 5 0 0 1 5.9 20" />
|
||||||
|
<path d="M2 12.05A9 9 0 0 1 9.95 20" />
|
||||||
|
<path d="M2 8V6a14 14 0 0 1 14 14h-2" />
|
||||||
|
</svg>`
|
||||||
|
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 15V9" />
|
||||||
|
<path d="M8 19V5" />
|
||||||
|
<path d="M12 16V8" />
|
||||||
|
<path d="M16 18V6" />
|
||||||
|
<path d="M20 15V9" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
engineBadgeEl.innerHTML = `${iconSvg}<span>${label}</span>`;
|
||||||
|
engineBadgeEl.title = title;
|
||||||
|
engineBadgeEl.classList.remove('engine-ffmpeg', 'engine-cast', 'engine-html');
|
||||||
|
engineBadgeEl.classList.add(`engine-${kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Volume persistence
|
// Volume persistence
|
||||||
function saveVolumeToStorage(val) {
|
function saveVolumeToStorage(val) {
|
||||||
try {
|
try {
|
||||||
@@ -134,6 +168,15 @@ function startLocalPlayerStatePolling() {
|
|||||||
} else if (status === 'error') {
|
} else if (status === 'error') {
|
||||||
statusTextEl.textContent = st.error ? `Error: ${st.error}` : 'Error';
|
statusTextEl.textContent = st.error ? `Error: ${st.error}` : 'Error';
|
||||||
statusDotEl.style.backgroundColor = 'var(--danger)';
|
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 {
|
} else {
|
||||||
// idle/stopped: keep UI consistent with our isPlaying flag
|
// idle/stopped: keep UI consistent with our isPlaying flag
|
||||||
}
|
}
|
||||||
@@ -884,6 +927,44 @@ function loadStation(index) {
|
|||||||
if (nowArtistEl) nowArtistEl.textContent = '';
|
if (nowArtistEl) nowArtistEl.textContent = '';
|
||||||
if (nowTitleEl) nowTitleEl.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)
|
// Sync coverflow transforms (if present)
|
||||||
try { updateCoverflowTransforms(); } catch (e) {}
|
try { updateCoverflowTransforms(); } catch (e) {}
|
||||||
// When loading a station, ensure only this station's poller runs
|
// When loading a station, ensure only this station's poller runs
|
||||||
@@ -1021,6 +1102,8 @@ function updateUI() {
|
|||||||
statusDotEl.style.backgroundColor = 'var(--text-muted)';
|
statusDotEl.style.backgroundColor = 'var(--text-muted)';
|
||||||
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream';
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateEngineBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVolumeInput() {
|
function handleVolumeInput() {
|
||||||
@@ -1105,13 +1188,29 @@ async function selectCastDevice(deviceName) {
|
|||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', init);
|
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) {
|
if ('serviceWorker' in navigator) {
|
||||||
|
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', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('sw.js')
|
navigator.serviceWorker.register('sw.js')
|
||||||
.then((reg) => console.log('ServiceWorker registered:', reg.scope))
|
.then((reg) => console.log('ServiceWorker registered:', reg.scope))
|
||||||
.catch((err) => console.debug('ServiceWorker registration failed:', err));
|
.catch((err) => console.debug('ServiceWorker registration failed:', err));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open overlay and show list of stations (used by menu/hamburger)
|
// Open overlay and show list of stations (used by menu/hamburger)
|
||||||
|
|||||||
@@ -211,6 +211,31 @@ body {
|
|||||||
gap: 8px;
|
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 {
|
.status-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -341,6 +366,7 @@ body {
|
|||||||
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
|
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
margin-left:1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Logo blobs container sits behind logo but inside artwork placeholder */
|
/* Logo blobs container sits behind logo but inside artwork placeholder */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'radiocast-core-v1';
|
const CACHE_NAME = 'radiocast-core-v2';
|
||||||
const CORE_ASSETS = [
|
const CORE_ASSETS = [
|
||||||
'.',
|
'.',
|
||||||
'index.html',
|
'index.html',
|
||||||
@@ -11,6 +11,8 @@ const CORE_ASSETS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
|
// Activate updated SW as soon as it's installed.
|
||||||
|
self.skipWaiting();
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(CORE_ASSETS))
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(CORE_ASSETS))
|
||||||
);
|
);
|
||||||
@@ -18,9 +20,12 @@ self.addEventListener('install', (event) => {
|
|||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
Promise.all([
|
||||||
|
self.clients.claim(),
|
||||||
caches.keys().then((keys) => Promise.all(
|
caches.keys().then((keys) => Promise.all(
|
||||||
keys.map((k) => { if (k !== CACHE_NAME) return caches.delete(k); return null; })
|
keys.map((k) => { if (k !== CACHE_NAME) return caches.delete(k); return null; })
|
||||||
))
|
)),
|
||||||
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
66
tools/copy-ffmpeg.js
Normal file
66
tools/copy-ffmpeg.js
Normal file
@@ -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();
|
||||||
45
tools/ffmpeg/README.md
Normal file
45
tools/ffmpeg/README.md
Normal file
@@ -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.
|
||||||
BIN
tools/ffmpeg/bin/ffmpeg.exe
Normal file
BIN
tools/ffmpeg/bin/ffmpeg.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user