style(settings): update look&feel, add missing i18n strings

This commit is contained in:
4gray committed 2026-03-10 08:37:46 +01:00
1 parent bf7adcd7b4
commit 0012fcfa7b
43 files changed
+1334 -521

No files matched your search

+8 -3
View File
@@ -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 }) => {
+2 -10
View File
@@ -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');
}
));
});
});
+73 -4
View File
@@ -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>
+270 -154
View File
@@ -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);
+46 -24
View File
@@ -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',
}
);
}
}
@@ -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>
@@ -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 {
@@ -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>
@@ -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,
@@ -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
@@ -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();
@@ -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';
}
}
@@ -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 {
@@ -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 '';
});
@@ -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;
@@ -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);
}
@@ -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 {
@@ -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>
}
}
@@ -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);
@@ -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';
}
}
@@ -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 }}"
@@ -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',
},
];
@@ -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);
}
}