fix(downloads): open completed movies in details

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
1 parent 5ef5d6713f
commit 4f3567e1d0
4 files changed
+173 -40

No files matched your search

@@ -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) {
<img
@@ -84,11 +89,16 @@
type="button"
class="download-library__title-button"
data-test-id="download-library-series-open"
[disabled]="
isPending(entity.representative)
"
[class.download-library__title-button--disabled]="
!canOpenSeries(entity)
!canOpen(entity.representative)
"
[attr.aria-disabled]="
!canOpenSeries(entity) ? 'true' : null
!canOpen(entity.representative)
? 'true'
: null
"
[attr.aria-label]="
('DOWNLOADS.OPEN_DETAILS' | translate) +
@@ -99,8 +109,10 @@
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
| translate
"
[matTooltipDisabled]="canOpenSeries(entity)"
(click)="openSeries(entity)"
[matTooltipDisabled]="
canOpen(entity.representative)
"
(click)="openDetails(entity.representative)"
>
{{ entity.title }}
</button>
@@ -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) {
<img
@@ -236,12 +274,37 @@
type="button"
class="download-library__title-button"
[disabled]="isPending(entity.item)"
[class.download-library__title-button--disabled]="
entity.kind === 'movie' &&
!canOpen(entity.item)
"
[attr.aria-disabled]="
entity.kind === 'movie' &&
!canOpen(entity.item)
? 'true'
: null
"
[attr.aria-label]="
('DOWNLOADS.PLAY' | translate) +
((entity.kind === 'movie'
? 'DOWNLOADS.OPEN_DETAILS'
: 'DOWNLOADS.PLAY'
) | translate) +
': ' +
entity.item.title
"
(click)="emitAction('play', entity.item)"
[matTooltip]="
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
| translate
"
[matTooltipDisabled]="
entity.kind !== 'movie' ||
canOpen(entity.item)
"
(click)="
entity.kind === 'movie'
? openDetails(entity.item)
: emitAction('play', entity.item)
"
>
{{ entity.item.title }}
</button>
@@ -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;
}
@@ -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))'
);
});
});
@@ -39,7 +39,7 @@ export class DownloadLibraryComponent {
readonly availablePlaylistIds = input.required<ReadonlySet<string>>();
readonly pendingIds = input<ReadonlySet<number>>(new Set());
readonly itemAction = output<DownloadItemAction>();
readonly seriesOpened = output<DownloadItem>();
readonly openRequested = output<DownloadItem>();
readonly episodesOpened = output<DownloadSeriesCardViewModel>();
readonly failedArtwork = signal<ReadonlySet<string>>(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);
}
}