diff --git a/apps/web/src/_cover-size.scss b/apps/web/src/_cover-size.scss new file mode 100644 index 000000000..6a45242b0 --- /dev/null +++ b/apps/web/src/_cover-size.scss @@ -0,0 +1,32 @@ +// Cover size tokens — consumed by all cover/poster grids and rails. +// Toggled via [data-cover-size] attribute on , set from SettingsStore. +:root { + --cover-grid-min-width: 148px; + --cover-rail-width: 172px; + --cover-gap: 16px; +} + +:root[data-cover-size='small'] { + --cover-grid-min-width: 120px; + --cover-rail-width: 140px; + --cover-gap: 12px; +} + +:root[data-cover-size='medium'] { + --cover-grid-min-width: 148px; + --cover-rail-width: 172px; + --cover-gap: 16px; +} + +:root[data-cover-size='large'] { + --cover-grid-min-width: 200px; + --cover-rail-width: 220px; + --cover-gap: 20px; +} + +@media (max-width: 600px) { + :root[data-cover-size='large'] { + --cover-grid-min-width: 160px; + --cover-rail-width: 170px; + } +} diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 7ffaba87f..764a6c31f 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { Component, HostBinding, inject, OnInit } from '@angular/core'; +import { Component, effect, HostBinding, inject, OnInit } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router, RouterOutlet } from '@angular/router'; import { Actions, ofType } from '@ngrx/effects'; @@ -9,7 +9,7 @@ import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel'; import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state'; import { filter, take } from 'rxjs'; -import { DataService } from 'services'; +import { DataService, SettingsStore } from 'services'; import { AUTO_UPDATE_PLAYLISTS, Language, @@ -39,6 +39,7 @@ export class AppComponent implements OnInit { private store = inject(Store); private translate = inject(TranslateService); private settingsService = inject(SettingsService); + private settingsStore = inject(SettingsStore); private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS); /** Default language as fallback */ @@ -64,6 +65,11 @@ export class AppComponent implements OnInit { }); } } + effect(() => { + const size = this.settingsStore.coverSize?.() ?? 'medium'; + document.documentElement.dataset.coverSize = size; + }); + if (window.electron) { document.addEventListener('keydown', (event) => { if (event.ctrlKey || event.metaKey) { diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 76f79c56e..efcbeed16 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -119,6 +119,49 @@ +
+
+

{{ 'SETTINGS.COVER_SIZE' | translate }}

+

{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}

