Files
iptvnator/apps/web/src/app/settings/settings-form.utils.ts
T
4grayandClaude Fable 5.1 ef3f98d026 feat(portals): posters-only cover wall for movie and series grids (#1604)
* feat(portals): posters-only cover wall for movie and series grids

Add `Settings.showCoverTitles` (Settings > General, default on). Turning it
off drops the title row under VOD/series covers in catalog, favorites and
recent grids and reveals the title as a bottom-gradient overlay on hover and
keyboard focus, pinned open for items whose cover is missing or failed.

`CoverTitlesService` is the single resolver: the opt-out AND a hover-capable
pointer, so touch-only devices keep their titles. Live channel grids, search
results, "recently added" rails and dashboard rails always keep labels.

Catalog and collection cards become keyboard buttons (role, tabindex,
aria-label, Enter/Space, focus ring) and poster alt text is the title. The
default-on boolean coercion moves into `settings-opt-out.util.ts` because
the settings store reached the max-lines limit.

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

* fix(portals): keep nested Remove key presses from activating the card

Enter/Space on the content card's nested Remove button bubbled into the
card's own key handlers: Enter opened the item before removing it and
Space opened it while cancelling the removal. Only keys pressed on the
card element itself now activate it. Regression spec added.

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

* fix(portals): keep cover titles while an in-section search filters the grid

The posters-only wall exempts search results because they are identified
by the name the user typed; the category grid's own in-section filter is
the same case, so `app-grid-list` now keeps the title row while its
`searchTerm` is non-blank. Contract docs updated, regression spec added.

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

* fix(portals): keep cover titles while the collection tab search is active

The unified favorites/recent tab filters by its own search term, so its
matches are identified by name like every other search result. The tab
now opts its cards out of the posters-only wall while the term is
non-blank. Contract docs updated, regression spec added.

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

* fix(portals): move the card Remove control out of the button surface

An interactive control nested inside a role="button" is an invalid
accessibility structure. The content card's activation surface is now its
own inner element and the Remove button a sibling positioned over the
poster corner, labelled by its tooltip text. Spec asserts the control is
never a descendant of the button.

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

* fix(portals): draw the collection card focus ring where it is not clipped

The card's overflow: hidden clipped an outline drawn on the inner
activation surface on every edge, so keyboard users saw no focus
indication. The ring now sits on the outer card via
:has(> .content-card__activation:focus-visible).

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

* fix(portals): detect any hover-capable pointer for the posters-only wall

`hover` describes only the primary pointer, so a touch-first tablet with
a mouse or hover-capable stylus attached lost the wall. The resolver now
reads `(any-hover: hover)`.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 18:34:21 +02:00

234 lines
8.8 KiB
TypeScript

import {
AbstractControl,
FormArray,
FormBuilder,
FormControl,
ValidationErrors,
Validators,
} from '@angular/forms';
import {
CoverSize,
DEFAULT_DASHBOARD_RAILS_SETTINGS,
DEFAULT_TMDB_SETTINGS,
EpgViewMode,
Language,
normalizeDashboardRailsSettings,
normalizeEmbeddedMpvExtraOptions,
normalizeExternalPlayerArguments,
normalizeEpgOffsetMinutes,
normalizeStartupWindowMode,
Settings,
StartupBehavior,
StartupWindowMode,
StreamFormat,
Theme,
validateEmbeddedMpvExtraOptions,
validateEpgSourceReferenceControl,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
/**
* Rejects malformed lines and the option keys the embed depends on, so a
* value that the main process would have to drop never gets saved.
*/
export function embeddedMpvExtraOptionsValidator(
control: AbstractControl
): ValidationErrors | null {
// Only the embedded MPV player consumes these lines. While another player
// is selected the field is hidden, and a stale error must not keep the
// whole settings form unsaveable.
if (control.parent?.get('player')?.value !== VideoPlayer.EmbeddedMpv) {
return null;
}
return validateEmbeddedMpvExtraOptions(control.value);
}
/**
* An EPG source is a remote XMLTV link or a local file (absolute path or
* `file:` URL); the accepted shapes live in `@iptvnator/shared/interfaces`
* so the playlist dialog validates identically.
*/
export function createEpgUrlControl(value = ''): FormControl<string | null> {
return new FormControl(value, [validateEpgSourceReferenceControl]);
}
export function createSettingsForm(
formBuilder: FormBuilder,
supportsEpg: boolean
) {
const form = formBuilder.group({
player: [VideoPlayer.VideoJs],
webPlayerSharedControls: true,
playerAmbientMode: false,
playerUpNextRail: true,
fullscreenChannelPanel: true,
vodAutoFailover: false,
m3uVodDetails: true,
...(supportsEpg
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
: {}),
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
dashboardRails: formBuilder.group({
hero: DEFAULT_DASHBOARD_RAILS_SETTINGS.hero,
continueWatching: DEFAULT_DASHBOARD_RAILS_SETTINGS.continueWatching,
liveFavorites: DEFAULT_DASHBOARD_RAILS_SETTINGS.liveFavorites,
recentlyWatchedLive:
DEFAULT_DASHBOARD_RAILS_SETTINGS.recentlyWatchedLive,
favoriteMoviesAndSeries:
DEFAULT_DASHBOARD_RAILS_SETTINGS.favoriteMoviesAndSeries,
recentSources: DEFAULT_DASHBOARD_RAILS_SETTINGS.recentSources,
xtreamRecentlyAdded:
DEFAULT_DASHBOARD_RAILS_SETTINGS.xtreamRecentlyAdded,
tmdbTrending: DEFAULT_DASHBOARD_RAILS_SETTINGS.tmdbTrending,
tmdbRecommendations:
DEFAULT_DASHBOARD_RAILS_SETTINGS.tmdbRecommendations,
}),
startupBehavior: StartupBehavior.FirstView,
startupWindowMode: 'normal' as StartupWindowMode,
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
vlcPlayerArguments: '',
vlcReuseInstance: false,
remoteControl: false,
remoteControlPort: [
8765,
[
Validators.required,
Validators.min(1),
Validators.max(65535),
Validators.pattern(/^\d+$/),
],
],
recordingFolder: '',
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: ['', [embeddedMpvExtraOptionsValidator]],
embeddedMpvAutoReconnect: true,
portalConnectivityGuard: true,
coverSize: 'medium' as CoverSize,
showCoverTitles: true,
...(supportsEpg
? {
preferUploadedEpgOverXtream: false,
epgViewMode: 'timeline' as EpgViewMode,
epgOffsetMinutes: new FormControl<number>(0, [
Validators.required,
Validators.min(-720),
Validators.max(720),
Validators.pattern(/^-?\d+$/),
]),
}
: {}),
tmdb: formBuilder.group({
enabled: DEFAULT_TMDB_SETTINGS.enabled,
apiKey: DEFAULT_TMDB_SETTINGS.apiKey ?? '',
}),
});
// The extra-options validator reads the sibling `player` control, which
// Angular does not track as a dependency: re-validate on player changes
// so a hidden field's stale error cannot block Save.
form.get('player')?.valueChanges.subscribe(() =>
form.get('embeddedMpvExtraOptions')?.updateValueAndValidity()
);
return form;
}
export type SettingsForm = ReturnType<typeof createSettingsForm>;
export function applyEpgUrlsToFormArray(
epgUrl: FormArray,
epgUrls: string[] | string
): void {
const urls = Array.isArray(epgUrls) ? epgUrls : [epgUrls];
const filteredUrls = urls
.map((url) => url.trim())
.filter((url) => url !== '');
filteredUrls.forEach((url) => {
epgUrl.push(createEpgUrlControl(url));
});
}
export function createSettingsFromFormValue(
settingsForm: SettingsForm,
currentSettings: Settings
): Settings {
const value = settingsForm.getRawValue();
const epgUrl = Array.isArray(value.epgUrl)
? value.epgUrl.filter((url): url is string => typeof url === 'string')
: (currentSettings.epgUrl ?? []);
return {
player: value.player ?? VideoPlayer.VideoJs,
webPlayerSharedControls: value.webPlayerSharedControls ?? true,
playerAmbientMode: value.playerAmbientMode ?? false,
playerUpNextRail: value.playerUpNextRail ?? true,
fullscreenChannelPanel: value.fullscreenChannelPanel ?? true,
vodAutoFailover: value.vodAutoFailover ?? false,
m3uVodDetails: value.m3uVodDetails ?? true,
streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false,
language: value.language ?? Language.ENGLISH,
showCaptions: value.showCaptions ?? false,
showDashboard: value.showDashboard ?? true,
dashboardRails: normalizeDashboardRailsSettings(value.dashboardRails),
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
startupWindowMode: normalizeStartupWindowMode(value.startupWindowMode),
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
stripCountryPrefix: value.stripCountryPrefix ?? false,
theme: value.theme ?? Theme.SystemTheme,
mpvPlayerPath: normalizeExternalPlayerPath(value.mpvPlayerPath),
mpvPlayerArguments: normalizeExternalPlayerArguments(
value.mpvPlayerArguments
),
mpvReuseInstance: value.mpvReuseInstance ?? false,
vlcPlayerPath: normalizeExternalPlayerPath(value.vlcPlayerPath),
vlcPlayerArguments: normalizeExternalPlayerArguments(
value.vlcPlayerArguments
),
vlcReuseInstance: value.vlcReuseInstance ?? false,
remoteControl: value.remoteControl ?? false,
remoteControlPort: Number(value.remoteControlPort ?? 8765),
recordingFolder: value.recordingFolder ?? '',
embeddedMpvFrameCopy: value.embeddedMpvFrameCopy ?? false,
embeddedMpvExtraOptions: normalizeEmbeddedMpvExtraOptions(
value.embeddedMpvExtraOptions
),
embeddedMpvAutoReconnect: value.embeddedMpvAutoReconnect ?? true,
portalConnectivityGuard: value.portalConnectivityGuard !== false,
coverSize: value.coverSize ?? 'medium',
showCoverTitles: value.showCoverTitles !== false,
epgUrl,
preferUploadedEpgOverXtream:
value.preferUploadedEpgOverXtream ??
currentSettings.preferUploadedEpgOverXtream ??
false,
epgViewMode:
value.epgViewMode ?? currentSettings.epgViewMode ?? 'timeline',
epgOffsetMinutes: normalizeEpgOffsetMinutes(
value.epgOffsetMinutes ?? currentSettings.epgOffsetMinutes
),
trustedPrivateNetworkEpgUrls:
currentSettings.trustedPrivateNetworkEpgUrls ?? [],
trustedInsecureTlsHosts: currentSettings.trustedInsecureTlsHosts ?? [],
tmdb: {
enabled: value.tmdb?.enabled ?? DEFAULT_TMDB_SETTINGS.enabled,
apiKey: value.tmdb?.apiKey?.trim() ?? '',
},
};
}
function normalizeExternalPlayerPath(
playerPath: string | null | undefined
): string {
return playerPath?.trim() ?? '';
}