fix(sources): no idle Refresh on held rows; Recent counts the filtered list

- The Sources page offered Refresh on a source the startup auto-refresh,
  an import or a delete held, and refresh() then refused it. The row now
  offers Refresh only while it can run, keeping it (with its spinner)
  while that source refreshes.
- The switcher showed Recent whenever the library had six sources, so a
  type filter down to one or two listed them twice and the keyboard
  visited each twice. Recent now depends on the filtered count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 02:09:28 +02:00
1 parent f48911a889
commit c4da37ebb0
5 files changed
+39 -4

No files matched your search

@@ -45,6 +45,10 @@ describe('buildSwitcherGroups', () => {
expect(
buildSwitcherGroups(many.slice(0, 3), ['a'], 'all', '').recent
).toEqual([]);
// A long library filtered down to one type is a short list too.
expect(buildSwitcherGroups(many, ['b'], 'xtream', '').recent).toEqual(
[]
);
});
it('filters by type and by name or host', () => {
@@ -65,8 +65,8 @@ export interface SwitcherGroups {
/**
* The switcher's two groups: "Recent" (newest first, only without a search
* and with enough sources to be worth it) and "All" in the given order,
* both narrowed by type and search.
* and when enough sources are shown to be worth it) and "All" in the given
* order, both narrowed by type and search.
*/
export function buildSwitcherGroups(
ordered: readonly PlaylistMeta[],
@@ -86,8 +86,10 @@ export function buildSwitcherGroups(
group,
playlist,
});
// Counted after the type filter: a short filtered list would show the
// same sources twice, and the keyboard would visit each twice.
const showRecent =
!query.trim() && ordered.length >= SWITCHER_RECENT_MIN_SOURCES;
!query.trim() && visible.length >= SWITCHER_RECENT_MIN_SOURCES;
const byId = new Map(visible.map((playlist) => [playlist._id, playlist]));
const recent = showRecent
? recentIds
@@ -35,7 +35,7 @@
[playlist]="item"
[current]="activePlaylistId() === item._id"
[draggable]="isCustomSort()"
[canRefresh]="refresh.canRefresh(item)"
[canRefresh]="canRefresh(item)"
[refreshing]="isRefreshing(item)"
[removable]="isRemovable(item)"
[deleting]="removal.progress.isDeleting(item._id)"
@@ -305,6 +305,23 @@ describe('RecentPlaylistsComponent', () => {
).toBe(true);
});
it('offers no Refresh while something else holds a source', () => {
const canRefresh = () =>
fixture.debugElement
.queryAll(By.directive(SourceRowComponent))
.map((row) =>
(row.componentInstance as SourceRowComponent).canRefresh()
);
expect(canRefresh()).toEqual([true, true, true]);
// The startup auto-refresh or an import: refresh() would refuse.
const release = TestBed.inject(SourceActivityService).begin(['m3u']);
fixture.detectChanges();
expect(canRefresh()).toEqual([false, true, true]);
release();
fixture.detectChanges();
expect(canRefresh()).toEqual([true, true, true]);
});
it('offers no Remove while something else holds a source', () => {
const removable = () =>
fixture.debugElement
@@ -230,6 +230,18 @@ export class RecentPlaylistsComponent {
);
}
/**
* Refresh is offered while it can run, and kept (with its spinner)
* while this source refreshes. Held by anything else (the startup
* auto-refresh, an import, a delete), refresh() would refuse it.
*/
canRefresh(item: PlaylistMeta): boolean {
return (
this.refresh.canRefresh(item) &&
(this.isRefreshing(item) || !this.activity.isBusy(item._id))
);
}
/** Nothing else (a refresh, the startup auto-refresh) holds it. */
isRemovable(item: PlaylistMeta): boolean {
return !this.activity.isBusy(item._id);