+
+
+
+ @for ( + option of coverSizeOptions; + track option.value + ) { + + } +
+
+
+

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index a37bd39f9..d98251079 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -20,6 +20,7 @@ import { Validators, } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MAT_DIALOG_DATA, @@ -57,6 +58,7 @@ import { } from 'services'; import { /* EmbeddedMpvSupport, */ + CoverSize, Language, StartupBehavior, StreamFormat, @@ -93,6 +95,12 @@ interface StartupBehaviorOption { labelKey: string; } +interface CoverSizeOption { + value: CoverSize; + icon: string; + labelKey: string; +} + type SettingsPlaylistDeleteSummary = SettingsDeleteAllPlaylistsDialogData['summary']; @@ -104,6 +112,7 @@ type SettingsPlaylistDeleteSummary = EpgSourceStatusComponent, FormsModule, MatButtonModule, + MatButtonToggleModule, MatCheckboxModule, MatDividerModule, MatIconModule, @@ -225,6 +234,24 @@ export class SettingsComponent implements OnInit, OnDestroy { }, ]; + readonly coverSizeOptions: CoverSizeOption[] = [ + { + value: 'small', + icon: 'view_module', + labelKey: 'SETTINGS.COVER_SIZE_SMALL', + }, + { + value: 'medium', + icon: 'view_comfy', + labelKey: 'SETTINGS.COVER_SIZE_MEDIUM', + }, + { + value: 'large', + icon: 'view_quilt', + labelKey: 'SETTINGS.COVER_SIZE_LARGE', + }, + ]; + readonly startupBehaviorOptions: StartupBehaviorOption[] = [ { value: StartupBehavior.FirstView, @@ -260,6 +287,7 @@ export class SettingsComponent implements OnInit, OnDestroy { Validators.pattern(/^\d+$/), ], ], + coverSize: 'medium' as CoverSize, }); /** Form array with epg sources */ @@ -534,6 +562,17 @@ export class SettingsComponent implements OnInit, OnDestroy { this.settingsService.changeTheme(theme); } + selectCoverSize(size: CoverSize): void { + if (this.settingsForm.value.coverSize === size) { + return; + } + + this.settingsForm.patchValue({ coverSize: size }); + this.settingsForm.get('coverSize')?.markAsDirty(); + this.settingsForm.markAsDirty(); + this.settingsStore.updateSettings({ coverSize: size }); + } + /** * Sets the epg urls to the form array * @param epgUrls urls of the EPG sources diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index b0fdd0aeb..b3db88055 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -264,6 +264,11 @@ "STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)", "LANGUAGE_DESCRIPTION": "Select language of the application", "THEME_DESCRIPTION": "Select visual theme of the application", + "COVER_SIZE": "Cover size", + "COVER_SIZE_DESCRIPTION": "Adjust the size of poster cards across grids and rails. Larger sizes are easier to see on TVs; smaller sizes show more content per row on big monitors.", + "COVER_SIZE_SMALL": "Small", + "COVER_SIZE_MEDIUM": "Medium", + "COVER_SIZE_LARGE": "Large", "STARTUP_BEHAVIOR": "Startup view", "STARTUP_BEHAVIOR_DESCRIPTION": "Choose whether IPTVnator opens the first available workspace view or restores your last section-level view. First view means Dashboard when enabled, otherwise Sources.", "STARTUP_BEHAVIOR_FIRST_VIEW": "First available view", diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index a70e14cd4..c55c0e60e 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -1,5 +1,6 @@ @use '../../../libs/ui/components/src/lib/resizable/resizable.scss'; @use './settings-theme'; +@use './cover-size'; // ─── Tailwind CSS ───────────────────────────────────────────────────────────── // Preflight is disabled in tailwind.config.js to avoid conflicts with diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html index d4d2812f2..4e787fa3b 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html @@ -30,7 +30,7 @@ /> } - @if (type()) { + @if (type() && showTypeBadge()) { {{ type() }} diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts index d4466e136..f1cc1d469 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts @@ -51,6 +51,9 @@ export class ContentCardComponent { /** Whether to show placeholder when no poster */ readonly showPlaceholder = input(true); + /** Whether to render the type badge (live/movie/series) on the poster */ + readonly showTypeBadge = input(true); + /** Emitted when the card is clicked */ readonly cardClick = output(); diff --git a/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss index 76123f01a..c419cf714 100644 --- a/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss +++ b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss @@ -1,6 +1,6 @@ :host { display: block; - --rail-gap: 16px; + --rail-gap: var(--cover-gap, 16px); } .rail { diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html index 8b2901cc3..3abdc71c3 100644 --- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html +++ b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html @@ -55,6 +55,7 @@ 'Unknown' " [type]="item.type" + [showTypeBadge]="false" [date]="item.added_at" [showRemoveButton]="true" [removeTooltip]=" diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.scss b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.scss index 64dc76ee5..232c9e901 100644 --- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.scss +++ b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.scss @@ -81,7 +81,7 @@ } .items-grid { - @include grid.content-grid(140px, 20px); + @include grid.content-grid; padding: 16px; width: 100%; box-sizing: border-box; diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss index 86f4f4fc0..98fa26fa9 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss @@ -16,7 +16,14 @@ mat-card:hover { } // ─── Grid layout ───────────────────────────────────────────────────────────── -.grid { +.grid-list__grid { + display: grid; + grid-template-columns: repeat( + auto-fill, + minmax(var(--cover-grid-min-width, 148px), 1fr) + ); + gap: var(--cover-gap, 16px); + mat-card { cursor: pointer; width: 100%; diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts index 58e635af9..44b0fff05 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts @@ -56,9 +56,7 @@ export function resolveGridRating( @Component({ selector: 'app-grid-list', - template: `
+ template: `
@if (isLoading()) { @for (row of skeletonRows(); track row) {