mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor: remove 'on-air' widget and related functionality from dashboard
This commit is contained in:
1 parent
3b04b5105f
commit
1780bb0e53
11 files changed
+36
-732
No files matched your search
+36
-35
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user