fix(portals): keep provider playback for a downloaded movie

Once a movie is downloaded the primary button plays the local file, and
the sources popover only exists when another playlist carries the same
film. The icon-only rework left those as the only two paths, so a
downloaded movie in a single-playlist library had no way at all to stream
the provider's copy — the labeled action that used to do it was gone.

Restores it as an icon button beside the downloaded checkmark, under the
same condition the old one used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-02 21:25:20 +02:00
1 parent 63ce638458
commit ea505a6a9a
4 files changed
+101

No files matched your search

@@ -624,6 +624,36 @@ describe('VodDetailsRouteComponent fallback actions', () => {
expect(openResolvedPlayback).not.toHaveBeenCalled();
});
it('still offers provider playback for a downloaded movie with no alternatives', async () => {
// The primary button plays the local file once downloaded, and the
// sources chip only appears when another playlist carries the film —
// so without this control a single-playlist library loses every way
// to stream the provider's copy.
selectedItem.set(sparseItem());
downloadsAvailable.set(true);
isDownloaded.mockReturnValue(true);
const playPinned = jest
.spyOn(fixture.componentInstance.multiSource, 'playPinnedSource')
.mockResolvedValue('played');
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(fixture.componentInstance.multiSource.hasAlternatives()).toBe(
false
);
const providerButton = host.querySelector<HTMLButtonElement>(
'[data-testid="vod-play-provider"]'
);
expect(providerButton).not.toBeNull();
providerButton?.click();
await fixture.whenStable();
expect(playPinned).toHaveBeenCalled();
expect(playDownload).not.toHaveBeenCalled();
});
it('reveals the finished file from the downloaded state button', async () => {
selectedItem.set(sparseItem());
downloadsAvailable.set(true);
@@ -109,6 +109,29 @@
>
<mat-icon>check_circle</mat-icon>
</button>
<!-- The primary button plays the local file once a movie
is downloaded, and the sources popover only exists
when another playlist also carries the film. Without
this, a downloaded movie in a single-playlist library
offers no way to stream the provider's copy at all. -->
@if (externalPrimaryButtonState() === 'idle') {
<button
type="button"
class="icon-action-btn"
(click)="playFromProviderSource(playableItem)"
[matTooltip]="
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
| translate
"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
| translate
"
data-testid="vod-play-provider"
>
<mat-icon>play_arrow</mat-icon>
</button>
}
} @else if (isDownloading()) {
<!-- Covers downloading, queued and paused: the ring holds
the real percent, spins when the size is unknown, and
@@ -405,6 +405,35 @@ describe('VodMultiSourceHostService', () => {
await Promise.all(pending);
});
it('drops queued checks once the user opens another movie', async () => {
const alts = [2, 3, 4, 5, 6, 7].map(alternative);
await loadMovie(alts);
const gates = new Map<string, ReturnType<typeof createDeferred>>();
resolver.resolve.mockImplementation((candidate) => {
const gate = createDeferred<null>();
gates.set((candidate as VodSourceCandidate).id, gate);
return gate.promise;
});
const pending = alts.map((alt) => service.check(alt.id));
await new Promise((resolve) => setTimeout(resolve));
// Four in flight, two still waiting for a slot.
expect(resolver.resolve).toHaveBeenCalledTimes(4);
// The user navigates away while those two are still queued. They are
// about a film that is no longer on screen, so they must not spend a
// resolve against a foreign portal when a slot frees up.
await loadMovie([], MOVIE_B);
for (const [, gate] of gates) {
gate.resolve(null);
}
await Promise.all(pending);
expect(resolver.resolve).toHaveBeenCalledTimes(4);
});
it('remembers verdicts across sessions instead of re-checking', async () => {
probes.probe.mockResolvedValue({
...PROBE_OK,
@@ -411,6 +411,25 @@ export class VodMultiSourceHostService {
this.controller.setProbe(sourceId, { status: 'probing' });
this.publish();
// Bound to the session that ASKED for the check, not to whatever is
// current when a slot frees up. "Check all" can leave a dozen tasks
// waiting, and the user is free to open another movie meanwhile —
// those tasks are then about a film that is no longer on screen, so
// they are dropped before spending a resolve on a foreign portal.
// Both the controller and the session are read when a slot frees up,
// NOT captured here — and that is deliberate.
//
// Capturing them would look safer and is worse: `sessionToken` also
// moves when the movie identity is momentarily null, and that path
// does NOT replace the controller (only a different film does). A
// task dropped on a stale captured token would therefore abandon a
// row it had already marked `probing` in the controller still on
// screen, leaving it spinning for good.
//
// Read late, a queued task simply asks the CURRENT controller for the
// source: gone when the user moved to another film, so `probeSource`
// returns before it spends a request, and present when they came back
// to the same one, where finishing the check is exactly right.
return this.checkQueue(() =>
probeSource(sourceId, {
controller: this.controller,