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

+36 -35
View File
@@ -1,37 +1,38 @@
{
"permissions": {
"allow": [
"Bash(tree:*)",
"WebSearch",
"WebFetch(domain:www.electronjs.org)",
"Bash(npm run build:frontend:*)",
"Bash(cat:*)",
"Bash(npx asar:*)",
"Bash(npm run package:app:*)",
"Bash(npm run build:backend:*)",
"Bash(pnpm add:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:www.npmjs.com)",
"Skill(example-skills:frontend-design)",
"Bash(npm run test:frontend:*)",
"Bash(nx lint:*)",
"Bash(npx tsc:*)",
"Bash(find:*)",
"Bash(xargs sed:*)",
"Skill(frontend-design)",
"mcp__angular-cli__get_best_practices",
"mcp__nx-mcp__nx_workspace",
"Bash(grep:*)",
"Bash(npx nx lint:*)",
"Bash(npx nx build:*)",
"Bash(npx eslint:*)",
"Bash(npx nx run:*)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"nx-mcp"
]
"permissions": {
"allow": [
"Bash(tree:*)",
"WebSearch",
"WebFetch(domain:www.electronjs.org)",
"Bash(npm run build:frontend:*)",
"Bash(cat:*)",
"Bash(npx asar:*)",
"Bash(npm run package:app:*)",
"Bash(npm run build:backend:*)",
"Bash(pnpm add:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:www.npmjs.com)",
"Skill(example-skills:frontend-design)",
"Bash(npm run test:frontend:*)",
"Bash(nx lint:*)",
"Bash(npx tsc:*)",
"Bash(find:*)",
"Bash(xargs sed:*)",
"Skill(frontend-design)",
"mcp__angular-cli__get_best_practices",
"mcp__nx-mcp__nx_workspace",
"Bash(grep:*)",
"Bash(npx nx lint:*)",
"Bash(npx nx build:*)",
"Bash(npx eslint:*)",
"Bash(npx nx run:*)",
"Bash(lsof:*)",
"Bash(xargs kill:*)",
"Bash(curl:*)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": ["nx-mcp"]
}
@@ -177,8 +177,6 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }),
searchEpgPrograms: (searchTerm: string, limit?: number) =>
ipcRenderer.invoke('EPG_DB_SEARCH_PROGRAMS', searchTerm, limit),
getNowPlayingPrograms: (options?: { category?: string; limit?: number }) =>
ipcRenderer.invoke('EPG_DB_GET_NOW_PLAYING', options ?? {}),
setMpvPlayerPath: (mpvPlayerPath: string) =>
ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath),
setVlcPlayerPath: (vlcPlayerPath: string) =>
@@ -329,66 +329,6 @@ ipcMain.handle('EPG_DB_CLEAR_ALL', async () => {
}
});
/**
* Get currently airing programs across all channels (batch, for On Air Now widget)
* Returns programs where start <= now <= stop, joined with channel info.
*/
ipcMain.handle(
'EPG_DB_GET_NOW_PLAYING',
async (
_event,
{ category, limit = 25 }: { category?: string; limit?: number } = {}
) => {
try {
const db = await getDatabase();
const now = new Date().toISOString();
// Build base query using Drizzle ORM (consistent with other handlers)
// stop >= now is highly selective with idx_epg_programs_stop,
// filtering out all past programs before checking start <= now.
const results = await db
.select({
channel_id: schema.epgPrograms.channelId,
title: schema.epgPrograms.title,
description: schema.epgPrograms.description,
category: schema.epgPrograms.category,
start: schema.epgPrograms.start,
stop: schema.epgPrograms.stop,
channel_name: schema.epgChannels.displayName,
channel_icon: schema.epgChannels.iconUrl,
})
.from(schema.epgPrograms)
.leftJoin(
schema.epgChannels,
eq(schema.epgPrograms.channelId, schema.epgChannels.id)
)
.where(
category && category !== 'all'
? and(
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now),
sql`LOWER(${schema.epgPrograms.category}) LIKE LOWER(${'%' + category + '%'})`
)
: and(
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
)
)
.orderBy(schema.epgChannels.displayName)
.limit(limit);
return results;
} catch (error) {
console.error(
loggerLabel,
'Error getting now playing programs:',
error
);
throw error;
}
}
);
/**
* Get EPG statistics
*/
-11
View File
@@ -587,17 +587,6 @@
"TYPE_LIVE": "Live",
"TYPE_MOVIE": "Movie",
"TYPE_SERIES": "Series",
"ON_AIR_EYEBROW": "On Air",
"ON_AIR_TITLE": "Currently Airing",
"ON_AIR_EMPTY": "No EPG data available",
"ON_AIR_EMPTY_HINT": "Load an EPG source in Settings to see what's on right now.",
"ON_AIR_EMPTY_FILTER": "No programs match this category.",
"ON_AIR_ADD_EPG": "Add EPG Source",
"ON_AIR_CAT_ALL": "All",
"ON_AIR_CAT_SPORTS": "Sports",
"ON_AIR_CAT_NEWS": "News",
"ON_AIR_CAT_MOVIES": "Movies",
"ON_AIR_CAT_KIDS": "Kids",
"MIN_LEFT": "min left"
}
}
Vendored
-4
View File
@@ -75,10 +75,6 @@ declare global {
searchTerm: string,
limit?: number
) => Promise<any[]>;
getNowPlayingPrograms: (options?: {
category?: string;
limit?: number;
}) => Promise<any[]>;
updateSettings: (settings: any) => Promise<void>;
getAiSettings: () => Promise<{
aiProvider: string;
@@ -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);
}
}
}