refactor(dashboard): improvements for dashboard widgets

- Updated dashboard activity items styles for improved layout and media representation.
- Introduced new styles for movie and series media types.
- Enhanced placeholder styles for live, movie, and series items using oklch color model.
- Added hover effects for activity items and action buttons in grid mode.
- Refactored dashboard widget shell styles for better alignment and padding.
- Created new on-air widget component with category filtering and loading skeletons.
- Implemented responsive design for on-air widget and improved empty state handling.
- Removed deprecated recent sources widget and integrated recent sources functionality into existing components.
- Updated recently watched widget to include action links for adding sources.
- Enhanced source stats widget with new styling for different source types (Xtream, Stalker, M3U).
This commit is contained in:
4gray committed 2026-03-08 11:23:34 +01:00
1 parent a8fd16b16d
commit 268a710bf2
27 files changed
+1487 -352

No files matched your search

@@ -1,4 +1,4 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Injectable, NgZone, computed, inject, signal } from '@angular/core';
import { Store } from '@ngrx/store';
import {
PlaylistActions,
@@ -58,6 +58,7 @@ interface DashboardNavigationTarget {
export class DashboardDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly ngZone = inject(NgZone);
private readonly xtreamGlobalRecentItems = signal<GlobalRecentItem[]>([]);
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
@@ -133,9 +134,13 @@ export class DashboardDataService {
const normalized = recentItems.map((item) =>
this.mapGlobalRecent(item)
);
this.xtreamGlobalRecentItems.set(normalized);
} catch {
this.xtreamGlobalRecentItems.set([]);
this.ngZone.run(() => this.xtreamGlobalRecentItems.set(normalized));
} catch (err) {
console.warn(
'[DashboardData] Failed to reload global recent items',
err
);
this.ngZone.run(() => this.xtreamGlobalRecentItems.set([]));
}
}
@@ -150,9 +155,13 @@ export class DashboardDataService {
const normalized = favorites.map((item) =>
this.mapGlobalFavorite(item)
);
this.xtreamGlobalFavorites.set(normalized);
} catch {
this.xtreamGlobalFavorites.set([]);
this.ngZone.run(() => this.xtreamGlobalFavorites.set(normalized));
} catch (err) {
console.warn(
'[DashboardData] Failed to reload global favorites',
err
);
this.ngZone.run(() => this.xtreamGlobalFavorites.set([]));
}
}
@@ -1,7 +1,7 @@
export type DashboardWidgetType =
| 'recent-sources'
| 'source-stats'
| 'continue-watching'
| 'on-air'
| 'recently-watched'
| 'global-favorites';
@@ -33,7 +33,7 @@ export interface DashboardLayoutState {
widgets: DashboardWidgetConfig[];
}
export const DASHBOARD_LAYOUT_VERSION = 6;
export const DASHBOARD_LAYOUT_VERSION = 12;
export const DASHBOARD_WIDGET_SIZE_OPTIONS: DashboardWidgetSize[] = [
'one-third',
'half',
@@ -58,22 +58,13 @@ export function createDefaultWidgetScope(): DashboardWidgetScopeSettings {
}
export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
{
id: 'recent-sources',
type: 'recent-sources',
title: 'Recent Sources',
description: 'Latest added or refreshed playlists across providers.',
size: 'half',
enabled: true,
order: 0,
},
{
id: 'source-stats',
type: 'source-stats',
title: 'Source Statistics',
description: 'Current source mix and total connected libraries.',
size: 'one-third',
enabled: true,
enabled: false,
order: 1,
},
{
@@ -81,10 +72,19 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
type: 'continue-watching',
title: 'Continue Watching',
description: 'Jump back into the last active source.',
size: 'half',
size: 'full',
enabled: true,
order: 2,
},
{
id: 'on-air',
type: 'on-air',
title: 'On Air Now',
description: 'Live programs currently airing across your EPG channels.',
size: 'full',
enabled: true,
order: 3,
},
{
id: 'recently-watched',
type: 'recently-watched',
@@ -92,7 +92,7 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
description: 'Global watch history across channels, VOD and series.',
size: 'two-thirds',
enabled: true,
order: 3,
order: 4,
settings: {
scope: createDefaultWidgetScope(),
},
@@ -104,7 +104,7 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
description: 'Pinned items across all connected providers.',
size: 'one-third',
enabled: true,
order: 4,
order: 5,
settings: {
scope: createDefaultWidgetScope(),
},
@@ -1,43 +1,45 @@
<section class="dashboard-page">
<header class="dashboard-header">
<div>
<p class="eyebrow">Workspace Dashboard</p>
<h1>Control Center</h1>
<p class="description">
Keep your daily sources in one place. In customize mode, edit
directly on the canvas, then use the sidebar for visibility and
scope.
</p>
</div>
</header>
<section
class="dashboard-toolbar"
[class.dashboard-toolbar--editing]="editMode()"
class="dashboard-topbar"
[class.dashboard-topbar--editing]="editMode()"
>
<p class="toolbar-hint">
@if (editMode()) {
Drag cards by the handle. Resize inline. Use the sidebar for
visibility and scope.
} @else {
Start customize mode to rearrange and resize widgets in place.
}
</p>
<div class="toolbar-actions">
@if (editMode()) {
@if (editMode()) {
<p class="topbar-edit-hint">
{{ 'WORKSPACE.DASHBOARD.TOOLBAR_HINT_EDITING' | translate }}
</p>
<div class="topbar-edit-actions">
<button
type="button"
mat-button
(click)="layoutService.reset()"
>
Reset Layout
{{ 'WORKSPACE.DASHBOARD.TOOLBAR_RESET' | translate }}
</button>
}
<button type="button" mat-flat-button (click)="toggleEditMode()">
<mat-icon>{{ editMode() ? 'check' : 'tune' }}</mat-icon>
{{ editMode() ? 'Done' : 'Customize' }}
<button
type="button"
mat-stroked-button
(click)="toggleEditMode()"
>
<mat-icon>check</mat-icon>
{{ 'WORKSPACE.DASHBOARD.TOOLBAR_DONE' | translate }}
</button>
</div>
} @else {
<p class="topbar-label">
{{ 'WORKSPACE.DASHBOARD.TOPBAR_LABEL' | translate }}
</p>
<button
type="button"
mat-icon-button
(click)="toggleEditMode()"
[matTooltip]="
'WORKSPACE.DASHBOARD.TOOLBAR_CUSTOMIZE' | translate
"
aria-label="Customize dashboard layout"
>
<mat-icon>tune</mat-icon>
</button>
</div>
}
</section>
<section
@@ -80,26 +82,31 @@
>
<mat-icon>drag_indicator</mat-icon>
</button>
<p class="widget-inline-editor__title">
{{ widget.title }}
</p>
<mat-button-toggle-group
class="inline-size-control"
[value]="widget.size"
(change)="
setWidgetSize(widget.id, $event.value)
"
aria-label="Widget width"
>
@for (
size of sizeOptions;
track size.value
) {
<mat-button-toggle [value]="size.value">
{{ size.label }}
</mat-button-toggle>
}
</mat-button-toggle-group>
<div class="size-stepper">
<button
type="button"
mat-icon-button
class="size-stepper__btn"
[disabled]="isFirstSize(widget.size)"
(click)="cycleWidgetSize(widget.id, -1)"
aria-label="Make widget narrower"
>
<mat-icon>chevron_left</mat-icon>
</button>
<span class="size-stepper__label">{{
getSizeLabel(widget.size)
}}</span>
<button
type="button"
mat-icon-button
class="size-stepper__btn"
[disabled]="isLastSize(widget.size)"
(click)="cycleWidgetSize(widget.id, 1)"
aria-label="Make widget wider"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<button
type="button"
mat-icon-button
@@ -115,8 +122,17 @@
</article>
} @empty {
<article class="empty-state">
<h3>No visible widgets</h3>
<p>Enable at least one widget from the sidebar.</p>
<h3>
{{
'WORKSPACE.DASHBOARD.EMPTY_WIDGETS' | translate
}}
</h3>
<p>
{{
'WORKSPACE.DASHBOARD.EMPTY_WIDGETS_HINT'
| translate
}}
</p>
</article>
}
</section>
@@ -125,10 +141,17 @@
@if (editMode()) {
<aside class="widget-library">
<section class="layout-editor">
<h2>Widget Library</h2>
<h2>
{{
'WORKSPACE.DASHBOARD.WIDGET_LIBRARY_TITLE'
| translate
}}
</h2>
<p class="layout-editor__hint">
Show/hide widgets and configure provider scope without
leaving the canvas.
{{
'WORKSPACE.DASHBOARD.WIDGET_LIBRARY_HINT'
| translate
}}
</p>
<div class="editor-list">
@for (widget of allWidgets(); track widget.id) {
@@ -139,7 +162,10 @@
</div>
<div class="editor-item__actions">
<p class="editor-item__action-label">
Visibility
{{
'WORKSPACE.DASHBOARD.WIDGET_VISIBILITY'
| translate
}}
</p>
<mat-slide-toggle
[checked]="widget.enabled"
@@ -148,19 +174,16 @@
widget.id
)
"
color="primary"
>
Visible
</mat-slide-toggle>
></mat-slide-toggle>
</div>
@if (supportsScope(widget)) {
<mat-expansion-panel class="advanced-panel">
<mat-expansion-panel-header>
<mat-panel-title
>Advanced
settings</mat-panel-title
>
<mat-panel-title>{{
'WORKSPACE.DASHBOARD.WIDGET_ADVANCED'
| translate
}}</mat-panel-title>
<mat-panel-description>
Scope ({{
getScopeProviderSummary(
@@ -171,10 +194,16 @@
</mat-expansion-panel-header>
<section class="scope-editor">
<p class="scope-editor__title">
Scope
{{
'WORKSPACE.DASHBOARD.WIDGET_SCOPE'
| translate
}}
</p>
<p class="scope-editor__subtitle">
Provider filters:
{{
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_PROVIDERS'
| translate
}}
{{
getScopeProviderSummary(
widget
@@ -185,8 +214,10 @@
<p
class="scope-editor__subtitle"
>
No providers selected means
all providers are included.
{{
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_ALL_HINT'
| translate
}}
</p>
}
<mat-chip-listbox
@@ -221,9 +252,10 @@
</mat-chip-listbox>
<p class="scope-editor__subtitle">
Playlists (leave empty to use
all playlists in current
provider scope)
{{
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_PLAYLISTS'
| translate
}}
</p>
<div class="scope-playlists">
@for (
@@ -254,8 +286,10 @@
</mat-checkbox>
} @empty {
<p class="scope-empty">
No playlists available
for selected providers.
{{
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_EMPTY'
| translate
}}
</p>
}
</div>
@@ -16,7 +16,7 @@
var(--mat-sys-surface-container-high) 88%
);
--widget-grid-gap: 12px;
--dashboard-widget-height: 530px;
--dashboard-widget-height: 400px;
}
.dashboard-page {
@@ -24,84 +24,62 @@
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 14px;
gap: 6px;
min-width: 0;
overflow-x: clip;
}
.dashboard-header {
border: 1px solid var(--dashboard-frame-border);
border-radius: 18px;
background:
radial-gradient(
1100px 300px at 105% -40%,
color-mix(in srgb, var(--dashboard-accent) 24%, transparent),
transparent 58%
),
linear-gradient(
165deg,
var(--mat-sys-surface-container-highest) 0%,
var(--mat-sys-surface-container-high) 55%,
var(--mat-sys-surface-container) 100%
);
padding: 20px;
box-shadow: 0 16px 30px -28px var(--dashboard-frame-glow);
}
.eyebrow {
margin: 0;
color: var(--mat-sys-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.11em;
font-size: 0.72rem;
font-weight: 700;
}
h1 {
margin: 8px 0 10px;
font-size: clamp(1.44rem, 2vw, 1.95rem);
line-height: 1.14;
}
.description {
margin: 0;
color: var(--mat-sys-on-surface-variant);
max-width: 780px;
line-height: 1.45;
}
.dashboard-toolbar {
border: 1px solid var(--dashboard-frame-border);
border-radius: 14px;
background: linear-gradient(
180deg,
var(--mat-sys-surface-container-high) 0%,
var(--mat-sys-surface-container) 100%
);
padding: 10px 12px;
// ─── Dashboard Topbar ────────────────────────────────────────────────────────
.dashboard-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
height: 44px;
padding: 0 4px 0 10px;
border-radius: 12px;
}
.dashboard-toolbar--editing {
.dashboard-topbar--editing {
position: sticky;
top: 8px;
z-index: 20;
box-shadow: 0 14px 26px -24px var(--dashboard-frame-glow);
padding: 0 8px 0 12px;
border: 1px solid var(--dashboard-frame-border);
background: linear-gradient(
180deg,
var(--mat-sys-surface-container-highest) 0%,
var(--mat-sys-surface-container-high) 100%
);
box-shadow: 0 8px 24px -16px var(--dashboard-frame-glow);
backdrop-filter: blur(8px);
}
.toolbar-hint {
.topbar-label {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--mat-sys-on-surface-variant);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.6;
}
.toolbar-actions {
.topbar-edit-hint {
margin: 0;
font-size: 0.82rem;
color: var(--mat-sys-on-surface-variant);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-edit-actions {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
flex-shrink: 0;
}
@@ -146,12 +124,16 @@ h1 {
}
.layout-editor {
padding: 14px;
padding: 14px 14px 24px;
}
.layout-editor h2 {
margin: 0;
font-size: 1rem;
margin: 0 0 4px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--mat-sys-on-surface-variant);
}
.layout-editor__hint {
@@ -184,7 +166,9 @@ h1 {
.editor-item__meta h3 {
margin: 0;
font-size: 0.92rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mat-sys-on-surface);
}
.editor-item__meta p {
@@ -199,7 +183,9 @@ h1 {
gap: 8px;
width: 100%;
justify-content: space-between;
padding-top: 4px;
padding-top: 6px;
border-top: 1px solid color-mix(in srgb, var(--dashboard-frame-border) 50%, transparent);
margin-top: 6px;
}
.editor-item__action-label {
@@ -327,6 +313,8 @@ h1 {
border-color 0.2s ease;
z-index: 1;
height: var(--dashboard-widget-height);
// Suppress the inner mat-card outlined border — slot provides the only visible border
--mat-card-outline-width: 0;
}
.widget-slot:hover {
@@ -336,13 +324,17 @@ h1 {
0 18px 28px -20px var(--dashboard-frame-glow);
}
.widget-slot[data-widget-type="source-stats"] {
// Content-light widgets: collapse to their natural height instead of the fixed row height
.widget-slot[data-widget-type='source-stats'],
.widget-slot[data-widget-type='continue-watching'] {
height: auto;
align-self: start;
}
.widget-slot--editing {
cursor: grab;
// Push mat-card content below the floating inline editor bar
padding-top: 52px;
}
.widget-slot--editing:active {
@@ -374,29 +366,60 @@ h1 {
backdrop-filter: blur(4px);
}
.widget-inline-editor__title {
margin: 0;
font-size: 0.74rem;
font-weight: 600;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drag-handle {
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
.inline-size-control {
// ── Size stepper ─────────────────────────────────────────────────────────────
.size-stepper {
display: flex;
align-items: center;
gap: 0;
margin-left: auto;
background: var(--mat-sys-surface-container);
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
background: var(--mat-sys-surface-container);
overflow: hidden;
flex-shrink: 0;
}
.size-stepper__btn {
width: 32px !important;
height: 32px !important;
border-radius: 0 !important;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&[disabled] {
opacity: 0.3;
}
}
.size-stepper__label {
font-size: 0.75rem;
font-weight: 600;
color: var(--mat-sys-on-surface);
min-width: 26px;
text-align: center;
letter-spacing: 0.02em;
border-left: 1px solid var(--mat-sys-outline-variant);
border-right: 1px solid var(--mat-sys-outline-variant);
padding: 0 2px;
line-height: 32px;
height: 32px;
display: inline-block;
}
.widget-inline-editor__hide {
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
.widget-slot app-dashboard-widget-host {
@@ -471,15 +494,10 @@ h1 {
padding: 14px;
}
.dashboard-toolbar {
flex-direction: column;
align-items: flex-start;
}
.toolbar-actions {
width: 100%;
justify-content: flex-start;
.dashboard-topbar--editing {
flex-wrap: wrap;
height: auto;
padding: 8px 8px;
}
.dashboard-editor-shell--editing {
@@ -496,15 +514,11 @@ h1 {
}
.widget-inline-editor {
flex-wrap: wrap;
gap: 4px;
}
.widget-inline-editor__title {
max-width: none;
}
.inline-size-control {
margin-left: 0;
.size-stepper__label {
display: none;
}
.widget-grid {
@@ -1,22 +1,27 @@
import { computed, Component, inject, signal } from '@angular/core';
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import { Component, computed, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatExpansionModule } from '@angular/material/expansion';
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 { selectAllPlaylistsMeta } from 'm3u-state';
import { DashboardLayoutService } from 'workspace-dashboard-data-access';
import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
import {
DashboardLayoutService,
DashboardWidgetConfig,
DashboardWidgetProvider,
DashboardWidgetSize,
createDefaultWidgetScope,
} from 'workspace-dashboard-data-access';
import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
@Component({
selector: 'app-workspace-dashboard',
@@ -24,12 +29,13 @@ import {
DashboardWidgetHostComponent,
DragDropModule,
MatButtonModule,
MatButtonToggleModule,
MatCheckboxModule,
MatChipsModule,
MatExpansionModule,
MatIcon,
MatSlideToggleModule,
MatTooltip,
TranslatePipe,
],
templateUrl: './workspace-dashboard.component.html',
styleUrl: './workspace-dashboard.component.scss',
@@ -44,21 +50,26 @@ export class WorkspaceDashboardComponent {
'xtream',
'stalker',
];
readonly sizeOptions: Array<{ value: DashboardWidgetSize; label: string }> = [
{ value: 'one-third', label: '1/3' },
{ value: 'half', label: '1/2' },
{ value: 'two-thirds', label: '2/3' },
{ value: 'full', label: 'Full' },
private readonly sizeOrder: DashboardWidgetSize[] = [
'one-third',
'half',
'two-thirds',
'full',
];
readonly allWidgets = computed(() =>
[...this.layoutService.state().widgets].sort((a, b) => a.order - b.order)
[...this.layoutService.state().widgets].sort(
(a, b) => a.order - b.order
)
);
readonly visibleWidgets = computed(() =>
this.allWidgets().filter((widget) => widget.enabled)
);
constructor(readonly layoutService: DashboardLayoutService) {}
constructor(readonly layoutService: DashboardLayoutService) {
// Data reload is handled reactively by DashboardDataService
// via Router NavigationEnd events
}
toggleEditMode(): void {
this.editMode.update((value) => !value);
@@ -70,7 +81,9 @@ export class WorkspaceDashboardComponent {
}
const reordered = [...this.visibleWidgets()];
moveItemInArray(reordered, event.previousIndex, event.currentIndex);
this.layoutService.reorderVisibleWidgets(reordered.map((widget) => widget.id));
this.layoutService.reorderVisibleWidgets(
reordered.map((widget) => widget.id)
);
}
setWidgetSize(widgetId: string, size: unknown): void {
@@ -84,6 +97,32 @@ export class WorkspaceDashboardComponent {
}
}
cycleWidgetSize(widgetId: string, dir: 1 | -1): void {
const widget = this.allWidgets().find((w) => w.id === widgetId);
if (!widget) return;
const idx = this.sizeOrder.indexOf(widget.size);
const next = this.sizeOrder[idx + dir];
if (next) this.layoutService.setWidgetSize(widgetId, next);
}
getSizeLabel(size: DashboardWidgetSize): string {
const labels: Record<DashboardWidgetSize, string> = {
'one-third': '1/3',
half: '1/2',
'two-thirds': '2/3',
full: 'Full',
};
return labels[size];
}
isFirstSize(size: DashboardWidgetSize): boolean {
return this.sizeOrder.indexOf(size) === 0;
}
isLastSize(size: DashboardWidgetSize): boolean {
return this.sizeOrder.indexOf(size) === this.sizeOrder.length - 1;
}
hideWidget(widgetId: string): void {
this.layoutService.toggleWidget(widgetId);
}
@@ -103,7 +142,8 @@ export class WorkspaceDashboardComponent {
}
getScopeProviderCount(widget: DashboardWidgetConfig): number {
return (widget.settings?.scope ?? createDefaultWidgetScope()).providers.length;
return (widget.settings?.scope ?? createDefaultWidgetScope()).providers
.length;
}
getScopeProviderSummary(widget: DashboardWidgetConfig): string {
@@ -120,27 +160,28 @@ export class WorkspaceDashboardComponent {
provider: DashboardWidgetProvider
): boolean {
return (
(widget.settings?.scope ?? createDefaultWidgetScope()).providers.includes(
provider
)
);
widget.settings?.scope ?? createDefaultWidgetScope()
).providers.includes(provider);
}
getScopePlaylists(widget: DashboardWidgetConfig) {
const scope = widget.settings?.scope ?? createDefaultWidgetScope();
const scopedProviders =
scope.providers.length === 0 ? this.providerOptions : scope.providers;
scope.providers.length === 0
? this.providerOptions
: scope.providers;
return this.playlists().filter((playlist) =>
scopedProviders.includes(this.getPlaylistProviderType(playlist))
);
}
isPlaylistSelected(widget: DashboardWidgetConfig, playlistId: string): boolean {
isPlaylistSelected(
widget: DashboardWidgetConfig,
playlistId: string
): boolean {
return (
(widget.settings?.scope ?? createDefaultWidgetScope()).playlistIds.includes(
playlistId
)
);
widget.settings?.scope ?? createDefaultWidgetScope()
).playlistIds.includes(playlistId);
}
toggleScopeProvider(
@@ -1,13 +1,13 @@
@switch (widget().type) {
@case ('recent-sources') {
<app-recent-sources-widget />
}
@case ('source-stats') {
<app-source-stats-widget />
}
@case ('continue-watching') {
<app-continue-watching-widget />
}
@case ('on-air') {
<app-on-air-widget />
}
@case ('recently-watched') {
<app-recently-watched-widget [widget]="widget()" />
}
@@ -3,7 +3,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { DashboardWidgetConfig } from 'workspace-dashboard-data-access';
import { ContinueWatchingWidgetComponent } from './widgets/continue-watching-widget.component';
import { GlobalFavoritesWidgetComponent } from './widgets/global-favorites-widget.component';
import { RecentSourcesWidgetComponent } from './widgets/recent-sources-widget.component';
import { OnAirWidgetComponent } from './widgets/on-air-widget.component';
import { RecentlyWatchedWidgetComponent } from './widgets/recently-watched-widget.component';
import { SourceStatsWidgetComponent } from './widgets/source-stats-widget.component';
@@ -12,7 +12,7 @@ import { SourceStatsWidgetComponent } from './widgets/source-stats-widget.compon
imports: [
ContinueWatchingWidgetComponent,
GlobalFavoritesWidgetComponent,
RecentSourcesWidgetComponent,
OnAirWidgetComponent,
RecentlyWatchedWidgetComponent,
SourceStatsWidgetComponent,
TranslatePipe,
@@ -2,36 +2,47 @@
[eyebrow]="'WORKSPACE.DASHBOARD.FOCUS' | translate"
[title]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
>
@if (data.activePlaylist(); as active) {
<article class="focus-source">
<p class="focus-source__provider">
{{ data.getPlaylistProvider(active) }}
</p>
<h4
class="focus-source__title"
[attr.title]="
active.title ||
active.filename ||
('WORKSPACE.DASHBOARD.UNTITLED_SOURCE' | translate)
"
>
{{
active.title ||
active.filename ||
('WORKSPACE.DASHBOARD.UNTITLED_SOURCE' | translate)
}}
</h4>
<div class="focus-source__actions">
<a mat-flat-button [routerLink]="data.getPlaylistLink(active)">
<mat-icon>play_arrow</mat-icon>
{{ 'WORKSPACE.DASHBOARD.OPEN_SOURCE' | translate }}
</a>
<a mat-stroked-button routerLink="/workspace/sources">
<mat-icon>library_books</mat-icon>
{{ 'WORKSPACE.DASHBOARD.MANAGE_SOURCES' | translate }}
</a>
</div>
</article>
@if (lastItem(); as item) {
<a class="hero" [routerLink]="getLink(item)">
<!-- Thumbnail -->
<span class="hero__thumb" [class]="'hero__thumb--' + item.type">
@if (hasImage(item)) {
<img
[src]="item.poster_url"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span class="hero__thumb-placeholder">
<mat-icon>{{ typeIcon(item.type) }}</mat-icon>
</span>
}
</span>
<!-- Meta -->
<span class="hero__body">
<span
class="hero__type-badge hero__type-badge--{{ item.type }}"
>
<mat-icon>{{ typeIcon(item.type) }}</mat-icon>
{{ typeKey(item.type) | translate }}
</span>
<span class="hero__title" [attr.title]="item.title">{{
item.title
}}</span>
@if (item.playlist_name) {
<span class="hero__playlist">{{ item.playlist_name }}</span>
}
<span class="hero__time">{{
relativeTime(item.viewed_at)
}}</span>
</span>
<!-- Play CTA -->
<span class="hero__play" aria-hidden="true">
<mat-icon>play_circle</mat-icon>
</span>
</a>
} @else {
<article class="focus-empty">
<div class="focus-empty__content">
@@ -40,17 +51,16 @@
>
<div class="focus-empty__text">
<p class="focus-empty__title">
{{ 'WORKSPACE.DASHBOARD.NO_ACTIVE_SOURCE' | translate }}
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY_CW' | translate }}
</p>
<p class="focus-empty__hint">
{{
'WORKSPACE.DASHBOARD.NO_ACTIVE_SOURCE_HINT'
| translate
'WORKSPACE.DASHBOARD.NO_HISTORY_CW_HINT' | translate
}}
</p>
</div>
</div>
<a mat-flat-button routerLink="/workspace/sources">
<a mat-stroked-button routerLink="/workspace/sources">
<mat-icon>add</mat-icon>
{{ 'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate }}
</a>
@@ -59,7 +69,7 @@
@if (data.quickRecent().length > 0) {
<div class="quick-list">
<p class="quick-list__title">
<p class="quick-list__label">
{{ 'WORKSPACE.DASHBOARD.RECENTLY_USED' | translate }}
</p>
@for (item of data.quickRecent(); track item._id) {
@@ -75,9 +85,9 @@
| translate)
}}
</span>
<span class="quick-item__meta">
{{ data.getPlaylistProvider(item) }}
</span>
<span class="quick-item__meta">{{
data.getPlaylistProvider(item)
}}</span>
</a>
}
</div>
@@ -1,44 +1,213 @@
.focus-source {
:host {
// Deep-resume accent — vivid green
--app-eyebrow-color: oklch(60% 0.18 155);
--app-widget-border: color-mix(
in srgb,
oklch(60% 0.18 155) 22%,
var(--mat-sys-outline-variant) 78%
);
// Per-type accent vars (used by hero thumb + badge)
--_live-accent: oklch(58% 0.21 25);
--_movie-accent: oklch(60% 0.17 295);
--_series-accent: oklch(68% 0.16 55);
}
// ── Hero card ─────────────────────────────────────────────────────────────────
.hero {
display: flex;
align-items: center;
gap: 14px;
padding: 12px;
border-radius: 14px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-low);
border-radius: 12px;
padding: 12px;
text-decoration: none;
color: inherit;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
min-width: 0;
&:hover {
background: var(--mat-sys-surface-container);
border-color: color-mix(in srgb, var(--mat-sys-outline) 60%, var(--app-eyebrow-color) 40%);
transform: translateY(-2px);
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.15);
.hero__play mat-icon {
color: var(--mat-sys-primary);
transform: scale(1.1);
}
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 2px;
}
}
// ── Thumbnail ─────────────────────────────────────────────────────────────────
.hero__thumb {
flex-shrink: 0;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--mat-sys-outline-variant);
display: flex;
align-items: center;
justify-content: center;
background: var(--mat-sys-surface-container-highest);
}
// Live: square logo
.hero__thumb--live {
width: 60px;
height: 60px;
}
// Movie: portrait poster 2:3
.hero__thumb--movie {
width: 45px;
height: 60px;
border-radius: 8px;
}
// Series: widescreen 16:9
.hero__thumb--series {
width: 80px;
height: 45px;
border-radius: 8px;
}
.hero__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.hero__thumb--live img {
object-fit: contain;
padding: 6px;
box-sizing: border-box;
}
.hero__thumb-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
.hero__thumb--live .hero__thumb-placeholder {
background: linear-gradient(135deg,
color-mix(in srgb, var(--_live-accent) 14%, transparent),
color-mix(in srgb, var(--_live-accent) 2%, transparent));
color: var(--_live-accent);
}
.hero__thumb--movie .hero__thumb-placeholder {
background: linear-gradient(135deg,
color-mix(in srgb, var(--_movie-accent) 14%, transparent),
color-mix(in srgb, var(--_movie-accent) 2%, transparent));
color: var(--_movie-accent);
}
.hero__thumb--series .hero__thumb-placeholder {
background: linear-gradient(135deg,
color-mix(in srgb, var(--_series-accent) 14%, transparent),
color-mix(in srgb, var(--_series-accent) 2%, transparent));
color: var(--_series-accent);
}
// ── Body ─────────────────────────────────────────────────────────────────────
.hero__body {
display: flex;
flex-direction: column;
gap: 10px;
gap: 3px;
min-width: 0;
flex: 1;
}
.focus-source__provider {
margin: 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
.hero__type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
width: fit-content;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
.focus-source h4 {
margin: 0;
font-size: 1rem;
.hero__type-badge--live { color: var(--_live-accent); }
.hero__type-badge--movie { color: var(--_movie-accent); }
.hero__type-badge--series { color: var(--_series-accent); }
.hero__title {
font-size: 0.9rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.focus-source__title {
.hero__playlist {
font-size: 0.73rem;
color: var(--mat-sys-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.focus-source__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
.hero__time {
font-size: 0.68rem;
color: color-mix(in srgb, var(--mat-sys-on-surface-variant) 60%, transparent);
}
// ── Play icon ─────────────────────────────────────────────────────────────────
.hero__play {
flex-shrink: 0;
display: flex;
align-items: center;
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: color-mix(in srgb, var(--mat-sys-on-surface) 30%, transparent);
transition: color 0.15s ease, transform 0.15s ease;
}
}
// ── Empty state ───────────────────────────────────────────────────────────────
.focus-empty {
border: 1px dashed var(--mat-sys-outline);
background: var(--mat-sys-surface-container-low);
border-radius: 12px;
border: 1px dashed color-mix(in srgb, var(--app-eyebrow-color) 30%, var(--mat-sys-outline-variant) 70%);
background: radial-gradient(
ellipse at 0% 50%,
color-mix(in srgb, var(--app-eyebrow-color) 6%, transparent),
transparent 70%
);
border-radius: 14px;
padding: 16px 20px;
display: flex;
align-items: center;
@@ -54,11 +223,16 @@
}
.focus-empty__icon {
font-size: 32px;
width: 32px;
height: 32px;
color: var(--mat-sys-outline);
opacity: 0.7;
font-size: 28px;
width: 28px;
height: 28px;
color: var(--app-eyebrow-color);
opacity: 0.8;
background: color-mix(in srgb, var(--app-eyebrow-color) 12%, transparent);
padding: 12px;
border-radius: 50%;
box-sizing: content-box;
flex-shrink: 0;
}
.focus-empty__text {
@@ -78,19 +252,21 @@
margin: 0;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
max-width: 320px;
max-width: 260px;
line-height: 1.4;
}
// ── Quick playlist list ───────────────────────────────────────────────────────
.quick-list {
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.quick-list__title {
.quick-list__label {
margin: 0;
font-size: 0.8rem;
font-size: 0.73rem;
color: var(--mat-sys-on-surface-variant);
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -105,27 +281,30 @@
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 10px;
background: var(--mat-sys-surface-container-low);
padding: 8px 10px;
padding: 7px 10px;
text-decoration: none;
color: inherit;
min-width: 0;
}
transition: background 0.12s ease, border-color 0.12s ease, transform 0.2s ease, box-shadow 0.2s ease;
.quick-item:hover {
border-color: var(--mat-sys-outline);
background: var(--mat-sys-surface-container-high);
&:hover {
border-color: color-mix(in srgb, var(--mat-sys-outline) 60%, var(--app-eyebrow-color) 40%);
background: var(--mat-sys-surface-container-high);
transform: translateY(-2px);
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.15);
}
}
.quick-item__title {
min-width: 0;
font-size: 0.82rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.88rem;
}
.quick-item__meta {
flex-shrink: 0;
font-size: 0.72rem;
color: var(--mat-sys-on-surface-variant);
font-size: 0.74rem;
white-space: nowrap;
}
@@ -1,9 +1,12 @@
import { Component, inject } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import {
DashboardDataService,
GlobalRecentItem,
} from 'workspace-dashboard-data-access';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
@Component({
@@ -20,4 +23,39 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen
})
export class ContinueWatchingWidgetComponent {
readonly data = inject(DashboardDataService);
/** The most recently watched item across all sources. */
readonly lastItem = computed<GlobalRecentItem | null>(
() => this.data.globalRecentItems()[0] ?? null
);
hasImage(item: GlobalRecentItem): boolean {
return !!item.poster_url;
}
getLink(item: GlobalRecentItem): string[] {
return this.data.getRecentItemLink(item);
}
typeIcon(type: GlobalRecentItem['type']): string {
if (type === 'live') return 'live_tv';
if (type === 'movie') return 'movie';
return 'video_library';
}
typeKey(type: GlobalRecentItem['type']): string {
if (type === 'live') return 'WORKSPACE.DASHBOARD.TYPE_LIVE';
if (type === 'movie') return 'WORKSPACE.DASHBOARD.TYPE_MOVIE';
return 'WORKSPACE.DASHBOARD.TYPE_SERIES';
}
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`;
}
}
@@ -13,6 +13,10 @@
<span
class="activity-item__media"
[class.activity-item__media--live]="item.type === 'live'"
[class.activity-item__media--movie]="item.type === 'movie'"
[class.activity-item__media--series]="
item.type === 'series'
"
[class.activity-item__media--poster]="item.type !== 'live'"
[class.is-placeholder]="!hasImage(item)"
[class.is-placeholder-live]="
@@ -32,7 +36,7 @@
loading="lazy"
(error)="markImageAsFailed(item.id)"
/>
} @else {
} @else if (viewMode() === 'grid') {
<mat-icon>{{ getFallbackIcon(item) }}</mat-icon>
}
</span>
@@ -75,5 +79,15 @@
@if (emptyHint()) {
<p class="empty-state__hint">{{ emptyHint() }}</p>
}
@if (emptyActionLink() && emptyActionLabel()) {
<a
mat-stroked-button
class="empty-state__action"
[routerLink]="emptyActionLink()"
>
<mat-icon>add</mat-icon>
{{ emptyActionLabel() }}
</a>
}
</div>
}
@@ -51,7 +51,7 @@
);
padding: 8px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
@@ -100,8 +100,10 @@
}
.activity-item__media {
// Default: square for channels/live
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: 9px;
overflow: hidden;
background: var(--mat-sys-surface-container-highest);
@@ -112,6 +114,21 @@
border: 1px solid var(--mat-sys-outline-variant);
}
// Movie: portrait poster 2:3
.activity-item__media--movie {
width: 30px;
height: 44px;
border-radius: 7px;
}
// Series: widescreen 16:9 — 64×36 keeps proportion without dominating the row
.activity-item__media--series {
width: 64px;
height: 36px;
border-radius: 7px;
align-self: center;
}
.activity-item__media img {
width: 100%;
height: 100%;
@@ -131,22 +148,35 @@
height: 1.15rem;
}
// Placeholder tints use oklch() matching the stat-tile accent approach
.is-placeholder-live {
background: linear-gradient(135deg, rgba(56, 189, 248, 0.15), rgba(56, 189, 248, 0.02)) !important;
color: #38bdf8 !important;
border: 1px solid rgba(56, 189, 248, 0.2) !important;
--_accent: oklch(66% 0.17 210); // sky-blue: live TV
background: linear-gradient(135deg,
color-mix(in srgb, var(--_accent) 14%, transparent),
color-mix(in srgb, var(--_accent) 2%, transparent)
) !important;
color: var(--_accent) !important;
border-color: color-mix(in srgb, var(--_accent) 22%, var(--mat-sys-outline-variant) 78%) !important;
}
.is-placeholder-movie {
background: linear-gradient(135deg, rgba(167, 139, 250, 0.15), rgba(167, 139, 250, 0.02)) !important;
color: #a78bfa !important;
border: 1px solid rgba(167, 139, 250, 0.2) !important;
--_accent: oklch(60% 0.17 295); // violet: movies
background: linear-gradient(135deg,
color-mix(in srgb, var(--_accent) 14%, transparent),
color-mix(in srgb, var(--_accent) 2%, transparent)
) !important;
color: var(--_accent) !important;
border-color: color-mix(in srgb, var(--_accent) 22%, var(--mat-sys-outline-variant) 78%) !important;
}
.is-placeholder-series {
background: linear-gradient(135deg, rgba(251, 146, 60, 0.15), rgba(251, 146, 60, 0.02)) !important;
color: #fb923c !important;
border: 1px solid rgba(251, 146, 60, 0.2) !important;
--_accent: oklch(68% 0.16 55); // amber: series
background: linear-gradient(135deg,
color-mix(in srgb, var(--_accent) 14%, transparent),
color-mix(in srgb, var(--_accent) 2%, transparent)
) !important;
color: var(--_accent) !important;
border-color: color-mix(in srgb, var(--_accent) 22%, var(--mat-sys-outline-variant) 78%) !important;
}
.is-placeholder mat-icon {
@@ -187,6 +217,40 @@
align-items: stretch;
gap: 5px;
padding: 5px;
position: relative;
&:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--mat-sys-outline) 60%, var(--mat-sys-primary) 40%);
background: linear-gradient(
180deg,
var(--mat-sys-surface-container-high) 0%,
var(--mat-sys-surface-container-low) 100%
);
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.15);
}
}
// In grid mode: float the remove button over the top-right corner
.activity-items--grid .activity-item__action-btn {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 90%, transparent);
backdrop-filter: blur(4px);
opacity: 0;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
}
.activity-items--grid .activity-item:hover .activity-item__action-btn {
opacity: 0.75;
}
.activity-items--grid .activity-item__media {
@@ -223,13 +287,18 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 36px 16px;
padding: 32px 16px;
text-align: center;
border: 1px dashed var(--mat-sys-outline-variant);
border-radius: 12px;
margin: 8px 0;
color: var(--mat-sys-on-surface-variant);
flex: 1;
background: radial-gradient(
ellipse at 50% 0%,
color-mix(in srgb, var(--mat-sys-primary) 4%, transparent),
transparent 70%
);
&.empty-state--grid {
min-height: 180px;
@@ -237,16 +306,20 @@
}
.empty-state__icon {
font-size: 36px;
width: 36px;
height: 36px;
margin-bottom: 12px;
color: var(--mat-sys-outline);
opacity: 0.7;
font-size: 40px;
width: 40px;
height: 40px;
margin-bottom: 4px;
color: var(--mat-sys-primary);
opacity: 0.5;
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
padding: 14px;
border-radius: 50%;
box-sizing: content-box;
}
.empty-state__title {
margin: 0;
margin: 8px 0 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
@@ -260,6 +333,10 @@
line-height: 1.45;
}
.empty-state__action {
margin-top: 16px;
}
@media (max-width: 920px) {
.activity-items--grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
@@ -1,4 +1,5 @@
import { Component, EventEmitter, input, Output, signal } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
@@ -16,7 +17,7 @@ type DashboardActivityViewMode = 'list' | 'grid';
@Component({
selector: 'app-dashboard-activity-items',
imports: [MatIcon, RouterLink],
imports: [MatButton, MatIcon, RouterLink],
templateUrl: './dashboard-activity-items.component.html',
styleUrl: './dashboard-activity-items.component.scss',
})
@@ -27,6 +28,8 @@ export class DashboardActivityItemsComponent {
readonly emptyLabel = input('No items to show.');
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');
@@ -20,7 +20,7 @@
.widget-shell__header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 10px;
@@ -33,6 +33,7 @@
.widget-shell__headline {
min-width: 0;
align-self: flex-start;
}
.widget-shell__eyebrow {
@@ -40,7 +41,7 @@
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.67rem;
font-size: 0.72rem;
font-weight: 600;
}
@@ -70,11 +71,11 @@
gap: 12px;
min-height: 0;
flex: 1;
padding: 0 16px 0;
padding: 0 16px 12px;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
scrollbar-color: color-mix(in srgb, var(--mat-sys-on-surface) 12%, transparent) transparent;
}
@@ -1,3 +1,9 @@
:host {
// Favorites / library tint — amber-gold
--app-eyebrow-color: oklch(60% 0.16 55);
--app-widget-border: color-mix(in srgb, oklch(60% 0.16 55) 22%, var(--mat-sys-outline-variant) 78%);
}
.view-toggle {
border-radius: 8px;
height: 32px;
@@ -1,4 +1,11 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import {
Component,
OnInit,
computed,
inject,
input,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
@@ -40,7 +47,7 @@ const PAGE_SIZE = 20;
templateUrl: './global-favorites-widget.component.html',
styleUrl: './global-favorites-widget.component.scss',
})
export class GlobalFavoritesWidgetComponent {
export class GlobalFavoritesWidgetComponent implements OnInit {
readonly widget = input.required<DashboardWidgetConfig>();
readonly data = inject(DashboardDataService);
readonly selectedKind = signal<DashboardContentKind>(
@@ -94,8 +101,12 @@ export class GlobalFavoritesWidgetComponent {
}))
);
constructor() {
void this.data.reloadGlobalFavorites();
constructor() {}
ngOnInit(): void {
this.data.reloadGlobalFavorites().then(() => {
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
});
}
onKindChange(kind: DashboardContentKind): void {
@@ -0,0 +1,130 @@
<app-dashboard-widget-shell
[eyebrow]="'WORKSPACE.DASHBOARD.ON_AIR_EYEBROW' | translate"
[title]="'WORKSPACE.DASHBOARD.ON_AIR_TITLE' | translate"
>
<ng-container widget-actions>
<mat-button-toggle-group
class="category-filter"
[value]="selectedCategory()"
hideSingleSelectionIndicator
aria-label="Filter by category"
(change)="setCategory($event.value)"
>
@for (cat of categoryKeys; track cat) {
<mat-button-toggle
[value]="cat"
[attr.aria-label]="categoryTranslationKey(cat) | translate"
>
{{ categoryTranslationKey(cat) | translate }}
</mat-button-toggle>
}
</mat-button-toggle-group>
</ng-container>
@if (isLoading()) {
<div class="on-air-skeleton" aria-busy="true">
@for (_ of [1, 2, 3, 4]; track $index) {
<div class="skeleton-row">
<div class="skeleton-avatar"></div>
<div class="skeleton-body">
<div class="skeleton-line skeleton-line--wide"></div>
<div class="skeleton-line skeleton-line--narrow"></div>
</div>
</div>
}
</div>
} @else if (filtered().length > 0) {
<div class="on-air-list" role="list">
@for (item of filtered(); track $index) {
<div class="on-air-item" role="listitem">
<button
type="button"
class="on-air-item__avatar"
[class.on-air-item__avatar--img]="item.channel_icon"
[matTooltip]="
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS'
| translate
"
(click)="openInfo(item)"
>
@if (item.channel_icon) {
<img
[src]="item.channel_icon"
[alt]="item.channel_name ?? ''"
loading="lazy"
/>
<span
class="on-air-item__avatar-badge"
aria-hidden="true"
>
<mat-icon>info_outline</mat-icon>
</span>
} @else {
<mat-icon>info_outline</mat-icon>
}
</button>
<span class="on-air-item__body">
<span class="on-air-item__channel">
<span
class="on-air-item__pulse"
aria-hidden="true"
></span>
{{ item.channel_name ?? item.channel_id }}
@if (item.category) {
<span class="on-air-item__category"
>· {{ item.category }}</span
>
}
</span>
<span
class="on-air-item__title"
[attr.title]="item.title"
>{{ item.title }}</span
>
<span
class="on-air-item__progress-row"
[attr.aria-label]="
item.minutesLeft + ' minutes left'
"
>
<span class="on-air-item__bar-track">
<span
class="on-air-item__bar-fill"
[style.width.%]="item.progressPercent"
></span>
</span>
<span class="on-air-item__time-left"
>{{ item.minutesLeft }} min</span
>
</span>
</span>
</div>
}
</div>
} @else {
<div class="on-air-empty">
<mat-icon class="on-air-empty__icon">schedule</mat-icon>
<p class="on-air-empty__title">
{{ 'WORKSPACE.DASHBOARD.ON_AIR_EMPTY' | translate }}
</p>
<p class="on-air-empty__hint">
{{
(items().length > 0
? 'WORKSPACE.DASHBOARD.ON_AIR_EMPTY_FILTER'
: 'WORKSPACE.DASHBOARD.ON_AIR_EMPTY_HINT'
) | translate
}}
</p>
@if (!items().length) {
<a
mat-stroked-button
class="on-air-empty__action"
routerLink="/settings"
>
<mat-icon>add</mat-icon>
{{ 'WORKSPACE.DASHBOARD.ON_AIR_ADD_EPG' | translate }}
</a>
}
</div>
}
</app-dashboard-widget-shell>
@@ -0,0 +1,279 @@
@use 'sass:color';
:host {
--on-air-accent: oklch(62% 0.20 200);
--on-air-accent-muted: oklch(62% 0.20 200 / 0.15);
--on-air-pulse: oklch(62% 0.22 160);
display: block;
height: 100%;
}
/* ─── Category filter ───────────────────────────────────────────────── */
.category-filter {
::ng-deep .mat-button-toggle {
font-size: 0.75rem;
font-weight: 500;
}
::ng-deep .mat-button-toggle-checked {
color: var(--on-air-accent);
}
}
/* ─── Channel list ──────────────────────────────────────────────────── */
.on-air-list {
display: flex;
flex-direction: column;
gap: 2px;
container-type: inline-size;
}
/* ─── Row item ──────────────────────────────────────────────────────── */
.on-air-item {
display: grid;
grid-template-columns: 44px 1fr;
gap: 10px;
align-items: center;
padding: 8px 6px;
border-radius: 10px;
border: 1px solid transparent;
transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
cursor: default;
&:hover {
transform: translateY(-2px);
background: var(--on-air-accent-muted);
border-color: color-mix(in srgb, var(--on-air-accent) 30%, var(--mat-sys-outline-variant) 70%);
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.15);
}
}
/* ─── Avatar button ─────────────────────────────────────────────────── */
.on-air-item__avatar {
display: grid;
place-items: center;
position: relative;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--on-air-accent-muted);
color: var(--on-air-accent);
overflow: hidden;
flex-shrink: 0;
// Reset button defaults
border: none;
padding: 0;
cursor: pointer;
transition: filter 0.2s ease;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
&:hover {
filter: brightness(1.2);
}
&--img img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
/* Small always-visible info badge over channel logo */
.on-air-item__avatar-badge {
position: absolute;
bottom: -1px;
right: -1px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--on-air-accent);
color: #fff;
display: grid;
place-items: center;
border: 1.5px solid var(--mat-sys-surface-container);
mat-icon {
font-size: 10px;
width: 10px;
height: 10px;
}
}
/* ─── Body ──────────────────────────────────────────────────────────── */
.on-air-item__body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.on-air-item__channel {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.on-air-item__category {
opacity: 0.75;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.on-air-item__title {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ─── Progress row ──────────────────────────────────────────────────── */
.on-air-item__progress-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.on-air-item__bar-track {
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--on-air-accent-muted);
overflow: hidden;
}
.on-air-item__bar-fill {
display: block;
height: 100%;
border-radius: 2px;
background: var(--on-air-accent);
transition: width 0.4s ease;
min-width: 3px;
}
.on-air-item__time-left {
font-size: 0.7rem;
opacity: 0.55;
white-space: nowrap;
}
/* ─── Live pulse dot ────────────────────────────────────────────────── */
.on-air-item__pulse {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--on-air-pulse);
flex-shrink: 0;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
/* ─── Loading skeleton ──────────────────────────────────────────────── */
.on-air-skeleton {
display: flex;
flex-direction: column;
gap: 8px;
}
.skeleton-row {
display: grid;
grid-template-columns: 44px 1fr;
gap: 10px;
align-items: center;
padding: 8px 6px;
}
.skeleton-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--mat-sys-outline-variant, rgba(128, 128, 128, 0.2));
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-body {
display: flex;
flex-direction: column;
gap: 6px;
}
.skeleton-line {
height: 11px;
border-radius: 6px;
background: var(--mat-sys-outline-variant, rgba(128, 128, 128, 0.2));
animation: shimmer 1.5s ease-in-out infinite;
&--wide { width: 80%; }
&--narrow { width: 50%; }
}
@keyframes shimmer {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
/* ─── Empty state ───────────────────────────────────────────────────── */
.on-air-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 10px;
padding: 40px 24px;
border-radius: 14px;
border: 1.5px dashed var(--on-air-accent-muted);
background: radial-gradient(ellipse at 50% 0%, var(--on-air-accent-muted) 0%, transparent 70%);
}
.on-air-empty__icon {
width: 52px;
height: 52px;
font-size: 28px;
line-height: 52px;
text-align: center;
border-radius: 50%;
background: var(--on-air-accent-muted);
color: var(--on-air-accent);
}
.on-air-empty__title {
margin: 0;
font-weight: 600;
font-size: 0.95rem;
}
.on-air-empty__hint {
margin: 0;
font-size: 0.8rem;
opacity: 0.6;
max-width: 28ch;
line-height: 1.5;
}
.on-air-empty__action {
margin-top: 6px;
color: var(--on-air-accent);
border-color: var(--on-air-accent);
}
@@ -0,0 +1,196 @@
import {
ChangeDetectionStrategy,
Component,
NgZone,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgItemDescriptionComponent } from 'components';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
export interface NowPlayingItem {
channel_id: string;
title: string;
description: string | null;
category: string | null;
start: string;
stop: string;
channel_name: string | null;
channel_icon: string | null;
}
/** NowPlayingItem enriched with pre-computed display values */
export interface NowPlayingItemView extends NowPlayingItem {
progressPercent: number;
minutesLeft: number;
initial: string;
}
export type OnAirCategoryKey = 'all' | 'sports' | 'news' | 'movies' | 'kids';
@Component({
selector: 'app-on-air-widget',
imports: [
MatButtonModule,
MatButtonToggleModule,
MatIcon,
MatTooltip,
RouterLink,
DashboardWidgetShellComponent,
TranslatePipe,
],
templateUrl: './on-air-widget.component.html',
styleUrl: './on-air-widget.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OnAirWidgetComponent implements OnInit, OnDestroy {
private readonly zone = inject(NgZone);
private readonly dialog = inject(MatDialog);
readonly items = signal<NowPlayingItemView[]>([]);
readonly isLoading = signal(true);
readonly selectedCategory = signal<OnAirCategoryKey>('all');
readonly categoryKeys: OnAirCategoryKey[] = [
'all',
'sports',
'news',
'movies',
'kids',
];
private readonly categoryKeywords: Record<OnAirCategoryKey, string[]> = {
all: [],
sports: [
'sport',
'football',
'soccer',
'basketball',
'tennis',
'f1',
'cricket',
'rugby',
],
news: ['news', 'nachrichten', 'info', 'actualité', 'noticias', 'haber'],
movies: ['movie', 'film', 'cinema', 'kino', 'cine', 'films'],
kids: [
'kids',
'children',
'child',
'cartoon',
'animation',
'junior',
'enfants',
],
};
readonly filtered = computed<NowPlayingItemView[]>(() => {
const cat = this.selectedCategory();
if (cat === 'all') return this.items();
const keywords = this.categoryKeywords[cat];
return this.items().filter((item) =>
keywords.some((kw) =>
(item.category ?? '').toLowerCase().includes(kw)
)
);
});
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
ngOnInit(): void {
void this.loadItems();
// Run timer outside Angular Zone — prevents zone-triggered CD on every tick.
// Signal updates inside loadItems() propagate to the view via Angular's
// signal reactivity without needing zone.run().
this.zone.runOutsideAngular(() => {
this.refreshTimer = setInterval(
() => void this.loadItems(),
60_000
);
});
}
ngOnDestroy(): void {
if (this.refreshTimer !== null) {
clearInterval(this.refreshTimer);
}
}
setCategory(cat: OnAirCategoryKey): void {
this.selectedCategory.set(cat);
}
categoryTranslationKey(cat: OnAirCategoryKey): string {
return `WORKSPACE.DASHBOARD.ON_AIR_CAT_${cat.toUpperCase()}`;
}
openInfo(item: NowPlayingItemView): void {
this.dialog.open(EpgItemDescriptionComponent, {
data: {
channel: item.channel_id,
channel_name: item.channel_name,
title: item.title,
desc: item.description,
category: item.category,
start: item.start,
stop: item.stop,
episodeNum: null,
rating: null,
iconUrl: item.channel_icon ?? null,
},
width: '480px',
maxWidth: '96vw',
});
}
private async loadItems(): Promise<void> {
if (!window.electron) {
this.isLoading.set(false);
return;
}
try {
const data = await window.electron.getNowPlayingPrograms({
limit: 25,
});
const now = Date.now();
const enriched: NowPlayingItemView[] = (
data as NowPlayingItem[]
).map((item) => {
const start = new Date(item.start).getTime();
const stop = new Date(item.stop).getTime();
const range = stop - start;
return {
...item,
progressPercent:
range > 0
? Math.round(
Math.min(
100,
Math.max(0, ((now - start) / range) * 100)
)
)
: 0,
minutesLeft: Math.max(0, Math.round((stop - now) / 60_000)),
initial: (item.channel_name ?? item.channel_id ?? '?')
.charAt(0)
.toUpperCase(),
};
});
this.items.set(enriched);
} catch {
this.items.set([]);
} finally {
this.isLoading.set(false);
}
}
}
@@ -10,16 +10,18 @@
@for (item of data.recentPlaylists(); track item._id) {
<a class="source-row" [routerLink]="data.getPlaylistLink(item)">
<div class="source-row__head">
<span class="provider">
{{ data.getPlaylistProvider(item) }}
</span>
<span
class="provider-pill"
[class]="
'provider-pill--' +
data.getPlaylistProvider(item).toLowerCase()
"
>{{ data.getPlaylistProvider(item) }}</span
>
@if (item.count) {
<span class="count"
>{{ item.count }}
{{
'WORKSPACE.DASHBOARD.CHANNELS' | translate
}}</span
>
<span class="count-chip">{{
item.count | number
}}</span>
}
</div>
<h4
@@ -1,3 +1,9 @@
:host {
--_xtream-accent: oklch(55% 0.13 185);
--_stalker-accent: oklch(68% 0.16 55);
--_m3u-accent: oklch(55% 0.18 295);
}
[widget-actions] {
color: var(--mat-sys-primary);
text-decoration: none;
@@ -10,6 +16,55 @@
text-decoration: underline;
}
// ── Provider pill ─────────────────────────────────────────────────────────────
.provider-pill {
display: inline-flex;
align-items: center;
font-size: 0.67rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
padding: 1px 7px;
border-radius: 20px;
border: 1px solid currentColor;
// Default (fallback)
color: var(--mat-sys-on-surface-variant);
background: color-mix(in srgb, currentColor 8%, transparent);
}
.provider-pill--xtream {
color: var(--_xtream-accent);
border-color: color-mix(in srgb, var(--_xtream-accent) 40%, transparent);
background: color-mix(in srgb, var(--_xtream-accent) 10%, transparent);
}
.provider-pill--stalker {
color: var(--_stalker-accent);
border-color: color-mix(in srgb, var(--_stalker-accent) 40%, transparent);
background: color-mix(in srgb, var(--_stalker-accent) 10%, transparent);
}
.provider-pill--m3u {
color: var(--_m3u-accent);
border-color: color-mix(in srgb, var(--_m3u-accent) 40%, transparent);
background: color-mix(in srgb, var(--_m3u-accent) 10%, transparent);
}
// ── Channel count chip ────────────────────────────────────────────────────────
.count-chip {
font-size: 0.68rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
background: var(--mat-sys-surface-container-high);
border: 1px solid var(--mat-sys-outline-variant);
padding: 0 6px;
border-radius: 10px;
line-height: 18px;
white-space: nowrap;
}
.source-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -41,18 +96,6 @@
gap: 8px;
}
.provider,
.count {
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
}
.provider {
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.source-row h4 {
margin: 8px 0 6px;
font-size: 0.95rem;
@@ -1,3 +1,4 @@
import { DecimalPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
@@ -9,6 +10,7 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen
@Component({
selector: 'app-recent-sources-widget',
imports: [
DecimalPipe,
MatButtonModule,
MatIcon,
RouterLink,
@@ -81,6 +81,8 @@
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_HISTORY' | translate"
emptyIcon="history"
[emptyHint]="'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate"
[emptyActionLabel]="'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate"
[emptyActionLink]="['/workspace/sources']"
[viewMode]="viewMode()"
[showRemoveAction]="true"
[removeActionLabel]="'Remove from history'"
@@ -1,3 +1,9 @@
:host {
// Activity / history tint — blue-teal
--app-eyebrow-color: oklch(54% 0.13 225);
--app-widget-border: color-mix(in srgb, oklch(54% 0.13 225) 22%, var(--mat-sys-outline-variant) 78%);
}
.view-toggle {
border-radius: 8px;
height: 32px;
@@ -1,4 +1,11 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import {
Component,
OnInit,
computed,
inject,
input,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
@@ -40,7 +47,7 @@ const PAGE_SIZE = 20;
templateUrl: './recently-watched-widget.component.html',
styleUrl: './recently-watched-widget.component.scss',
})
export class RecentlyWatchedWidgetComponent {
export class RecentlyWatchedWidgetComponent implements OnInit {
readonly widget = input.required<DashboardWidgetConfig>();
readonly data = inject(DashboardDataService);
readonly selectedKind = signal<DashboardContentKind>(
@@ -93,8 +100,12 @@ export class RecentlyWatchedWidgetComponent {
}))
);
constructor() {
void this.data.reloadGlobalRecentItems();
constructor() {}
ngOnInit(): void {
this.data.reloadGlobalRecentItems().then(() => {
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
});
}
onKindChange(kind: DashboardContentKind): void {
@@ -12,7 +12,7 @@
</div>
<p class="value">{{ data.stats().total }}</p>
</article>
<article class="stat-tile">
<article class="stat-tile stat-tile--xtream">
<div class="stat-tile__header">
<mat-icon>tv</mat-icon>
<p class="label">
@@ -21,7 +21,7 @@
</div>
<p class="value">{{ data.stats().xtream }}</p>
</article>
<article class="stat-tile">
<article class="stat-tile stat-tile--stalker">
<div class="stat-tile__header">
<mat-icon>router</mat-icon>
<p class="label">
@@ -30,7 +30,7 @@
</div>
<p class="value">{{ data.stats().stalker }}</p>
</article>
<article class="stat-tile">
<article class="stat-tile stat-tile--m3u">
<div class="stat-tile__header">
<mat-icon>list_alt</mat-icon>
<p class="label">{{ 'WORKSPACE.DASHBOARD.M3U' | translate }}</p>
@@ -28,6 +28,33 @@
color: var(--mat-sys-on-primary-container);
}
}
// Xtream: teal accent
&--xtream {
--stat-accent: oklch(55% 0.13 185);
border-color: color-mix(in srgb, var(--stat-accent) 35%, var(--mat-sys-outline-variant) 65%);
background: color-mix(in srgb, var(--stat-accent) 7%, var(--mat-sys-surface-container-low) 93%);
.stat-tile__header mat-icon { color: var(--stat-accent); }
}
// Stalker: amber accent
&--stalker {
--stat-accent: oklch(68% 0.16 55);
border-color: color-mix(in srgb, var(--stat-accent) 35%, var(--mat-sys-outline-variant) 65%);
background: color-mix(in srgb, var(--stat-accent) 7%, var(--mat-sys-surface-container-low) 93%);
.stat-tile__header mat-icon { color: var(--stat-accent); }
}
// M3U: violet accent
&--m3u {
--stat-accent: oklch(55% 0.18 295);
border-color: color-mix(in srgb, var(--stat-accent) 35%, var(--mat-sys-outline-variant) 65%);
background: color-mix(in srgb, var(--stat-accent) 7%, var(--mat-sys-surface-container-low) 93%);
.stat-tile__header mat-icon { color: var(--stat-accent); }
}
}
.stat-tile__header {