From 4f3567e1d0b9b981471479dddf3bc4409c9ad0c9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 08:22:16 +0200 Subject: [PATCH] fix(downloads): open completed movies in details --- .../src/lib/download-library.component.html | 93 +++++++++++++--- .../src/lib/download-library.component.scss | 8 +- .../lib/download-library.component.spec.ts | 100 +++++++++++++++--- .../src/lib/download-library.component.ts | 12 +-- 4 files changed, 173 insertions(+), 40 deletions(-) diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.html b/libs/portal/downloads/feature/src/lib/download-library.component.html index 35e3185ca..d73c3b1f4 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.html +++ b/libs/portal/downloads/feature/src/lib/download-library.component.html @@ -23,11 +23,14 @@ type="button" class="download-library__artwork-button" data-test-id="download-library-series-open" + [disabled]="isPending(entity.representative)" [class.download-library__artwork-button--disabled]=" - !canOpenSeries(entity) + !canOpen(entity.representative) " [attr.aria-disabled]=" - !canOpenSeries(entity) ? 'true' : null + !canOpen(entity.representative) + ? 'true' + : null " [attr.aria-label]=" 'DOWNLOADS.OPEN_ARTWORK' @@ -37,8 +40,10 @@ 'DOWNLOADS.SOURCE_PLAYLIST_MISSING' | translate " - [matTooltipDisabled]="canOpenSeries(entity)" - (click)="openSeries(entity)" + [matTooltipDisabled]=" + canOpen(entity.representative) + " + (click)="openDetails(entity.representative)" > @if (artworkUrl(entity); as artwork) { {{ entity.title }} @@ -177,12 +189,38 @@ type="button" class="download-library__artwork-button" [disabled]="isPending(entity.item)" - [attr.aria-label]=" - ('DOWNLOADS.PLAY' | translate) + - ': ' + - entity.item.title + [class.download-library__artwork-button--disabled]=" + entity.kind === 'movie' && + !canOpen(entity.item) + " + [attr.aria-disabled]=" + entity.kind === 'movie' && + !canOpen(entity.item) + ? 'true' + : null + " + [attr.aria-label]=" + entity.kind === 'movie' + ? ('DOWNLOADS.OPEN_ARTWORK' + | translate + : { title: entity.item.title }) + : ('DOWNLOADS.PLAY' | translate) + + ': ' + + entity.item.title + " + [matTooltip]=" + 'DOWNLOADS.SOURCE_PLAYLIST_MISSING' + | translate + " + [matTooltipDisabled]=" + entity.kind !== 'movie' || + canOpen(entity.item) + " + (click)=" + entity.kind === 'movie' + ? openDetails(entity.item) + : emitAction('play', entity.item) " - (click)="emitAction('play', entity.item)" > @if (artworkUrl(entity); as artwork) { {{ entity.item.title }} diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.scss b/libs/portal/downloads/feature/src/lib/download-library.component.scss index 99409edc5..f78024458 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.scss +++ b/libs/portal/downloads/feature/src/lib/download-library.component.scss @@ -21,8 +21,8 @@ .download-library__grid { @include grid.content-grid( - $min-width: min(100%, 168px), - $gap: clamp(12px, 1.5vw, 20px) + $min-width: min(100%, var(--cover-grid-min-width, 148px)), + $gap: var(--cover-gap, 16px) ); } @@ -271,10 +271,6 @@ button:disabled { } @media (max-width: 620px) { - .download-library__grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .download-library__body { padding: 10px; } diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts index 6100dda11..6a8e16194 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { FocusMonitor } from '@angular/cdk/a11y'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -242,12 +244,63 @@ describe('DownloadLibraryComponent', () => { ).toContain('1 episode'); }); - it('emits every concrete movie action with the movie item', async () => { + it('opens movie details from artwork and title without playing locally', async () => { const card = byTestId('download-library-movie-9'); + const opened: DownloadItem[] = []; const actions: unknown[] = []; + component.openRequested.subscribe((selected) => opened.push(selected)); component.itemAction.subscribe((action) => actions.push(action)); - await click(button(card, 'Play: Moonrise')); + await click(button(card, 'Open details: Moonrise artwork')); + await click(button(card, 'Open details: Moonrise')); + + expect(opened).toEqual([MOVIE, MOVIE]); + expect(actions).toEqual([]); + }); + + it('keeps orphaned movie navigation focusable and explains why it cannot open', async () => { + fixture.componentRef.setInput('availablePlaylistIds', new Set()); + await fixture.whenStable(); + const card = byTestId('download-library-movie-9'); + const artwork = button(card, 'Open details: Moonrise artwork'); + const title = button(card, 'Open details: Moonrise'); + const opened: DownloadItem[] = []; + component.openRequested.subscribe((item) => opened.push(item)); + + expect(artwork.disabled).toBe(false); + expect(title.disabled).toBe(false); + expect(artwork.getAttribute('aria-disabled')).toBe('true'); + expect(title.getAttribute('aria-disabled')).toBe('true'); + + const loader = TestbedHarnessEnvironment.loader(fixture); + const tooltip = await loader.getHarness( + MatTooltipHarness.with({ + selector: + '.download-library__artwork-button[aria-label="Open details: Moonrise artwork"]', + }) + ); + TestBed.inject(FocusMonitor).focusVia(artwork, 'keyboard'); + await fixture.whenStable(); + expect(await tooltip.isOpen()).toBe(true); + expect(await tooltip.getTooltipText()).toBe( + 'This download can no longer open because its source was removed.' + ); + await tooltip.hide(); + + await click(artwork); + await click(title); + expect(opened).toEqual([]); + }); + + it('keeps explicit movie toolbar and menu commands local', async () => { + const card = byTestId('download-library-movie-9'); + const actions: unknown[] = []; + const toolbar = card.querySelector( + '.download-library__actions' + ) as HTMLElement; + component.itemAction.subscribe((action) => actions.push(action)); + + await click(button(toolbar, 'Play: Moonrise')); await click(button(card, 'Show in folder: Moonrise')); await clickMenuAction( card, @@ -271,7 +324,7 @@ describe('DownloadLibraryComponent', () => { it('opens grouped-series details from both real navigation buttons', async () => { const card = byTestId('download-library-series-playlist-a-77'); const opened: DownloadItem[] = []; - component.seriesOpened.subscribe((selected) => opened.push(selected)); + component.openRequested.subscribe((selected) => opened.push(selected)); const artwork = button(card, 'Open details: Northwind artwork'); const title = button(card, 'Open details: Northwind'); @@ -294,7 +347,7 @@ describe('DownloadLibraryComponent', () => { const title = button(card, 'Open details: Northwind'); const episodes = button(card, 'Open downloaded episodes: Northwind'); const opened: DownloadItem[] = []; - component.seriesOpened.subscribe((item) => opened.push(item)); + component.openRequested.subscribe((item) => opened.push(item)); expect(artwork.disabled).toBe(false); expect(title.disabled).toBe(false); @@ -305,7 +358,8 @@ describe('DownloadLibraryComponent', () => { const loader = TestbedHarnessEnvironment.loader(fixture); const tooltip = await loader.getHarness( MatTooltipHarness.with({ - selector: '.download-library__artwork-button', + selector: + '[data-test-id="download-library-series-open"].download-library__artwork-button', }) ); TestBed.inject(FocusMonitor).focusVia(artwork, 'keyboard'); @@ -339,23 +393,30 @@ describe('DownloadLibraryComponent', () => { it('keeps an invalid-series episode local without series navigation', async () => { const card = byTestId('download-library-episode-21'); const itemActions: unknown[] = []; - const seriesOpened: DownloadItem[] = []; + const opened: DownloadItem[] = []; component.itemAction.subscribe((action) => itemActions.push(action)); - component.seriesOpened.subscribe((selected) => - seriesOpened.push(selected) - ); + component.openRequested.subscribe((selected) => opened.push(selected)); expect( card.querySelector('[data-test-id="download-library-series-open"]') ).toBeNull(); - await click(button(card, 'Play: Legacy episode')); - await click(button(card, 'Show in folder: Legacy episode')); + const artwork = card.querySelector( + '.download-library__artwork-button' + ) as HTMLButtonElement; + const title = card.querySelector( + '.download-library__title-button' + ) as HTMLButtonElement; + + expect(artwork.getAttribute('aria-label')).toBe('Play: Legacy episode'); + expect(title.getAttribute('aria-label')).toBe('Play: Legacy episode'); + await click(artwork); + await click(title); expect(itemActions).toEqual([ { type: 'play', item: FALLBACK_EPISODE }, - { type: 'reveal', item: FALLBACK_EPISODE }, + { type: 'play', item: FALLBACK_EPISODE }, ]); - expect(seriesOpened).toEqual([]); + expect(opened).toEqual([]); }); it('disables every local command while the concrete item is pending', async () => { @@ -388,4 +449,17 @@ describe('DownloadLibraryComponent', () => { ); expect(placeholders).toHaveLength(1); }); + + it('uses the shared cover-grid sizing contract', () => { + const styles = readFileSync( + join(__dirname, 'download-library.component.scss'), + 'utf8' + ); + + expect(styles).toContain('var(--cover-grid-min-width, 148px)'); + expect(styles).toContain('var(--cover-gap, 16px)'); + expect(styles).not.toContain( + 'grid-template-columns: repeat(2, minmax(0, 1fr))' + ); + }); }); diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.ts b/libs/portal/downloads/feature/src/lib/download-library.component.ts index 217cf996b..6d9f67e1e 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.component.ts @@ -39,7 +39,7 @@ export class DownloadLibraryComponent { readonly availablePlaylistIds = input.required>(); readonly pendingIds = input>(new Set()); readonly itemAction = output(); - readonly seriesOpened = output(); + readonly openRequested = output(); readonly episodesOpened = output(); readonly failedArtwork = signal>(new Set()); @@ -81,8 +81,8 @@ export class DownloadLibraryComponent { return this.pendingIds().has(item.id); } - protected canOpenSeries(group: DownloadSeriesCardViewModel): boolean { - return this.availablePlaylistIds().has(group.representative.playlistId); + protected canOpen(item: DownloadItem): boolean { + return this.availablePlaylistIds().has(item.playlistId); } protected emitAction( @@ -94,9 +94,9 @@ export class DownloadLibraryComponent { } } - protected openSeries(group: DownloadSeriesCardViewModel): void { - if (this.canOpenSeries(group)) { - this.seriesOpened.emit(group.representative); + protected openDetails(item: DownloadItem): void { + if (!this.isPending(item) && this.canOpen(item)) { + this.openRequested.emit(item); } }