fix(downloads): keep renderer download state global

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
1 parent 505face7e4
commit 117bd75f01
11 files changed
+102 -23

No files matched your search

@@ -191,9 +191,9 @@ export class DownloadsComponent {
constructor() {
effect(() => {
const playlistId = this.playlistId();
this.playlistId();
this.collectionContext.setCategoryId('all');
void this.downloadsService.loadDownloads(playlistId || undefined);
void this.downloadsService.loadDownloads();
});
}
@@ -37,7 +37,7 @@ type DownloadsElectronStub = {
[number, string]
>;
downloadsSelectFolder?: jest.Mock<Promise<string | null>, []>;
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, [string?]>;
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, []>;
};
describe('DownloadsService', () => {
@@ -121,7 +121,7 @@ describe('DownloadsService', () => {
}
});
it('tracks loading and loaded state around a successful download list request', async () => {
it('loads the global download list without a playlist filter and tracks request state', async () => {
const item = createDownload(1);
const pending = createDeferred<DownloadItem[]>();
const electron = {
@@ -130,11 +130,11 @@ describe('DownloadsService', () => {
testWindow.electron = electron;
const service = createService();
const request = service.loadDownloads('playlist-1');
const request = service.loadDownloads();
expect(service.isLoadingDownloads()).toBe(true);
expect(service.hasLoadedDownloads()).toBe(false);
expect(electron.downloadsGetList).toHaveBeenCalledWith('playlist-1');
expect(electron.downloadsGetList).toHaveBeenCalledWith();
pending.resolve([item]);
await request;
@@ -245,8 +245,8 @@ describe('DownloadsService', () => {
testWindow.electron = electron;
const service = createService();
const firstRequest = service.loadDownloads('playlist-old');
const secondRequest = service.loadDownloads('playlist-new');
const firstRequest = service.loadDownloads();
const secondRequest = service.loadDownloads();
expect(service.isLoadingDownloads()).toBe(true);
@@ -262,14 +262,8 @@ describe('DownloadsService', () => {
expect(service.downloads()).toEqual([latestItem]);
expect(service.isLoadingDownloads()).toBe(false);
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(
1,
'playlist-old'
);
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(
2,
'playlist-new'
);
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(1);
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(2);
});
it('reports paused content and resumes it by content identity', async () => {
+2 -2
View File
@@ -106,14 +106,14 @@ export class DownloadsService implements OnDestroy {
/**
* Load downloads from the backend
*/
async loadDownloads(playlistId?: string): Promise<void> {
async loadDownloads(): Promise<void> {
if (!this.isAvailable()) return;
const requestId = ++this.loadDownloadsRequestId;
this._isLoadingDownloads.set(true);
try {
const list = await window.electron.downloadsGetList(playlistId);
const list = await window.electron.downloadsGetList();
if (requestId === this.loadDownloadsRequestId) {
this.downloads.set(list);
this._hasLoadedDownloads.set(true);
@@ -121,6 +121,15 @@
<span class="download-activity-bar__fill"></span>
</span>
}
@if (activeDownloadsCount() > 0) {
<span
class="download-count-badge"
data-test-id="global-download-count"
aria-hidden="true"
>
{{ activeDownloadsCount() }}
</span>
}
<button
type="button"
class="header-shortcut"
@@ -214,6 +214,28 @@
justify-content: center;
}
.download-count-badge {
position: absolute;
top: -2px;
right: -4px;
z-index: 11;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border: 1px solid var(--mat-sys-surface-container-low);
border-radius: 999px;
color: var(--app-selection-on-color, var(--mat-sys-on-primary));
background: var(--app-selection-color, var(--mat-sys-primary));
box-shadow: 0 2px 8px var(--app-selection-glow);
font-size: 0.6875rem;
font-weight: 700;
line-height: 1;
pointer-events: none;
}
.download-activity-bar {
position: absolute;
bottom: 2px;
@@ -172,4 +172,32 @@ describe('WorkspaceShellHeaderComponent', () => {
expect(chips).toEqual(['Movies / All Items', 'Loaded channels only']);
});
it('renders the global active download count without duplicating the button name', () => {
fixture.componentRef.setInput('isElectron', true);
fixture.componentRef.setInput('activeDownloadsCount', 3);
fixture.detectChanges();
const badge: HTMLElement | null = fixture.nativeElement.querySelector(
'[data-test-id="global-download-count"]'
);
expect(badge?.textContent?.trim()).toBe('3');
expect(badge?.getAttribute('aria-hidden')).toBe('true');
expect(
fixture.nativeElement.querySelector('.download-activity-bar')
).not.toBeNull();
});
it('does not render a global download badge when no downloads are active', () => {
fixture.componentRef.setInput('isElectron', true);
fixture.componentRef.setInput('activeDownloadsCount', 0);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="global-download-count"]'
)
).toBeNull();
});
});
@@ -1,6 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
input,
output,
@@ -52,7 +53,10 @@ export class WorkspaceShellHeaderComponent {
readonly isElectron = input(false);
readonly hasNoPlaylists = input(false);
readonly isDownloadsView = input(false);
readonly hasActiveDownloads = input(false);
readonly activeDownloadsCount = input(0);
readonly hasActiveDownloads = computed(
() => this.activeDownloadsCount() > 0
);
/**
* When true the playlist switcher + the "+ Add source" / refresh /
* bulk-action buttons are hidden — those controls scope to a
@@ -376,6 +376,21 @@ describe('WorkspaceShellFacade', () => {
expect(facade.isMacOS).toBe(false);
});
it('exposes the global active download count and derives activity when supported', () => {
downloadsActiveCountSignal.set(3);
expect(facade.activeDownloadsCount()).toBe(3);
expect(facade.hasActiveDownloads()).toBe(true);
});
it('hides the active download count when downloads are unsupported', () => {
runtime.supportsDownloads = false;
downloadsActiveCountSignal.set(3);
expect(facade.activeDownloadsCount()).toBe(0);
expect(facade.hasActiveDownloads()).toBe(false);
});
it('routes dashboard search Enter into the active Xtream playlist search', () => {
const xtreamStore = TestBed.inject(
XtreamStore
@@ -112,8 +112,11 @@ export class WorkspaceShellFacade {
readonly isRefreshingPlaylist = this.header.isRefreshingPlaylist;
readonly headerBulkAction = this.header.headerBulkAction;
readonly playlistSubtitle = this.header.playlistSubtitle;
readonly activeDownloadsCount = computed(() =>
this.supportsDownloads ? this.downloadsService.activeCount() : 0
);
readonly hasActiveDownloads = computed(
() => this.supportsDownloads && this.downloadsService.activeCount() > 0
() => this.activeDownloadsCount() > 0
);
constructor() {
@@ -39,7 +39,7 @@
[isElectron]="facade.isElectron"
[hasNoPlaylists]="facade.hasNoPlaylists()"
[isDownloadsView]="facade.isDownloadsView()"
[hasActiveDownloads]="facade.hasActiveDownloads()"
[activeDownloadsCount]="facade.activeDownloadsCount()"
[isSettingsRoute]="facade.isSettingsRoute()"
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
(searchChanged)="facade.onSearchInput($event)"
@@ -58,7 +58,7 @@ class MockWorkspaceShellHeaderComponent {
readonly isElectron = input(false);
readonly hasNoPlaylists = input(false);
readonly isDownloadsView = input(false);
readonly hasActiveDownloads = input(false);
readonly activeDownloadsCount = input(0);
readonly isSettingsRoute = input(false);
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly searchChanged = output<string>();
@@ -154,7 +154,7 @@ class MockWorkspaceShellFacade {
readonly isRefreshingPlaylist = signal(false);
readonly hasNoPlaylists = signal(false);
readonly isDownloadsView = signal(false);
readonly hasActiveDownloads = signal(false);
readonly activeDownloadsCount = signal(3);
readonly headerBulkAction = signal<WorkspaceHeaderBulkAction | null>(null);
readonly showContextPanel = signal(true);
readonly contextPanel = signal<WorkspaceShellContextPanel>('settings');
@@ -246,6 +246,10 @@ describe('WorkspaceShellComponent', () => {
expect(
fixture.nativeElement.querySelector('app-workspace-shell-header')
).not.toBeNull();
const header = fixture.debugElement.query(
By.directive(MockWorkspaceShellHeaderComponent)
).componentInstance as MockWorkspaceShellHeaderComponent;
expect(header.activeDownloadsCount()).toBe(3);
expect(
fixture.nativeElement.querySelector(
'app-workspace-shell-context-sidebar'