feat(playback): theater stage and opt-in ambient fill for the inline portal player (#1223)

* fix(tmdb): purge obsolete search cache rows

* feat(playback): theater stage and opt-in ambient fill for the inline portal player

On wide-short windows the VOD/series inline player left a strip of app
surface next to the video: with `width: auto`, the viewport's `max-height`
transferred through `aspect-ratio` into a max-width (CSS transferred size
constraints), re-clamping the stage to 16:9 and leaving the leftover
outside it.

- Theater stage: give `.player-shell__viewport` a definite `width: 100%`
  so it always fills the content row; the player renders as the largest
  16:9 box that fits the stage height, centered — the leftover is always
  the stage's black background, never app surface (YouTube-style
  letterbox). Applies to every inline engine.
- Ambient fill: new `playerAmbientMode` setting (default off, Settings >
  Playback, web players only) renders a blurred, dimmed copy of the
  poster behind the player, filling the letterbox margins. Enforced at
  runtime too: Embedded MPV never gets the extra DOM layer. Live channels
  and non-http(s) poster URLs are excluded.

Verified live via CDP at 1720x760 (stage 1362x532, player 946x532 with
symmetric 208px margins) and 1280x950 (stage exactly 16:9, no bars).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(i18n): add ambient-mode setting keys to all remaining locales

The i18n drift gate requires SETTINGS.PLAYER_AMBIENT_MODE and its
description in every locale; the feature commit only covered en and ru.
Translated via the i18n-fill workflow (per-locale patch + mechanical
merge, glossary-matched against each existing file).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(settings): include playerAmbientMode in expected default settings

settings.component.spec asserts the persisted settings object with
toEqual; the new default-off field has to be part of the fixture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.8 authored and GitHub committed 2026-07-24 08:02:11 +02:00
1 parent b3e130aa65
commit db70b07093
34 files changed
+426 -23

No files matched your search

@@ -6,6 +6,7 @@ const {
createTableStatements,
indexMigrationStatements,
runMigrations,
cleanupLegacyTmdbSearchCache,
} = __databaseConnectionTestHooks;
type SqliteHandle = Parameters<typeof runMigrations>[0];
@@ -22,10 +23,7 @@ type StatementHandler = {
type HandlerRule = [pattern: string, handler: StatementHandler];
function createSqliteMock(
rules: HandlerRule[],
exec: jest.Mock = jest.fn()
) {
function createSqliteMock(rules: HandlerRule[], exec: jest.Mock = jest.fn()) {
const prepare = jest.fn((statement: string) => {
const compact = compactSql(statement);
const rule = rules.find(([pattern]) => compact.includes(pattern));
@@ -129,6 +127,45 @@ describe('runMigrations error tolerance', () => {
});
});
describe('TMDB search lookup v2 cache cleanup', () => {
it('deletes legacy search rows and records the migration atomically', () => {
const deleteRun = jest.fn();
const markerRun = jest.fn();
const { sqlite, transaction } = createSqliteMock([
['SELECT value FROM app_state', { get: () => undefined }],
['DELETE FROM tmdb_metadata', { run: deleteRun }],
['INSERT INTO app_state', { run: markerRun }],
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).toHaveBeenCalledTimes(1);
expect(deleteRun).toHaveBeenCalledTimes(1);
expect(markerRun).toHaveBeenCalledWith(
'migration:tmdb-search-lookup-v2-cache-cleanup:v1'
);
const deleteSql = compactSql(
(sqlite.prepare as jest.Mock).mock.calls.find(([statement]) =>
statement.includes('DELETE FROM tmdb_metadata')
)?.[0]
);
expect(deleteSql).toContain(
"lookup_key LIKE 'title:%|year:%' AND lookup_key NOT LIKE 'title:%|year:%|v%'"
);
});
it('does nothing after the migration has completed', () => {
const { sqlite, prepare, transaction } = createSqliteMock([
completedMigrationStateRule,
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).not.toHaveBeenCalled();
expect(prepare).toHaveBeenCalledTimes(1);
});
});
describe('runMigrations Xtream cache deduplication', () => {
it('re-points content to the canonical duplicate category before deleting the rest', () => {
const candidatesAll = jest.fn(() => [
@@ -141,7 +178,11 @@ describe('runMigrations Xtream cache deduplication', () => {
completedMigrationStateRule,
[
'FROM categories GROUP BY playlist_id, type, xtream_id',
{ all: () => [{ playlistId: 'p1', type: 'live', xtreamId: 5 }] },
{
all: () => [
{ playlistId: 'p1', type: 'live', xtreamId: 5 },
],
},
],
['LEFT JOIN content', { all: candidatesAll }],
[
@@ -149,7 +190,10 @@ describe('runMigrations Xtream cache deduplication', () => {
{ run: updateContentRun },
],
['DELETE FROM categories WHERE id = ?', { run: deleteCategoryRun }],
['FROM content GROUP BY category_id, type, xtream_id', { all: () => [] }],
[
'FROM content GROUP BY category_id, type, xtream_id',
{ all: () => [] },
],
]);
runMigrations(sqlite);
@@ -30,6 +30,8 @@ const CONTENT_TITLE_FTS_MIGRATION_KEY =
'migration:content-title-fts-trigram:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY =
'migration:epg-program-source-url-backfill:v1';
const TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000;
function readTraceFlag(name: string): boolean {
@@ -373,6 +375,7 @@ export const __databaseConnectionTestHooks = {
normalizeXtreamContentAddedEpochs,
ensureContentTitleFts,
backfillEpgProgramSourceUrls,
cleanupLegacyTmdbSearchCache,
runMigrations,
} as const;
@@ -768,11 +771,61 @@ function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void {
}
}
/**
* Search-match cache keys gained a v2 suffix when title normalization changed.
* Remove the now-unreachable unversioned rows once rather than leaving negative
* resolutions and other legacy search matches in long-lived installations.
*/
function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
try {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as
| { value?: unknown }
| undefined;
if (migrationState?.value === 'done') {
return;
}
const executeCleanup = sqliteDb.transaction(() => {
sqliteDb
.prepare(
`DELETE FROM tmdb_metadata
WHERE lookup_key LIKE 'title:%|year:%'
AND lookup_key NOT LIKE 'title:%|year:%|v%'`
)
.run();
sqliteDb
.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
)
.run(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY);
});
executeCleanup();
} catch (error) {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: unknown }).message ?? error)
: String(error);
console.warn(
`Legacy TMDB search cache cleanup failed (continuing): ${message}`
);
}
}
/**
* Run migrations that may fail if already applied
*/
function runMigrations(sqliteDb: Database.Database): void {
widenTmdbMetadataMediaTypeCheck(sqliteDb);
cleanupLegacyTmdbSearchCache(sqliteDb);
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
ensureContentTitleFts(sqliteDb);
deduplicateXtreamCache(sqliteDb);