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);
}
}