Persist web UI state across reloads

Reloading the tab no longer blows away the user's context. The active
view (channels / continue / recent / favourites / bookmarks / waiting /
specific channel / specific playlist), the current filter input, and
the chosen sort all survive a hard refresh via localStorage.

- New `saveUiState()` serializes `{view, search, sort}` after every
  interaction that could change them: each setX() view switch, search
  oninput, sort onchange.
- `currentViewToken()` collapses the seven `showX` booleans + the two
  index integers into a single string. Channel-view tokens reference
  the channel by name (`channel:<name>|<playlistIdx>`) instead of by
  array index, so a library re-order doesn't strand the user on the
  wrong channel.
- `restoreUiState()` runs once after the first library load completes —
  the channel resolution needs the `library` array populated, so the
  init block was wrapped in an async IIFE to sequence the two steps.

Channels deleted between sessions fall back to "All" cleanly because
`findIndex` returns -1 and we just leave the show* booleans at their
default.

55 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-25 22:35:17 -07:00
parent 171553c137
commit 3adcc60b78

View file

@ -120,8 +120,8 @@
<header> <header>
<button id="menu-btn" onclick="toggleSidebar()"></button> <button id="menu-btn" onclick="toggleSidebar()"></button>
<h1>yt-offline</h1> <h1>yt-offline</h1>
<input type="search" id="search" placeholder="Filter…" oninput="renderGrid()"> <input type="search" id="search" placeholder="Filter…" oninput="saveUiState();renderGrid()">
<select id="sort" onchange="renderGrid()"> <select id="sort" onchange="saveUiState();renderGrid()">
<option value="date-desc">Newest</option> <option value="date-desc">Newest</option>
<option value="date-asc">Oldest</option> <option value="date-asc">Oldest</option>
<option value="title">Title</option> <option value="title">Title</option>
@ -310,14 +310,69 @@ function resetViewSelectors(){
showFavourites=false;showBookmarks=false;showWaiting=false; showFavourites=false;showBookmarks=false;showWaiting=false;
activeChannelIdx=null;activePlaylistIdx=null; activeChannelIdx=null;activePlaylistIdx=null;
} }
function setContinue(){resetViewSelectors();showContinue=true;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setRecent(){resetViewSelectors();showRecent=true;selected.clear();closeSidebar();renderSidebar();renderGrid()} // Persist the user's current view + search + sort to localStorage so
function setFavourites(){resetViewSelectors();showFavourites=true;selected.clear();closeSidebar();renderSidebar();renderGrid()} // reloading the tab doesn't blow away their context. Cheap to write on
function setBookmarks(){resetViewSelectors();showBookmarks=true;selected.clear();closeSidebar();renderSidebar();renderGrid()} // every interaction — localStorage is synchronous but the payload is tiny.
function setWaiting(){resetViewSelectors();showWaiting=true;selected.clear();closeSidebar();renderSidebar();renderGrid()} function currentViewToken(){
function setChannels(){resetViewSelectors();showChannels=true;selected.clear();closeSidebar();renderSidebar();renderGrid()} if(showContinue)return 'continue';
function setView(ci,pi){resetViewSelectors();activeChannelIdx=ci;activePlaylistIdx=pi;selected.clear();closeSidebar();renderSidebar();renderGrid()} if(showRecent)return 'recent';
function setMusic(){resetViewSelectors();showMusic=true;selected.clear();closeSidebar();renderSidebar();renderGrid()} if(showFavourites)return 'favourites';
if(showBookmarks)return 'bookmarks';
if(showWaiting)return 'waiting';
if(showChannels)return 'channels';
if(showMusic)return 'music';
if(activeChannelIdx!=null)return `channel:${library[activeChannelIdx]?.name||''}|${activePlaylistIdx??''}`;
return 'all';
}
function saveUiState(){
try{
localStorage.setItem('ui-state',JSON.stringify({
view:currentViewToken(),
search:document.getElementById('search')?.value||'',
sort:document.getElementById('sort')?.value||'date-desc',
}));
}catch{}
}
function restoreUiState(){
let raw;try{raw=JSON.parse(localStorage.getItem('ui-state')||'null')}catch{}
if(!raw)return;
const s=document.getElementById('search'); if(s&&raw.search)s.value=raw.search;
const sortEl=document.getElementById('sort'); if(sortEl&&raw.sort)sortEl.value=raw.sort;
// Map the persisted view back to the matching show* boolean. Channels
// are stored by name (not by index) so a library re-order doesn't land
// the user on the wrong channel.
resetViewSelectors();
switch(raw.view){
case 'continue':showContinue=true;break;
case 'recent':showRecent=true;break;
case 'favourites':showFavourites=true;break;
case 'bookmarks':showBookmarks=true;break;
case 'waiting':showWaiting=true;break;
case 'channels':showChannels=true;break;
case 'music':showMusic=true;break;
case 'all':break; // default
default:
if(raw.view&&raw.view.startsWith('channel:')){
const rest=raw.view.slice('channel:'.length);
const [name,plStr]=rest.split('|');
const idx=library.findIndex(c=>c.name===name);
if(idx>=0){
activeChannelIdx=idx;
const pl=parseInt(plStr,10);
activePlaylistIdx=Number.isFinite(pl)?pl:null;
}
}
}
}
function setContinue(){resetViewSelectors();showContinue=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setRecent(){resetViewSelectors();showRecent=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setFavourites(){resetViewSelectors();showFavourites=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setBookmarks(){resetViewSelectors();showBookmarks=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setWaiting(){resetViewSelectors();showWaiting=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setChannels(){resetViewSelectors();showChannels=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setView(ci,pi){resetViewSelectors();activeChannelIdx=ci;activePlaylistIdx=pi;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setMusic(){resetViewSelectors();showMusic=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
/* ── Grid ───────────────────────────────────────────────────────── */ /* ── Grid ───────────────────────────────────────────────────────── */
function currentVideos(){ function currentVideos(){
@ -1536,7 +1591,16 @@ async function loadSourceNotice(){
/* ── Init ───────────────────────────────────────────────────────── */ /* ── Init ───────────────────────────────────────────────────────── */
applyTheme(localStorage.getItem('theme')||'dark'); applyTheme(localStorage.getItem('theme')||'dark');
loadLibrary(); // Library load is async, but the sort + search inputs can be restored
// immediately. View selectors need the library array to be populated
// before we can resolve `channel:<name>` back to an index — do that
// after loadLibrary() resolves.
(async()=>{
await loadLibrary();
restoreUiState();
renderSidebar();
renderGrid();
})();
loadSourceNotice(); loadSourceNotice();
</script> </script>
</body> </body>