From 7399fbfa9eb91dbc775b1af40cd7dfd2824d4b13 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 01:21:10 +0200 Subject: [PATCH] refactor(multi-epg): match app theme, widen channels, label the now-line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four UX-audit fixes for the Multi-channel EPG view: 1. Theme harmonization - $bg-deep / $bg-surface / $bg-elevated / $bg-hover now resolve through --app-content-bg / --app-widget-bg / --app-widget-header-bg / --app-card-hover-bg. Multi-EPG was the only screen that switched to near-black (#0a0a0f) instead of sitting on the app's dark surface, so the audit described it as "visually detaching from the rest of the app". It now inherits the same dark graphite chain. - Cleaned up 29 remaining hard-coded rgba(139,92,246,…) purples and rgba(0,212,170,…) teal accents that survived the earlier color unification commit. Purple shadows/glows are gone; the cyan glow used by the now-line is now driven by the unified $5cd6ff token. 2. Channel column widened 140px → 180px - Long names like "13th Street Universal HD" no longer truncate to 5 characters. Logo size dropped to 24px so the name actually has room. The label allows up to two lines and wraps cleanly on long titles. - Responsive breakpoint mirrors the change: 100px → 140px at <768px. 3. Now-line time badge - New `currentTimeLabel` computed signal renders an "HH:MM" pill pinned to the top of the now-line. Recomputes on the same 60s tick as the line position so they always stay in lockstep. 4. "Airing now" program highlight - New `isProgramAiringNow(program)` returns true when the now-line's x-coordinate falls inside [startPosition, startPosition + width]. Programs that match get a .is-now class → 1.5px cyan border. Users can now see what's on every channel at a glance without tracing the line down each row. Toolbar harmonization (audit item 3 for this screen) intentionally deferred — that's part of the app-wide toolbar unification work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../multi-epg-container.component.html | 25 ++- .../multi-epg-container.component.scss | 176 ++++++++++++------ .../multi-epg-container.component.ts | 28 +++ 3 files changed, 168 insertions(+), 61 deletions(-) diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html index 06ecb3ca9..9242ff3ef 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html @@ -123,16 +123,16 @@ @for (item of filteredChannels(); track item.id; let i = $index) { - + @@ -141,7 +141,6 @@ } {{ getChannelName(item) }} @@ -191,6 +190,7 @@ [attr.transform]="'translate(' + program.startPosition + ',' + (barHeight * i + barHeight) + ')'" class="program" [class.highlighted]="highlightedProgramKey() === getProgramKey(program)" + [class.is-now]="isProgramAiringNow(program)" (click)="showDescription(program)" > } - + + + + {{ currentTimeLabel() }} + diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss index 05885b7ba..94bf9af24 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss @@ -1,23 +1,31 @@ -// Color palette - Broadcast control room aesthetic -$bg-deep: #0a0a0f; -$bg-surface: #12121a; -$bg-elevated: #1a1a24; -$bg-hover: #24243a; -$border-subtle: rgba(255, 255, 255, 0.06); -$border-medium: rgba(255, 255, 255, 0.12); +// Color palette — every value resolves through the app's theme tokens so +// Multi-EPG looks like part of IPTVnator instead of a separate broadcast +// utility. Previously hard-coded #0a0a0f / #12121a / #1a1a24 made the grid +// switch to near-black while the rest of the app sat on --app-content-bg, +// which the UX audit called out as the screen "visually detaching" from +// the rest of the app. +$bg-deep: var(--app-content-bg); +$bg-surface: var(--app-widget-bg); +$bg-elevated: var(--app-widget-header-bg); +$bg-hover: var(--app-card-hover-bg); +$border-subtle: var(--app-widget-header-border); +$border-medium: var(--app-separator); // Accent colors — unified with the rest of the app: -// cyan is reserved for the EPG "now" indicator, blue is the app primary, -// purple/pink had no semantic role and are dropped per the UX audit. +// cyan stays on the "now" indicator only; blue is the primary; the old +// $8b5cf6 purple and $00d4aa teal are dropped per the UX audit. $accent-primary: #5cd6ff; // Cyan — reserved for "now" indicator -$accent-secondary: #4f8eff; // Blue — programs (matches --app-selection-color) -$accent-tertiary: #4f8eff; // Blue — highlights +$accent-now-glow: rgba(92, 214, 255, 0.45); // Cyan glow for the now-line +$accent-secondary: var(--app-selection-color); // Blue — programs +$accent-tertiary: var(--app-selection-color); // Blue — highlights $accent-warning: #fbbf24; -// Text colors -$text-primary: #f8fafc; -$text-secondary: rgba(248, 250, 252, 0.7); -$text-muted: rgba(248, 250, 252, 0.4); +// Text colors — derived from app heading/body so light and dark themes +// stay in sync without a separate `.light-theme` override at the bottom +// (which was duplicating responsibility with the same token chain). +$text-primary: var(--app-heading-color); +$text-secondary: var(--app-body-color); +$text-muted: var(--app-muted-color); // Gradients $gradient-program: linear-gradient( @@ -56,11 +64,11 @@ $gradient-header: linear-gradient( } &::-webkit-scrollbar-thumb { - background: rgba(139, 92, 246, 0.4); + background: rgba(47, 123, 255, 0.4); border-radius: 4px; &:hover { - background: rgba(139, 92, 246, 0.6); + background: rgba(47, 123, 255, 0.6); } } } @@ -89,8 +97,8 @@ $gradient-header: linear-gradient( background: linear-gradient( 90deg, transparent 0%, - rgba(0, 212, 170, 0.3) 20%, - rgba(139, 92, 246, 0.3) 80%, + rgba(92, 214, 255, 0.3) 20%, + rgba(47, 123, 255, 0.3) 80%, transparent 100% ); } @@ -104,7 +112,7 @@ $gradient-header: linear-gradient( -webkit-app-region: no-drag; &:not([disabled]):hover { - background: rgba(139, 92, 246, 0.15); + background: rgba(47, 123, 255, 0.15); } &[disabled] { @@ -138,7 +146,7 @@ $gradient-header: linear-gradient( transition: all 0.2s ease; &:focus-within { - border-color: rgba(139, 92, 246, 0.5); + border-color: rgba(47, 123, 255, 0.5); background: rgba(255, 255, 255, 0.08); } @@ -241,7 +249,7 @@ $gradient-header: linear-gradient( transition: all 0.2s ease; &:focus-within { - border-color: rgba(139, 92, 246, 0.5); + border-color: rgba(47, 123, 255, 0.5); background: rgba(255, 255, 255, 0.08); } @@ -328,7 +336,7 @@ $gradient-header: linear-gradient( } &::-webkit-scrollbar-thumb { - background: rgba(139, 92, 246, 0.3); + background: rgba(47, 123, 255, 0.3); border-radius: 3px; } @@ -446,11 +454,11 @@ $gradient-header: linear-gradient( @keyframes highlightPulse { 0%, 100% { - filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6)); + filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)); } 50% { - filter: drop-shadow(0 0 20px rgba(0, 212, 170, 0.9)) - drop-shadow(0 0 40px rgba(0, 212, 170, 0.4)); + filter: drop-shadow(0 0 20px rgba(92, 214, 255, 0.9)) + drop-shadow(0 0 40px rgba(92, 214, 255, 0.4)); } } @@ -498,12 +506,14 @@ $gradient-header: linear-gradient( text-transform: uppercase; // Glowing text effect - text-shadow: 0 0 20px rgba(0, 212, 170, 0.3); + text-shadow: 0 0 20px rgba(92, 214, 255, 0.3); } -// Channel column +// Channel column — bumped from 140px to 180px per the UX audit so long +// channel names ("13th Street Universal HD", "ARD alpha", etc.) stop being +// truncated to 5 characters. #channels-column { - width: 140px; + width: 180px; flex: none; border-right: 1px solid $border-medium; background: $bg-surface; @@ -529,8 +539,8 @@ $gradient-header: linear-gradient( } &.active rect { - fill: rgba(139, 92, 246, 0.2); - stroke: rgba(139, 92, 246, 0.4); + fill: rgba(47, 123, 255, 0.2); + stroke: rgba(47, 123, 255, 0.4); } &.active .channel-name { @@ -542,25 +552,40 @@ $gradient-header: linear-gradient( .channel-name { color: $text-primary; padding: 8px 12px; - font-size: 11px; + font-size: 12.5px; font-weight: 500; display: flex; align-items: center; + gap: 10px; height: 100%; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - letter-spacing: 0.02em; + letter-spacing: 0.01em; + line-height: 1.2; img { + // Smaller logo (24px) per audit recommendation so the channel + // name has room to breathe in the 180px column. + width: 24px !important; + height: 24px !important; + margin-right: 0 !important; + flex: 0 0 auto; border-radius: 4px; background: rgba(255, 255, 255, 0.05); padding: 2px; + object-fit: contain; } span { + // Allow up to two lines so "13th Street Universal HD" and + // similar long names wrap instead of disappearing into an + // ellipsis after 5 characters. + min-width: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; + word-break: break-word; } } @@ -572,7 +597,7 @@ $gradient-header: linear-gradient( // EPG container #epg-container { - width: calc(100vw - 140px); + width: calc(100vw - 180px); overflow-x: scroll; overflow-y: clip; background: $bg-deep; @@ -588,11 +613,11 @@ $gradient-header: linear-gradient( } &::-webkit-scrollbar-thumb { - background: rgba(139, 92, 246, 0.4); + background: rgba(47, 123, 255, 0.4); border-radius: 4px; &:hover { - background: rgba(139, 92, 246, 0.6); + background: rgba(47, 123, 255, 0.6); } } } @@ -633,13 +658,13 @@ $gradient-header: linear-gradient( &:hover rect { fill: $bg-hover; - stroke: rgba(139, 92, 246, 0.4); - filter: drop-shadow(0 0 8px rgba(139, 92, 246, 0.2)); + stroke: rgba(47, 123, 255, 0.4); + filter: drop-shadow(0 0 8px rgba(47, 123, 255, 0.2)); } // Highlighted program (from search) &.highlighted rect { - fill: rgba(0, 212, 170, 0.25); + fill: rgba(92, 214, 255, 0.25); stroke: $accent-primary; stroke-width: 2px; animation: highlightPulse 1s ease-in-out 3; @@ -653,12 +678,12 @@ $gradient-header: linear-gradient( // Active channel row highlighting .active-row .program rect { - fill: rgba(139, 92, 246, 0.15); - stroke: rgba(139, 92, 246, 0.3); + fill: rgba(47, 123, 255, 0.15); + stroke: rgba(47, 123, 255, 0.3); } .active-row .program:hover rect { - fill: rgba(139, 92, 246, 0.25); + fill: rgba(47, 123, 255, 0.25); } .program-title { @@ -683,12 +708,45 @@ $gradient-header: linear-gradient( line[stroke='red'] { stroke: $accent-primary !important; stroke-width: 2px; - filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6)) - drop-shadow(0 0 16px rgba(0, 212, 170, 0.3)); + filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)) + drop-shadow(0 0 16px rgba(92, 214, 255, 0.3)); // Animated pulse animation: timePulse 2s ease-in-out infinite; } + + // Now-line time badge — a small filled pill sitting on top of the time + // header that reads "HH:MM". Keeps the cyan visual language of the line + // itself so the relationship is unmistakable. + .now-label { + pointer-events: none; + + rect { + fill: $accent-primary; + stroke: none; + filter: drop-shadow(0 0 6px $accent-now-glow); + } + + text { + fill: var(--app-shell-bg, #0d0f12); + font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, + monospace; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + } + } + + // Currently-airing program — a thin cyan border on every program block + // whose time window contains the now-line. Audit recommended this to + // make "what's airing right now" scannable without finding the line + // first then tracing it down each row. + .program.is-now { + rect { + stroke: $accent-primary; + stroke-width: 1.5px; + } + } } // Pulse animation for time line @@ -696,13 +754,13 @@ $gradient-header: linear-gradient( 0%, 100% { opacity: 1; - filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6)) - drop-shadow(0 0 16px rgba(0, 212, 170, 0.3)); + filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)) + drop-shadow(0 0 16px rgba(92, 214, 255, 0.3)); } 50% { opacity: 0.8; - filter: drop-shadow(0 0 12px rgba(0, 212, 170, 0.8)) - drop-shadow(0 0 24px rgba(0, 212, 170, 0.4)); + filter: drop-shadow(0 0 12px rgba(92, 214, 255, 0.8)) + drop-shadow(0 0 24px rgba(92, 214, 255, 0.4)); } } @@ -810,7 +868,7 @@ rect { &:hover rect { fill: #f1f5f9; - stroke: rgba(139, 92, 246, 0.4); + stroke: rgba(47, 123, 255, 0.4); } } @@ -819,8 +877,8 @@ rect { } line[stroke='red'] { - stroke: #059669 !important; - filter: drop-shadow(0 0 6px rgba(5, 150, 105, 0.4)); + stroke: $accent-primary !important; + filter: drop-shadow(0 0 6px $accent-now-glow); } } } @@ -833,16 +891,22 @@ rect { } #channels-column { - width: 100px; + width: 140px; .channel-name { - font-size: 10px; + font-size: 11.5px; padding: 6px 8px; + gap: 8px; + + img { + width: 20px !important; + height: 20px !important; + } } } #epg-container { - width: calc(100vw - 100px); + width: calc(100vw - 140px); } .today-date { diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts index 5e2d740c8..5ef6564d0 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts @@ -145,6 +145,19 @@ export class MultiEpgContainerComponent return (now.getHours() + now.getMinutes() / 60) * this.hourWidth(); }); + // "16:32"-style label rendered as a badge above the now-line. Recomputes + // on the same 60-second tick that drives `currentTimeLine` (both depend + // on hourWidth's update pulse from `ngOnInit`). + readonly currentTimeLabel = computed(() => { + // Subscribe to the same tick source as currentTimeLine. + this.hourWidth(); + const now = new Date(); + return `${now.getHours().toString().padStart(2, '0')}:${now + .getMinutes() + .toString() + .padStart(2, '0')}`; + }); + // Constants readonly timeHeader = Array.from({ length: 24 }, (_, i) => i); readonly barHeight = 50; @@ -228,6 +241,21 @@ export class MultiEpgContainerComponent return `${program.start}|${program?.title?.toString() ?? ''}`; } + /** + * Returns true when the now-line falls within this program's rendered + * span — used to add a `.is-now` class so the cyan border lights up + * every airing program at once. The template reads `currentTimeLine()` + * (a signal) here so the highlight refreshes on the same 60-second tick + * as the line itself. + */ + isProgramAiringNow(program: EnrichedProgram): boolean { + const nowX = this.currentTimeLine(); + return ( + nowX >= program.startPosition && + nowX <= program.startPosition + program.width + ); + } + async requestPrograms(): Promise { if (!window.electron) { console.warn('Multi-EPG not available: Electron not detected');