fix(dashboard): localize progress labels

This commit is contained in:
4gray committed 2026-05-22 01:02:04 +03:00
1 parent 2ecb5c28f4
commit 5cfe3aef06
21 files changed
+152 -20

No files matched your search

+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "متابعة المشاهدة",
"LIVE_FAVORITES": "يُبث الآن على المفضلة لديك",
"LIVE_RECENT": "متابعة البث المباشر",
"REMAINING_SECONDS": "متبقٍ {{seconds}} ث",
"REMAINING_MINUTES": "متبقٍ {{minutes}} د",
"REMAINING_HOURS": "متبقٍ {{hours}} س",
"REMAINING_HOURS_MINUTES": "متبقٍ {{hours}} س {{minutes}} د",
"PERCENT_WATCHED": "تمت مشاهدة {{value}}%",
"UNTITLED_SOURCE": "مصدر بدون عنوان",
"GLOBAL_FAVORITES": "المفضلة العامة",
"REMOVE_FROM_HISTORY": "إزالة من السجل",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "كمل المشاهدة",
"LIVE_FAVORITES": "مباشر دابا على المفضلة ديالك",
"LIVE_RECENT": "كمل مع المباشر",
"REMAINING_SECONDS": "باقي {{seconds}} ث",
"REMAINING_MINUTES": "باقي {{minutes}} د",
"REMAINING_HOURS": "باقي {{hours}} س",
"REMAINING_HOURS_MINUTES": "باقي {{hours}} س {{minutes}} د",
"PERCENT_WATCHED": "تشاهد {{value}}%",
"UNTITLED_SOURCE": "مصدر بلا عنوان",
"GLOBAL_FAVORITES": "المفضلة العامة",
"REMOVE_FROM_HISTORY": "حيد من السجل",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "Працягнуць прагляд",
"LIVE_FAVORITES": "Цяпер у эфіры на выбраных",
"LIVE_RECENT": "Працягнуць прагляд эфіру",
"REMAINING_SECONDS": "Засталося {{seconds}} с",
"REMAINING_MINUTES": "Засталося {{minutes}} хв",
"REMAINING_HOURS": "Засталося {{hours}} г",
"REMAINING_HOURS_MINUTES": "Засталося {{hours}} г {{minutes}} хв",
"PERCENT_WATCHED": "Прагледжана {{value}}%",
"UNTITLED_SOURCE": "Крыніца без назвы",
"GLOBAL_FAVORITES": "Усе фаварыты",
"REMOVE_FROM_HISTORY": "Выдаліць з гісторыі",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "Weiter schauen",
"LIVE_FAVORITES": "Jetzt live auf Ihren Favoriten",
"LIVE_RECENT": "Mit Live-TV weiterschauen",
"REMAINING_SECONDS": "Noch {{seconds}} s",
"REMAINING_MINUTES": "Noch {{minutes}} Min.",
"REMAINING_HOURS": "Noch {{hours}} Std.",
"REMAINING_HOURS_MINUTES": "Noch {{hours}} Std. {{minutes}} Min.",
"PERCENT_WATCHED": "{{value}}% angesehen",
"UNTITLED_SOURCE": "Unbenannte Quelle",
"GLOBAL_FAVORITES": "Globale Favoriten",
"REMOVE_FROM_HISTORY": "Aus Verlauf entfernen",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"LIVE_FAVORITES": "Ζωντανά τώρα στα αγαπημένα σας",
"LIVE_RECENT": "Συνέχιση με ζωντανή TV",
"REMAINING_SECONDS": "Απομένουν {{seconds}} δ",
"REMAINING_MINUTES": "Απομένουν {{minutes}} λ",
"REMAINING_HOURS": "Απομένουν {{hours}} ώ",
"REMAINING_HOURS_MINUTES": "Απομένουν {{hours}} ώ {{minutes}} λ",
"PERCENT_WATCHED": "Προβλήθηκε {{value}}%",
"UNTITLED_SOURCE": "Πηγή χωρίς τίτλο",
"GLOBAL_FAVORITES": "Βασικά Αγαπημένα",
"REMOVE_FROM_HISTORY": "Αφαίρεση από το ιστορικό",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "Continue Watching",
"LIVE_FAVORITES": "Live now on your favorites",
"LIVE_RECENT": "Continue with live TV",
"REMAINING_SECONDS": "{{seconds}}s left",
"REMAINING_MINUTES": "{{minutes}}m left",
"REMAINING_HOURS": "{{hours}}h left",
"REMAINING_HOURS_MINUTES": "{{hours}}h {{minutes}}m left",
"PERCENT_WATCHED": "{{value}}% watched",
"UNTITLED_SOURCE": "Untitled source",
"GLOBAL_FAVORITES": "Global Favorites",
"REMOVE_FROM_HISTORY": "Remove from history",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "Continuar viendo",
"LIVE_FAVORITES": "Ahora en vivo en tus favoritos",
"LIVE_RECENT": "Continuar con TV en vivo",
"REMAINING_SECONDS": "Quedan {{seconds}} s",
"REMAINING_MINUTES": "Quedan {{minutes}} min",
"REMAINING_HOURS": "Quedan {{hours}} h",
"REMAINING_HOURS_MINUTES": "Quedan {{hours}} h {{minutes}} min",
"PERCENT_WATCHED": "{{value}}% visto",
"UNTITLED_SOURCE": "Fuente sin título",
"GLOBAL_FAVORITES": "Favoritos globales",
"REMOVE_FROM_HISTORY": "Quitar del historial",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "Reprendre la lecture",
"LIVE_FAVORITES": "En direct sur vos favoris",
"LIVE_RECENT": "Reprendre la TV en direct",
"REMAINING_SECONDS": "Il reste {{seconds}} s",
"REMAINING_MINUTES": "Il reste {{minutes}} min",
"REMAINING_HOURS": "Il reste {{hours}} h",
"REMAINING_HOURS_MINUTES": "Il reste {{hours}} h {{minutes}} min",
"PERCENT_WATCHED": "{{value}}% regardé",
"UNTITLED_SOURCE": "Source sans titre",
"GLOBAL_FAVORITES": "Favoris globaux",
"REMOVE_FROM_HISTORY": "Retirer de l'historique",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "Continua a guardare",
"LIVE_FAVORITES": "Live ora sui tuoi preferiti",
"LIVE_RECENT": "Continua con la TV live",
"REMAINING_SECONDS": "Mancano {{seconds}} s",
"REMAINING_MINUTES": "Mancano {{minutes}} min",
"REMAINING_HOURS": "Mancano {{hours}} h",
"REMAINING_HOURS_MINUTES": "Mancano {{hours}} h {{minutes}} min",
"PERCENT_WATCHED": "{{value}}% visto",
"UNTITLED_SOURCE": "Sorgente senza titolo",
"GLOBAL_FAVORITES": "Preferiti globali",
"REMOVE_FROM_HISTORY": "Rimuovi dalla cronologia",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "視聴を再開",
"LIVE_FAVORITES": "お気に入りで現在放送中",
"LIVE_RECENT": "ライブTVの視聴を再開",
"REMAINING_SECONDS": "残り{{seconds}}秒",
"REMAINING_MINUTES": "残り{{minutes}}分",
"REMAINING_HOURS": "残り{{hours}}時間",
"REMAINING_HOURS_MINUTES": "残り{{hours}}時間{{minutes}}分",
"PERCENT_WATCHED": "{{value}}% 視聴済み",
"UNTITLED_SOURCE": "無題のソース",
"GLOBAL_FAVORITES": "グローバルお気に入り",
"REMOVE_FROM_HISTORY": "履歴から削除",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "이어서 시청",
"LIVE_FAVORITES": "즐겨찾기에서 지금 라이브 중인 채널입니다",
"LIVE_RECENT": "라이브 TV 이어서 시청",
"REMAINING_SECONDS": "{{seconds}}초 남음",
"REMAINING_MINUTES": "{{minutes}}분 남음",
"REMAINING_HOURS": "{{hours}}시간 남음",
"REMAINING_HOURS_MINUTES": "{{hours}}시간 {{minutes}}분 남음",
"PERCENT_WATCHED": "{{value}}% 시청함",
"UNTITLED_SOURCE": "제목 없는 소스",
"GLOBAL_FAVORITES": "전체 즐겨찾기",
"REMOVE_FROM_HISTORY": "기록에서 제거",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "Verder kijken",
"LIVE_FAVORITES": "Nu live op je favorieten",
"LIVE_RECENT": "Verder kijken met live tv",
"REMAINING_SECONDS": "Nog {{seconds}} sec",
"REMAINING_MINUTES": "Nog {{minutes}} min",
"REMAINING_HOURS": "Nog {{hours}} u",
"REMAINING_HOURS_MINUTES": "Nog {{hours}} u {{minutes}} min",
"PERCENT_WATCHED": "{{value}}% bekeken",
"UNTITLED_SOURCE": "Naamloze bron",
"GLOBAL_FAVORITES": "Algemene favorieten",
"REMOVE_FROM_HISTORY": "Verwijderen uit geschiedenis",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
"LIVE_FAVORITES": "Na żywo w Twoich ulubionych",
"LIVE_RECENT": "Kontynuuj oglądanie na żywo",
"REMAINING_SECONDS": "Pozostało {{seconds}} s",
"REMAINING_MINUTES": "Pozostało {{minutes}} min",
"REMAINING_HOURS": "Pozostało {{hours}} godz.",
"REMAINING_HOURS_MINUTES": "Pozostało {{hours}} godz. {{minutes}} min",
"PERCENT_WATCHED": "Obejrzano {{value}}%",
"UNTITLED_SOURCE": "Źródło bez tytułu",
"GLOBAL_FAVORITES": "Globalne ulubione",
"REMOVE_FROM_HISTORY": "Usuń z historii",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "Continuar assistindo",
"LIVE_FAVORITES": "Ao vivo agora nos seus favoritos",
"LIVE_RECENT": "Continuar com TV ao vivo",
"REMAINING_SECONDS": "Restam {{seconds}} s",
"REMAINING_MINUTES": "Restam {{minutes}} min",
"REMAINING_HOURS": "Restam {{hours}} h",
"REMAINING_HOURS_MINUTES": "Restam {{hours}} h {{minutes}} min",
"PERCENT_WATCHED": "{{value}}% assistido",
"UNTITLED_SOURCE": "Fonte sem título",
"GLOBAL_FAVORITES": "Favoritos globais",
"REMOVE_FROM_HISTORY": "Remover do histórico",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "Продолжить просмотр",
"LIVE_FAVORITES": "Сейчас в эфире на избранных каналах",
"LIVE_RECENT": "Продолжить просмотр прямого эфира",
"REMAINING_SECONDS": "Осталось {{seconds}} с",
"REMAINING_MINUTES": "Осталось {{minutes}} мин",
"REMAINING_HOURS": "Осталось {{hours}} ч",
"REMAINING_HOURS_MINUTES": "Осталось {{hours}} ч {{minutes}} мин",
"PERCENT_WATCHED": "Просмотрено {{value}}%",
"UNTITLED_SOURCE": "Источник без названия",
"GLOBAL_FAVORITES": "Глобальные избранные",
"REMOVE_FROM_HISTORY": "Удалить из истории",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "İzlemeye Devam Et",
"LIVE_FAVORITES": "Favorilerinizde şu an canlı",
"LIVE_RECENT": "Canlı TV ile devam et",
"REMAINING_SECONDS": "{{seconds}} sn kaldı",
"REMAINING_MINUTES": "{{minutes}} dk kaldı",
"REMAINING_HOURS": "{{hours}} sa kaldı",
"REMAINING_HOURS_MINUTES": "{{hours}} sa {{minutes}} dk kaldı",
"PERCENT_WATCHED": "{{value}}% izlendi",
"UNTITLED_SOURCE": "Adsız kaynak",
"GLOBAL_FAVORITES": "Tüm Favoriler",
"REMOVE_FROM_HISTORY": "Geçmişten kaldır",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "继续观看",
"LIVE_FAVORITES": "收藏频道正在播放",
"LIVE_RECENT": "继续观看直播",
"REMAINING_SECONDS": "剩余 {{seconds}} 秒",
"REMAINING_MINUTES": "剩余 {{minutes}} 分钟",
"REMAINING_HOURS": "剩余 {{hours}} 小时",
"REMAINING_HOURS_MINUTES": "剩余 {{hours}} 小时 {{minutes}} 分钟",
"PERCENT_WATCHED": "已观看 {{value}}%",
"UNTITLED_SOURCE": "未命名源",
"GLOBAL_FAVORITES": "全局收藏",
"REMOVE_FROM_HISTORY": "从历史记录中移除",
+5
View File
@@ -853,6 +853,11 @@
"CONTINUE_WATCHING": "繼續觀看",
"LIVE_FAVORITES": "我的最愛正在直播",
"LIVE_RECENT": "繼續觀看直播電視",
"REMAINING_SECONDS": "剩餘 {{seconds}} 秒",
"REMAINING_MINUTES": "剩餘 {{minutes}} 分鐘",
"REMAINING_HOURS": "剩餘 {{hours}} 小時",
"REMAINING_HOURS_MINUTES": "剩餘 {{hours}} 小時 {{minutes}} 分鐘",
"PERCENT_WATCHED": "已觀看 {{value}}%",
"UNTITLED_SOURCE": "未命名的來源",
"GLOBAL_FAVORITES": "全域我的最愛",
"REMOVE_FROM_HISTORY": "從歷史記錄中移除",
@@ -85,12 +85,18 @@
</span>
<span class="hero__progress-meta">
@if (item.remainingLabel) {
<span>{{ item.remainingLabel }}</span>
<span>{{
item.remainingLabel.key
| translate: item.remainingLabel.params
}}</span>
<span class="hero__progress-meta-sep"
>·</span
>
}
<span>{{ item.watchProgress }}% watched</span>
<span>{{
'WORKSPACE.DASHBOARD.PERCENT_WATCHED'
| translate: { value: item.watchProgress }
}}</span>
</span>
}
@@ -349,40 +349,55 @@ describe('playback-position helpers', () => {
).toBeNull();
});
it('formats sub-minute, minute, and hour-spanning remainders', () => {
it('returns translation keys and params for sub-minute, minute, and hour-spanning remainders', () => {
expect(
formatRemainingLabel({
positionSeconds: 5970,
durationSeconds: 6000,
})
).toBe('30s left');
).toEqual({
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
params: { seconds: 30 },
});
expect(
formatRemainingLabel({
positionSeconds: 0,
durationSeconds: 1800,
})
).toBe('30m left');
).toEqual({
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
params: { minutes: 30 },
});
expect(
formatRemainingLabel({
positionSeconds: 0,
durationSeconds: 3600,
})
).toBe('1h left');
).toEqual({
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
params: { hours: 1 },
});
expect(
formatRemainingLabel({
positionSeconds: 600,
durationSeconds: 6840,
})
).toBe('1h 44m left');
).toEqual({
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
params: { hours: 1, minutes: 44 },
});
});
it('clamps below zero — a completed program reads as "0s left" not negative', () => {
it('clamps below zero to the translated zero-seconds state', () => {
expect(
formatRemainingLabel({
positionSeconds: 7000,
durationSeconds: 6000,
})
).toBe('0s left');
).toEqual({
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
params: { seconds: 0 },
});
});
});
});
@@ -76,8 +76,12 @@ interface DashboardHeroModel {
readonly title: string;
/** 0–100 watched, when a resume position is known. */
readonly watchProgress?: number | null;
/** "1h 04m left" — pre-formatted for direct rendering. */
readonly remainingLabel?: string | null;
readonly remainingLabel?: DashboardRemainingLabel | null;
}
export interface DashboardRemainingLabel {
readonly key: string;
readonly params: Record<string, number>;
}
export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
@@ -334,7 +338,7 @@ export function playbackProgressPercent(
export function formatRemainingLabel(
position: { positionSeconds: number; durationSeconds?: number } | null
): string | null {
): DashboardRemainingLabel | null {
if (
!position ||
position.durationSeconds == null ||
@@ -347,18 +351,30 @@ export function formatRemainingLabel(
Math.round(position.durationSeconds - position.positionSeconds)
);
if (remaining < 60) {
return `${remaining}s left`;
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
params: { seconds: remaining },
};
}
const totalMinutes = Math.round(remaining / 60);
if (totalMinutes < 60) {
return `${totalMinutes}m left`;
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
params: { minutes: totalMinutes },
};
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) {
return `${hours}h left`;
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
params: { hours },
};
}
return `${hours}h ${minutes.toString().padStart(2, '0')}m left`;
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
params: { hours, minutes },
};
}
function isXtreamAccountPlaylist(
@@ -401,6 +417,10 @@ export class WorkspaceDashboardRailsComponent {
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly epgService = inject(EpgService);
@@ -447,12 +467,13 @@ export class WorkspaceDashboardRailsComponent {
// (movies/series) get one rail; live channels get their own. Two card
// formats — one rail each — so users can scan each surface at a glance
// instead of decoding a mixed grid of fallback posters and channel logos.
readonly continueWatchingCards = computed<DashboardRailCard[]>(() =>
this.data
readonly continueWatchingCards = computed<DashboardRailCard[]>(() => {
this.languageTick();
return this.data
.globalRecentVodItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentCard(item))
);
.map((item) => this.toRecentCard(item));
});
// Live rail source — favorited live channels take precedence so the EPG
// enrichment lands on the channels the user actually cares about. When