mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
style(settings): update look&feel, add missing i18n strings
This commit is contained in:
1 parent
bf7adcd7b4
commit
0012fcfa7b
43 files changed
+1334
-521
No files matched your search
@@ -43,8 +43,10 @@ test.describe('Settings', () => {
|
||||
test('Change app theme', async ({ page }) => {
|
||||
await page.getByTestId('pwa-menu').click();
|
||||
await page.getByTestId('pwa-open-settings').click();
|
||||
await expect(page.locator('text="Light theme"')).toBeVisible();
|
||||
await page.getByTestId('select-theme').click();
|
||||
await expect(page.getByTestId('SYSTEM_THEME')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
await page.getByTestId('DARK_THEME').click();
|
||||
|
||||
await page.getByTestId('save-settings').click();
|
||||
@@ -53,7 +55,10 @@ test.describe('Settings', () => {
|
||||
await page.getByTestId('pwa-menu').click();
|
||||
await page.getByTestId('pwa-open-settings').click();
|
||||
|
||||
await expect(page.locator('text="Dark theme"')).toBeVisible();
|
||||
await expect(page.getByTestId('DARK_THEME')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
});
|
||||
|
||||
test('Change app language', async ({ page }) => {
|
||||
|
||||
@@ -123,18 +123,10 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the operation system uses dark mode and changes the theme
|
||||
* Applies the operating system color scheme when no explicit theme is set
|
||||
*/
|
||||
detectDarkMode(): void {
|
||||
if (
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
this.settingsService.changeTheme(Theme.DarkTheme);
|
||||
this.settingsService.setValueToLocalStorage(STORE_KEY.Settings, {
|
||||
theme: Theme.DarkTheme,
|
||||
});
|
||||
}
|
||||
this.settingsService.changeTheme(Theme.SystemTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,38 @@ import { STORE_KEY, Theme } from 'shared-interfaces';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
describe('Service: Settings', () => {
|
||||
let systemThemeListeners: Array<(event: MediaQueryListEvent) => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
systemThemeListeners = [];
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
addEventListener: jest.fn(
|
||||
(
|
||||
_event: 'change',
|
||||
listener: (event: MediaQueryListEvent) => void
|
||||
) => {
|
||||
systemThemeListeners.push(listener);
|
||||
}
|
||||
),
|
||||
removeEventListener: jest.fn(
|
||||
(
|
||||
_event: 'change',
|
||||
listener: (event: MediaQueryListEvent) => void
|
||||
) => {
|
||||
systemThemeListeners = systemThemeListeners.filter(
|
||||
(registeredListener) =>
|
||||
registeredListener !== listener
|
||||
);
|
||||
}
|
||||
),
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [SettingsService],
|
||||
imports: [HttpClientTestingModule],
|
||||
@@ -74,5 +105,20 @@ describe('Service: Settings', () => {
|
||||
expect(spyOnAdd).toHaveBeenCalledTimes(0);
|
||||
}
|
||||
));
|
||||
|
||||
it('should follow the system theme', inject(
|
||||
[SettingsService],
|
||||
(service: SettingsService) => {
|
||||
service.changeTheme(Theme.SystemTheme);
|
||||
|
||||
expect(spyOnAdd).toHaveBeenCalledWith('dark-theme');
|
||||
|
||||
systemThemeListeners[0]?.({
|
||||
matches: false,
|
||||
} as MediaQueryListEvent);
|
||||
|
||||
expect(spyOnRemove).toHaveBeenCalledWith('dark-theme');
|
||||
}
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,11 @@ interface ParsedVersion {
|
||||
prerelease: boolean;
|
||||
}
|
||||
|
||||
type LegacyMediaQueryList = MediaQueryList & {
|
||||
addListener?: (listener: (event: MediaQueryListEvent) => void) => void;
|
||||
removeListener?: (listener: (event: MediaQueryListEvent) => void) => void;
|
||||
};
|
||||
|
||||
function parseVersion(input: string): ParsedVersion | null {
|
||||
const normalized = input.trim().replace(/^v/i, '');
|
||||
const match = normalized.match(
|
||||
@@ -60,17 +65,81 @@ function compareVersions(a: ParsedVersion, b: ParsedVersion): number {
|
||||
export class SettingsService {
|
||||
private http = inject(HttpClient);
|
||||
private storage = inject(StorageMap);
|
||||
private readonly systemThemeMediaQuery =
|
||||
typeof window !== 'undefined' && 'matchMedia' in window
|
||||
? window.matchMedia('(prefers-color-scheme: dark)')
|
||||
: null;
|
||||
private readonly systemThemeChangeHandler = (
|
||||
event: MediaQueryListEvent
|
||||
): void => {
|
||||
this.applyResolvedTheme(
|
||||
event.matches ? Theme.DarkTheme : Theme.LightTheme
|
||||
);
|
||||
};
|
||||
private isSystemThemeSyncActive = false;
|
||||
|
||||
/**
|
||||
* Changes the visual theme of the application
|
||||
* @param selectedTheme theme to set
|
||||
*/
|
||||
changeTheme(selectedTheme: Theme): void {
|
||||
if (selectedTheme === Theme.LightTheme) {
|
||||
document.body.classList.remove('dark-theme');
|
||||
} else if (selectedTheme === Theme.DarkTheme) {
|
||||
document.body.classList.add('dark-theme');
|
||||
this.stopSystemThemeSync();
|
||||
|
||||
if (selectedTheme === Theme.SystemTheme) {
|
||||
this.startSystemThemeSync();
|
||||
this.applyResolvedTheme(
|
||||
this.systemThemeMediaQuery?.matches
|
||||
? Theme.DarkTheme
|
||||
: Theme.LightTheme
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyResolvedTheme(selectedTheme);
|
||||
}
|
||||
|
||||
private applyResolvedTheme(selectedTheme: Theme): void {
|
||||
if (selectedTheme === Theme.DarkTheme) {
|
||||
document.body.classList.add('dark-theme');
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.classList.remove('dark-theme');
|
||||
}
|
||||
|
||||
private startSystemThemeSync(): void {
|
||||
if (!this.systemThemeMediaQuery || this.isSystemThemeSyncActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = this.systemThemeMediaQuery as LegacyMediaQueryList;
|
||||
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', this.systemThemeChangeHandler);
|
||||
} else {
|
||||
mediaQuery.addListener?.(this.systemThemeChangeHandler);
|
||||
}
|
||||
|
||||
this.isSystemThemeSyncActive = true;
|
||||
}
|
||||
|
||||
private stopSystemThemeSync(): void {
|
||||
if (!this.systemThemeMediaQuery || !this.isSystemThemeSyncActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = this.systemThemeMediaQuery as LegacyMediaQueryList;
|
||||
|
||||
if (mediaQuery.removeEventListener) {
|
||||
mediaQuery.removeEventListener(
|
||||
'change',
|
||||
this.systemThemeChangeHandler
|
||||
);
|
||||
} else {
|
||||
mediaQuery.removeListener?.(this.systemThemeChangeHandler);
|
||||
}
|
||||
|
||||
this.isSystemThemeSyncActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,19 +13,19 @@
|
||||
class="settings-layout"
|
||||
novalidate
|
||||
>
|
||||
<section class="settings-intro" id="settings-intro">
|
||||
<div class="settings-intro__content">
|
||||
<div class="settings-intro__eyebrow">
|
||||
{{ 'SETTINGS.HERO_EYEBROW' | translate }}
|
||||
@if (!isDialog) {
|
||||
<header
|
||||
class="settings-page-header"
|
||||
data-test-id="settings-page-header"
|
||||
>
|
||||
<div class="settings-page-header__text">
|
||||
<h1 data-test-id="settings-page-title">
|
||||
{{ 'SETTINGS.TITLE' | translate }}
|
||||
</h1>
|
||||
<p>{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
<h1 class="settings-intro__title">
|
||||
{{ 'SETTINGS.HERO_TITLE' | translate }}
|
||||
</h1>
|
||||
<p class="settings-intro__lead">
|
||||
{{ 'SETTINGS.HERO_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
}
|
||||
|
||||
<div class="settings-content">
|
||||
<section
|
||||
@@ -49,7 +49,10 @@
|
||||
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
||||
}}</mat-label>
|
||||
@@ -75,34 +78,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.THEME' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{
|
||||
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="theme"
|
||||
data-test-id="select-theme"
|
||||
>
|
||||
@for (
|
||||
theme of themeEnum | keyvalue;
|
||||
track theme
|
||||
) {
|
||||
<mat-option
|
||||
[value]="theme.value"
|
||||
[attr.data-test-id]="theme.value"
|
||||
>{{
|
||||
'THEMES.' + theme.value | translate
|
||||
}}</mat-option
|
||||
>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.THEME' | translate"
|
||||
data-test-id="select-theme"
|
||||
>
|
||||
@for (
|
||||
themeOption of themeOptions;
|
||||
track themeOption.value
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
settingsForm.value.theme ===
|
||||
themeOption.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
settingsForm.value.theme ===
|
||||
themeOption.value
|
||||
"
|
||||
[attr.data-test-id]="themeOption.value"
|
||||
role="radio"
|
||||
(click)="selectTheme(themeOption.value)"
|
||||
>
|
||||
<mat-icon>{{
|
||||
themeOption.icon
|
||||
}}</mat-icon>
|
||||
<span>{{
|
||||
themeOption.labelKey | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +167,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
||||
}}</mat-label>
|
||||
@@ -182,7 +200,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
||||
}}</mat-label>
|
||||
@@ -245,7 +266,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label for="mpvPlayerPath">{{
|
||||
'SETTINGS.MPV_PLAYER_PATH' | translate
|
||||
}}</mat-label>
|
||||
@@ -298,7 +322,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label for="vlcPlayerPath">{{
|
||||
'SETTINGS.VLC_PLAYER_PATH' | translate
|
||||
}}</mat-label>
|
||||
@@ -347,6 +374,7 @@
|
||||
<div class="epg-source-row">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
class="w-full"
|
||||
>
|
||||
<mat-label>{{
|
||||
@@ -480,7 +508,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.PORT_RANGE_LABEL' | translate
|
||||
}}</mat-label>
|
||||
|
||||
@@ -1,64 +1,72 @@
|
||||
:host {
|
||||
display: block;
|
||||
--settings-intro-bg: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.96) 0%,
|
||||
color-mix(in srgb, var(--mat-sys-surface-container-low) 88%, white) 100%
|
||||
);
|
||||
--settings-intro-border: color-mix(
|
||||
--settings-page-header-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 80%,
|
||||
transparent
|
||||
var(--mat-sys-surface-container-low) 94%,
|
||||
rgba(255, 255, 255, 0.98)
|
||||
);
|
||||
--settings-intro-shadow:
|
||||
0 10px 24px -22px rgba(15, 23, 42, 0.26),
|
||||
0 1px 0 rgba(255, 255, 255, 0.8) inset;
|
||||
--settings-group-bg: rgba(255, 255, 255, 0.72);
|
||||
--settings-group-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 70%,
|
||||
transparent
|
||||
);
|
||||
--settings-group-shadow: 0 10px 24px -24px rgba(15, 23, 42, 0.18);
|
||||
--settings-group-active-bg: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 4%,
|
||||
rgba(255, 255, 255, 0.94)
|
||||
);
|
||||
--settings-group-active-shadow:
|
||||
inset 0 0 0 1px var(--app-selection-border),
|
||||
0 14px 28px -26px var(--app-selection-glow);
|
||||
--settings-item-bg: rgba(255, 255, 255, 0.92);
|
||||
--settings-item-border: color-mix(
|
||||
--settings-page-header-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 72%,
|
||||
transparent
|
||||
);
|
||||
--settings-item-shadow: 0 6px 20px -24px rgba(15, 23, 42, 0.18);
|
||||
--settings-page-header-shadow:
|
||||
0 10px 20px -28px rgba(15, 23, 42, 0.16),
|
||||
0 1px 0 rgba(255, 255, 255, 0.68) inset;
|
||||
--settings-group-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface) 96%,
|
||||
rgba(255, 255, 255, 0.96)
|
||||
);
|
||||
--settings-group-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 78%,
|
||||
transparent
|
||||
);
|
||||
--settings-group-shadow: 0 14px 28px -32px rgba(15, 23, 42, 0.14);
|
||||
--settings-group-active-bg: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 2%,
|
||||
rgba(255, 255, 255, 0.98)
|
||||
);
|
||||
--settings-group-active-ring: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border) 68%,
|
||||
var(--settings-group-border)
|
||||
);
|
||||
--settings-group-active-glow: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-glow) 28%,
|
||||
transparent
|
||||
);
|
||||
--settings-group-active-shadow:
|
||||
inset 0 0 0 1px var(--settings-group-active-ring),
|
||||
0 12px 24px -28px var(--settings-group-active-glow);
|
||||
--settings-item-border: var(--app-separator);
|
||||
--settings-item-danger-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 5%,
|
||||
rgba(255, 255, 255, 0.94)
|
||||
var(--mat-sys-error) 4%,
|
||||
rgba(255, 255, 255, 0.96)
|
||||
);
|
||||
--settings-item-danger-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 18%,
|
||||
var(--mat-sys-error) 14%,
|
||||
transparent
|
||||
);
|
||||
--settings-action-bar-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-low) 90%,
|
||||
rgba(255, 255, 255, 0.86)
|
||||
var(--mat-sys-surface-container-low) 94%,
|
||||
rgba(255, 255, 255, 0.9)
|
||||
);
|
||||
--settings-action-bar-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 68%,
|
||||
var(--mat-sys-outline-variant) 62%,
|
||||
transparent
|
||||
);
|
||||
--settings-action-bar-shadow: 0 10px 26px -24px rgba(15, 23, 42, 0.28);
|
||||
--settings-action-bar-shadow: 0 10px 26px -26px rgba(15, 23, 42, 0.22);
|
||||
--settings-muted: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-on-surface) 68%,
|
||||
var(--mat-sys-on-surface) 64%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
@@ -66,55 +74,64 @@
|
||||
:host-context(.dark-theme),
|
||||
:host-context([data-theme='dark']),
|
||||
:host-context(.mat-app-background.dark) {
|
||||
--settings-intro-bg: color-mix(
|
||||
--settings-page-header-bg: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface) 72%,
|
||||
rgba(10, 16, 26, 0.96)
|
||||
var(--mat-sys-surface-container) 92%,
|
||||
rgba(12, 16, 24, 0.94)
|
||||
);
|
||||
--settings-intro-border: color-mix(
|
||||
--settings-page-header-border: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border) 55%,
|
||||
var(--mat-sys-outline-variant) 38%,
|
||||
transparent
|
||||
);
|
||||
--settings-intro-shadow:
|
||||
--settings-page-header-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 18px 28px -30px rgba(0, 0, 0, 0.45);
|
||||
--settings-group-bg: rgba(255, 255, 255, 0.02);
|
||||
--settings-group-border: rgba(255, 255, 255, 0.06);
|
||||
--settings-group-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 14px 26px -26px rgba(0, 0, 0, 0.45);
|
||||
0 16px 26px -34px rgba(0, 0, 0, 0.4);
|
||||
--settings-group-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container) 94%,
|
||||
rgba(10, 14, 22, 0.82)
|
||||
);
|
||||
--settings-group-border: rgba(255, 255, 255, 0.08);
|
||||
--settings-group-shadow: 0 16px 28px -32px rgba(0, 0, 0, 0.42);
|
||||
--settings-group-active-bg: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface) 82%,
|
||||
rgba(255, 255, 255, 0.02)
|
||||
var(--app-selection-surface) 24%,
|
||||
rgba(255, 255, 255, 0.015)
|
||||
);
|
||||
--settings-group-active-ring: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border) 54%,
|
||||
rgba(255, 255, 255, 0.08)
|
||||
);
|
||||
--settings-group-active-glow: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-glow) 22%,
|
||||
transparent
|
||||
);
|
||||
--settings-group-active-shadow:
|
||||
inset 0 0 0 1px var(--app-selection-border),
|
||||
0 16px 28px -24px var(--app-selection-glow);
|
||||
--settings-item-bg: rgba(8, 12, 20, 0.3);
|
||||
--settings-item-border: rgba(255, 255, 255, 0.05);
|
||||
--settings-item-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
inset 0 0 0 1px var(--settings-group-active-ring),
|
||||
0 10px 22px -28px var(--settings-group-active-glow);
|
||||
--settings-item-danger-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 10%,
|
||||
rgba(24, 10, 12, 0.9)
|
||||
var(--mat-sys-error) 8%,
|
||||
rgba(24, 10, 12, 0.86)
|
||||
);
|
||||
--settings-item-danger-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 34%,
|
||||
var(--mat-sys-error) 24%,
|
||||
transparent
|
||||
);
|
||||
--settings-action-bar-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container) 88%,
|
||||
var(--mat-sys-surface-container) 92%,
|
||||
transparent
|
||||
);
|
||||
--settings-action-bar-border: var(--mat-sys-outline-variant);
|
||||
--settings-action-bar-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
--settings-action-bar-border: rgba(255, 255, 255, 0.08);
|
||||
--settings-action-bar-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
|
||||
--settings-muted: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-on-surface) 74%,
|
||||
var(--mat-sys-on-surface) 68%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
@@ -122,7 +139,7 @@
|
||||
.settings-container {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 20px 20px 32px;
|
||||
padding: 18px 20px 30px;
|
||||
}
|
||||
|
||||
.settings-container.dialog-mode {
|
||||
@@ -135,60 +152,45 @@
|
||||
.settings-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
max-width: 960px;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 80px; /* space for FAB */
|
||||
padding-bottom: 72px; /* space for FAB */
|
||||
}
|
||||
|
||||
.settings-intro {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--settings-intro-border);
|
||||
border-radius: 28px;
|
||||
background: var(--settings-intro-bg);
|
||||
box-shadow: var(--settings-intro-shadow);
|
||||
.settings-page-header {
|
||||
padding: 14px 18px;
|
||||
border: 1px solid var(--settings-page-header-border);
|
||||
border-radius: 18px;
|
||||
background: var(--settings-page-header-bg);
|
||||
box-shadow: var(--settings-page-header-shadow);
|
||||
}
|
||||
|
||||
.settings-intro__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 640px;
|
||||
}
|
||||
.settings-page-header__text {
|
||||
max-width: 56ch;
|
||||
|
||||
.settings-intro__eyebrow {
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--app-selection-color);
|
||||
opacity: 0.86;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 2vw, 1.75rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.03em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-intro__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.8rem, 3vw, 2.6rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.settings-intro__lead {
|
||||
margin: 12px 0 0;
|
||||
max-width: 52ch;
|
||||
color: color-mix(in srgb, var(--mat-sys-on-surface) 78%, transparent);
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.5;
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--settings-muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
@@ -196,9 +198,9 @@
|
||||
background: var(--settings-group-bg);
|
||||
overflow: hidden;
|
||||
scroll-margin-top: 24px;
|
||||
padding: 4px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--settings-group-border);
|
||||
border-radius: 24px;
|
||||
border-radius: 22px;
|
||||
box-shadow: var(--settings-group-shadow);
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
@@ -208,13 +210,17 @@
|
||||
|
||||
.settings-group--active {
|
||||
background: var(--settings-group-active-bg);
|
||||
border-color: var(--app-selection-border);
|
||||
border-color: var(--settings-group-active-ring);
|
||||
box-shadow: var(--settings-group-active-shadow);
|
||||
|
||||
.settings-group__header-icon {
|
||||
background: var(--app-selection-surface-strong);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface-strong) 70%,
|
||||
var(--mat-sys-surface-container-high)
|
||||
);
|
||||
color: var(--app-selection-color);
|
||||
box-shadow: inset 0 0 0 1px var(--app-selection-border);
|
||||
border-color: var(--settings-group-active-ring);
|
||||
}
|
||||
|
||||
.settings-group__header-text h3 {
|
||||
@@ -225,59 +231,66 @@
|
||||
.settings-group__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 18px 14px;
|
||||
gap: 14px;
|
||||
padding: 16px 18px 14px;
|
||||
|
||||
.settings-group__header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 14px;
|
||||
background: var(--mat-sys-secondary-container);
|
||||
color: var(--mat-sys-on-secondary-container);
|
||||
border: 1px solid color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 74%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 12px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-high) 78%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-group__header-text {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
opacity: 0.72;
|
||||
font-size: 0.9rem;
|
||||
margin: 2px 0 0;
|
||||
color: var(--settings-muted);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-group__footer {
|
||||
padding: 14px 16px 12px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px dashed var(--mat-sys-outline-variant);
|
||||
padding: 12px 18px 16px;
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(240px, 440px);
|
||||
gap: 18px;
|
||||
margin: 0 10px 10px;
|
||||
padding: 16px 16px;
|
||||
border: 1px solid var(--settings-item-border);
|
||||
border-radius: 18px;
|
||||
background: var(--settings-item-bg);
|
||||
box-shadow: var(--settings-item-shadow);
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(220px, 340px);
|
||||
gap: 14px;
|
||||
margin: 0 18px;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
background: transparent;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
@@ -291,15 +304,16 @@
|
||||
.setting-item__meta {
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
margin: 3px 0 0;
|
||||
color: var(--settings-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,11 +321,16 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
min-height: 40px;
|
||||
|
||||
mat-form-field,
|
||||
.w-full {
|
||||
width: min(100%, 380px);
|
||||
width: min(100%, 340px);
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,12 +340,97 @@
|
||||
|
||||
.setting-item--full-control {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-item--full-control .setting-item__control {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.theme-switcher {
|
||||
width: min(100%, 540px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.theme-switcher__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 54px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 70%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 16px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-low) 88%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
color 0.18s ease;
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-high) 86%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--app-selection-color) 18%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-switcher__option--selected {
|
||||
border-color: var(--settings-group-active-ring);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface-strong) 72%,
|
||||
var(--mat-sys-surface-container-high)
|
||||
),
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface) 72%,
|
||||
var(--mat-sys-surface-container)
|
||||
)
|
||||
);
|
||||
color: var(--app-selection-color);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border) 54%,
|
||||
transparent
|
||||
),
|
||||
0 10px 18px -22px var(--settings-group-active-glow);
|
||||
}
|
||||
|
||||
.epg-sources {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -359,7 +463,7 @@
|
||||
}
|
||||
|
||||
.remote-control-url-item {
|
||||
width: min(100%, 380px);
|
||||
width: min(100%, 340px);
|
||||
}
|
||||
|
||||
.url-row {
|
||||
@@ -402,12 +506,13 @@
|
||||
}
|
||||
|
||||
.settings-note {
|
||||
margin: 0 24px 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.9rem;
|
||||
margin: 12px 18px 18px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
font-size: 0.84rem;
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
|
||||
border: 1px solid var(--mat-sys-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--mat-sys-primary) 30%, transparent);
|
||||
line-height: 1.45;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
@@ -416,8 +521,12 @@
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 18px;
|
||||
padding: 14px 16px;
|
||||
background: var(--settings-item-danger-bg);
|
||||
border-color: var(--settings-item-danger-border);
|
||||
border: 1px solid var(--settings-item-danger-border);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
@@ -429,9 +538,9 @@
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--settings-action-bar-border);
|
||||
border-radius: 99px; /* Pill shape */
|
||||
border-radius: 18px;
|
||||
background: var(--settings-action-bar-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: var(--settings-action-bar-shadow);
|
||||
@@ -459,14 +568,16 @@
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.settings-intro {
|
||||
padding: 22px 18px;
|
||||
border-radius: 24px;
|
||||
.settings-page-header {
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
margin: 0 14px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.setting-item__control {
|
||||
@@ -480,6 +591,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.theme-switcher {
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.remote-control-urls,
|
||||
.version-block {
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -36,6 +36,7 @@ import { SETTINGS_UPDATE } from 'shared-interfaces';
|
||||
import { ElectronServiceStub } from '../services/electron.service.stub';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from '../services/settings.service';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
class MatSnackBarStub {
|
||||
open() {
|
||||
@@ -55,7 +56,7 @@ const DEFAULT_SETTINGS = {
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
showExternalPlaybackBar: true,
|
||||
theme: Theme.LightTheme,
|
||||
theme: Theme.SystemTheme,
|
||||
mpvPlayerPath: '',
|
||||
vlcPlayerPath: '',
|
||||
remoteControl: false,
|
||||
@@ -147,6 +148,58 @@ describe('SettingsComponent', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render a compact page header outside dialog mode', () => {
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(
|
||||
nativeElement.querySelector('[data-test-id="settings-page-header"]')
|
||||
).not.toBeNull();
|
||||
expect(nativeElement.querySelector('.settings-intro')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render the page header in dialog mode', () => {
|
||||
component.isDialog = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
expect(
|
||||
nativeElement.querySelector('[data-test-id="settings-page-header"]')
|
||||
).toBeNull();
|
||||
expect(
|
||||
nativeElement.querySelector('h2[mat-dialog-title]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should scroll the general navigation target to the general section', async () => {
|
||||
const settingsContext = TestBed.inject(SettingsContextService);
|
||||
const scrollIntoView = jest.fn();
|
||||
const originalGetElementById = document.getElementById.bind(document);
|
||||
|
||||
const getElementByIdSpy = jest
|
||||
.spyOn(document, 'getElementById')
|
||||
.mockImplementation((id: string) => {
|
||||
if (id === 'general') {
|
||||
return {
|
||||
scrollIntoView,
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
return originalGetElementById(id);
|
||||
});
|
||||
|
||||
settingsContext.navigateToSection('general');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(getElementByIdSpy).toHaveBeenCalledWith('general');
|
||||
expect(getElementByIdSpy).not.toHaveBeenCalledWith('settings-intro');
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
expect(settingsContext.pendingScrollTarget()).toBeNull();
|
||||
});
|
||||
|
||||
describe('Get and set settings on component init', () => {
|
||||
const settings = {
|
||||
player: VideoPlayer.VideoJs,
|
||||
@@ -239,6 +292,13 @@ describe('SettingsComponent', () => {
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('should update the selected theme and mark the form dirty', () => {
|
||||
component.selectTheme(Theme.DarkTheme);
|
||||
|
||||
expect(component.settingsForm.value.theme).toBe(Theme.DarkTheme);
|
||||
expect(component.settingsForm.dirty).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should save settings on submit', async () => {
|
||||
const mockStore = settingsStore as unknown as MockSettingsStore;
|
||||
mockStore.updateSettings.mockResolvedValue(undefined);
|
||||
|
||||
@@ -63,6 +63,12 @@ interface ObservedSettingsSection {
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
interface ThemeOption {
|
||||
value: Theme;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss'],
|
||||
@@ -153,8 +159,23 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
/** EPG availability flag */
|
||||
epgAvailable$ = this.store.select(selectIsEpgAvailable);
|
||||
|
||||
/** All available visual themes */
|
||||
themeEnum = Theme;
|
||||
readonly themeOptions: ThemeOption[] = [
|
||||
{
|
||||
value: Theme.LightTheme,
|
||||
icon: 'light_mode',
|
||||
labelKey: 'THEMES.LIGHT_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.DarkTheme,
|
||||
icon: 'dark_mode',
|
||||
labelKey: 'THEMES.DARK_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.SystemTheme,
|
||||
icon: 'desktop_windows',
|
||||
labelKey: 'THEMES.SYSTEM_THEME',
|
||||
},
|
||||
];
|
||||
|
||||
/** Settings form object */
|
||||
settingsForm = this.formBuilder.group({
|
||||
@@ -164,7 +185,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
showExternalPlaybackBar: true,
|
||||
theme: Theme.LightTheme,
|
||||
theme: Theme.SystemTheme,
|
||||
mpvPlayerPath: '',
|
||||
mpvReuseInstance: false,
|
||||
vlcPlayerPath: '',
|
||||
@@ -239,10 +260,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollTargetId =
|
||||
sectionId === 'general' ? 'settings-intro' : sectionId;
|
||||
document
|
||||
.getElementById(scrollTargetId)
|
||||
.getElementById(sectionId)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
this.settingsCtx.clearPendingScrollTarget();
|
||||
},
|
||||
@@ -257,26 +276,23 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
`#${activeSectionId}`
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!activeSectionElement || activeSectionId === 'general') {
|
||||
if (!activeSectionElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animation = activeSectionElement.animate(
|
||||
[
|
||||
{
|
||||
transform: 'translateY(0)',
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--app-selection-border), 0 12px 20px -18px var(--app-selection-glow)',
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--app-selection-border), 0 18px 28px -18px var(--app-selection-glow)',
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
transform: 'translateY(0)',
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--app-selection-border), 0 12px 20px -18px var(--app-selection-glow)',
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
],
|
||||
{
|
||||
@@ -351,6 +367,16 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
selectTheme(theme: Theme): void {
|
||||
if (this.settingsForm.value.theme === theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.settingsForm.patchValue({ theme });
|
||||
this.settingsForm.get('theme')?.markAsDirty();
|
||||
this.settingsForm.markAsDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the epg urls to the form array
|
||||
* @param epgUrls urls of the EPG sources
|
||||
@@ -465,7 +491,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
this.translate.use(this.settingsForm.value.language);
|
||||
this.settingsService.changeTheme(this.settingsForm.value.theme);
|
||||
this.settingsService.changeTheme(
|
||||
this.settingsForm.value.theme ?? Theme.SystemTheme
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translate.instant('SETTINGS.SETTINGS_SAVED'),
|
||||
null,
|
||||
@@ -624,23 +652,17 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
const introSection = this.elementRef.nativeElement.querySelector(
|
||||
'#settings-intro'
|
||||
) as HTMLElement | null;
|
||||
const contentSections = Array.from(
|
||||
this.elementRef.nativeElement.querySelectorAll(
|
||||
'.settings-group[id]'
|
||||
)
|
||||
) as HTMLElement[];
|
||||
const sections: ObservedSettingsSection[] = [
|
||||
...(introSection
|
||||
? [{ id: 'general', element: introSection }]
|
||||
: []),
|
||||
...contentSections.map((section) => ({
|
||||
const sections: ObservedSettingsSection[] = contentSections.map(
|
||||
(section) => ({
|
||||
id: section.id,
|
||||
element: section,
|
||||
})),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -23,7 +23,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
showExternalPlaybackBar: true,
|
||||
theme: Theme.LightTheme,
|
||||
theme: Theme.SystemTheme,
|
||||
mpvPlayerPath: '',
|
||||
mpvReuseInstance: false,
|
||||
vlcPlayerPath: '',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum Theme {
|
||||
DarkTheme = 'DARK_THEME',
|
||||
LightTheme = 'LIGHT_THEME',
|
||||
SystemTheme = 'SYSTEM_THEME',
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { of } from 'rxjs';
|
||||
import { DatabaseService } from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { DashboardDataService } from './dashboard-data.service';
|
||||
@@ -47,6 +49,15 @@ describe('DashboardDataService.matchesScope', () => {
|
||||
DashboardDataService,
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{ provide: DatabaseService, useValue: dbServiceMock },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(DashboardDataService);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Injectable, NgZone, computed, inject, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectActivePlaylist,
|
||||
selectAllPlaylistsMeta,
|
||||
} from 'm3u-state';
|
||||
import { startWith } from 'rxjs';
|
||||
import { DatabaseService } from 'services';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
@@ -16,7 +19,7 @@ import {
|
||||
import {
|
||||
buildStalkerFavoriteItems,
|
||||
buildStalkerRecentItems,
|
||||
getTypeLabel,
|
||||
getActivityTypeLabelKey,
|
||||
mapDbFavoriteToItem,
|
||||
mapDbRecentToItem,
|
||||
toDateTimestamp,
|
||||
@@ -44,6 +47,11 @@ export class DashboardDataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
private readonly xtreamGlobalRecentItems = signal<GlobalRecentItem[]>([]);
|
||||
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
|
||||
@@ -52,9 +60,13 @@ export class DashboardDataService {
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
readonly activePlaylist = this.store.selectSignal(selectActivePlaylist);
|
||||
|
||||
readonly stalkerGlobalRecentItems = computed<GlobalRecentItem[]>(() =>
|
||||
buildStalkerRecentItems(this.playlists())
|
||||
);
|
||||
readonly stalkerGlobalRecentItems = computed<GlobalRecentItem[]>(() => {
|
||||
this.languageTick();
|
||||
return buildStalkerRecentItems(
|
||||
this.playlists(),
|
||||
this.translateText('WORKSPACE.DASHBOARD.STALKER_PORTAL')
|
||||
);
|
||||
});
|
||||
|
||||
readonly globalRecentItems = computed<GlobalRecentItem[]>(() =>
|
||||
[...this.xtreamGlobalRecentItems(), ...this.stalkerGlobalRecentItems()]
|
||||
@@ -65,9 +77,13 @@ export class DashboardDataService {
|
||||
.slice(0, 200)
|
||||
);
|
||||
|
||||
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() =>
|
||||
buildStalkerFavoriteItems(this.playlists())
|
||||
);
|
||||
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() => {
|
||||
this.languageTick();
|
||||
return buildStalkerFavoriteItems(
|
||||
this.playlists(),
|
||||
this.translateText('WORKSPACE.DASHBOARD.STALKER_PORTAL')
|
||||
);
|
||||
});
|
||||
|
||||
readonly globalFavoriteItems = computed(() =>
|
||||
[...this.xtreamGlobalFavorites(), ...this.stalkerGlobalFavorites()]
|
||||
@@ -217,30 +233,35 @@ export class DashboardDataService {
|
||||
return ['/workspace', 'playlists', playlist._id];
|
||||
}
|
||||
|
||||
getPlaylistProvider(playlist: PlaylistMeta): 'Xtream' | 'Stalker' | 'M3U' {
|
||||
getPlaylistProvider(playlist: PlaylistMeta): string {
|
||||
this.languageTick();
|
||||
|
||||
if (playlist.serverUrl) {
|
||||
return 'Xtream';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
||||
}
|
||||
|
||||
if (playlist.macAddress) {
|
||||
return 'Stalker';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
|
||||
}
|
||||
|
||||
return 'M3U';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.M3U');
|
||||
}
|
||||
|
||||
getRecentItemProviderLabel(item: GlobalRecentItem): string {
|
||||
this.languageTick();
|
||||
|
||||
if (item.source === 'stalker') {
|
||||
return 'Stalker';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
|
||||
}
|
||||
if (item.source === 'xtream') {
|
||||
return 'Xtream';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
||||
}
|
||||
return 'Provider';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
|
||||
}
|
||||
|
||||
getRecentItemTypeLabel(item: GlobalRecentItem): string {
|
||||
return getTypeLabel(item.type);
|
||||
this.languageTick();
|
||||
return this.translateText(getActivityTypeLabelKey(item.type));
|
||||
}
|
||||
|
||||
getRecentItemLink(item: GlobalRecentItem): string[] {
|
||||
@@ -301,17 +322,20 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string {
|
||||
this.languageTick();
|
||||
|
||||
if (item.source === 'stalker') {
|
||||
return 'Stalker';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
|
||||
}
|
||||
if (item.source === 'xtream') {
|
||||
return 'Xtream';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
||||
}
|
||||
return 'Provider';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
|
||||
}
|
||||
|
||||
getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string {
|
||||
return getTypeLabel(item.type);
|
||||
this.languageTick();
|
||||
return this.translateText(getActivityTypeLabelKey(item.type));
|
||||
}
|
||||
|
||||
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
|
||||
@@ -367,12 +391,14 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
formatTimestamp(value?: string | number): string {
|
||||
this.languageTick();
|
||||
|
||||
const timestamp = toTimestamp(value);
|
||||
if (!timestamp) {
|
||||
return 'Not yet synced';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.NOT_YET_SYNCED');
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleString();
|
||||
return new Date(timestamp).toLocaleString(this.getLocale());
|
||||
}
|
||||
|
||||
private normalizeScope(
|
||||
@@ -399,4 +425,19 @@ export class DashboardDataService {
|
||||
private getRecentTimestamp(item: PlaylistMeta): number {
|
||||
return toTimestamp(item.updateDate) || toTimestamp(item.importDate);
|
||||
}
|
||||
|
||||
private getLocale(): string | undefined {
|
||||
return (
|
||||
this.translate.currentLang ||
|
||||
this.translate.defaultLang ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
private translateText(
|
||||
key: string,
|
||||
params?: Record<string, string | number>
|
||||
): string {
|
||||
return this.translate.instant(key, params);
|
||||
}
|
||||
}
|
||||
@@ -296,12 +296,18 @@ export class DashboardLayoutService {
|
||||
defaultWidget: DashboardWidgetConfig,
|
||||
loaded: DashboardWidgetConfig
|
||||
): DashboardWidgetConfig {
|
||||
return {
|
||||
const merged = {
|
||||
...defaultWidget,
|
||||
...loaded,
|
||||
size: this.normalizeSize(loaded.size ?? defaultWidget.size),
|
||||
settings: this.mergeSettings(defaultWidget, loaded),
|
||||
};
|
||||
|
||||
return {
|
||||
...merged,
|
||||
title: defaultWidget.title,
|
||||
description: defaultWidget.description,
|
||||
};
|
||||
}
|
||||
|
||||
private mergeSettings(
|
||||
|
||||
@@ -20,12 +20,10 @@ export function normalizeActivityType(value: string): PortalActivityType {
|
||||
return value === 'live' || value === 'series' ? value : 'movie';
|
||||
}
|
||||
|
||||
export function getTypeLabel(
|
||||
type: PortalActivityType
|
||||
): 'Live' | 'Movie' | 'Series' {
|
||||
if (type === 'live') return 'Live';
|
||||
if (type === 'series') return 'Series';
|
||||
return 'Movie';
|
||||
export function getActivityTypeLabelKey(type: PortalActivityType): string {
|
||||
if (type === 'live') return 'WORKSPACE.DASHBOARD.TYPE_LIVE';
|
||||
if (type === 'series') return 'WORKSPACE.DASHBOARD.TYPE_SERIES';
|
||||
return 'WORKSPACE.DASHBOARD.TYPE_MOVIE';
|
||||
}
|
||||
|
||||
// ────── Xtream DB → ViewModel ──────
|
||||
@@ -65,7 +63,8 @@ export function mapDbRecentToItem(item: DbGlobalRecentItem): PortalRecentItem {
|
||||
// ────── Stalker playlist → ViewModel ──────
|
||||
|
||||
export function buildStalkerRecentItems(
|
||||
playlists: PlaylistMeta[]
|
||||
playlists: PlaylistMeta[],
|
||||
defaultPlaylistName: string
|
||||
): PortalRecentItem[] {
|
||||
return playlists
|
||||
.filter((playlist) => Boolean(playlist.macAddress))
|
||||
@@ -86,7 +85,7 @@ export function buildStalkerRecentItems(
|
||||
title: extractStalkerItemTitle(item),
|
||||
type: extractStalkerItemType(item),
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || 'Stalker Portal',
|
||||
playlist_name: playlist.title || defaultPlaylistName,
|
||||
viewed_at: normalizeStalkerDate(item['added_at']),
|
||||
category_id: String(item['category_id'] ?? ''),
|
||||
xtream_id: id,
|
||||
@@ -102,7 +101,8 @@ export function buildStalkerRecentItems(
|
||||
}
|
||||
|
||||
export function buildStalkerFavoriteItems(
|
||||
playlists: PlaylistMeta[]
|
||||
playlists: PlaylistMeta[],
|
||||
defaultPlaylistName: string
|
||||
): PortalFavoriteItem[] {
|
||||
return playlists
|
||||
.filter((playlist) => Boolean(playlist.macAddress))
|
||||
@@ -120,7 +120,7 @@ export function buildStalkerFavoriteItems(
|
||||
title: extractStalkerItemTitle(raw),
|
||||
type: extractStalkerItemType(raw),
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || 'Stalker Portal',
|
||||
playlist_name: playlist.title || defaultPlaylistName,
|
||||
added_at: normalizeStalkerDate(raw['added_at']),
|
||||
category_id: String(raw['category_id'] ?? ''),
|
||||
xtream_id: id,
|
||||
|
||||
@@ -154,7 +154,7 @@ export function buildStalkerStateItem(
|
||||
};
|
||||
}
|
||||
|
||||
const title = fallback.title || 'Unknown';
|
||||
const title = fallback.title || '';
|
||||
return {
|
||||
id: String(fallback.id ?? ''),
|
||||
title,
|
||||
|
||||
@@ -60,8 +60,8 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
{
|
||||
id: 'source-stats',
|
||||
type: 'source-stats',
|
||||
title: 'Source Statistics',
|
||||
description: 'Current source mix and total connected libraries.',
|
||||
title: 'WORKSPACE.DASHBOARD.SOURCE_STATS',
|
||||
description: 'WORKSPACE.DASHBOARD.SOURCE_STATS_DESC',
|
||||
size: 'one-third',
|
||||
enabled: false,
|
||||
order: 1,
|
||||
@@ -69,8 +69,8 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
{
|
||||
id: 'continue',
|
||||
type: 'continue-watching',
|
||||
title: 'Continue Watching',
|
||||
description: 'Jump back into the last active source.',
|
||||
title: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING',
|
||||
description: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING_DESC',
|
||||
size: 'full',
|
||||
enabled: true,
|
||||
order: 2,
|
||||
@@ -78,8 +78,8 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
{
|
||||
id: 'recently-watched',
|
||||
type: 'recently-watched',
|
||||
title: 'Recently Watched',
|
||||
description: 'Global watch history across channels, VOD and series.',
|
||||
title: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED',
|
||||
description: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_DESC',
|
||||
size: 'two-thirds',
|
||||
enabled: true,
|
||||
order: 4,
|
||||
@@ -90,8 +90,8 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
{
|
||||
id: 'global-favorites',
|
||||
type: 'global-favorites',
|
||||
title: 'Global Favorites',
|
||||
description: 'Pinned items across all connected providers.',
|
||||
title: 'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES',
|
||||
description: 'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES_DESC',
|
||||
size: 'one-third',
|
||||
enabled: true,
|
||||
order: 5,
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
[matTooltip]="
|
||||
'WORKSPACE.DASHBOARD.TOOLBAR_CUSTOMIZE' | translate
|
||||
"
|
||||
aria-label="Customize dashboard layout"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.CUSTOMIZE_LAYOUT' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
@@ -78,7 +80,9 @@
|
||||
mat-icon-button
|
||||
class="drag-handle"
|
||||
cdkDragHandle
|
||||
[attr.aria-label]="'Drag ' + widget.title"
|
||||
[attr.aria-label]="
|
||||
getDragWidgetLabel(widget)
|
||||
"
|
||||
>
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
</button>
|
||||
@@ -89,7 +93,10 @@
|
||||
class="size-stepper__btn"
|
||||
[disabled]="isFirstSize(widget.size)"
|
||||
(click)="cycleWidgetSize(widget.id, -1)"
|
||||
aria-label="Make widget narrower"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.MAKE_WIDGET_NARROWER'
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
@@ -102,7 +109,10 @@
|
||||
class="size-stepper__btn"
|
||||
[disabled]="isLastSize(widget.size)"
|
||||
(click)="cycleWidgetSize(widget.id, 1)"
|
||||
aria-label="Make widget wider"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.MAKE_WIDGET_WIDER'
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
@@ -111,7 +121,9 @@
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="widget-inline-editor__hide"
|
||||
[attr.aria-label]="'Hide ' + widget.title"
|
||||
[attr.aria-label]="
|
||||
getHideWidgetLabel(widget)
|
||||
"
|
||||
(click)="hideWidget(widget.id)"
|
||||
>
|
||||
<mat-icon>visibility_off</mat-icon>
|
||||
@@ -157,8 +169,8 @@
|
||||
@for (widget of allWidgets(); track widget.id) {
|
||||
<article class="editor-item">
|
||||
<div class="editor-item__meta">
|
||||
<h3>{{ widget.title }}</h3>
|
||||
<p>{{ widget.description }}</p>
|
||||
<h3>{{ getWidgetTitle(widget) }}</h3>
|
||||
<p>{{ getWidgetDescription(widget) }}</p>
|
||||
</div>
|
||||
<div class="editor-item__actions">
|
||||
<p class="editor-item__action-label">
|
||||
@@ -184,13 +196,9 @@
|
||||
'WORKSPACE.DASHBOARD.WIDGET_ADVANCED'
|
||||
| translate
|
||||
}}</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
Scope ({{
|
||||
getScopeProviderSummary(
|
||||
widget
|
||||
)
|
||||
}})
|
||||
</mat-panel-description>
|
||||
<mat-panel-description>{{
|
||||
getScopePanelDescription(widget)
|
||||
}}</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
<section class="scope-editor">
|
||||
<p class="scope-editor__title">
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
@@ -12,8 +13,9 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
DashboardLayoutService,
|
||||
DashboardWidgetConfig,
|
||||
@@ -42,9 +44,14 @@ import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
|
||||
})
|
||||
export class WorkspaceDashboardComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly editMode = signal(false);
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
readonly providerOptions: DashboardWidgetProvider[] = [
|
||||
'm3u',
|
||||
'xtream',
|
||||
@@ -106,11 +113,13 @@ export class WorkspaceDashboardComponent {
|
||||
}
|
||||
|
||||
getSizeLabel(size: DashboardWidgetSize): string {
|
||||
this.languageTick();
|
||||
|
||||
const labels: Record<DashboardWidgetSize, string> = {
|
||||
'one-third': '1/3',
|
||||
half: '1/2',
|
||||
'two-thirds': '2/3',
|
||||
full: 'Full',
|
||||
full: this.translateText('WORKSPACE.DASHBOARD.SIZE_FULL'),
|
||||
};
|
||||
return labels[size];
|
||||
}
|
||||
@@ -132,13 +141,15 @@ export class WorkspaceDashboardComponent {
|
||||
}
|
||||
|
||||
getProviderLabel(provider: DashboardWidgetProvider): string {
|
||||
this.languageTick();
|
||||
|
||||
if (provider === 'xtream') {
|
||||
return 'Xtream';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
||||
}
|
||||
if (provider === 'stalker') {
|
||||
return 'Stalker';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
|
||||
}
|
||||
return 'M3U';
|
||||
return this.translateText('WORKSPACE.DASHBOARD.M3U');
|
||||
}
|
||||
|
||||
getScopeProviderCount(widget: DashboardWidgetConfig): number {
|
||||
@@ -147,8 +158,16 @@ export class WorkspaceDashboardComponent {
|
||||
}
|
||||
|
||||
getScopeProviderSummary(widget: DashboardWidgetConfig): string {
|
||||
this.languageTick();
|
||||
|
||||
const count = this.getScopeProviderCount(widget);
|
||||
return count === 0 ? 'All providers' : `${count} selected`;
|
||||
if (count === 0) {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.ALL_PROVIDERS');
|
||||
}
|
||||
|
||||
return this.translateText('WORKSPACE.DASHBOARD.SELECTED_COUNT', {
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
isAllProvidersScope(widget: DashboardWidgetConfig): boolean {
|
||||
@@ -201,15 +220,45 @@ export class WorkspaceDashboardComponent {
|
||||
url?: string;
|
||||
_id?: string;
|
||||
}): string {
|
||||
this.languageTick();
|
||||
|
||||
return (
|
||||
playlist.title ||
|
||||
playlist.filename ||
|
||||
playlist.url ||
|
||||
playlist._id ||
|
||||
'Untitled playlist'
|
||||
this.translateText('WORKSPACE.DASHBOARD.UNTITLED_SOURCE')
|
||||
);
|
||||
}
|
||||
|
||||
getWidgetTitle(widget: DashboardWidgetConfig): string {
|
||||
this.languageTick();
|
||||
return this.translateText(widget.title);
|
||||
}
|
||||
|
||||
getWidgetDescription(widget: DashboardWidgetConfig): string {
|
||||
this.languageTick();
|
||||
return this.translateText(widget.description);
|
||||
}
|
||||
|
||||
getDragWidgetLabel(widget: DashboardWidgetConfig): string {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.DRAG_WIDGET', {
|
||||
title: this.getWidgetTitle(widget),
|
||||
});
|
||||
}
|
||||
|
||||
getHideWidgetLabel(widget: DashboardWidgetConfig): string {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.HIDE_WIDGET', {
|
||||
title: this.getWidgetTitle(widget),
|
||||
});
|
||||
}
|
||||
|
||||
getScopePanelDescription(widget: DashboardWidgetConfig): string {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.WIDGET_SCOPE_SUMMARY', {
|
||||
summary: this.getScopeProviderSummary(widget),
|
||||
});
|
||||
}
|
||||
|
||||
private getPlaylistProviderType(playlist: {
|
||||
serverUrl?: string;
|
||||
macAddress?: string;
|
||||
@@ -222,4 +271,11 @@ export class WorkspaceDashboardComponent {
|
||||
}
|
||||
return 'm3u';
|
||||
}
|
||||
|
||||
private translateText(
|
||||
key: string,
|
||||
params?: Record<string, string | number>
|
||||
): string {
|
||||
return this.translate.instant(key, params);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
DashboardDataService,
|
||||
GlobalRecentItem,
|
||||
@@ -23,6 +25,11 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen
|
||||
})
|
||||
export class ContinueWatchingWidgetComponent {
|
||||
readonly data = inject(DashboardDataService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
/** The most recently watched item across all sources. */
|
||||
readonly lastItem = computed<GlobalRecentItem | null>(
|
||||
@@ -50,12 +57,32 @@ export class ContinueWatchingWidgetComponent {
|
||||
}
|
||||
|
||||
relativeTime(dateStr: string): string {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
if (diffMin < 1) return 'Just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffH = Math.floor(diffMin / 60);
|
||||
if (diffH < 24) return `${diffH}h ago`;
|
||||
return `${Math.floor(diffH / 24)}d ago`;
|
||||
this.languageTick();
|
||||
|
||||
const date = new Date(dateStr);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return this.translate.instant('WORKSPACE.DASHBOARD.RECENTLY');
|
||||
}
|
||||
|
||||
const diffMinutes = Math.floor((Date.now() - date.getTime()) / 60_000);
|
||||
if (diffMinutes < 1) {
|
||||
return this.translate.instant('WORKSPACE.DASHBOARD.JUST_NOW');
|
||||
}
|
||||
if (diffMinutes < 60) {
|
||||
return this.translate.instant('WORKSPACE.DASHBOARD.MINUTES_AGO', {
|
||||
count: diffMinutes,
|
||||
});
|
||||
}
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) {
|
||||
return this.translate.instant('WORKSPACE.DASHBOARD.HOURS_AGO', {
|
||||
count: diffHours,
|
||||
});
|
||||
}
|
||||
|
||||
return this.translate.instant('WORKSPACE.DASHBOARD.DAYS_AGO', {
|
||||
count: Math.floor(diffHours / 24),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,14 @@ export class DashboardActivityItemsComponent {
|
||||
@Output() readonly scrolledToEnd = new EventEmitter<void>();
|
||||
|
||||
readonly items = input.required<DashboardActivityItemViewModel[]>();
|
||||
readonly emptyLabel = input('No items to show.');
|
||||
readonly emptyLabel = input('');
|
||||
readonly emptyIcon = input<string>();
|
||||
readonly emptyHint = input<string>();
|
||||
readonly emptyActionLabel = input<string>();
|
||||
readonly emptyActionLink = input<string[]>();
|
||||
readonly viewMode = input<DashboardActivityViewMode>('list');
|
||||
readonly showRemoveAction = input(false);
|
||||
readonly removeActionLabel = input('Remove item');
|
||||
readonly removeActionLabel = input('');
|
||||
|
||||
@Output() readonly itemAction =
|
||||
new EventEmitter<DashboardActivityItemViewModel>();
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="filterMenu"
|
||||
aria-label="Filter global favorites content"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.FILTER_GLOBAL_FAVORITES' | translate
|
||||
"
|
||||
[matTooltip]="'WORKSPACE.DASHBOARD.FILTER_ITEMS' | translate"
|
||||
>
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
@@ -58,7 +60,9 @@
|
||||
class="view-toggle"
|
||||
[value]="viewMode()"
|
||||
hideSingleSelectionIndicator
|
||||
aria-label="Select global favorites view mode"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES_VIEW_MODE' | translate
|
||||
"
|
||||
(change)="onViewModeChange($event.value)"
|
||||
>
|
||||
<mat-button-toggle
|
||||
@@ -83,7 +87,7 @@
|
||||
[emptyHint]="'WORKSPACE.DASHBOARD.NO_FAVORITES_HINT' | translate"
|
||||
[viewMode]="viewMode()"
|
||||
[showRemoveAction]="true"
|
||||
[removeActionLabel]="'Remove favorite'"
|
||||
[removeActionLabel]="'WORKSPACE.DASHBOARD.REMOVE_FAVORITE' | translate"
|
||||
(itemAction)="onRemoveItem($event)"
|
||||
(scrolledToEnd)="onItemsScrolledToEnd()"
|
||||
/>
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
DashboardContentKind,
|
||||
DashboardDataService,
|
||||
@@ -50,6 +52,11 @@ const PAGE_SIZE = 20;
|
||||
export class GlobalFavoritesWidgetComponent implements OnInit {
|
||||
readonly widget = input.required<DashboardWidgetConfig>();
|
||||
readonly data = inject(DashboardDataService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
readonly selectedKind = signal<DashboardContentKind>(
|
||||
this.readStoredKind() ?? 'all'
|
||||
);
|
||||
@@ -208,16 +215,23 @@ export class GlobalFavoritesWidgetComponent implements OnInit {
|
||||
}
|
||||
|
||||
private formatTimestamp(value: string): string {
|
||||
this.languageTick();
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Recently';
|
||||
return this.translate.instant('WORKSPACE.DASHBOARD.RECENTLY');
|
||||
}
|
||||
|
||||
return date.toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return date.toLocaleString(
|
||||
this.translate.currentLang ||
|
||||
this.translate.defaultLang ||
|
||||
undefined,
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="filterMenu"
|
||||
aria-label="Filter recently watched content"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.FILTER_RECENTLY_WATCHED' | translate
|
||||
"
|
||||
[matTooltip]="'WORKSPACE.DASHBOARD.FILTER_ITEMS' | translate"
|
||||
>
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
@@ -58,7 +60,9 @@
|
||||
class="view-toggle"
|
||||
[value]="viewMode()"
|
||||
hideSingleSelectionIndicator
|
||||
aria-label="Select recently watched view mode"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_VIEW_MODE' | translate
|
||||
"
|
||||
(change)="onViewModeChange($event.value)"
|
||||
>
|
||||
<mat-button-toggle
|
||||
@@ -85,7 +89,9 @@
|
||||
[emptyActionLink]="['/workspace/sources']"
|
||||
[viewMode]="viewMode()"
|
||||
[showRemoveAction]="true"
|
||||
[removeActionLabel]="'Remove from history'"
|
||||
[removeActionLabel]="
|
||||
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
|
||||
"
|
||||
(itemAction)="onRemoveItem($event)"
|
||||
(scrolledToEnd)="onItemsScrolledToEnd()"
|
||||
/>
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
DashboardContentKind,
|
||||
DashboardDataService,
|
||||
@@ -50,6 +52,11 @@ const PAGE_SIZE = 20;
|
||||
export class RecentlyWatchedWidgetComponent implements OnInit {
|
||||
readonly widget = input.required<DashboardWidgetConfig>();
|
||||
readonly data = inject(DashboardDataService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
readonly selectedKind = signal<DashboardContentKind>(
|
||||
this.readStoredKind() ?? 'all'
|
||||
);
|
||||
@@ -207,16 +214,23 @@ export class RecentlyWatchedWidgetComponent implements OnInit {
|
||||
}
|
||||
|
||||
private formatTimestamp(value: string): string {
|
||||
this.languageTick();
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Recently';
|
||||
return this.translate.instant('WORKSPACE.DASHBOARD.RECENTLY');
|
||||
}
|
||||
|
||||
return date.toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return date.toLocaleString(
|
||||
this.translate.currentLang ||
|
||||
this.translate.defaultLang ||
|
||||
undefined,
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -4,7 +4,9 @@
|
||||
<input
|
||||
matInput
|
||||
type="search"
|
||||
placeholder="Search favorites..."
|
||||
[placeholder]="
|
||||
'WORKSPACE.GLOBAL_FAVORITES.SEARCH_PLACEHOLDER' | translate
|
||||
"
|
||||
[value]="searchTerm()"
|
||||
(input)="searchTerm.set($any($event.target).value)"
|
||||
/>
|
||||
@@ -16,13 +18,23 @@
|
||||
<div class="empty-state">
|
||||
<mat-icon class="empty-icon">star_border</mat-icon>
|
||||
@if (searchTerm()) {
|
||||
<p class="empty-title">No results</p>
|
||||
<p class="empty-hint">Try a different search term</p>
|
||||
} @else {
|
||||
<p class="empty-title">No live TV favorites yet</p>
|
||||
<p class="empty-title">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
Star live channels from M3U, Xtream, or Stalker playlists to
|
||||
see them here.
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
} @else {
|
||||
<p class="empty-title">
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate
|
||||
}}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_HINT' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
+2
@@ -14,6 +14,7 @@ import {
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ChannelListItemComponent } from 'components';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
|
||||
@@ -34,6 +35,7 @@ export interface EnrichedUnifiedFavorite extends UnifiedFavoriteChannel {
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class GlobalFavoritesListComponent {
|
||||
|
||||
+12
-5
@@ -23,10 +23,12 @@
|
||||
storageKey="global-favorites-sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Global Favorites</span>
|
||||
<span class="sidebar-subtitle"
|
||||
>Live TV from all playlists</span
|
||||
>
|
||||
<span class="sidebar-title">{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.TITLE' | translate
|
||||
}}</span>
|
||||
<span class="sidebar-subtitle">{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.SUBTITLE' | translate
|
||||
}}</span>
|
||||
</div>
|
||||
<app-global-favorites-list
|
||||
[channels]="channels()"
|
||||
@@ -109,7 +111,12 @@
|
||||
} @else {
|
||||
<div class="no-channel-selected">
|
||||
<mat-icon>live_tv</mat-icon>
|
||||
<p>Select a channel to start watching</p>
|
||||
<p>
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_PLAYER'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import {
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
@@ -62,6 +63,7 @@ interface StalkerCreateLinkResponse {
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
@@ -57,6 +58,7 @@ export class GlobalFavoritesService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
/**
|
||||
* Fetch and merge live TV favorites from all source types.
|
||||
@@ -272,12 +274,21 @@ export class GlobalFavoritesService {
|
||||
const streamId = fav.stream_id ?? fav.id;
|
||||
results.push({
|
||||
uid: buildFavoriteUid('stalker', meta._id, streamId),
|
||||
name: fav.o_name || fav.name || 'Unknown',
|
||||
name:
|
||||
fav.o_name ||
|
||||
fav.name ||
|
||||
this.translate.instant(
|
||||
'WORKSPACE.GLOBAL_FAVORITES.UNKNOWN_CHANNEL'
|
||||
),
|
||||
logo: fav.logo ?? fav.cover ?? null,
|
||||
sourceType: 'stalker' as const,
|
||||
playlistId: meta._id,
|
||||
playlistName:
|
||||
meta.title || meta.filename || 'Stalker Portal',
|
||||
meta.title ||
|
||||
meta.filename ||
|
||||
this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.STALKER_PORTAL'
|
||||
),
|
||||
stalkerCmd: fav.cmd,
|
||||
stalkerPortalUrl: playlist.portalUrl ?? playlist.url,
|
||||
stalkerMacAddress: playlist.macAddress,
|
||||
|
||||
+12
-6
@@ -1,7 +1,7 @@
|
||||
<div class="palette-shell">
|
||||
<header class="palette-header">
|
||||
<h2>Command palette</h2>
|
||||
<p>Global and contextual actions</p>
|
||||
<h2>{{ 'WORKSPACE.COMMAND_PALETTE.TITLE' | translate }}</h2>
|
||||
<p>{{ 'WORKSPACE.COMMAND_PALETTE.SUBTITLE' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<label class="palette-search">
|
||||
@@ -13,19 +13,25 @@
|
||||
[value]="query()"
|
||||
(input)="onQueryInput($event)"
|
||||
(keydown)="onInputKeydown($event)"
|
||||
placeholder="Type to filter commands"
|
||||
aria-label="Filter commands"
|
||||
[placeholder]="
|
||||
'WORKSPACE.COMMAND_PALETTE.SEARCH_PLACEHOLDER' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.COMMAND_PALETTE.SEARCH_LABEL' | translate
|
||||
"
|
||||
/>
|
||||
<span class="palette-search__hint">Ctrl/Cmd+K</span>
|
||||
</label>
|
||||
|
||||
<div class="palette-results">
|
||||
@if (commandGroups().length === 0) {
|
||||
<div class="palette-empty">No commands found</div>
|
||||
<div class="palette-empty">
|
||||
{{ 'WORKSPACE.COMMAND_PALETTE.EMPTY' | translate }}
|
||||
</div>
|
||||
} @else {
|
||||
@for (group of commandGroups(); track group.scope) {
|
||||
<section class="palette-group">
|
||||
<h3>{{ group.title }}</h3>
|
||||
<h3>{{ getScopeTitleKey(group.scope) | translate }}</h3>
|
||||
<div class="palette-group__items">
|
||||
@for (command of group.items; track command.id) {
|
||||
<button
|
||||
|
||||
+11
@@ -1,5 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { WorkspaceCommandPaletteComponent } from './workspace-command-palette.component';
|
||||
|
||||
describe('WorkspaceCommandPaletteComponent', () => {
|
||||
@@ -41,6 +43,15 @@ describe('WorkspaceCommandPaletteComponent', () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
+17
-8
@@ -14,6 +14,7 @@ import {
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
export type WorkspaceCommandScope = 'global' | 'playlist' | 'section';
|
||||
|
||||
@@ -44,13 +45,12 @@ interface WorkspaceCommandPaletteData {
|
||||
|
||||
interface WorkspaceCommandGroup {
|
||||
scope: WorkspaceCommandScope;
|
||||
title: string;
|
||||
items: WorkspaceCommandItem[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-command-palette',
|
||||
imports: [MatDialogModule, MatIconModule],
|
||||
imports: [MatDialogModule, MatIconModule, TranslatePipe],
|
||||
templateUrl: './workspace-command-palette.component.html',
|
||||
styleUrl: './workspace-command-palette.component.scss',
|
||||
})
|
||||
@@ -88,20 +88,19 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
|
||||
const grouped: WorkspaceCommandGroup[] = [];
|
||||
|
||||
const buildGroup = (
|
||||
scope: WorkspaceCommandScope,
|
||||
title: string
|
||||
scope: WorkspaceCommandScope
|
||||
): WorkspaceCommandGroup | null => {
|
||||
const items = commands.filter((command) => command.scope === scope);
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { scope, title, items };
|
||||
return { scope, items };
|
||||
};
|
||||
|
||||
const groups = [
|
||||
buildGroup('global', 'Global actions'),
|
||||
buildGroup('playlist', 'This playlist'),
|
||||
buildGroup('section', 'This section'),
|
||||
buildGroup('global'),
|
||||
buildGroup('playlist'),
|
||||
buildGroup('section'),
|
||||
].filter((group): group is WorkspaceCommandGroup => group !== null);
|
||||
|
||||
grouped.push(...groups);
|
||||
@@ -219,4 +218,14 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
|
||||
|
||||
this.onCommandClick(items[index]);
|
||||
}
|
||||
|
||||
getScopeTitleKey(scope: WorkspaceCommandScope): string {
|
||||
if (scope === 'global') {
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
|
||||
}
|
||||
if (scope === 'playlist') {
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
|
||||
}
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_SECTION';
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -1,15 +1,17 @@
|
||||
<div class="context-column">
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>{{ title() }}</h2>
|
||||
<h2>{{ title() | translate }}</h2>
|
||||
@if (isXtreamCategories()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
(click)="openManageCategories()"
|
||||
matTooltip="Manage categories"
|
||||
aria-label="Manage categories"
|
||||
[matTooltip]="'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
@@ -46,8 +48,10 @@
|
||||
</div>
|
||||
} @else if (isStalkerCategoryFailed()) {
|
||||
<app-playlist-error-view
|
||||
[title]="'Unable to load categories'"
|
||||
[description]="'Please retry or verify portal settings.'"
|
||||
[title]="'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR' | translate"
|
||||
[description]="
|
||||
'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT' | translate
|
||||
"
|
||||
[showActionButtons]="false"
|
||||
/>
|
||||
} @else {
|
||||
|
||||
+19
-9
@@ -5,6 +5,7 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
@@ -30,6 +31,7 @@ interface XtreamCategoryLike {
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './workspace-context-panel.component.html',
|
||||
styleUrl: './workspace-context-panel.component.scss',
|
||||
@@ -76,26 +78,34 @@ export class WorkspaceContextPanelComponent {
|
||||
|
||||
readonly title = computed(() => {
|
||||
if (this.isXtreamCategories()) {
|
||||
if (this.section() === 'vod') return 'Movie Categories';
|
||||
if (this.section() === 'series') return 'Series Categories';
|
||||
return 'Live Categories';
|
||||
if (this.section() === 'vod') {
|
||||
return 'WORKSPACE.CONTEXT.MOVIE_CATEGORIES';
|
||||
}
|
||||
if (this.section() === 'series') {
|
||||
return 'WORKSPACE.CONTEXT.SERIES_CATEGORIES';
|
||||
}
|
||||
return 'WORKSPACE.CONTEXT.LIVE_CATEGORIES';
|
||||
}
|
||||
|
||||
if (this.isStalkerCategories()) {
|
||||
if (this.section() === 'vod') return 'Movie Categories';
|
||||
if (this.section() === 'series') return 'Series Categories';
|
||||
return 'Live Categories';
|
||||
if (this.section() === 'vod') {
|
||||
return 'WORKSPACE.CONTEXT.MOVIE_CATEGORIES';
|
||||
}
|
||||
if (this.section() === 'series') {
|
||||
return 'WORKSPACE.CONTEXT.SERIES_CATEGORIES';
|
||||
}
|
||||
return 'WORKSPACE.CONTEXT.LIVE_CATEGORIES';
|
||||
}
|
||||
|
||||
return 'Categories';
|
||||
return 'WORKSPACE.CONTEXT.CATEGORIES';
|
||||
});
|
||||
|
||||
readonly subtitle = computed(() => {
|
||||
if (this.isXtreamCategories()) {
|
||||
return 'Xtream source';
|
||||
return 'WORKSPACE.CONTEXT.XTREAM_SOURCE';
|
||||
}
|
||||
if (this.isStalkerCategories()) {
|
||||
return 'Stalker portal';
|
||||
return 'WORKSPACE.CONTEXT.STALKER_PORTAL';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
+24
-16
@@ -1,4 +1,5 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
|
||||
|
||||
@@ -12,15 +13,29 @@ import { FavoritesContextService } from '@iptvnator/portal/shared/util';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-workspace-favorites-context-panel',
|
||||
imports: [CategoryViewComponent],
|
||||
imports: [CategoryViewComponent, TranslatePipe],
|
||||
template: `
|
||||
<div class="context-column">
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>{{ title() }}</h2>
|
||||
<h2>
|
||||
{{
|
||||
selectedCategory()?.category_name ||
|
||||
('WORKSPACE.CONTEXT.FILTER_BY_TYPE' | translate)
|
||||
}}
|
||||
</h2>
|
||||
</div>
|
||||
@if (countBadge(); as text) {
|
||||
<span class="context-header__badge">{{ text }}</span>
|
||||
@if (selectedCount() !== null) {
|
||||
<span class="context-header__badge">
|
||||
@if (selectedCount() === 1) {
|
||||
{{ 'WORKSPACE.CONTEXT.ITEM_COUNT_ONE' | translate }}
|
||||
} @else {
|
||||
{{
|
||||
'WORKSPACE.CONTEXT.ITEM_COUNT_OTHER'
|
||||
| translate: { count: selectedCount() }
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</header>
|
||||
|
||||
@@ -38,23 +53,16 @@ import { FavoritesContextService } from '@iptvnator/portal/shared/util';
|
||||
export class WorkspaceFavoritesContextPanelComponent {
|
||||
readonly ctx = inject(FavoritesContextService);
|
||||
|
||||
readonly title = computed(() => {
|
||||
readonly selectedCategory = computed(() => {
|
||||
const categories = this.ctx.categories();
|
||||
if (!categories.length) return 'Filter by type';
|
||||
const selected = categories.find(
|
||||
return categories.find(
|
||||
(c) => c.category_id === this.ctx.selectedCategoryId()
|
||||
);
|
||||
return selected?.category_name ?? 'Filter by type';
|
||||
});
|
||||
|
||||
readonly countBadge = computed(() => {
|
||||
const categories = this.ctx.categories();
|
||||
const selected = categories.find(
|
||||
(c) => c.category_id === this.ctx.selectedCategoryId()
|
||||
);
|
||||
if (!selected || selected.count === undefined) return '';
|
||||
return `${selected.count} ${selected.count === 1 ? 'item' : 'items'}`;
|
||||
});
|
||||
readonly selectedCount = computed(
|
||||
() => this.selectedCategory()?.count ?? null
|
||||
);
|
||||
|
||||
onCategoryClicked(item: {
|
||||
category_id?: string | number;
|
||||
|
||||
+19
-8
@@ -15,12 +15,23 @@
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px 16px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
.settings-panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.settings-sections-list {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-panel-footer {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 8px 12px;
|
||||
border-top: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.settings-section-item:hover:not(.active):not(.selected),
|
||||
.settings-back-button:hover:not(.active):not(.selected) {
|
||||
background: var(--app-selection-surface);
|
||||
}
|
||||
+13
-16
@@ -10,25 +10,12 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
styleUrls: ['./workspace-settings-context-panel.component.scss'],
|
||||
template: `
|
||||
<h2 class="panel-title">{{ 'SETTINGS.TITLE' | translate }}</h2>
|
||||
<div class="nav-list" style="margin-top: 16px">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item"
|
||||
(click)="onBack()"
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>{{ 'SETTINGS.BACK_TO_HOME' | translate }}</span>
|
||||
</button>
|
||||
|
||||
<p class="panel-header" style="padding-top: 8px;">
|
||||
{{ 'SETTINGS.APP_PREFERENCES' | translate }}
|
||||
</p>
|
||||
<div class="nav-list">
|
||||
<div class="settings-panel-body">
|
||||
<div class="nav-list settings-sections-list">
|
||||
@for (section of ctx.sections(); track section.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item"
|
||||
class="nav-item settings-section-item"
|
||||
[class.active]="ctx.activeSection() === section.id"
|
||||
(click)="ctx.navigateToSection(section.id)"
|
||||
>
|
||||
@@ -38,6 +25,16 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-panel-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item settings-back-button"
|
||||
(click)="onBack()"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>{{ 'SETTINGS.BACK_TO_HOME' | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class WorkspaceSettingsContextPanelComponent {
|
||||
|
||||
+42
-21
@@ -3,17 +3,20 @@
|
||||
<a
|
||||
class="brand"
|
||||
routerLink="/workspace/dashboard"
|
||||
matTooltip="Dashboard (all playlists)"
|
||||
[matTooltip]="'WORKSPACE.SHELL.RAIL_DASHBOARD' | translate"
|
||||
[matTooltipPosition]="'right'"
|
||||
aria-label="Open dashboard"
|
||||
[attr.aria-label]="'WORKSPACE.SHELL.OPEN_DASHBOARD' | translate"
|
||||
>
|
||||
<img src="./assets/icons/icon-tv-256.png" alt="IPTVnator logo" />
|
||||
<img
|
||||
src="./assets/icons/icon-tv-256.png"
|
||||
[attr.alt]="'WORKSPACE.SHELL.BRAND_ALT' | translate"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<app-portal-rail-links
|
||||
variant="rail"
|
||||
activeClass="is-active"
|
||||
[links]="workspaceLinks"
|
||||
[links]="workspaceLinks()"
|
||||
/>
|
||||
|
||||
@if (primaryContextLinks().length > 0) {
|
||||
@@ -41,9 +44,9 @@
|
||||
class="rail-shortcut"
|
||||
routerLink="/workspace/settings"
|
||||
[class.is-active]="isSettingsRoute()"
|
||||
matTooltip="Settings (global)"
|
||||
[matTooltip]="'WORKSPACE.SHELL.RAIL_SETTINGS' | translate"
|
||||
[matTooltipPosition]="'right'"
|
||||
aria-label="Open settings"
|
||||
[attr.aria-label]="'WORKSPACE.SHELL.OPEN_SETTINGS' | translate"
|
||||
>
|
||||
<mat-icon>settings</mat-icon>
|
||||
</a>
|
||||
@@ -66,16 +69,20 @@
|
||||
(input)="onSearchInput($event)"
|
||||
(keydown.enter)="onSearchEnter($event)"
|
||||
[disabled]="!canUseSearch()"
|
||||
[placeholder]="searchPlaceholder()"
|
||||
aria-label="Search"
|
||||
[placeholder]="searchPlaceholder() | translate"
|
||||
[attr.aria-label]="'WORKSPACE.SHELL.SEARCH_LABEL' | translate"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="command-trigger"
|
||||
mat-icon-button
|
||||
(click)="openCommandPalette()"
|
||||
matTooltip="Command palette (Ctrl/Cmd+K)"
|
||||
aria-label="Open command palette"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.SHELL.COMMAND_PALETTE_SHORTCUT' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.OPEN_COMMAND_PALETTE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>keyboard_command_key</mat-icon>
|
||||
</button>
|
||||
@@ -86,8 +93,8 @@
|
||||
type="button"
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="addPlaylistMenu().menu()"
|
||||
matTooltip="Add playlist (all playlists)"
|
||||
aria-label="Add playlist"
|
||||
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST_ALL' | translate"
|
||||
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
@@ -100,8 +107,12 @@
|
||||
mat-icon-button
|
||||
routerLink="/workspace/global-favorites"
|
||||
[class.is-active]="isGlobalFavoritesRoute()"
|
||||
matTooltip="Global favorites"
|
||||
aria-label="Open global favorites"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.SHELL.OPEN_GLOBAL_FAVORITES' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.OPEN_GLOBAL_FAVORITES' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>star</mat-icon>
|
||||
</a>
|
||||
@@ -112,8 +123,8 @@
|
||||
mat-icon-button
|
||||
[class.is-active]="isDownloadsView()"
|
||||
(click)="openDownloadsShortcut()"
|
||||
matTooltip="Downloads"
|
||||
aria-label="Open downloads"
|
||||
[matTooltip]="'WORKSPACE.SHELL.RAIL_DOWNLOADS' | translate"
|
||||
[attr.aria-label]="'WORKSPACE.SHELL.OPEN_DOWNLOADS' | translate"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
@@ -124,8 +135,12 @@
|
||||
type="button"
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="contextActionsMenu"
|
||||
matTooltip="Section actions"
|
||||
aria-label="Open context actions"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.SHELL.RAIL_CONTEXT_ACTIONS' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.OPEN_CONTEXT_ACTIONS' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>more_horiz</mat-icon>
|
||||
</button>
|
||||
@@ -135,20 +150,26 @@
|
||||
<mat-menu #contextActionsMenu="matMenu">
|
||||
@if (!hasContextActions()) {
|
||||
<button mat-menu-item disabled>
|
||||
<span>No actions for this section</span>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.NO_SECTION_ACTIONS' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
} @else {
|
||||
@if (contextActionGroups().hasPlaylistActions) {
|
||||
@if (canOpenPlaylistInfo()) {
|
||||
<button mat-menu-item (click)="openPlaylistInfo()">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
<span>Playlist info (this playlist)</span>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.PLAYLIST_INFO' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
@if (canOpenAccountInfo()) {
|
||||
<button mat-menu-item (click)="openAccountInfo()">
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>Account info (this playlist)</span>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.ACCOUNT_INFO' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
+14
-4
@@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
FavoritesContextService,
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
@@ -142,19 +143,28 @@ describe('WorkspaceShellComponent action matrix', () => {
|
||||
openAccountInfo: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows manage categories action for xtream category sections', () => {
|
||||
it('detects xtream category routes and shows the context panel', () => {
|
||||
const component = TestBed.runInInjectionContext(
|
||||
() => new WorkspaceShellComponent()
|
||||
);
|
||||
|
||||
component.currentUrl.set('/workspace/xtreams/pl-1/vod');
|
||||
|
||||
expect(component.canManageCategories()).toBe(true);
|
||||
expect(component.contextActionGroups().hasSectionActions).toBe(true);
|
||||
expect(component.isCategoryContextRoute()).toBe(true);
|
||||
expect(component.showContextPanel()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows cleanup action for recent sections', () => {
|
||||
@@ -167,7 +177,7 @@ describe('WorkspaceShellComponent action matrix', () => {
|
||||
expect(component.headerBulkAction()).toEqual(
|
||||
expect.objectContaining({
|
||||
icon: 'delete_sweep',
|
||||
tooltip: 'Clear recently viewed',
|
||||
tooltip: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION',
|
||||
})
|
||||
);
|
||||
expect(component.hasContextActions()).toBe(true);
|
||||
|
||||
+207
-68
@@ -7,7 +7,10 @@ import {
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
takeUntilDestroyed,
|
||||
toSignal,
|
||||
} from '@angular/core/rxjs-interop';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
RouterOutlet,
|
||||
} from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
ExternalPlaybackDockComponent,
|
||||
ResizableDirective,
|
||||
@@ -36,12 +40,13 @@ import {
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistTitle,
|
||||
} from 'm3u-state';
|
||||
import { filter, firstValueFrom } from 'rxjs';
|
||||
import { filter, firstValueFrom, startWith } from 'rxjs';
|
||||
import {
|
||||
FavoritesContextService,
|
||||
buildPortalRailLinks,
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PortalRailLink,
|
||||
PortalRailSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PortalRailLinksComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
@@ -102,8 +107,9 @@ const SEARCH_INPUT_DEBOUNCE_MS = 350;
|
||||
WorkspaceFavoritesContextPanelComponent,
|
||||
WorkspaceSettingsContextPanelComponent,
|
||||
WorkspaceSourcesFiltersPanelComponent,
|
||||
PortalRailLinksComponent,
|
||||
ResizableDirective,
|
||||
PortalRailLinksComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './workspace-shell.component.html',
|
||||
styleUrl: './workspace-shell.component.scss',
|
||||
@@ -118,9 +124,14 @@ export class WorkspaceShellComponent {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
readonly favoritesCtx = inject(FavoritesContextService);
|
||||
readonly settingsCtx = inject(SettingsContextService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
private searchDebounceTimeoutId: ReturnType<typeof setTimeout> | null =
|
||||
null;
|
||||
@@ -149,19 +160,23 @@ export class WorkspaceShellComponent {
|
||||
readonly lastM3uPlaylistId = signal<string | null>(null);
|
||||
readonly isElectron = !!window.electron;
|
||||
readonly isMacOS = window.electron?.platform === 'darwin';
|
||||
readonly workspaceLinks: PortalRailLink[] = [
|
||||
{
|
||||
icon: 'dashboard',
|
||||
tooltip: 'Dashboard (all playlists)',
|
||||
path: ['/workspace/dashboard'],
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
icon: 'library_books',
|
||||
tooltip: 'Sources (all playlists)',
|
||||
path: ['/workspace/sources'],
|
||||
},
|
||||
];
|
||||
readonly workspaceLinks = computed<PortalRailLink[]>(() => {
|
||||
this.languageTick();
|
||||
|
||||
return [
|
||||
{
|
||||
icon: 'dashboard',
|
||||
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_DASHBOARD'),
|
||||
path: ['/workspace/dashboard'],
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
icon: 'library_books',
|
||||
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_SOURCES'),
|
||||
path: ['/workspace/sources'],
|
||||
},
|
||||
];
|
||||
});
|
||||
readonly currentUrl = signal(this.router.url);
|
||||
readonly currentContext = computed<WorkspaceContext | null>(() =>
|
||||
this.parseWorkspaceContext(this.currentUrl())
|
||||
@@ -300,76 +315,85 @@ export class WorkspaceShellComponent {
|
||||
this.externalPlayback.dismissActiveSession();
|
||||
}
|
||||
readonly searchPlaceholder = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
if (this.isSourcesRoute()) {
|
||||
return 'Search sources (all playlists)...';
|
||||
return 'WORKSPACE.SHELL.SEARCH_SOURCES_PLACEHOLDER';
|
||||
}
|
||||
|
||||
const context = this.currentContext();
|
||||
const section = this.currentSection();
|
||||
|
||||
if (!context) {
|
||||
if (this.dashboardXtreamContext()) {
|
||||
return 'Search in this playlist...';
|
||||
}
|
||||
return 'Search in this playlist...';
|
||||
return 'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
|
||||
}
|
||||
|
||||
if (context.provider === 'xtreams') {
|
||||
if (section === 'search') {
|
||||
return 'Search in this playlist...';
|
||||
return 'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
|
||||
}
|
||||
if (
|
||||
section === 'vod' ||
|
||||
section === 'series' ||
|
||||
section === 'live'
|
||||
) {
|
||||
return 'Search in this section...';
|
||||
return 'WORKSPACE.SHELL.SEARCH_SECTION_PLACEHOLDER';
|
||||
}
|
||||
if (section === 'favorites') {
|
||||
return 'Filter this section...';
|
||||
return 'WORKSPACE.SHELL.FILTER_SECTION_PLACEHOLDER';
|
||||
}
|
||||
if (section === 'recent') {
|
||||
return 'Filter this section...';
|
||||
return 'WORKSPACE.SHELL.FILTER_SECTION_PLACEHOLDER';
|
||||
}
|
||||
if (section === 'recently-added') {
|
||||
return 'Filter this section...';
|
||||
return 'WORKSPACE.SHELL.FILTER_SECTION_PLACEHOLDER';
|
||||
}
|
||||
if (section === 'downloads') {
|
||||
return 'Filter this section...';
|
||||
return 'WORKSPACE.SHELL.FILTER_SECTION_PLACEHOLDER';
|
||||
}
|
||||
}
|
||||
|
||||
if (context.provider === 'stalker') {
|
||||
if (section === 'search') {
|
||||
return 'Search in this playlist...';
|
||||
return 'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
|
||||
}
|
||||
if (section === 'favorites' || section === 'recent') {
|
||||
return 'Filter this section...';
|
||||
return 'WORKSPACE.SHELL.FILTER_SECTION_PLACEHOLDER';
|
||||
}
|
||||
return 'Search in this section...';
|
||||
return 'WORKSPACE.SHELL.SEARCH_SECTION_PLACEHOLDER';
|
||||
}
|
||||
|
||||
return 'Search in this playlist...';
|
||||
return 'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
|
||||
});
|
||||
readonly primaryContextLinks = computed<PortalRailLink[]>(() => {
|
||||
this.languageTick();
|
||||
|
||||
const context = this.railContext();
|
||||
if (!context) return [];
|
||||
return buildPortalRailLinks({
|
||||
provider: context.provider,
|
||||
playlistId: context.playlistId,
|
||||
isElectron: this.isElectron,
|
||||
workspace: true,
|
||||
}).primary;
|
||||
return this.translateRailLinks(
|
||||
buildPortalRailLinks({
|
||||
provider: context.provider,
|
||||
playlistId: context.playlistId,
|
||||
isElectron: this.isElectron,
|
||||
workspace: true,
|
||||
}).primary,
|
||||
context.provider
|
||||
);
|
||||
});
|
||||
readonly secondaryContextLinks = computed<PortalRailLink[]>(() => {
|
||||
this.languageTick();
|
||||
|
||||
const context = this.railContext();
|
||||
if (!context) return [];
|
||||
return buildPortalRailLinks({
|
||||
provider: context.provider,
|
||||
playlistId: context.playlistId,
|
||||
isElectron: this.isElectron,
|
||||
workspace: true,
|
||||
}).secondary.filter((link) => link.section !== 'downloads');
|
||||
return this.translateRailLinks(
|
||||
buildPortalRailLinks({
|
||||
provider: context.provider,
|
||||
playlistId: context.playlistId,
|
||||
isElectron: this.isElectron,
|
||||
workspace: true,
|
||||
}).secondary.filter((link) => link.section !== 'downloads'),
|
||||
context.provider
|
||||
);
|
||||
});
|
||||
readonly isDownloadsView = computed(
|
||||
() =>
|
||||
@@ -384,6 +408,8 @@ export class WorkspaceShellComponent {
|
||||
);
|
||||
readonly headerBulkAction = computed<WorkspaceHeaderBulkAction | null>(
|
||||
() => {
|
||||
this.languageTick();
|
||||
|
||||
const context = this.currentContext();
|
||||
const section = this.currentSection();
|
||||
const isGlobalDownloads = this.isGlobalDownloadsRoute();
|
||||
@@ -408,11 +434,19 @@ export class WorkspaceShellComponent {
|
||||
return {
|
||||
icon: 'delete_sweep',
|
||||
tooltip: isGlobalDownloads
|
||||
? 'Clear completed downloads (all playlists)'
|
||||
: 'Clear completed downloads (this playlist)',
|
||||
? this.translateText(
|
||||
'WORKSPACE.SHELL.CLEAR_COMPLETED_DOWNLOADS_ALL'
|
||||
)
|
||||
: this.translateText(
|
||||
'WORKSPACE.SHELL.CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST'
|
||||
),
|
||||
ariaLabel: isGlobalDownloads
|
||||
? 'Clear completed downloads for all playlists'
|
||||
: 'Clear completed downloads for this playlist',
|
||||
? this.translateText(
|
||||
'WORKSPACE.SHELL.CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA'
|
||||
)
|
||||
: this.translateText(
|
||||
'WORKSPACE.SHELL.CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA'
|
||||
),
|
||||
disabled: !hasClearable,
|
||||
};
|
||||
}
|
||||
@@ -424,8 +458,12 @@ export class WorkspaceShellComponent {
|
||||
if (context.provider === 'xtreams' && section === 'recent') {
|
||||
return {
|
||||
icon: 'delete_sweep',
|
||||
tooltip: 'Clear recently viewed (this section)',
|
||||
ariaLabel: 'Clear recently viewed for this section',
|
||||
tooltip: this.translateText(
|
||||
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION'
|
||||
),
|
||||
ariaLabel: this.translateText(
|
||||
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION_ARIA'
|
||||
),
|
||||
disabled: this.xtreamStore.recentItems().length === 0,
|
||||
};
|
||||
}
|
||||
@@ -433,8 +471,12 @@ export class WorkspaceShellComponent {
|
||||
if (context.provider === 'stalker' && section === 'recent') {
|
||||
return {
|
||||
icon: 'delete_sweep',
|
||||
tooltip: 'Clear recently viewed (this section)',
|
||||
ariaLabel: 'Clear recently viewed for this section',
|
||||
tooltip: this.translateText(
|
||||
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION'
|
||||
),
|
||||
ariaLabel: this.translateText(
|
||||
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION_ARIA'
|
||||
),
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
@@ -543,15 +585,31 @@ export class WorkspaceShellComponent {
|
||||
}
|
||||
|
||||
readonly playlistSubtitle = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
const active = this.activePlaylist();
|
||||
if (active?.serverUrl) return 'Xtream Code';
|
||||
if (active?.macAddress) return 'Stalker Portal';
|
||||
if (active?.count) return `${active.count} channels`;
|
||||
if (active?.serverUrl) {
|
||||
return this.translateText('WORKSPACE.SHELL.XTREAM_CODE');
|
||||
}
|
||||
if (active?.macAddress) {
|
||||
return this.translateText('WORKSPACE.SHELL.STALKER_PORTAL');
|
||||
}
|
||||
if (active?.count) {
|
||||
return this.translateText('WORKSPACE.SHELL.CHANNELS_COUNT', {
|
||||
count: active.count,
|
||||
});
|
||||
}
|
||||
|
||||
const sourcesCount = this.playlists().length;
|
||||
if (sourcesCount === 0) return 'No sources available';
|
||||
if (sourcesCount === 1) return '1 source available';
|
||||
return `${sourcesCount} sources available`;
|
||||
if (sourcesCount === 0) {
|
||||
return this.translateText('WORKSPACE.SHELL.NO_SOURCES_AVAILABLE');
|
||||
}
|
||||
if (sourcesCount === 1) {
|
||||
return this.translateText('WORKSPACE.SHELL.ONE_SOURCE_AVAILABLE');
|
||||
}
|
||||
return this.translateText('WORKSPACE.SHELL.SOURCES_AVAILABLE', {
|
||||
count: sourcesCount,
|
||||
});
|
||||
});
|
||||
|
||||
onSearchInput(event: Event): void {
|
||||
@@ -704,15 +762,23 @@ export class WorkspaceShellComponent {
|
||||
return [
|
||||
{
|
||||
id: 'global-search',
|
||||
label: 'Search all Xtream playlists',
|
||||
description: 'Open global search overlay',
|
||||
label: this.translateText(
|
||||
'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_LABEL'
|
||||
),
|
||||
description: this.translateText(
|
||||
'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_DESCRIPTION'
|
||||
),
|
||||
scope: 'global',
|
||||
enabled: hasXtreamPlaylists,
|
||||
},
|
||||
{
|
||||
id: 'playlist-search',
|
||||
label: 'Search this playlist',
|
||||
description: 'Open playlist search route',
|
||||
label: this.translateText(
|
||||
'WORKSPACE.SHELL.COMMANDS.PLAYLIST_SEARCH_LABEL'
|
||||
),
|
||||
description: this.translateText(
|
||||
'WORKSPACE.SHELL.COMMANDS.PLAYLIST_SEARCH_DESCRIPTION'
|
||||
),
|
||||
scope: 'playlist',
|
||||
enabled: Boolean(
|
||||
dashboardXtream ||
|
||||
@@ -723,22 +789,34 @@ export class WorkspaceShellComponent {
|
||||
},
|
||||
{
|
||||
id: 'open-global-favorites',
|
||||
label: 'Open global favorites',
|
||||
description: 'Navigate to aggregated favorites',
|
||||
label: this.translateText(
|
||||
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_FAVORITES_LABEL'
|
||||
),
|
||||
description: this.translateText(
|
||||
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_FAVORITES_DESCRIPTION'
|
||||
),
|
||||
scope: 'global',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'open-downloads',
|
||||
label: 'Open downloads',
|
||||
description: 'Navigate to downloads view',
|
||||
label: this.translateText(
|
||||
'WORKSPACE.SHELL.COMMANDS.OPEN_DOWNLOADS_LABEL'
|
||||
),
|
||||
description: this.translateText(
|
||||
'WORKSPACE.SHELL.COMMANDS.OPEN_DOWNLOADS_DESCRIPTION'
|
||||
),
|
||||
scope: 'global',
|
||||
enabled: this.isElectron,
|
||||
},
|
||||
{
|
||||
id: 'open-global-recent',
|
||||
label: 'Open recently viewed',
|
||||
description: 'Open global recently viewed overlay',
|
||||
label: this.translateText(
|
||||
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_RECENT_LABEL'
|
||||
),
|
||||
description: this.translateText(
|
||||
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_RECENT_DESCRIPTION'
|
||||
),
|
||||
scope: 'global',
|
||||
enabled: true,
|
||||
},
|
||||
@@ -1018,4 +1096,65 @@ export class WorkspaceShellComponent {
|
||||
}
|
||||
return 'playlists';
|
||||
}
|
||||
|
||||
private translateText(
|
||||
key: string,
|
||||
params?: Record<string, string | number>
|
||||
): string {
|
||||
return this.translate.instant(key, params);
|
||||
}
|
||||
|
||||
private translateRailLinks(
|
||||
links: PortalRailLink[],
|
||||
provider: WorkspaceContext['provider']
|
||||
): PortalRailLink[] {
|
||||
return links.map((link) => ({
|
||||
...link,
|
||||
tooltip: this.translateText(
|
||||
this.getRailTooltipKey(provider, link.section)
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private getRailTooltipKey(
|
||||
provider: WorkspaceContext['provider'],
|
||||
section?: PortalRailSection
|
||||
): string {
|
||||
if (provider === 'xtreams' && section === 'library') {
|
||||
return 'WORKSPACE.SHELL.RAIL_LIBRARY';
|
||||
}
|
||||
|
||||
if (section === 'vod') {
|
||||
return 'WORKSPACE.SHELL.RAIL_MOVIES';
|
||||
}
|
||||
if (section === 'live' || section === 'itv') {
|
||||
return 'WORKSPACE.SHELL.RAIL_LIVE';
|
||||
}
|
||||
if (section === 'series') {
|
||||
return 'WORKSPACE.SHELL.RAIL_SERIES';
|
||||
}
|
||||
if (section === 'recently-added') {
|
||||
return 'WORKSPACE.SHELL.RAIL_RECENTLY_ADDED';
|
||||
}
|
||||
if (section === 'search') {
|
||||
return 'WORKSPACE.SHELL.RAIL_SEARCH';
|
||||
}
|
||||
if (section === 'recent') {
|
||||
return 'WORKSPACE.SHELL.RAIL_RECENT';
|
||||
}
|
||||
if (section === 'favorites') {
|
||||
return 'WORKSPACE.SHELL.RAIL_FAVORITES';
|
||||
}
|
||||
if (section === 'downloads') {
|
||||
return 'WORKSPACE.SHELL.RAIL_DOWNLOADS';
|
||||
}
|
||||
if (section === 'all') {
|
||||
return 'WORKSPACE.SHELL.RAIL_ALL_CHANNELS';
|
||||
}
|
||||
if (section === 'groups') {
|
||||
return 'WORKSPACE.SHELL.RAIL_GROUPS';
|
||||
}
|
||||
|
||||
return 'WORKSPACE.SHELL.RAIL_CONTEXT_ACTIONS';
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -1,16 +1,18 @@
|
||||
<div class="context-column">
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>Sources</h2>
|
||||
<h2>{{ 'WORKSPACE.SOURCES.TITLE' | translate }}</h2>
|
||||
</div>
|
||||
<p>Filter and sort playlists</p>
|
||||
<p>{{ 'WORKSPACE.SOURCES.SUBTITLE' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="context-divider"></div>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="nav-list option-list">
|
||||
<h3 class="panel-section-title">Type</h3>
|
||||
<h3 class="panel-section-title">
|
||||
{{ 'WORKSPACE.SOURCES.TYPE' | translate }}
|
||||
</h3>
|
||||
@for (option of typeOptions; track option.id) {
|
||||
<a
|
||||
href="#type-{{ option.id }}"
|
||||
|
||||
+2
-5
@@ -16,7 +16,7 @@ type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker';
|
||||
interface PlaylistFilterOption {
|
||||
id: PlaylistFilterId;
|
||||
icon: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
translationKey?: string;
|
||||
}
|
||||
|
||||
@@ -52,25 +52,22 @@ export class WorkspaceSourcesFiltersPanelComponent {
|
||||
{
|
||||
id: 'all',
|
||||
icon: 'layers',
|
||||
label: 'All',
|
||||
translationKey: 'WORKSPACE.SOURCES.ALL',
|
||||
},
|
||||
{
|
||||
id: 'm3u',
|
||||
icon: 'playlist_play',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
|
||||
label: 'M3U',
|
||||
},
|
||||
{
|
||||
id: 'xtream',
|
||||
icon: 'cloud',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
|
||||
label: 'Xtream',
|
||||
},
|
||||
{
|
||||
id: 'stalker',
|
||||
icon: 'router',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
|
||||
label: 'Stalker',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+31
-6
@@ -6,8 +6,9 @@ import {
|
||||
PlaylistType,
|
||||
RecentPlaylistsComponent,
|
||||
} from '@iptvnator/playlist/shared/ui';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { selectActiveTypeFilters, selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { map } from 'rxjs';
|
||||
import { map, startWith } from 'rxjs';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
@Component({
|
||||
@@ -20,6 +21,7 @@ export class WorkspaceSourcesComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly store = inject(Store);
|
||||
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
private readonly activeTypeFilters = this.store.selectSignal(
|
||||
selectActiveTypeFilters
|
||||
@@ -27,27 +29,35 @@ export class WorkspaceSourcesComponent {
|
||||
private readonly playlists = this.store.selectSignal(
|
||||
selectAllPlaylistsMeta
|
||||
);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
readonly searchQuery = toSignal(
|
||||
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
|
||||
{ initialValue: '' }
|
||||
);
|
||||
readonly title = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
const filters = this.activeTypeFilters();
|
||||
|
||||
if (filters.length === 1) {
|
||||
if (filters[0] === 'm3u') {
|
||||
return 'M3U Playlists';
|
||||
return this.translateText('WORKSPACE.SOURCES.M3U_PLAYLISTS');
|
||||
}
|
||||
if (filters[0] === 'xtream') {
|
||||
return 'Xtream Playlists';
|
||||
return this.translateText('WORKSPACE.SOURCES.XTREAM_PLAYLISTS');
|
||||
}
|
||||
if (filters[0] === 'stalker') {
|
||||
return 'Stalker Playlists';
|
||||
return this.translateText(
|
||||
'WORKSPACE.SOURCES.STALKER_PLAYLISTS'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return 'All Playlists';
|
||||
return this.translateText('WORKSPACE.SOURCES.ALL_PLAYLISTS');
|
||||
});
|
||||
|
||||
readonly visibleSourcesCount = computed(() => {
|
||||
@@ -78,11 +88,26 @@ export class WorkspaceSourcesComponent {
|
||||
});
|
||||
|
||||
readonly subtitle = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
const count = this.visibleSourcesCount();
|
||||
return `${count} ${count === 1 ? 'playlist' : 'playlists'}`;
|
||||
if (count === 1) {
|
||||
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_ONE');
|
||||
}
|
||||
|
||||
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_OTHER', {
|
||||
count,
|
||||
});
|
||||
});
|
||||
|
||||
onAddPlaylist(playlistType: PlaylistType): void {
|
||||
this.workspaceActions.openAddPlaylistDialog(playlistType);
|
||||
}
|
||||
|
||||
private translateText(
|
||||
key: string,
|
||||
params?: Record<string, string | number>
|
||||
): string {
|
||||
return this.translate.instant(key, params);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user