diff --git a/src/app.rs b/src/app.rs index 7e8ba1d..797466e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -451,9 +451,14 @@ impl App { let mut cards = Vec::new(); let add_video = |cards: &mut Vec, ch_name: &str, v: &library::Video| { + // Search matches title / id / channel name. Description matching + // would require reading the .description sidecar per-video on every + // keystroke — punted to a future "load descriptions into the + // search index on rescan" pass if users ask for it. if !query.is_empty() && !v.title.to_lowercase().contains(&query) && !v.id.to_lowercase().contains(&query) + && !ch_name.to_lowercase().contains(&query) { return; } @@ -762,6 +767,32 @@ impl App { ); } + /// Bulk-apply a single per-video flag (`"bookmark"` / `"favourite"` / + /// `"waiting"` / `"archive"`) across the current selection. Same shape + /// as [`bulk_mark_watched`], extended for the smart-folder flag set. + fn bulk_set_flag(&mut self, flag: &'static str, value: bool) { + let ids: Vec = self.bulk_selected.iter().cloned().collect(); + let count = ids.len(); + for id in &ids { + if self.db.set_video_flag(id, flag, value).is_err() { continue; } + let set = match flag { + "bookmark" => &mut self.flags.bookmark, + "favourite" => &mut self.flags.favourite, + "waiting" => &mut self.flags.waiting, + "archive" => &mut self.flags.archive, + _ => return, + }; + if value { set.insert(id.clone()); } else { set.remove(id); } + } + self.bulk_selected.clear(); + self.cards_cache_key = None; + self.status = format!( + "{count} {} {}{flag}", + if count == 1 { "video" } else { "videos" }, + if value { "→ " } else { "un" }, + ); + } + fn run_scheduled_check(&mut self) { // Snapshot each channel's URL + options first so we don't hold an // immutable borrow of `self.library` while calling @@ -2623,14 +2654,26 @@ impl App { ui.separator(); let n = self.bulk_selected.len(); ui.label(format!("{n} selected")); - if ui.button("✓ Mark watched").clicked() { + if ui.button("✓ Watched").clicked() { self.bulk_mark_watched(true); self.bulk_mode = false; } - if ui.button("○ Mark unwatched").clicked() { + if ui.button("○ Unwatched").clicked() { self.bulk_mark_watched(false); self.bulk_mode = false; } + if ui.button("★ Favourite").on_hover_text("Mark every selected video as favourite").clicked() { + self.bulk_set_flag("favourite", true); + self.bulk_mode = false; + } + if ui.button("🔖 Bookmark").on_hover_text("Mark every selected video as bookmarked").clicked() { + self.bulk_set_flag("bookmark", true); + self.bulk_mode = false; + } + if ui.button("⏳ Waiting").on_hover_text("Add every selected video to the waiting list").clicked() { + self.bulk_set_flag("waiting", true); + self.bulk_mode = false; + } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 07149ec..661552b 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -144,8 +144,11 @@
@@ -323,7 +326,7 @@ function currentVideos(){ if(showContinue){ for(const ch of library) for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]) - if(v.resume_pos&&v.resume_pos>5&&!v.watched&&(!q||v.title.toLowerCase().includes(q)||v.id.includes(q))) + if(v.resume_pos&&v.resume_pos>5&&!v.watched&&(!q||matchesSearch(v,ch?.name||v.channel,q))) vids.push({...v,channel:ch.name}); vids.sort((a,b)=>(b.resume_pos||0)-(a.resume_pos||0)); return vids; @@ -332,7 +335,7 @@ function currentVideos(){ // Most-recently-modified across the whole library, capped at 100. for(const ch of library) for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]) - if(v.mtime_unix&&(!q||v.title.toLowerCase().includes(q)||v.id.includes(q))) + if(v.mtime_unix&&(!q||matchesSearch(v,ch?.name||v.channel,q))) vids.push({...v,channel:ch.name}); vids.sort((a,b)=>(b.mtime_unix||0)-(a.mtime_unix||0)); return vids.slice(0,100); @@ -342,7 +345,7 @@ function currentVideos(){ const want = showFavourites ? 'favourite' : showBookmarks ? 'bookmark' : 'waiting'; for(const ch of library) for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]) - if(v[want]&&(!q||v.title.toLowerCase().includes(q)||v.id.includes(q))) + if(v[want]&&(!q||matchesSearch(v,ch?.name||v.channel,q))) vids.push({...v,channel:ch.name}); return vids; } @@ -351,7 +354,7 @@ function currentVideos(){ if(activeChannelIdx!==null&&i!==activeChannelIdx)continue; const pool=activePlaylistIdx!==null?(ch.playlists[activePlaylistIdx]?.videos||[]):[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]; for(const v of pool) - if(!q||v.title.toLowerCase().includes(q)||v.id.includes(q))vids.push({...v,channel:ch.name}); + if(!q||matchesSearch(v,ch?.name||v.channel,q))vids.push({...v,channel:ch.name}); } if(sort==='date-desc')vids.sort((a,b)=>(b.upload_date||'').localeCompare(a.upload_date||'')); if(sort==='date-asc')vids.sort((a,b)=>{const ax=a.upload_date||'￿',bx=b.upload_date||'￿';return ax.localeCompare(bx)}); @@ -483,6 +486,22 @@ function playAudio(id,title,url){ function toggleBulk(){bulkMode=document.getElementById('bulk').checked;selected.clear();document.getElementById('bulk-actions').style.display=bulkMode?'flex':'none';renderGrid()} function toggleSel(id,on){if(on)selected.add(id);else selected.delete(id);document.getElementById('sel-count').textContent=selected.size+' selected'} async function bulkWatched(on){await Promise.all([...selected].map(id=>api('/api/watched/'+id,{method:'POST'})));selected.clear();await loadLibrary()} +async function bulkFlag(flag){ + // Set the chosen flag on every selected video in parallel, then refresh. + // Flag stays on (toggle-off in bulk is rarely what users want — opt for + // explicit setting only, matching the desktop behaviour). + const ids=[...selected]; + if(!ids.length){setStatus('Nothing selected');return} + try{ + await Promise.all(ids.map(id=>api( + `/api/videos/${encodeURIComponent(id)}/flags/${encodeURIComponent(flag)}`, + {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({value:true})} + ))); + setStatus(`${ids.length} marked ${flag}`); + selected.clear(); + await loadLibrary(); + }catch(e){setStatus('Error: '+e.message)} +} /* ── Download preview ───────────────────────────────────────────── */ async function previewDownload(){ @@ -1430,6 +1449,15 @@ schedulePoll(600); function fmtDur(s){s=Math.floor(s);const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;return h?`${h}:${p(m)}:${p(sec)}`:`${m}:${p(sec)}`} function fmtSize(b){if(b>=1073741824)return(b/1073741824).toFixed(1)+' GB';if(b>=1048576)return Math.round(b/1048576)+' MB';return Math.round(b/1024)+' KB'} function fmtDate(d){if(!d||d.length<8)return d||'';return d.slice(0,4)+'-'+d.slice(4,6)+'-'+d.slice(6,8)} +// Title + id + channel-name match for the filter input. Lowercase once +// per call so callers don't have to thread the lowercased value around. +function matchesSearch(v,chName,q){ + if(!q)return true; + const t=(v.title||'').toLowerCase(); + const id=(v.id||'').toLowerCase(); + const ch=(chName||'').toLowerCase(); + return t.includes(q)||id.includes(q)||ch.includes(q); +} function p(n){return String(n).padStart(2,'0')} function esc(s){return String(s??'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')} /* Sanitize a URL before inserting into an href/src attribute. Allows