fixed library scan using event listeners instead of store pattern
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
# Plan: Consolidate `LibraryScanComplete` Handling
|
||||
|
||||
Addresses refactoring catalog #7. Eliminates redundant direct `LibraryScanComplete` event listeners from components by making stores eagerly re-fetch data after invalidation, so the existing reactive controller subscription (`requestUpdate()`) delivers fresh data automatically.
|
||||
|
||||
---
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
The refactoring catalog describes 10+ components that each independently listen for `LibraryScanComplete` and re-fetch their data. It claims these listeners are redundant because "the stores already invalidate their caches and notify subscribers."
|
||||
|
||||
**This claim is incorrect in the current architecture.** Here is why:
|
||||
|
||||
1. When `LibraryScanComplete` fires, `LibraryStore.invalidate()` nulls out cached data (`tracks`, `albums`, `artists`) and calls `notify()`.
|
||||
2. `notify()` triggers subscriber callbacks, which are `LibraryController.host.requestUpdate()` — a Lit re-render.
|
||||
3. But `requestUpdate()` only re-runs `render()`, and components read from **local `@state()` properties** (e.g., `this.tracks`, `this.albums`), not from the store. The local data is still stale.
|
||||
4. Nobody calls the `loadTracks()`/`loadAlbums()` methods again except the direct `LibraryScanComplete` listener.
|
||||
|
||||
**The root cause:** the stores use **lazy-fetch** — `invalidate()` clears the cache but does not re-fetch. The next `getTracks()` call will hit the backend, but nothing triggers that call except the component's own event listener.
|
||||
|
||||
**The fix:** make stores **eagerly re-fetch** after invalidation, so when the controller calls `requestUpdate()`, the store already has fresh data. Then refactor components to read data reactively from the store/controller instead of from local state populated by imperative load calls.
|
||||
|
||||
---
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
1. **Incremental migration.** The store change (eager refetch) is backwards-compatible. Components are migrated one by one from easiest to hardest. Both patterns (old imperative + new reactive) coexist during migration.
|
||||
|
||||
2. **Preserve existing UX.** Scroll restoration, selection clearing, loading indicators, and search filtering must work identically. No regressions.
|
||||
|
||||
3. **Three categories of listeners.** Not all `LibraryScanComplete` listeners are the same:
|
||||
- **Data refresh listeners** (8 components): re-fetch library/playlist data → these are what we're consolidating.
|
||||
- **UI status listeners** (`config-page`, `library-manager`): update scan progress UI and display metrics → these MUST keep their direct listeners since no store handles scan status.
|
||||
- **Store-bypassing listeners** (`playlist-picker`): calls Go bindings directly → addressed separately.
|
||||
|
||||
4. **Don't fight the `externalAlbums`/`externalTracks` pattern.** Parent-child data delegation (`artist-details` → `cover-grid`, `genre-details` → `track-list`) is a valid pattern. The parent gets migrated; the child already skips the scan listener when driven externally.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Store Eager-Refetch
|
||||
|
||||
### 0A. `LibraryStore` — add eager refetch after invalidation
|
||||
|
||||
**File:** `frontend/src/store/library-store.ts`
|
||||
|
||||
Change `invalidate()` to eagerly re-fetch all three data types after clearing the cache. The existing `getTracks()`/`getAlbums()`/`getArtists()` methods already handle concurrent-request coalescing (via `waitFor*()` helpers) and notify subscribers when loading starts/finishes.
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
private invalidate(): void {
|
||||
this.tracks = null;
|
||||
this.albums = null;
|
||||
this.artists = null;
|
||||
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// After:
|
||||
private invalidate(): void {
|
||||
this.tracks = null;
|
||||
this.albums = null;
|
||||
this.artists = null;
|
||||
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
|
||||
this.notify();
|
||||
this.eagerRefetch();
|
||||
}
|
||||
|
||||
private eagerRefetch(): void {
|
||||
// Fire-and-forget. Each getter handles its own error/loading state
|
||||
// and calls notify() when done, which triggers requestUpdate()
|
||||
// on all subscribed controllers.
|
||||
void this.getTracks();
|
||||
void this.getAlbums();
|
||||
void this.getArtists();
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works:** After `eagerRefetch()`, the store is in a `loading=true` state. When the backend responses arrive, the cache is repopulated and `notify()` fires again (from the `finally` block in each getter). Controllers call `requestUpdate()`, and now any component reading from the store gets fresh data.
|
||||
|
||||
**Why it's backwards-compatible:** Components with direct listeners will still call their `load*()` methods. The store's `waitFor*()` helpers coalesce concurrent requests, so the eager fetch and the component's fetch share the same in-flight promise — no duplicate backend calls.
|
||||
|
||||
**Scroll position reset note:** The `scrollPositions` reset to `0` happens synchronously in `invalidate()`. This is correct — after a library scan, the content has changed and scroll positions are meaningless. Components that read scroll positions during their re-render will see `0`.
|
||||
|
||||
### 0B. `PlaylistStore` — add eager refetch after invalidation
|
||||
|
||||
**File:** `frontend/src/store/playlist-store.ts`
|
||||
|
||||
Same pattern. The `invalidate()` method already exists and is called from multiple event handlers (not just `LibraryScanComplete`).
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
invalidate(): void {
|
||||
this.playlists = null;
|
||||
this.scrollPosition = 0;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// After:
|
||||
invalidate(): void {
|
||||
this.playlists = null;
|
||||
this.scrollPosition = 0;
|
||||
this.notify();
|
||||
void this.getPlaylists();
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** `PlaylistStore.invalidate()` is public (called by `PlaylistController.invalidate()`). This eager refetch will also run for `PlaylistCreated`, `PlaylistDeleted`, `PlaylistRenamed`, `PlaylistTracksChanged`, and `PlaylistsRestored` events — which is desirable. Currently those events invalidate the cache and wait for a component to lazily re-fetch. Eager refetch means subscribers see fresh data faster.
|
||||
|
||||
### 0C. Verification
|
||||
|
||||
After Phase 0, both stores eagerly re-fetch on invalidation. Components with existing direct listeners still work (their fetches coalesce with the eager fetch). Components without listeners now get fresh data automatically through the controller subscription path, though they still need to read it reactively (Phase 1+).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Migrate `genre-details` and `artist-details` (LOW effort)
|
||||
|
||||
These are thin wrapper components that fetch data, filter/cache it, and pass it to a child via `externalTracks`/`externalAlbums`. The child already skips its own scan listener when receiving external data.
|
||||
|
||||
### 1A. `genre-details.ts`
|
||||
|
||||
**File:** `frontend/src/components/genre-details/genre-details.ts`
|
||||
|
||||
Current flow:
|
||||
1. `connectedCallback()` → `loadTracks()` → `libraryCtrl.getTracks()` → filter by genre → `this.tracks = filtered`
|
||||
2. `LibraryScanComplete` → `loadTracks()` again
|
||||
|
||||
New flow:
|
||||
1. `connectedCallback()` → `loadTracks()` (initial load, unchanged)
|
||||
2. Remove the direct `LibraryScanComplete` listener and its cancellation
|
||||
3. Add reactive consumption in `willUpdate()` or `updated()`: when the store notifies (cache repopulated after eager refetch), the controller calls `requestUpdate()`, triggering a re-render. In `willUpdate()`, detect that the store's cached tracks have changed (or that loading finished) and re-run the genre filtering.
|
||||
|
||||
Implementation approach — use `updated()` to react to controller-triggered re-renders:
|
||||
|
||||
```typescript
|
||||
// Remove from connectedCallback:
|
||||
// this.cancelScanComplete = EventsOn(Events.LibraryScanComplete, () => this.loadTracks());
|
||||
// Remove from disconnectedCallback:
|
||||
// this.cancelScanComplete?.();
|
||||
// Remove the cancelScanComplete field.
|
||||
|
||||
// Add a version counter to detect store changes:
|
||||
private lastStoreVersion = 0;
|
||||
|
||||
override updated() {
|
||||
// The library controller's subscription calls requestUpdate() when the
|
||||
// store notifies. Check if tracks have been refreshed since our last load.
|
||||
const storeVersion = this.libraryCtrl.storeVersion;
|
||||
if (storeVersion !== this.lastStoreVersion && !this.libraryCtrl.tracksLoading) {
|
||||
this.lastStoreVersion = storeVersion;
|
||||
this.loadTracks();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative (simpler):** Instead of a version counter, check if the store's cached tracks reference has changed. Since `invalidate()` sets tracks to `null` and the eager refetch populates a new array, we can compare object identity:
|
||||
|
||||
```typescript
|
||||
private lastTracksRef: library.Track[] | null = null;
|
||||
|
||||
override updated() {
|
||||
const cached = this.libraryCtrl.cachedTracks;
|
||||
if (cached !== null && cached !== this.lastTracksRef) {
|
||||
this.lastTracksRef = cached;
|
||||
this.loadTracks();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Decision: Use the reference-comparison approach.** It's simpler, doesn't require adding version counters to the store, and leverages the fact that each eager refetch creates a new array instance.
|
||||
|
||||
However, there's a subtlety: `updated()` runs after every render, including renders triggered by the component's own `@state()` changes (like `this.tracks` being set). We need to ensure this doesn't create an infinite loop:
|
||||
- `loadTracks()` calls `libraryCtrl.getTracks()`, which if the cache is already populated returns the same reference.
|
||||
- `this.tracks` is set to the filtered result, triggering a render.
|
||||
- `updated()` runs, compares `cachedTracks` — same reference as `lastTracksRef`, so no re-load. Safe.
|
||||
|
||||
But the initial load path: `connectedCallback()` calls `loadTracks()` directly. At that point `cachedTracks` might be `null` (store hasn't loaded yet). After `loadTracks()` finishes, the store cache is populated, and `lastTracksRef` is set. Next `requestUpdate()` from the store won't trigger a re-load because the reference matches. Safe.
|
||||
|
||||
**Required controller addition:** Add a `storeVersion` or expose `cachedTracks` — the controller already exposes `cachedTracks` (line 72-74 of `library-controller.ts`). No changes needed to the controller.
|
||||
|
||||
### 1B. `artist-details.ts`
|
||||
|
||||
**File:** `frontend/src/components/artist-details/artist-details.ts`
|
||||
|
||||
Same approach. This component has a cache-then-fetch dual-load pattern (`getAlbumsByArtistNameCached` then `getAlbumsByArtist`). The scan-complete handler just calls `loadAlbums()`.
|
||||
|
||||
Changes:
|
||||
1. Remove the direct `LibraryScanComplete` listener and its cancellation.
|
||||
2. Add reference comparison in `updated()`:
|
||||
|
||||
```typescript
|
||||
private lastAlbumsRef: library.Album[] | null = null;
|
||||
|
||||
override updated() {
|
||||
const cached = this.libraryCtrl.cachedAlbums;
|
||||
if (cached !== null && cached !== this.lastAlbumsRef) {
|
||||
this.lastAlbumsRef = cached;
|
||||
this.loadAlbums();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** `getAlbumsByArtist(artistId)` is NOT cached by the store — it always hits the backend. But that's fine because `loadAlbums()` already handles this. The reference check on `cachedAlbums` (the full album list) serves as a proxy for "the library data has changed."
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Migrate `artists-view` (LOW-MEDIUM effort)
|
||||
|
||||
**File:** `frontend/src/components/artists-view/artists-view.ts`
|
||||
|
||||
Current flow:
|
||||
1. `connectedCallback()` → `loadArtists()` → `libraryCtrl.getArtists()` → `this.artists = result`
|
||||
2. `LibraryScanComplete` → `loadArtists()`
|
||||
3. `willUpdate()` → `recomputeArtistCaches()` (filters by search term)
|
||||
4. `render()` reads `cachedGridEntries`
|
||||
|
||||
New flow:
|
||||
1. `connectedCallback()` → `loadArtists()` (initial load, unchanged)
|
||||
2. Remove the direct `LibraryScanComplete` listener.
|
||||
3. React to store changes in `updated()`:
|
||||
|
||||
```typescript
|
||||
private lastArtistsRef: library.Artist[] | null = null;
|
||||
|
||||
override updated() {
|
||||
const cached = this.libraryCtrl.cachedArtists;
|
||||
if (cached !== null && cached !== this.lastArtistsRef) {
|
||||
this.lastArtistsRef = cached;
|
||||
this.loadArtists();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Scroll position consideration:** `loadArtists()` currently restores scroll position at the end. After a scan, scroll positions are reset to `0` by `invalidate()`. The `restoringScroll` flag and `restoreScrollPosition()` call in `loadArtists()` handle this correctly — they'll restore to position `0`, which is a no-op visually.
|
||||
|
||||
**Selection consideration:** `loadArtists()` does not currently clear selection. After migration, selection could reference stale artist IDs. Consider adding `this.selectedArtists.clear()` at the top of `loadArtists()` if not already present. (This is a minor improvement, not a regression from the migration.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Migrate `genres-view` (HIGH effort)
|
||||
|
||||
**File:** `frontend/src/components/genres-view/genres-view.ts`
|
||||
|
||||
This component derives genres from tracks — a transformation the store doesn't provide. The store exposes tracks, not genres.
|
||||
|
||||
Current flow:
|
||||
1. `loadGenres()` → `libraryCtrl.getTracks()` → `extractGenres(tracks)` → `this.genres = result`
|
||||
2. `LibraryScanComplete` → `loadGenres()`
|
||||
|
||||
New flow:
|
||||
1. Same `loadGenres()` for initial load.
|
||||
2. Remove the direct `LibraryScanComplete` listener.
|
||||
3. React to store changes in `updated()` using `cachedTracks` reference comparison:
|
||||
|
||||
```typescript
|
||||
private lastTracksRef: library.Track[] | null = null;
|
||||
|
||||
override updated() {
|
||||
// Existing updated() logic for search term, size properties, etc.
|
||||
// stays unchanged. Add this at the end:
|
||||
const cached = this.libraryCtrl.cachedTracks;
|
||||
if (cached !== null && cached !== this.lastTracksRef) {
|
||||
this.lastTracksRef = cached;
|
||||
this.loadGenres();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why not move genre extraction to the store?** The store's job is to cache backend data, not derive view-specific aggregations. Genres are only needed by `genres-view` and `genre-details`. Adding genre derivation to the store would couple it to a specific UI concern. The component is the right place for this derivation.
|
||||
|
||||
**Scroll/selection considerations:** Same as `artists-view`. `loadGenres()` handles scroll restoration. Consider adding `this.selectedGenres.clear()` if not already present.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Migrate `track-list` (MEDIUM effort)
|
||||
|
||||
**File:** `frontend/src/components/track-list/track-list.ts`
|
||||
|
||||
This has a dual-source pattern (`externalTracks` vs store fetch). The scan listener is already conditionally registered:
|
||||
|
||||
```typescript
|
||||
if (this.externalTracks) {
|
||||
this.tracks = this.externalTracks;
|
||||
} else {
|
||||
this.loadTracks();
|
||||
this.cancelScanComplete = EventsOn(Events.LibraryScanComplete, () => this.loadTracks());
|
||||
}
|
||||
```
|
||||
|
||||
New flow:
|
||||
1. Keep the `externalTracks` path unchanged — when a parent provides tracks, the parent is responsible for refreshing (and the parent's migration in Phase 1/3 handles this).
|
||||
2. For the standalone path (no `externalTracks`):
|
||||
- `connectedCallback()` → `loadTracks()` (initial load, unchanged)
|
||||
- Remove the `LibraryScanComplete` listener registration
|
||||
- Add reactive consumption in `updated()`, guarded by `!this.externalTracks`:
|
||||
|
||||
```typescript
|
||||
private lastTracksRef: library.Track[] | null = null;
|
||||
|
||||
override updated() {
|
||||
// ... existing updated() logic ...
|
||||
|
||||
if (!this.externalTracks) {
|
||||
const cached = this.libraryCtrl.cachedTracks;
|
||||
if (cached !== null && cached !== this.lastTracksRef) {
|
||||
this.lastTracksRef = cached;
|
||||
this.loadTracks();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Selection consideration:** `loadTracks()` already clears selection. Safe.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Migrate `playlist-view` (HIGH effort)
|
||||
|
||||
**File:** `frontend/src/components/playlist-view/playlist-view.ts`
|
||||
|
||||
This component reshapes `playlist.WithTracks[]` into `PlaylistEntry[]` with an `expanded` boolean per entry. It has a `refreshPlaylists()` method that preserves expanded state across refetches.
|
||||
|
||||
Current flow:
|
||||
1. `loadPlaylists()` → `playlistCtrl.getPlaylists()` → map to `PlaylistEntry[]` → `this.entries = result`
|
||||
2. `LibraryScanComplete` → `loadPlaylists()`
|
||||
|
||||
New flow:
|
||||
1. `connectedCallback()` → `loadPlaylists()` (initial load, unchanged)
|
||||
2. Remove the direct `LibraryScanComplete` listener.
|
||||
3. React to store changes in `updated()`:
|
||||
|
||||
```typescript
|
||||
private lastPlaylistsRef: playlist.WithTracks[] | null = null;
|
||||
|
||||
override updated() {
|
||||
// ... existing updated() logic ...
|
||||
|
||||
const cached = this.playlistCtrl.cachedPlaylists;
|
||||
if (cached !== null && cached !== this.lastPlaylistsRef) {
|
||||
this.lastPlaylistsRef = cached;
|
||||
this.refreshPlaylists(); // preserves expanded state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key choice: use `refreshPlaylists()` instead of `loadPlaylists()`.** The `refreshPlaylists()` method preserves which playlists are expanded, providing a better UX after a scan completes. `loadPlaylists()` resets all to collapsed. The current scan-complete handler uses `loadPlaylists()` (collapsing everything), but since we're improving the architecture anyway, switching to `refreshPlaylists()` is a UX improvement.
|
||||
|
||||
**Alternative consideration:** If `loadPlaylists()` is preferred (to reset UI state after a scan), that works too. The choice is a UX decision, not a technical constraint.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Migrate `cover-grid` (VERY HIGH effort)
|
||||
|
||||
**File:** `frontend/src/components/cover-grid/cover-grid.ts`
|
||||
|
||||
The most complex component. Dual-source pattern, split-mode scroll management, sort/filter pipeline.
|
||||
|
||||
Current flow:
|
||||
1. `loadAlbums()` → `libraryCtrl.getAlbums()` or `externalAlbums` → `this.albums = result`
|
||||
2. Only registers scan listener when `!this.externalAlbums`
|
||||
3. `LibraryScanComplete` → `loadAlbums()`
|
||||
|
||||
New flow:
|
||||
1. Keep the `externalAlbums` path unchanged.
|
||||
2. For the standalone path:
|
||||
- `connectedCallback()` → `loadAlbums()` (initial load, unchanged)
|
||||
- Remove the `LibraryScanComplete` listener registration
|
||||
- Add reactive consumption in `updated()`, guarded by `!this.externalAlbums`:
|
||||
|
||||
```typescript
|
||||
private lastAlbumsRef: library.Album[] | null = null;
|
||||
|
||||
override updated() {
|
||||
// ... existing updated() logic (size properties, wheel listener,
|
||||
// grid layout, search term selection clearing) ...
|
||||
|
||||
if (!this.externalAlbums) {
|
||||
const cached = this.libraryCtrl.cachedAlbums;
|
||||
if (cached !== null && cached !== this.lastAlbumsRef) {
|
||||
this.lastAlbumsRef = cached;
|
||||
this.loadAlbums();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Split-mode consideration:** If the album dropdown is open (`expandedAlbumId !== null`) when a scan completes, `loadAlbums()` will close it (resets `expandedAlbumId` and `expandedTracks`). This is the same behavior as the current direct listener. The split-mode transition logic in `willUpdate()` will handle the layout change.
|
||||
|
||||
**Selection consideration:** `loadAlbums()` already clears album selection. Safe.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Cleanup and Documentation
|
||||
|
||||
### 7A. Remove unused imports
|
||||
|
||||
After all data-refresh components are migrated, remove unused `EventsOn` and `Events` imports from migrated components (only if no other events are listened to in that component).
|
||||
|
||||
### 7B. Components that KEEP their direct listeners
|
||||
|
||||
These components are explicitly excluded from migration and should be documented:
|
||||
|
||||
| Component | Reason |
|
||||
|---|---|
|
||||
| `config-page.ts` | Uses event for UI status (scan progress, metrics display), not data refresh. No store handles scan status. |
|
||||
| `library-manager.ts` | Same as config-page — UI status listener for scan progress/metrics. |
|
||||
| `playlist-picker.ts` | Bypasses store entirely, calls `GetAllPlaylists()` directly for lightweight summary data. See refactoring catalog #19 for a future plan to route this through a store. |
|
||||
|
||||
### 7C. Update refactoring catalog
|
||||
|
||||
Mark item #7 as solved in `.opencode/plans/refactoring-catalog.md`.
|
||||
|
||||
---
|
||||
|
||||
## Migration Order Summary
|
||||
|
||||
| Phase | Component(s) | Effort | Depends On |
|
||||
|---|---|---|---|
|
||||
| 0 | `LibraryStore`, `PlaylistStore` (eager refetch) | Low | — |
|
||||
| 1 | `genre-details`, `artist-details` | Low | Phase 0 |
|
||||
| 2 | `artists-view` | Low-Medium | Phase 0 |
|
||||
| 3 | `genres-view` | High | Phase 0 |
|
||||
| 4 | `track-list` | Medium | Phase 0 |
|
||||
| 5 | `playlist-view` | High | Phase 0 |
|
||||
| 6 | `cover-grid` | Very High | Phase 0 |
|
||||
| 7 | Cleanup + docs | Low | Phases 1-6 |
|
||||
|
||||
Each phase after 0 is independent of the others and can be done in any order. The ordering above goes from easiest to hardest as a recommended sequence.
|
||||
|
||||
---
|
||||
|
||||
## Reactive Pattern: Reference Comparison
|
||||
|
||||
All component migrations use the same pattern to detect store data changes:
|
||||
|
||||
```typescript
|
||||
private lastDataRef: T[] | null = null;
|
||||
|
||||
override updated() {
|
||||
const cached = this.controller.cachedData;
|
||||
if (cached !== null && cached !== this.lastDataRef) {
|
||||
this.lastDataRef = cached;
|
||||
this.loadData(); // existing imperative load method
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why reference comparison instead of a version counter or dirty flag:**
|
||||
- **Simplicity:** No store API changes needed. `cachedTracks`/`cachedAlbums`/`cachedArtists`/`cachedPlaylists` are already exposed by controllers.
|
||||
- **Correctness:** Each backend fetch creates a new array instance. `invalidate()` sets cache to `null`. The reference comparison catches both "new data arrived" and "data was cleared and refetched."
|
||||
- **No infinite loops:** Setting `this.lastDataRef = cached` before calling `loadData()` prevents re-triggering. The `loadData()` call may set local `@state()` which triggers another `updated()`, but by then `lastDataRef` matches `cached` and the guard short-circuits.
|
||||
- **No store changes needed:** The controllers already expose `cachedTracks`, `cachedAlbums`, `cachedArtists`, `cachedPlaylists`.
|
||||
|
||||
**Why not move everything into `render()`:** Components do significant local work beyond just displaying store data — filtering, sorting, scroll restoration, selection management. Keeping the imperative `loadData()` call but triggering it reactively is the minimal change that achieves the goal.
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Double fetch on scan complete (eager + component listener during migration) | Store's `waitFor*()` helpers coalesce concurrent requests. Only one backend call actually fires. |
|
||||
| Infinite `updated()` loop | Reference comparison with `lastDataRef` assignment prevents re-triggering. Each migration should be tested for this. |
|
||||
| Stale selection after scan | `loadData()` methods already clear selection in most components. Verify for each migration. |
|
||||
| Scroll position regression | `invalidate()` resets scroll to `0`. `loadData()` methods handle scroll restoration. The `0` position means "start from top", which is correct after a scan. |
|
||||
| `externalAlbums`/`externalTracks` components don't refresh | Parent components (`artist-details`, `genre-details`) are migrated first. They re-fetch and update the `external*` property, which triggers the child's `willUpdate()` change detection. |
|
||||
| `playlist-picker` left unmigrated | Intentional. It uses a different API (`GetAllPlaylists` vs `GetAllPlaylistsWithTracks`). See catalog item #19. |
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
For each phase:
|
||||
1. **Manual test:** Trigger a library scan while each affected view is visible. Verify data refreshes without stale content.
|
||||
2. **Manual test:** Trigger a scan while a detail view is open (`genre-details`, `artist-details`). Verify child components (`track-list`, `cover-grid`) refresh via the parent's external data update.
|
||||
3. **Manual test:** Verify scroll position resets to top after scan.
|
||||
4. **Manual test:** Verify that adding/removing tracks from the library directory and scanning updates all views correctly.
|
||||
5. **Verify no console errors** — especially no infinite loop warnings or unhandled promise rejections.
|
||||
6. **Run `pnpm exec tsc --noEmit`** — ensure no TypeScript errors after each phase.
|
||||
@@ -18,30 +18,13 @@ Prioritized list of architectural improvements identified during a full codebase
|
||||
|
||||
---
|
||||
|
||||
### 4. Split `cover-grid.ts` (3740 lines)
|
||||
|
||||
**Problem:** The largest frontend component by far. It likely handles album grid rendering, context menus, drag-and-drop, selection, sorting, resizing, and more — all in a single file.
|
||||
|
||||
**Why it matters:** Difficult to understand, modify, or review. Changes to context menu logic risk breaking grid rendering and vice versa.
|
||||
|
||||
**Approach:** Extract logical sections into separate files/components:
|
||||
|
||||
- Context menu logic into a shared utility or sub-component
|
||||
- Selection logic already uses a `SelectionController` — verify it's fully extracted
|
||||
- Drag-and-drop setup into the existing `DragController` if not already
|
||||
- Grid rendering as the core component, delegating to these helpers
|
||||
### 4. ~~Split `cover-grid.ts` (3740 lines)~~ - solved
|
||||
|
||||
---
|
||||
|
||||
## P2 — Fix when convenient
|
||||
|
||||
### 5. Delete `backend/models/` package (dead code)
|
||||
|
||||
**Problem:** The `models` package (`files.go`, `music.go`, `art.go`) defines `AudioFile`, `AudioFileType`, `Album`, `Track`, `Artist`, and `Art` types. No package imports it anywhere.
|
||||
|
||||
**Why it matters:** Dead code creates confusion — new contributors may think these are the canonical domain types, but the actual types are in `library/`, `queue/`, `playlist/`, and `sqlcgen/`.
|
||||
|
||||
**Approach:** Delete the entire `backend/models/` directory.
|
||||
### ~~5. Delete `backend/models/` package (dead code)~~ - solved
|
||||
|
||||
---
|
||||
|
||||
@@ -55,13 +38,7 @@ Prioritized list of architectural improvements identified during a full codebase
|
||||
|
||||
---
|
||||
|
||||
### 7. Consolidate `LibraryScanComplete` handling
|
||||
|
||||
**Problem:** `LibraryScanComplete` is listened to directly in 10+ components (`genres-view.ts`, `artists-view.ts`, `cover-grid.ts`, `track-list.ts`, `playlist-view.ts`, `genre-details.ts`, `artist-details.ts`, `playlist-picker.ts`, `config-page.ts`, `library-manager.ts`) in addition to `library-store.ts` and `playlist-store.ts`. Each component independently re-fetches its data.
|
||||
|
||||
**Why it matters:** The stores already invalidate their caches and notify subscribers on this event. Components that use the store controllers should get re-rendered automatically. The direct listeners exist because many components load data independently from the stores (calling Go bindings directly), which means the stores aren't serving their full purpose as centralized data sources.
|
||||
|
||||
**Approach:** For components that already use `LibraryController`/`PlaylistController`, the store subscription should handle cache invalidation. The controller's `hostConnected` subscribes and `requestUpdate` triggers a re-render, which calls the async data getter, which will re-fetch since the cache was invalidated. Remove the redundant direct `EventsOn(LibraryScanComplete)` from components that go through stores. For components like `playlist-picker.ts` that call Go bindings directly (bypassing stores), either route them through the store or accept the direct listener as intentional.
|
||||
### 7. ~~Consolidate `LibraryScanComplete` handling~~ — solved
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// Package models defines domain types for music data.
|
||||
package models
|
||||
|
||||
// Art holds album artwork data.
|
||||
type Art struct{}
|
||||
@@ -1,13 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// AudioFileType identifies the format of an audio file.
|
||||
type AudioFileType int
|
||||
|
||||
// AudioFile represents a music file with its metadata.
|
||||
type AudioFile struct {
|
||||
Path string
|
||||
Type AudioFileType
|
||||
Length time.Duration
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package models
|
||||
|
||||
// Album represents a music album with its tracks and metadata.
|
||||
type Album struct {
|
||||
Name string
|
||||
Tracks []Track
|
||||
MusicBrainzReleaseID string
|
||||
CoverArt Art
|
||||
}
|
||||
|
||||
// Track represents a single music track.
|
||||
type Track struct {
|
||||
Name string
|
||||
MusicBrainzRecordingID string
|
||||
}
|
||||
|
||||
// Artist represents a music artist.
|
||||
type Artist struct {
|
||||
Name string
|
||||
MusicBrainzArtistID string
|
||||
}
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
property,
|
||||
state,
|
||||
} from 'lit/decorators.js';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { library } from '@go/models';
|
||||
import { LibraryController } from '@store/controllers/library-controller';
|
||||
import { Events } from '../../events';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@components/cover-grid/cover-grid.js';
|
||||
|
||||
@@ -26,7 +24,9 @@ export class ArtistDetails extends LitElement {
|
||||
private loading = true;
|
||||
|
||||
private libraryCtrl = new LibraryController(this);
|
||||
private cancelScanComplete?: () => void;
|
||||
|
||||
/** Tracks the store's cached array reference to detect refreshes. */
|
||||
private lastAlbumsRef: library.Album[] | null = null;
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
@@ -155,15 +155,18 @@ export class ArtistDetails extends LitElement {
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadAlbums();
|
||||
this.cancelScanComplete = EventsOn(
|
||||
Events.LibraryScanComplete,
|
||||
() => this.loadAlbums(),
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.cancelScanComplete?.();
|
||||
override updated() {
|
||||
const cached = this.libraryCtrl.cachedAlbums;
|
||||
|
||||
if (
|
||||
cached !== null &&
|
||||
cached !== this.lastAlbumsRef
|
||||
) {
|
||||
this.lastAlbumsRef = cached;
|
||||
this.loadAlbums();
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
state,
|
||||
query,
|
||||
} from 'lit/decorators.js';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import '@lit-labs/virtualizer';
|
||||
import type {
|
||||
LitVirtualizer,
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
contextMenuStyles,
|
||||
} from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { Events } from '../../events';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||
@@ -60,9 +58,12 @@ export class ArtistsView
|
||||
private libraryCtrl = new LibraryController(this);
|
||||
private searchCtrl = new SearchController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private cancelScanComplete?: () => void;
|
||||
private wheelListenerAttached = false;
|
||||
private lastSearchTerm = '';
|
||||
|
||||
/** Tracks the store's cached array reference to detect refreshes. */
|
||||
private lastArtistsRef: library.Artist[] | null =
|
||||
null;
|
||||
private scrollDebounceTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
@@ -376,15 +377,10 @@ export class ArtistsView
|
||||
super.connectedCallback();
|
||||
this.loadCardSize();
|
||||
this.loadArtists();
|
||||
this.cancelScanComplete = EventsOn(
|
||||
Events.LibraryScanComplete,
|
||||
() => this.loadArtists(),
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.cancelScanComplete?.();
|
||||
this.detachWheelListener();
|
||||
|
||||
if (this.scrollDebounceTimer !== null) {
|
||||
@@ -404,6 +400,19 @@ export class ArtistsView
|
||||
this.lastSearchTerm = currentTerm;
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
// Re-fetch when the store delivers fresh
|
||||
// data after eager refetch on invalidation.
|
||||
const cached =
|
||||
this.libraryCtrl.cachedArtists;
|
||||
|
||||
if (
|
||||
cached !== null &&
|
||||
cached !== this.lastArtistsRef
|
||||
) {
|
||||
this.lastArtistsRef = cached;
|
||||
this.loadArtists();
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
state,
|
||||
query,
|
||||
} from 'lit/decorators.js';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import '@lit-labs/virtualizer';
|
||||
import type {
|
||||
LitVirtualizer,
|
||||
@@ -17,7 +16,6 @@ import { library } from '@go/models';
|
||||
import { LibraryController } from '@store/controllers/library-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import { Events } from '../../events';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
@@ -70,17 +68,19 @@ export class CoverGrid
|
||||
/**
|
||||
* When set, the grid displays these albums instead of
|
||||
* fetching all albums from the library store. The
|
||||
* component also skips the LibraryScanComplete listener
|
||||
* since the parent is responsible for reloading.
|
||||
* parent is responsible for reloading when data changes.
|
||||
*/
|
||||
@property({ type: Array, attribute: false })
|
||||
externalAlbums?: library.Album[];
|
||||
|
||||
libraryCtrl = new LibraryController(this);
|
||||
private searchCtrl = new SearchController(this);
|
||||
private cancelScanComplete?: () => void;
|
||||
private lastSearchTerm = '';
|
||||
|
||||
/** Tracks the store's cached array reference to detect refreshes. */
|
||||
private lastAlbumsRef: library.Album[] | null =
|
||||
null;
|
||||
|
||||
// Fixed grid spacing constants.
|
||||
private static readonly GRID_GAP = 8;
|
||||
private static readonly GRID_PADDING = 8;
|
||||
@@ -481,16 +481,6 @@ export class CoverGrid
|
||||
this.restoreSortPreferences();
|
||||
this.loadAlbums();
|
||||
|
||||
// Skip the scan listener when driven by an
|
||||
// external album list — the parent manages
|
||||
// reloading.
|
||||
if (!this.externalAlbums) {
|
||||
this.cancelScanComplete = EventsOn(
|
||||
Events.LibraryScanComplete,
|
||||
() => this.loadAlbums(),
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
'mousedown',
|
||||
this.sortDropdownCloseHandler,
|
||||
@@ -507,7 +497,6 @@ export class CoverGrid
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.cancelScanComplete?.();
|
||||
|
||||
document.removeEventListener(
|
||||
'mousedown',
|
||||
@@ -725,6 +714,21 @@ export class CoverGrid
|
||||
);
|
||||
})();
|
||||
}
|
||||
|
||||
// Re-fetch when the store delivers fresh
|
||||
// data after eager refetch on invalidation.
|
||||
if (!this.externalAlbums) {
|
||||
const cached =
|
||||
this.libraryCtrl.cachedAlbums;
|
||||
|
||||
if (
|
||||
cached !== null &&
|
||||
cached !== this.lastAlbumsRef
|
||||
) {
|
||||
this.lastAlbumsRef = cached;
|
||||
this.loadAlbums();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
property,
|
||||
state,
|
||||
} from 'lit/decorators.js';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { library } from '@go/models';
|
||||
import { LibraryController } from '@store/controllers/library-controller';
|
||||
import { Events } from '../../events';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@components/track-list/track-list.js';
|
||||
|
||||
@@ -23,7 +21,9 @@ export class GenreDetails extends LitElement {
|
||||
private loading = true;
|
||||
|
||||
private libraryCtrl = new LibraryController(this);
|
||||
private cancelScanComplete?: () => void;
|
||||
|
||||
/** Tracks the store's cached array reference to detect refreshes. */
|
||||
private lastTracksRef: library.Track[] | null = null;
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
@@ -151,15 +151,18 @@ export class GenreDetails extends LitElement {
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadTracks();
|
||||
this.cancelScanComplete = EventsOn(
|
||||
Events.LibraryScanComplete,
|
||||
() => this.loadTracks(),
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.cancelScanComplete?.();
|
||||
override updated() {
|
||||
const cached = this.libraryCtrl.cachedTracks;
|
||||
|
||||
if (
|
||||
cached !== null &&
|
||||
cached !== this.lastTracksRef
|
||||
) {
|
||||
this.lastTracksRef = cached;
|
||||
this.loadTracks();
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
state,
|
||||
query,
|
||||
} from 'lit/decorators.js';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import '@lit-labs/virtualizer';
|
||||
import type {
|
||||
LitVirtualizer,
|
||||
@@ -15,7 +14,6 @@ import { library } from '@go/models';
|
||||
import { LibraryController } from '@store/controllers/library-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import { Events } from '../../events';
|
||||
import {
|
||||
ContextMenuController,
|
||||
contextMenuStyles,
|
||||
@@ -60,9 +58,12 @@ export class GenresView
|
||||
private libraryCtrl = new LibraryController(this);
|
||||
private searchCtrl = new SearchController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private cancelScanComplete?: () => void;
|
||||
private wheelListenerAttached = false;
|
||||
private lastSearchTerm = '';
|
||||
|
||||
/** Tracks the store's cached array reference to detect refreshes. */
|
||||
private lastTracksRef: library.Track[] | null =
|
||||
null;
|
||||
private scrollDebounceTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
@@ -376,15 +377,10 @@ export class GenresView
|
||||
super.connectedCallback();
|
||||
this.loadCardSize();
|
||||
this.loadGenres();
|
||||
this.cancelScanComplete = EventsOn(
|
||||
Events.LibraryScanComplete,
|
||||
() => this.loadGenres(),
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.cancelScanComplete?.();
|
||||
this.detachWheelListener();
|
||||
|
||||
if (this.scrollDebounceTimer !== null) {
|
||||
@@ -404,6 +400,19 @@ export class GenresView
|
||||
this.lastSearchTerm = currentTerm;
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
// Re-fetch when the store delivers fresh
|
||||
// data after eager refetch on invalidation.
|
||||
const cached =
|
||||
this.libraryCtrl.cachedTracks;
|
||||
|
||||
if (
|
||||
cached !== null &&
|
||||
cached !== this.lastTracksRef
|
||||
) {
|
||||
this.lastTracksRef = cached;
|
||||
this.loadGenres();
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state, query } from 'lit/decorators.js';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
ImportPlaylist,
|
||||
} from '@go/playlist/Service';
|
||||
import { PlaylistFilePicker } from '@go/frontendutil/FrontendUtil';
|
||||
import { Events } from '../../events';
|
||||
import type { playlist } from '@go/models';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
@@ -75,7 +72,11 @@ export class PlaylistView
|
||||
| undefined {
|
||||
return this.playlistSubmenuPopup;
|
||||
}
|
||||
private cancelScanComplete?: () => void;
|
||||
/** Tracks the store's cached array reference to detect refreshes. */
|
||||
private lastPlaylistsRef:
|
||||
| playlist.WithTracks[]
|
||||
| null = null;
|
||||
|
||||
private scrollDebounceTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
@@ -750,10 +751,6 @@ export class PlaylistView
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadPlaylists();
|
||||
this.cancelScanComplete = EventsOn(
|
||||
Events.LibraryScanComplete,
|
||||
() => this.loadPlaylists(),
|
||||
);
|
||||
document.addEventListener(
|
||||
'click',
|
||||
this.closePlaylistCtxMenuHandler,
|
||||
@@ -774,7 +771,6 @@ export class PlaylistView
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.cancelScanComplete?.();
|
||||
|
||||
if (this.scrollDebounceTimer !== null) {
|
||||
clearTimeout(this.scrollDebounceTimer);
|
||||
@@ -830,6 +826,19 @@ export class PlaylistView
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Re-fetch when the store delivers fresh
|
||||
// data after eager refetch on invalidation.
|
||||
const cached =
|
||||
this.playlistCtrl.cachedPlaylists;
|
||||
|
||||
if (
|
||||
cached !== null &&
|
||||
cached !== this.lastPlaylistsRef
|
||||
) {
|
||||
this.lastPlaylistsRef = cached;
|
||||
this.loadPlaylists();
|
||||
}
|
||||
}
|
||||
|
||||
private get scrollContainer(): HTMLElement | null {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
state,
|
||||
query,
|
||||
} from 'lit/decorators.js';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { SelectionController } from '@utils/selection-controller';
|
||||
import type { SelectionHost } from '@utils/selection-controller';
|
||||
import {
|
||||
@@ -19,7 +18,6 @@ import { SearchController } from '@store/controllers/search-controller';
|
||||
import { TrackListController } from '@store/controllers/tracklist-controller';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import { LibraryController } from '@store/controllers/library-controller';
|
||||
import { Events } from '../../events';
|
||||
import {
|
||||
COLUMN_DEFS,
|
||||
DEFAULT_COLUMN_IDS,
|
||||
@@ -61,8 +59,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
/**
|
||||
* When set, the list displays these tracks instead of
|
||||
* fetching all tracks from the library store. The
|
||||
* component also skips the LibraryScanComplete listener
|
||||
* since the parent is responsible for reloading.
|
||||
* parent is responsible for reloading when data changes.
|
||||
*/
|
||||
@property({ type: Array, attribute: false })
|
||||
externalTracks?: library.Track[];
|
||||
@@ -73,9 +70,12 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
private trackListCtrl = new TrackListController(this);
|
||||
private selection = new SelectionController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private cancelScanComplete?: () => void;
|
||||
private lastSearchTerm = '';
|
||||
|
||||
/** Tracks the store's cached array reference to detect refreshes. */
|
||||
private lastTracksRef: library.Track[] | null =
|
||||
null;
|
||||
|
||||
/**
|
||||
* Resolved column definitions for the currently configured
|
||||
* column IDs. Falls back to defaults for any unknown ID.
|
||||
@@ -942,10 +942,6 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
this.tracks = this.externalTracks;
|
||||
} else {
|
||||
this.loadTracks();
|
||||
this.cancelScanComplete = EventsOn(
|
||||
Events.LibraryScanComplete,
|
||||
() => this.loadTracks(),
|
||||
);
|
||||
}
|
||||
document.addEventListener('mousedown', this.sortDropdownCloseHandler);
|
||||
document.addEventListener('click', this.clearSelectionHandler);
|
||||
@@ -968,7 +964,6 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
);
|
||||
this.hasRestoredScroll = false;
|
||||
super.disconnectedCallback();
|
||||
this.cancelScanComplete?.();
|
||||
document.removeEventListener('mousedown', this.sortDropdownCloseHandler);
|
||||
document.removeEventListener('click', this.clearSelectionHandler);
|
||||
document.removeEventListener('mousemove', this.onColResizeMove);
|
||||
@@ -1033,6 +1028,21 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
this.lastSearchTerm = currentTerm;
|
||||
this.selection.clear();
|
||||
}
|
||||
|
||||
// Re-fetch when the store delivers fresh
|
||||
// data after eager refetch on invalidation.
|
||||
if (!this.externalTracks) {
|
||||
const cached =
|
||||
this.libraryCtrl.cachedTracks;
|
||||
|
||||
if (
|
||||
cached !== null &&
|
||||
cached !== this.lastTracksRef
|
||||
) {
|
||||
this.lastTracksRef = cached;
|
||||
this.loadTracks();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private previousHostWidth = 0;
|
||||
|
||||
@@ -251,6 +251,19 @@ class LibraryStore {
|
||||
this.artists = null;
|
||||
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
|
||||
this.notify();
|
||||
this.eagerRefetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetches all data after cache invalidation so that
|
||||
* controller subscribers receive fresh data on the next
|
||||
* requestUpdate() cycle without needing their own
|
||||
* LibraryScanComplete listener.
|
||||
*/
|
||||
private eagerRefetch(): void {
|
||||
void this.getTracks();
|
||||
void this.getAlbums();
|
||||
void this.getArtists();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -123,6 +123,7 @@ class PlaylistStore {
|
||||
this.playlists = null;
|
||||
this.scrollPosition = 0;
|
||||
this.notify();
|
||||
void this.getPlaylists();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
Reference in New Issue
Block a user