refactor: remove 'on-air' widget and related functionality from dashboard

This commit is contained in:
4gray committed 2026-03-08 18:55:18 +01:00
1 parent 3b04b5105f
commit 1780bb0e53
11 files changed
+36 -732

No files matched your search

@@ -1,7 +1,6 @@
export type DashboardWidgetType =
| 'source-stats'
| 'continue-watching'
| 'on-air'
| 'recently-watched'
| 'global-favorites';
@@ -76,15 +75,6 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
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',
@@ -5,9 +5,6 @@
@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,6 @@ 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 { 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 +11,6 @@ import { SourceStatsWidgetComponent } from './widgets/source-stats-widget.compon
imports: [
ContinueWatchingWidgetComponent,
GlobalFavoritesWidgetComponent,
OnAirWidgetComponent,
RecentlyWatchedWidgetComponent,
SourceStatsWidgetComponent,
TranslatePipe,
@@ -1,130 +0,0 @@
<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>
@@ -1,279 +0,0 @@
@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);
}
@@ -1,196 +0,0 @@
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);
}
}
}