mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
feat(settings): add cover size settings and implement responsive cover sizing
Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
1 parent
cbedb22698
commit
4b2076fa53
25 files changed
+185
-35
No files matched your search
@@ -0,0 +1,32 @@
|
||||
// Cover size tokens — consumed by all cover/poster grids and rails.
|
||||
// Toggled via [data-cover-size] attribute on <html>, 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -119,6 +119,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.COVER_SIZE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.COVER_SIZE' | translate"
|
||||
data-test-id="select-cover-size"
|
||||
>
|
||||
@for (
|
||||
option of coverSizeOptions;
|
||||
track option.value
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
settingsForm.value.coverSize ===
|
||||
option.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
settingsForm.value.coverSize ===
|
||||
option.value
|
||||
"
|
||||
[attr.data-test-id]="
|
||||
'cover-size-' + option.value
|
||||
"
|
||||
role="radio"
|
||||
(click)="selectCoverSize(option.value)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{
|
||||
option.labelKey | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
/>
|
||||
}
|
||||
|
||||
@if (type()) {
|
||||
@if (type() && showTypeBadge()) {
|
||||
<span class="type-badge" [class]="type()">
|
||||
{{ type() }}
|
||||
</span>
|
||||
|
||||
@@ -51,6 +51,9 @@ export class ContentCardComponent {
|
||||
/** Whether to show placeholder when no poster */
|
||||
readonly showPlaceholder = input<boolean>(true);
|
||||
|
||||
/** Whether to render the type badge (live/movie/series) on the poster */
|
||||
readonly showTypeBadge = input<boolean>(true);
|
||||
|
||||
/** Emitted when the card is clicked */
|
||||
readonly cardClick = output<void>();
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
--rail-gap: 16px;
|
||||
--rail-gap: var(--cover-gap, 16px);
|
||||
}
|
||||
|
||||
.rail {
|
||||
|
||||
+1
@@ -55,6 +55,7 @@
|
||||
'Unknown'
|
||||
"
|
||||
[type]="item.type"
|
||||
[showTypeBadge]="false"
|
||||
[date]="item.added_at"
|
||||
[showRemoveButton]="true"
|
||||
[removeTooltip]="
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
@include grid.content-grid(140px, 20px);
|
||||
@include grid.content-grid;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -56,9 +56,7 @@ export function resolveGridRating(
|
||||
|
||||
@Component({
|
||||
selector: 'app-grid-list',
|
||||
template: `<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-4"
|
||||
>
|
||||
template: `<div class="grid-list__grid">
|
||||
@if (isLoading()) {
|
||||
@for (row of skeletonRows(); track row) {
|
||||
<div class="grid-skeleton-card" aria-hidden="true">
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
|
||||
// Results grid mixin for consumers
|
||||
::ng-deep .results-grid {
|
||||
@include grid.content-grid(140px, 20px);
|
||||
@include grid.content-grid;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -16,8 +16,11 @@
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(var(--cover-grid-min-width, 148px), 1fr)
|
||||
);
|
||||
gap: var(--cover-gap, 16px);
|
||||
padding: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
[posterUrl]="item.posterUrl ?? ''"
|
||||
[title]="item.name"
|
||||
[type]="item.contentType"
|
||||
[showTypeBadge]="false"
|
||||
[date]="item.addedAt ?? item.viewedAt"
|
||||
[showRemoveButton]="true"
|
||||
[removeTooltip]="
|
||||
|
||||
+5
-2
@@ -37,8 +37,11 @@
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(var(--cover-grid-min-width, 148px), 1fr)
|
||||
);
|
||||
gap: var(--cover-gap, 16px);
|
||||
padding: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
@include grid.content-grid(140px, 20px);
|
||||
@include grid.content-grid;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
@@ -27,8 +27,8 @@
|
||||
// Projected card sizing lives on the caller — the rail shell can't reach
|
||||
// projected children through emulated encapsulation.
|
||||
.recently-added-page__rail app-content-card {
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
flex: 0 0 var(--cover-rail-width, 160px);
|
||||
width: var(--cover-rail-width, 160px);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
|
||||
.recently-added-page__skeleton-card {
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
flex: 0 0 var(--cover-rail-width, 160px);
|
||||
width: var(--cover-rail-width, 160px);
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 12px;
|
||||
}
|
||||
@@ -100,7 +100,7 @@
|
||||
}
|
||||
|
||||
.recently-added-page__skeleton-card {
|
||||
flex: 0 0 136px;
|
||||
width: 136px;
|
||||
flex: 0 0 min(var(--cover-rail-width, 160px), 136px);
|
||||
width: min(var(--cover-rail-width, 160px), 136px);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
// Results grid styling
|
||||
.results-grid {
|
||||
@include grid.content-grid(140px, 20px);
|
||||
@include grid.content-grid;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
remoteControlPort: 8765,
|
||||
epgUrl: [],
|
||||
downloadFolder: '',
|
||||
coverSize: 'medium',
|
||||
};
|
||||
|
||||
export const SettingsStore = signalStore(
|
||||
@@ -88,6 +89,7 @@ export const SettingsStore = signalStore(
|
||||
remoteControlPort: store.remoteControlPort(),
|
||||
epgUrl: store.epgUrl(),
|
||||
downloadFolder: store.downloadFolder!(),
|
||||
coverSize: store.coverSize!(),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export enum StartupBehavior {
|
||||
RestoreLastView = 'restore-last-view',
|
||||
}
|
||||
|
||||
export type CoverSize = 'small' | 'medium' | 'large';
|
||||
|
||||
/**
|
||||
* Describes all available settings options of the application
|
||||
*/
|
||||
@@ -39,4 +41,6 @@ export interface Settings {
|
||||
remoteControlPort: number;
|
||||
/** Custom download folder path (uses system Downloads folder if not set) */
|
||||
downloadFolder?: string;
|
||||
/** Cover/poster sizing preset applied across grids and rails */
|
||||
coverSize?: CoverSize;
|
||||
}
|
||||
@@ -5,9 +5,12 @@
|
||||
// @include grid.content-grid;
|
||||
// @include grid.content-card;
|
||||
|
||||
@mixin content-grid($min-width: 140px, $gap: 20px) {
|
||||
@mixin content-grid(
|
||||
$min-width: var(--cover-grid-min-width, 148px),
|
||||
$gap: var(--cover-gap, 16px)
|
||||
) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax($min-width, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(#{$min-width}, 1fr));
|
||||
gap: $gap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
--rail-card-width: 172px;
|
||||
--rail-gap: 14px;
|
||||
--rail-card-width: var(--cover-rail-width, 172px);
|
||||
--rail-gap: var(--cover-gap, 14px);
|
||||
--rail-radius: 12px;
|
||||
}
|
||||
|
||||
@@ -444,6 +444,6 @@
|
||||
|
||||
@media (max-width: 720px) {
|
||||
:host {
|
||||
--rail-card-width: 140px;
|
||||
--rail-card-width: min(var(--cover-rail-width, 172px), 140px);
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -417,18 +417,17 @@
|
||||
|
||||
.rails-page__skeleton-track {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
gap: var(--cover-gap, 14px);
|
||||
padding: 4px 0 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-card {
|
||||
@extend %skeleton-shimmer;
|
||||
// Matches the real .rail__card width (--rail-card-width: 172px) and
|
||||
// .rail__art aspect-ratio 2/3, so when cards arrive the geometry is
|
||||
// identical and there's no layout shift.
|
||||
flex: 0 0 172px;
|
||||
width: 172px;
|
||||
// Mirrors the real .rail__card width (--cover-rail-width) so geometry
|
||||
// matches when cards arrive — no layout shift.
|
||||
flex: 0 0 var(--cover-rail-width, 172px);
|
||||
width: var(--cover-rail-width, 172px);
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 12px;
|
||||
}
|
||||
@@ -474,8 +473,8 @@
|
||||
}
|
||||
|
||||
.rails-page__skeleton-card {
|
||||
flex: 0 0 140px;
|
||||
width: 140px;
|
||||
flex: 0 0 min(var(--cover-rail-width, 172px), 140px);
|
||||
width: min(var(--cover-rail-width, 172px), 140px);
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
|
||||
Reference in new issue
Block a user