diff --git a/src/web_ui/index.html b/src/web_ui/index.html
index 5a404f6..c91814e 100644
--- a/src/web_ui/index.html
+++ b/src/web_ui/index.html
@@ -571,19 +571,75 @@ function renderFilters(){
return `
${label}${chips}
`;
};
const toggle=(label,key,title)=>``;
- const anyActive=filter.watch!=='all'||filter.date!=='all'||filter.size!=='all'||filter.hasSubs||filter.hasChapters;
+ const anyActive=!filtersAreDefault(filter);
const clear=anyActive?``:'';
+ const save=anyActive?``:'';
+ // Saved presets: click name to apply, ✕ to delete. Active preset (one
+ // whose filters match the current set) is highlighted.
+ const presets=filterPresets.length
+ ? `★ Presets`
+ + filterPresets.map((p,i)=>
+ ``).join('')
+ + `
`
+ : '';
el.innerHTML=
grp('Watch','watch',[['all','All'],['unwatched','Unwatched'],['in-progress','In progress'],['watched','Watched']])+
grp('Date','date',[['all','All'],['today','Today'],['week','Week'],['month','Month'],['year','Year'],['older','Older']])+
grp('Size','size',[['all','All'],['small','< 100 MB'],['med','100 MB – 1 GB'],['large','> 1 GB']])+
toggle('💬 Subs','hasSubs','Only videos with subtitle tracks')+
toggle('🔖 Chapters','hasChapters','Only videos with chapter markers')+
- clear;
+ presets+save+clear;
}
function setFilter(key,val){filter[key]=val;saveUiState();renderFilters();renderGrid()}
function toggleFilter(key){filter[key]=!filter[key];saveUiState();renderFilters();renderGrid()}
-function clearFilters(){filter={watch:'all',date:'all',size:'all',hasSubs:false,hasChapters:false};saveUiState();renderFilters();renderGrid()}
+const DEFAULT_FILTER={watch:'all',date:'all',size:'all',hasSubs:false,hasChapters:false};
+function clearFilters(){filter={...DEFAULT_FILTER};saveUiState();renderFilters();renderGrid()}
+
+/* ── Named filter presets ───────────────────────────────────────── */
+// Saved chip-filter sets, persisted under their own localStorage key so
+// they outlive a clearFilters() and aren't tangled with view state.
+let filterPresets=[];
+function loadFilterPresets(){
+ try{filterPresets=JSON.parse(localStorage.getItem('filter-presets')||'[]')}catch{filterPresets=[]}
+ if(!Array.isArray(filterPresets))filterPresets=[];
+}
+function persistFilterPresets(){
+ try{localStorage.setItem('filter-presets',JSON.stringify(filterPresets))}catch{}
+}
+function filtersAreDefault(f){
+ return f.watch==='all'&&f.date==='all'&&f.size==='all'&&!f.hasSubs&&!f.hasChapters;
+}
+function presetMatchesCurrent(p){
+ const f=p.filter||{};
+ return f.watch===filter.watch&&f.date===filter.date&&f.size===filter.size
+ &&!!f.hasSubs===!!filter.hasSubs&&!!f.hasChapters===!!filter.hasChapters;
+}
+function saveCurrentFilterPreset(){
+ if(filtersAreDefault(filter)){setStatus('No active filters to save');return}
+ const name=(prompt('Name this filter preset:')||'').trim();
+ if(!name)return;
+ // Overwrite a same-named preset rather than duplicating.
+ filterPresets=filterPresets.filter(p=>p.name!==name);
+ filterPresets.push({name,filter:{...filter}});
+ persistFilterPresets();
+ renderFilters();
+ setStatus('Saved preset “'+name+'”');
+}
+// Index-based so preset names with quotes/apostrophes can't break the
+// inline onclick handlers; the array is re-rendered after every change so
+// indices always match what's on screen.
+function applyFilterPreset(i){
+ const p=filterPresets[i]; if(!p)return;
+ filter={...DEFAULT_FILTER,...p.filter};
+ saveUiState();renderFilters();renderGrid();
+}
+function deleteFilterPreset(i){
+ filterPresets.splice(i,1);
+ persistFilterPresets();
+ renderFilters();
+}
function renderGrid(){
renderFilters();
if(showChannels){renderChannelGrid();return}
@@ -2202,6 +2258,7 @@ applyTheme(localStorage.getItem('theme')||'dark');
// immediately. View selectors need the library array to be populated
// before we can resolve `channel:` back to an index — do that
// after loadLibrary() resolves.
+loadFilterPresets();
(async()=>{
await Promise.all([loadLibrary(), loadNotes()]);
restoreUiState();