feat(portals): rework the VOD sources popup and action row (#1359)

* feat(portals): rework the VOD sources popup and action row

The Sources popup now anchors above its button and always fits on screen:
the overlay caps it to the space beside the chip, only the source list
scrolls so the header, search, filter chips and footer stay visible, and
it flips below the button when the space above is too small. Filter chips
(All / Available / HD+ / language) compose with the playlist search, and
"Available" runs check-all itself when nothing has been checked yet.

Expanded copy rows no longer repeat the playlist domain: each copy shows
its parsed language chip, the provider's raw stream title, and only the
tags that differ from the parent copy.

Availability checks run at most four at a time and settled verdicts are
remembered per movie and source for ten minutes, so reopening a movie no
longer re-contacts every foreign portal.

Favorites and Download become icon-only buttons with real state: a filled
heart when favorited, and a download icon that turns into a progress ring
and then a checkmark that reveals the finished file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(portals): match the movie detail's icon-only favorite button

The Xtream movie detail's favorite control is now an icon-only button, so
the shared "add this detail to favorites" helpers no longer found it by
class. They are used against series and Stalker details too, which still
render the labeled variant, so they now select by accessible name — the
icon button carries the same label in aria-label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-02 22:27:24 +02:00
1 parent 83f6a270a5
commit 0010dd7351
66 files changed
+2583 -347

No files matched your search

@@ -254,13 +254,18 @@ async function goBackFromDetail(page: Page): Promise<void> {
}
}
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page.locator('button.favorite-btn').first();
const addButton = page
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.locator('button.favorite-btn--active').first()
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({
timeout: 20000,
});
@@ -576,13 +576,18 @@ const xtreamCredentials = {
password: defaultXtreamPassword,
};
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page.locator('button.favorite-btn').first();
const addButton = page
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.locator('button.favorite-btn--active').first()
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({
timeout: 20000,
});
+5 -6
View File
@@ -1524,19 +1524,18 @@ async function expectXtreamRootCatalogSearch(
);
}
// By accessible name, not class plus visible text: the Xtream movie detail's
// favorite control is an icon-only button whose label lives in aria-label,
// while series and Stalker details still use the labeled variant.
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.locator('button.favorite-btn')
.filter({ hasText: /Add to favorites/i })
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page
.locator('button.favorite-btn.favorite-btn--active')
.filter({ hasText: /Remove from favorites/i })
.first()
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({ timeout: 20000 });
}
@@ -65,8 +65,9 @@ test.describe('VOD multi-source', () => {
// Discovery is lazy, so the chip appears after the detail view.
const chip = app.mainWindow.locator('app-vod-sources-chip button');
await expect(chip).toBeVisible({ timeout: 20000 });
// Exactly one alternative: the same film in the other playlist.
await expect(chip).toContainText('1');
// The badge counts every copy across all playlists — the route's
// own plus the same film in the other portal.
await expect(chip).toContainText('2');
await chip.click();
@@ -93,11 +93,16 @@ test.describe('Xtream VOD Details', () => {
.locator('button.play-btn')
.first();
await expect(playButton).toBeVisible();
// Both secondaries are icon-only on the movie detail now.
await expect(
app.mainWindow.locator('button.favorite-btn').first()
app.mainWindow
.locator('[data-testid="vod-favorite-toggle"]')
.first()
).toBeVisible();
await expect(
app.mainWindow.locator('button.download-btn').first()
app.mainWindow
.locator('[data-testid="vod-download-start"]')
.first()
).toBeVisible();
const movieResponsePromise = app.mainWindow.waitForResponse(