Add offline cache & preload, Save/add-to-playlist UI, Settings page; player stream fallback + progressive-first ordering; protocol-asset feature; refresh Windows installers
This commit is contained in:
@@ -10,7 +10,7 @@ rust-version = "1.77"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
@@ -166,10 +166,12 @@ async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Quality list: adaptive video-only first, then progressive; dedupe by height.
|
||||
// Quality list: progressive (single-file, has audio) first so it survives the
|
||||
// per-height dedupe — it's the most reliable fallback when the dual adaptive
|
||||
// path fails. Adaptive video-only fills in the heights progressive doesn't cover.
|
||||
let mut qualities = Vec::new();
|
||||
let mut seen: HashSet<i64> = HashSet::new();
|
||||
for want_progressive in [false, true] {
|
||||
for want_progressive in [true, false] {
|
||||
for f in formats {
|
||||
let vcodec = str_or(f, "vcodec", "none");
|
||||
let acodec = str_or(f, "acodec", "none");
|
||||
@@ -238,6 +240,155 @@ fn data_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(dir.join("ytplayer-data.json"))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Offline cache — download a single self-contained file per video so playlist
|
||||
// items play instantly and work offline. Files live in <app_cache_dir>/videos
|
||||
// and are named "<videoId>.<ext>". They persist until explicitly deleted.
|
||||
// ============================================================================
|
||||
fn cache_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_cache_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("videos");
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Find an already-cached file for a video id (any extension), if present.
|
||||
fn cached_file(dir: &PathBuf, video_id: &str) -> Option<PathBuf> {
|
||||
let entries = std::fs::read_dir(dir).ok()?;
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.file_stem().and_then(|s| s.to_str()) == Some(video_id) {
|
||||
// Ignore partial yt-dlp downloads.
|
||||
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if ext != "part" && ext != "ytdl" {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn file_size(p: &PathBuf) -> u64 {
|
||||
std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Download a video into the cache as a single progressive mp4 (audio+video in
|
||||
/// one file, so it needs no ffmpeg muxing and plays offline). Idempotent.
|
||||
#[tauri::command]
|
||||
fn cache_download(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
if video_id.is_empty() {
|
||||
return json!({ "ok": false, "error": "missing videoId" });
|
||||
}
|
||||
let dir = match cache_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
if let Some(p) = cached_file(&dir, &video_id) {
|
||||
return json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) });
|
||||
}
|
||||
let url = format!("https://www.youtube.com/watch?v={}", video_id);
|
||||
let out_tmpl = dir.join(format!("{}.%(ext)s", video_id));
|
||||
let out_tmpl = out_tmpl.to_string_lossy().to_string();
|
||||
// Prefer a progressive mp4 (single file with audio); fall back to best single
|
||||
// file. Avoids ffmpeg by not requesting separate streams that need merging.
|
||||
let res = run_ytdlp(
|
||||
&app,
|
||||
&[
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"-f",
|
||||
"best[ext=mp4][acodec!=none][vcodec!=none]/best[acodec!=none][vcodec!=none]/best",
|
||||
"-o",
|
||||
&out_tmpl,
|
||||
&url,
|
||||
],
|
||||
);
|
||||
if let Err(e) = res {
|
||||
return json!({ "ok": false, "error": e });
|
||||
}
|
||||
match cached_file(&dir, &video_id) {
|
||||
Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }),
|
||||
None => json!({ "ok": false, "error": "download finished but no file was produced" }),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cache_status(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
let dir = match cache_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
match cached_file(&dir, &video_id) {
|
||||
Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }),
|
||||
None => json!({ "ok": true, "cached": false }),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cache_list(app: tauri::AppHandle) -> Value {
|
||||
let dir = match cache_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
let mut items = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if ext == "part" || ext == "ytdl" {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = p.file_stem().and_then(|s| s.to_str()) {
|
||||
let size = file_size(&p);
|
||||
total += size;
|
||||
items.push(json!({ "id": id, "size": size, "path": p.to_string_lossy() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({ "ok": true, "items": items, "total": total })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cache_delete(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
let dir = match cache_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
if let Some(p) = cached_file(&dir, &video_id) {
|
||||
match std::fs::remove_file(&p) {
|
||||
Ok(_) => json!({ "ok": true }),
|
||||
Err(e) => json!({ "ok": false, "error": e.to_string() }),
|
||||
}
|
||||
} else {
|
||||
json!({ "ok": true })
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cache_clear(app: tauri::AppHandle) -> Value {
|
||||
let dir = match cache_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
let mut removed = 0u32;
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_file() && std::fs::remove_file(&p).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({ "ok": true, "removed": removed })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn store_load(app: tauri::AppHandle) -> Value {
|
||||
let default = json!({
|
||||
@@ -273,7 +424,12 @@ fn main() {
|
||||
yt_search,
|
||||
yt_streams,
|
||||
store_load,
|
||||
store_save
|
||||
store_save,
|
||||
cache_download,
|
||||
cache_status,
|
||||
cache_list,
|
||||
cache_delete,
|
||||
cache_clear
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost; media-src 'self' https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost"
|
||||
"csp": "default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
Reference in New Issue
Block a user