mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
* 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>
234 lines
8.8 KiB
TypeScript
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() ?? '';
|
|
}
|