fix(epg): keep the import panel free of horizontal scrolling (#1599)

This commit is contained in:
4gray authored and GitHub committed 2026-09-13 17:25:53 +02:00
1 parent 17b8aa309d
commit d9c8ba4d0b
26 files changed
+280 -40

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: epg
---
The EPG import panel no longer shows a horizontal scrollbar while sources download. Source names now read as host/file instead of running the two together, channel and programme counts use your locale's digit grouping, and the minimize button and summary are translated.
+19
View File
@@ -334,6 +334,25 @@ test.describe('Electron EPG', () => {
.first()
).toBeVisible();
// The row must fit the 300px panel: Material icon buttons carry a
// 48px touch target that used to poke past the row padding and
// turn into a horizontal scrollbar.
const importsList = app.mainWindow.locator(
'.epg-progress-panel .imports-list'
);
await expect
.poll(() =>
importsList.evaluate(
(element) => element.scrollWidth - element.clientWidth
)
)
.toBe(0);
await expect(
app.mainWindow.locator('.epg-progress-panel .item-url')
).toHaveText(
`${new URL(epgServer.resourceUrl).hostname}/guide.xml`
);
await saveSettings(app.mainWindow);
await app.mainWindow
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "برامج",
"LOADING": "جارٍ التحميل",
"QUEUED": "في قائمة الانتظار",
"IMPORT_PANEL_MINIMIZE": "تصغير لوحة استيراد دليل البرامج",
"IMPORT_PANEL_EXPAND": "توسيع لوحة استيراد دليل البرامج",
"IMPORT_SUMMARY_LOADING": "{{count}} قيد التحميل",
"IMPORT_SUMMARY_QUEUED": "{{count}} في قائمة الانتظار",
"TOTAL_CHANNELS": "إجمالي القنوات",
"TOTAL_PROGRAMS": "إجمالي البرامج",
"UP_TO_DATE": "EPG محدث، لا حاجة للمزامنة",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "برامج",
"LOADING": "جاري التشريج",
"QUEUED": "في الطابور",
"IMPORT_PANEL_MINIMIZE": "صغّر لوحة استيراد دليل البرامج",
"IMPORT_PANEL_EXPAND": "كبّر لوحة استيراد دليل البرامج",
"IMPORT_SUMMARY_LOADING": "{{count}} كيتحمّل",
"IMPORT_SUMMARY_QUEUED": "{{count}} فالصف",
"TOTAL_CHANNELS": "مجموع القنوات",
"TOTAL_PROGRAMS": "مجموع البرامج",
"UP_TO_DATE": "EPG محدث، ما خاصش مزامنة",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "перадач",
"LOADING": "Загрузка",
"QUEUED": "У чарзе",
"IMPORT_PANEL_MINIMIZE": "Згарнуць панэль імпарту EPG",
"IMPORT_PANEL_EXPAND": "Разгарнуць панэль імпарту EPG",
"IMPORT_SUMMARY_LOADING": "Загружаецца: {{count}}",
"IMPORT_SUMMARY_QUEUED": "у чарзе: {{count}}",
"TOTAL_CHANNELS": "Усяго каналаў",
"TOTAL_PROGRAMS": "Усяго перадач",
"UP_TO_DATE": "EPG актуальны, сінхранізацыя не патрэбна",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "Programme",
"LOADING": "Wird geladen",
"QUEUED": "In Warteschlange",
"IMPORT_PANEL_MINIMIZE": "EPG-Importpanel minimieren",
"IMPORT_PANEL_EXPAND": "EPG-Importpanel ausklappen",
"IMPORT_SUMMARY_LOADING": "{{count}} wird geladen",
"IMPORT_SUMMARY_QUEUED": "{{count}} in Warteschlange",
"TOTAL_CHANNELS": "Sender insgesamt",
"TOTAL_PROGRAMS": "Programme insgesamt",
"UP_TO_DATE": "EPG ist aktuell, keine Synchronisation nötig",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "προγράμματα",
"LOADING": "Φόρτωση",
"QUEUED": "Σε ουρά",
"IMPORT_PANEL_MINIMIZE": "Ελαχιστοποίηση πίνακα εισαγωγής EPG",
"IMPORT_PANEL_EXPAND": "Ανάπτυξη πίνακα εισαγωγής EPG",
"IMPORT_SUMMARY_LOADING": "{{count}} σε φόρτωση",
"IMPORT_SUMMARY_QUEUED": "{{count}} σε αναμονή",
"TOTAL_CHANNELS": "Σύνολο καναλιών",
"TOTAL_PROGRAMS": "Σύνολο προγραμμάτων",
"UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "programs",
"LOADING": "Loading",
"QUEUED": "Queued",
"IMPORT_PANEL_MINIMIZE": "Minimize EPG import panel",
"IMPORT_PANEL_EXPAND": "Expand EPG import panel",
"IMPORT_SUMMARY_LOADING": "{{count}} loading",
"IMPORT_SUMMARY_QUEUED": "{{count}} queued",
"TOTAL_CHANNELS": "Total channels",
"TOTAL_PROGRAMS": "Total programs",
"UP_TO_DATE": "EPG is up-to-date, no sync needed",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "programas",
"LOADING": "Cargando",
"QUEUED": "En cola",
"IMPORT_PANEL_MINIMIZE": "Minimizar el panel de importación de EPG",
"IMPORT_PANEL_EXPAND": "Expandir el panel de importación de EPG",
"IMPORT_SUMMARY_LOADING": "{{count}} cargando",
"IMPORT_SUMMARY_QUEUED": "{{count}} en cola",
"TOTAL_CHANNELS": "Total de canales",
"TOTAL_PROGRAMS": "Total de programas",
"UP_TO_DATE": "EPG actualizada, no se necesita sincronización",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "programmes",
"LOADING": "Chargement",
"QUEUED": "En file d'attente",
"IMPORT_PANEL_MINIMIZE": "Réduire le panneau d’import EPG",
"IMPORT_PANEL_EXPAND": "Agrandir le panneau d’import EPG",
"IMPORT_SUMMARY_LOADING": "{{count}} en cours",
"IMPORT_SUMMARY_QUEUED": "{{count}} en attente",
"TOTAL_CHANNELS": "Total des chaînes",
"TOTAL_PROGRAMS": "Total des programmes",
"UP_TO_DATE": "L'EPG est à jour, aucune synchronisation nécessaire",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "műsor",
"LOADING": "Betöltés",
"QUEUED": "Várólistán",
"IMPORT_PANEL_MINIMIZE": "EPG-importálási panel összecsukása",
"IMPORT_PANEL_EXPAND": "EPG-importálási panel kibontása",
"IMPORT_SUMMARY_LOADING": "{{count}} betöltés alatt",
"IMPORT_SUMMARY_QUEUED": "{{count}} várakozik",
"TOTAL_CHANNELS": "Csatornák összesen",
"TOTAL_PROGRAMS": "Műsorok összesen",
"UP_TO_DATE": "Az EPG naprakész, nincs szükség szinkronizálásra",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "programmi",
"LOADING": "Caricamento",
"QUEUED": "In coda",
"IMPORT_PANEL_MINIMIZE": "Riduci il pannello di importazione EPG",
"IMPORT_PANEL_EXPAND": "Espandi il pannello di importazione EPG",
"IMPORT_SUMMARY_LOADING": "{{count}} in caricamento",
"IMPORT_SUMMARY_QUEUED": "{{count}} in coda",
"TOTAL_CHANNELS": "Totale canali",
"TOTAL_PROGRAMS": "Totale programmi",
"UP_TO_DATE": "L'EPG è aggiornato, nessuna sincronizzazione necessaria",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "番組",
"LOADING": "読み込み中",
"QUEUED": "待機中",
"IMPORT_PANEL_MINIMIZE": "EPG インポートパネルを最小化",
"IMPORT_PANEL_EXPAND": "EPG インポートパネルを展開",
"IMPORT_SUMMARY_LOADING": "{{count}} 件読み込み中",
"IMPORT_SUMMARY_QUEUED": "{{count}} 件待機中",
"TOTAL_CHANNELS": "総チャンネル数",
"TOTAL_PROGRAMS": "総番組数",
"UP_TO_DATE": "EPGは最新です。同期は不要です",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "프로그램",
"LOADING": "불러오는 중",
"QUEUED": "대기 중",
"IMPORT_PANEL_MINIMIZE": "EPG 가져오기 패널 최소화",
"IMPORT_PANEL_EXPAND": "EPG 가져오기 패널 펼치기",
"IMPORT_SUMMARY_LOADING": "{{count}}개 불러오는 중",
"IMPORT_SUMMARY_QUEUED": "{{count}}개 대기 중",
"TOTAL_CHANNELS": "전체 채널",
"TOTAL_PROGRAMS": "전체 프로그램",
"UP_TO_DATE": "EPG가 최신 상태이며 동기화가 필요하지 않습니다",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "programma's",
"LOADING": "Laden",
"QUEUED": "In wachtrij",
"IMPORT_PANEL_MINIMIZE": "EPG-importpaneel minimaliseren",
"IMPORT_PANEL_EXPAND": "EPG-importpaneel uitvouwen",
"IMPORT_SUMMARY_LOADING": "{{count}} wordt geladen",
"IMPORT_SUMMARY_QUEUED": "{{count}} in de wachtrij",
"TOTAL_CHANNELS": "Totaal aantal kanalen",
"TOTAL_PROGRAMS": "Totaal aantal programma's",
"UP_TO_DATE": "EPG is up-to-date, synchronisatie niet nodig",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "programów",
"LOADING": "Ładowanie",
"QUEUED": "W kolejce",
"IMPORT_PANEL_MINIMIZE": "Zwiń panel importu EPG",
"IMPORT_PANEL_EXPAND": "Rozwiń panel importu EPG",
"IMPORT_SUMMARY_LOADING": "Wczytywanie: {{count}}",
"IMPORT_SUMMARY_QUEUED": "w kolejce: {{count}}",
"TOTAL_CHANNELS": "Wszystkie kanały",
"TOTAL_PROGRAMS": "Wszystkie programy",
"UP_TO_DATE": "EPG jest aktualne, synchronizacja nie jest potrzebna",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "programas",
"LOADING": "Carregando",
"QUEUED": "Na fila",
"IMPORT_PANEL_MINIMIZE": "Minimizar o painel de importação do EPG",
"IMPORT_PANEL_EXPAND": "Expandir o painel de importação do EPG",
"IMPORT_SUMMARY_LOADING": "{{count}} a carregar",
"IMPORT_SUMMARY_QUEUED": "{{count}} na fila",
"TOTAL_CHANNELS": "Total de canais",
"TOTAL_PROGRAMS": "Total de programas",
"UP_TO_DATE": "EPG está atualizado, sincronização não é necessária",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "программ",
"LOADING": "Загрузка",
"QUEUED": "В очереди",
"IMPORT_PANEL_MINIMIZE": "Свернуть панель импорта EPG",
"IMPORT_PANEL_EXPAND": "Развернуть панель импорта EPG",
"IMPORT_SUMMARY_LOADING": "Загружается: {{count}}",
"IMPORT_SUMMARY_QUEUED": "в очереди: {{count}}",
"TOTAL_CHANNELS": "Всего каналов",
"TOTAL_PROGRAMS": "Всего программ",
"UP_TO_DATE": "EPG актуален, синхронизация не требуется",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "programlar",
"LOADING": "Yükleniyor",
"QUEUED": "Sırada",
"IMPORT_PANEL_MINIMIZE": "EPG içe aktarma panelini küçült",
"IMPORT_PANEL_EXPAND": "EPG içe aktarma panelini genişlet",
"IMPORT_SUMMARY_LOADING": "{{count}} yükleniyor",
"IMPORT_SUMMARY_QUEUED": "{{count}} sırada",
"TOTAL_CHANNELS": "Toplam kanal",
"TOTAL_PROGRAMS": "Toplam program",
"UP_TO_DATE": "EPG güncel, senkronizasyona gerek yok",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "节目",
"LOADING": "加载中",
"QUEUED": "排队中",
"IMPORT_PANEL_MINIMIZE": "最小化 EPG 导入面板",
"IMPORT_PANEL_EXPAND": "展开 EPG 导入面板",
"IMPORT_SUMMARY_LOADING": "{{count}} 个加载中",
"IMPORT_SUMMARY_QUEUED": "{{count}} 个排队中",
"TOTAL_CHANNELS": "频道总数",
"TOTAL_PROGRAMS": "节目总数",
"UP_TO_DATE": "EPG 已为最新,无需同步",
+4
View File
@@ -857,6 +857,10 @@
"PROGRAMS": "節目",
"LOADING": "載入中",
"QUEUED": "佇列中",
"IMPORT_PANEL_MINIMIZE": "最小化 EPG 匯入面板",
"IMPORT_PANEL_EXPAND": "展開 EPG 匯入面板",
"IMPORT_SUMMARY_LOADING": "{{count}} 個載入中",
"IMPORT_SUMMARY_QUEUED": "{{count}} 個排隊中",
"TOTAL_CHANNELS": "頻道總數",
"TOTAL_PROGRAMS": "節目總數",
"UP_TO_DATE": "EPG 已是最新,無需同步",
@@ -0,0 +1,53 @@
import { formatEpgImportDisplayUrl } from './epg-import-display-url';
describe('formatEpgImportDisplayUrl', () => {
it('separates the host from the file name', () => {
expect(
formatEpgImportDisplayUrl('http://epg.it999.ru/edem.xml.gz')
).toBe('epg.it999.ru/edem.xml.gz');
});
it('collapses intermediate path segments', () => {
expect(
formatEpgImportDisplayUrl(
'https://iptvx.one/static/epg/epg_lite.xml.gz?token=secret'
)
).toBe('iptvx.one/…/epg_lite.xml.gz');
});
it('decodes a percent-encoded remote file name', () => {
expect(
formatEpgImportDisplayUrl('https://example.org/guide%20v2.xml')
).toBe('example.org/guide v2.xml');
expect(
formatEpgImportDisplayUrl('https://example.org/epg/guide%ZZ.xml')
).toBe('example.org/…/guide%ZZ.xml');
});
it('falls back to the host when the path has no file name', () => {
expect(formatEpgImportDisplayUrl('https://example.org/')).toBe(
'example.org'
);
expect(formatEpgImportDisplayUrl('https://example.org')).toBe(
'example.org'
);
});
it('shows the file name of a local file URL or path', () => {
expect(
formatEpgImportDisplayUrl('file:///home/user/epg/guide%20v2.xml.gz')
).toBe('…/guide v2.xml.gz');
expect(formatEpgImportDisplayUrl('/home/user/epg/guide.xml')).toBe(
'…/guide.xml'
);
expect(formatEpgImportDisplayUrl('C:\\epg\\guide.xml.gz')).toBe(
'…/guide.xml.gz'
);
});
it('truncates unparsable values instead of throwing', () => {
const long = 'x'.repeat(60);
expect(formatEpgImportDisplayUrl(long)).toBe(`${'x'.repeat(40)}…`);
expect(formatEpgImportDisplayUrl('short')).toBe('short');
});
});
@@ -0,0 +1,48 @@
const MAX_PLAIN_LENGTH = 40;
/**
* Compact single-line label for an EPG source: the host, an ellipsis for
* any intermediate path, and the file name — `host/…/file.xml.gz`. A local
* file (a `file:` URL or a filesystem path) shows `…/file.xml.gz`, since the
* host slot would be empty. Query strings and credentials are never
* rendered; the full value belongs in the tooltip only.
*/
export function formatEpgImportDisplayUrl(url: string): string {
let parsed: URL | null = null;
try {
parsed = new URL(url);
} catch {
parsed = null;
}
if (parsed && parsed.protocol !== 'file:' && parsed.hostname) {
const segments = parsed.pathname.split('/').filter(Boolean);
const file = segments[segments.length - 1];
if (!file) {
return parsed.hostname;
}
const name = safeDecode(file);
return segments.length > 1
? `${parsed.hostname}/…/${name}`
: `${parsed.hostname}/${name}`;
}
const localPath = parsed?.protocol === 'file:' ? parsed.pathname : url;
const segments = localPath.split(/[\\/]/).filter(Boolean);
const file = segments[segments.length - 1];
if (file && segments.length > 1) {
return `…/${safeDecode(file)}`;
}
return url.length > MAX_PLAIN_LENGTH
? `${url.substring(0, MAX_PLAIN_LENGTH)}…`
: url;
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
@@ -10,9 +10,16 @@
{{ 'EPG.IMPORT_PROGRESS' | translate }}
} @else {
<span class="minimized-summary">
{{ loadingCount() }} loading
{{
'EPG.IMPORT_SUMMARY_LOADING'
| translate: { count: loadingCount() }
}}
@if (queuedCount() > 0) {
, {{ queuedCount() }} queued
,
{{
'EPG.IMPORT_SUMMARY_QUEUED'
| translate: { count: queuedCount() }
}}
}
</span>
}
@@ -22,7 +29,18 @@
mat-icon-button
class="minimize-btn"
(click)="toggleMinimize()"
[attr.aria-label]="minimized() ? 'Expand' : 'Minimize'"
[attr.aria-label]="
(minimized()
? 'EPG.IMPORT_PANEL_EXPAND'
: 'EPG.IMPORT_PANEL_MINIMIZE'
) | translate
"
[matTooltip]="
(minimized()
? 'EPG.IMPORT_PANEL_EXPAND'
: 'EPG.IMPORT_PANEL_MINIMIZE'
) | translate
"
>
<mat-icon>
{{ minimized() ? 'unfold_more' : 'unfold_less' }}
@@ -99,7 +117,7 @@
}}"
>
<mat-icon>tv</mat-icon>
{{ item.stats.totalChannels }}
{{ formatCount(item.stats.totalChannels) }}
</span>
<span
class="stat-badge"
@@ -108,7 +126,7 @@
}}"
>
<mat-icon>event</mat-icon>
{{ item.stats.totalPrograms }}
{{ formatCount(item.stats.totalPrograms) }}
</span>
} @else if (item.error) {
<span class="error-text">{{ item.error }}</span>
@@ -20,6 +20,34 @@
--epg-queued: #a78bfa;
}
// Material icon buttons keep a 48px absolutely positioned touch target and
// a 48px state layer regardless of the width/height they are given. Inside
// the scrollable list that 48px box pokes past the row padding and turns
// into a horizontal scrollbar. Compact buttons shrink the state layer to the
// visible size and keep a touch target that is still larger than the button
// but fits inside the row padding (6px on each side, the tightest row has
// 8px), so touch and motor-impaired users keep a forgiving hit area.
@mixin compact-icon-button($size, $icon-size) {
--mdc-icon-button-state-layer-size: #{$size};
--mdc-icon-button-icon-size: #{$icon-size};
width: $size;
height: $size;
padding: 0;
line-height: $size;
flex-shrink: 0;
::ng-deep .mat-mdc-button-touch-target {
width: $size + 12px;
height: $size + 12px;
}
mat-icon {
font-size: $icon-size;
width: $icon-size;
height: $icon-size;
}
}
.epg-progress-panel {
position: fixed;
bottom: 20px;
@@ -90,18 +118,9 @@
.minimize-btn,
.dismiss-all-btn {
width: 28px;
height: 28px;
padding: 0;
line-height: 28px;
@include compact-icon-button(28px, 18px);
border-radius: 8px;
color: var(--epg-text-tertiary);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
@@ -117,7 +136,9 @@
.imports-list {
max-height: 320px;
overflow-y: auto;
// Never scroll sideways: every row truncates or wraps instead.
overflow: hidden auto;
overscroll-behavior: contain;
padding: 4px 0;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent)
@@ -152,6 +173,7 @@
.item-url {
flex: 1;
min-width: 0;
font-size: 0.78rem;
font-weight: 500;
white-space: nowrap;
@@ -162,22 +184,13 @@
.dismiss-btn,
.retry-btn {
width: 24px;
height: 24px;
padding: 0;
line-height: 24px;
@include compact-icon-button(24px, 16px);
border-radius: 6px;
opacity: 0;
transition:
opacity 0.15s ease,
color 0.15s ease;
color: var(--epg-text-tertiary);
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
// Retry is a deliberate action — keep it discoverable, not hover-only,
@@ -207,7 +220,8 @@
}
}
&:hover .dismiss-btn {
&:hover .dismiss-btn,
.dismiss-btn:focus-visible {
opacity: 1;
}
@@ -260,8 +274,11 @@
}
.error-text {
min-width: 0;
font-size: 0.72rem;
line-height: 1.35;
color: var(--epg-error);
overflow-wrap: anywhere;
}
}
@@ -322,6 +339,7 @@
.queue-url {
flex: 1;
min-width: 0;
font-size: 0.72rem;
white-space: nowrap;
overflow: hidden;
@@ -329,17 +347,9 @@
}
.dismiss-btn {
width: 22px;
height: 22px;
padding: 0;
line-height: 22px;
@include compact-icon-button(22px, 14px);
border-radius: 6px;
color: var(--epg-text-tertiary);
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
}
@@ -14,6 +14,8 @@ import {
EpgProgressService,
} from '@iptvnator/epg/data-access';
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { formatEpgImportDisplayUrl } from './epg-import-display-url';
interface EpgTrustConfirmDialogData {
confirmLabel: string;
@@ -106,11 +108,19 @@ export class EpgProgressPanelComponent {
}
getDisplayUrl(url: string): string {
return formatEpgImportDisplayUrl(url);
}
formatCount(value: number): string {
// App language codes are not all BCP 47 tags (`zhtw`, `ary`, `by`);
// the date pipes already map those aliases for Intl.
const locale = normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
try {
const urlObject = new URL(url);
return urlObject.hostname + urlObject.pathname.split('/').pop();
return new Intl.NumberFormat(locale).format(value);
} catch {
return url.length > 40 ? `${url.substring(0, 40)}...` : url;
return String(value);
}
}