feat: add strip country prefix setting (#1162)

* feat: add strip country prefix setting

* feat: scope country-prefix stripping to live content and cover missing surfaces

- narrow the heuristic: pipes always strip, dash/colon separators only
  when the prefix is a short uppercase tag ("UK - BBC One" strips,
  "Sky - Sports F1" and "Mission: Impossible - Fallout" stay intact)
- fall back to the original name when stripping would leave nothing
- scope stripping to live content only: grid type (live/itv/radio),
  playback isLive, external sessions without contentInfo, dashboard
  cards with contentType 'live'
- cover previously missed surfaces: M3U player EPG timeline header,
  M3U inline player title, radio player, dashboard live rails
- replace hardcoded settings strings with translate keys and add
  SETTINGS.STRIP_COUNTRY_PREFIX(_DESCRIPTION) to all 18 locales
- add unit specs for the utility plus regression specs for
  channel-list-item and external-playback-dock

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: cover strip-country-prefix call sites for codecov

- dashboard-rail: new spec for cardTitle live/movie/series scoping
- grid-list: strip enabled/disabled, VOD passthrough, 'No name' fallback
- portal-inline-player: live strip vs VOD passthrough
- unified-live-tab: timeline channel name strip + M3U name precedence
- video-player: timeline/radio/inline titles with the setting on and off
- settings-store: default false + persisted true round-trip
- settings-form.utils: new spec for form default and ?? false fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-07-18 15:43:37 +02:00
1 parent c6ed504723
commit d61fd5db19
48 files changed
+773 -22

No files matched your search

@@ -0,0 +1,36 @@
import { FormBuilder } from '@angular/forms';
import { Settings } from '@iptvnator/shared/interfaces';
import {
createSettingsForm,
createSettingsFromFormValue,
} from './settings-form.utils';
describe('settings form utils — strip country prefix', () => {
const formBuilder = new FormBuilder();
it('defaults the form control to false', () => {
const form = createSettingsForm(formBuilder, true);
expect(form.getRawValue().stripCountryPrefix).toBe(false);
});
it('carries an enabled toggle into the settings object', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ stripCountryPrefix: true });
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.stripCountryPrefix).toBe(true);
});
it('falls back to false when the form value is missing', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({
stripCountryPrefix: null as unknown as boolean,
});
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.stripCountryPrefix).toBe(false);
});
});
@@ -55,6 +55,7 @@ export function createSettingsForm(
}),
startupBehavior: StartupBehavior.FirstView,
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
@@ -124,6 +125,7 @@ export function createSettingsFromFormValue(
dashboardRails: normalizeDashboardRailsSettings(value.dashboardRails),
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
stripCountryPrefix: value.stripCountryPrefix ?? false,
theme: value.theme ?? Theme.SystemTheme,
mpvPlayerPath: normalizeExternalPlayerPath(value.mpvPlayerPath),
mpvPlayerArguments: normalizeExternalPlayerArguments(
@@ -156,4 +156,16 @@
<mat-checkbox formControlName="showCaptions"></mat-checkbox>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}</h4>
<p>
{{ 'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox formControlName="stripCountryPrefix"></mat-checkbox>
</div>
</div>
</section>
@@ -92,6 +92,7 @@ const DEFAULT_SETTINGS = {
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "عرض أو إخفاء الترجمات افتراضيًا",
"SHOW_EXTERNAL_PLAYBACK_BAR": "عرض شريط التشغيل الحالي",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "إبقاء شريط حالة المشغل الخارجي مرئيًا في أسفل مساحة العمل على سطح المكتب.",
"STRIP_COUNTRY_PREFIX": "إزالة بادئات البلد من أسماء القنوات",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "يزيل بادئات البلد/المجموعة مثل \"US | \" أو \"UK - \" من أسماء قنوات البث المباشر في جميع أنحاء التطبيق.",
"VERSION_DESCRIPTION": "الإصدار الحالي للتطبيق",
"ABOUT_SUBTITLE": "الإصدار وروابط المشروع وطرق دعم IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "بيئة تشغيل MPV المضمّنة",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "وري ولا خفي الترجمة افتراضياً",
"SHOW_EXTERNAL_PLAYBACK_BAR": "وري شريط التشغيل الحالي",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "خلي شريط حالة المشغل الخارجي ظاهر في الأسفل ديال مساحة العمل على سطح المكتب.",
"STRIP_COUNTRY_PREFIX": "حيد بادئات البلد من أسماء القنوات",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "كيحيد بادئات البلد/المجموعة بحال \"US | \" ولا \"UK - \" من أسماء قنوات البث المباشر فكامل التطبيق.",
"VERSION_DESCRIPTION": "الإصدار الحالي ديال التطبيق",
"ABOUT_SUBTITLE": "الإصدار، روابط المشروع، وطرق دعم IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "وقت تشغيل MPV المدمج",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Паказаць або схаваць субтытры па змаўчанні.",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Паказаць ніжнюю панэль прайгравання",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Пакідаць панэль стану знешняга прайгравальніка бачнай унізе працоўнай прасторы на дэсктопе.",
"STRIP_COUNTRY_PREFIX": "Выдаляць прэфіксы краін з назваў каналаў",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Выдаляе прэфіксы краін/груп, такія як \"US | \" або \"UK - \", з назваў каналаў прамых трансляцый ва ўсёй праграме.",
"VERSION_DESCRIPTION": "Бягучая версія праграмы",
"ABOUT_SUBTITLE": "Версія, спасылкі праекта і спосабы падтрымаць IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Асяроддзе ўбудаванага MPV",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Untertitel standardmäßig ein- oder ausblenden",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Now-Playing-Fußleiste anzeigen",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Zeigt die Statusleiste für externe Player unten im Arbeitsbereich auf dem Desktop an.",
"STRIP_COUNTRY_PREFIX": "Länderpräfixe aus Sendernamen entfernen",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Entfernt Länder-/Gruppenpräfixe wie \"US | \" oder \"UK - \" aus den Namen von Live-Sendern in der gesamten App.",
"VERSION_DESCRIPTION": "Aktuelle Version der Anwendung",
"ABOUT_SUBTITLE": "Version, Projekt-Links und Möglichkeiten, IPTVnator zu unterstützen.",
"EMBEDDED_MPV_NOTICE_TITLE": "Eingebettete MPV-Laufzeit",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Εμφάνιση ή απόκρυψη υποτίτλων από προεπιλογή",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Εμφάνιση υποσέλιδου αναπαραγωγής",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Διατηρήστε ορατή τη γραμμή κατάστασης του εξωτερικού προγράμματος αναπαραγωγής στο κάτω μέρος του χώρου εργασίας στον υπολογιστή.",
"STRIP_COUNTRY_PREFIX": "Αφαίρεση προθεμάτων χώρας από τα ονόματα καναλιών",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Αφαιρεί προθέματα χώρας/ομάδας όπως \"US | \" ή \"UK - \" από τα ονόματα καναλιών ζωντανής ροής σε όλη την εφαρμογή.",
"VERSION_DESCRIPTION": "Τρέχουσα έκδοση της εφαρμογής",
"ABOUT_SUBTITLE": "Έκδοση, σύνδεσμοι έργου και τρόποι υποστήριξης του IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Ενσωματωμένος χρόνος εκτέλεσης MPV",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Show or hide subtitles by default",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Show now playing footer",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Keep the external-player status bar visible at the bottom of the workspace on desktop.",
"STRIP_COUNTRY_PREFIX": "Strip country prefixes from channel names",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Removes country/group prefixes like \"US | \" or \"UK - \" from live channel names throughout the app.",
"VERSION_DESCRIPTION": "Current version of the application",
"ABOUT_SUBTITLE": "Version, project links and ways to support IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Embedded MPV runtime",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Mostrar u ocultar subtítulos por defecto",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostrar pie de reproducción actual",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantén visible la barra de estado del reproductor externo en la parte inferior del espacio de trabajo en escritorio.",
"STRIP_COUNTRY_PREFIX": "Quitar prefijos de país de los nombres de los canales",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Elimina los prefijos de país o grupo, como \"US | \" o \"UK - \", de los nombres de los canales en vivo en toda la app.",
"VERSION_DESCRIPTION": "Versión actual de la aplicación.",
"ABOUT_SUBTITLE": "Versión, enlaces del proyecto y formas de apoyar a IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Runtime de MPV integrado",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Afficher ou masquer les sous-titres par défaut",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Afficher le pied de page de lecture en cours",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Garde la barre d'état du lecteur externe visible en bas de l'espace de travail sur ordinateur.",
"STRIP_COUNTRY_PREFIX": "Retirer les préfixes de pays des noms de chaînes",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Supprime les préfixes de pays ou de groupe comme \"US | \" ou \"UK - \" des noms des chaînes en direct dans toute l'application.",
"VERSION_DESCRIPTION": "Version actuelle de l'application",
"ABOUT_SUBTITLE": "Version, liens du projet et façons de soutenir IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV intégré",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Visualizza/nascondi i sottotitoli per impostazione predefinita",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostra footer In riproduzione",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantieni visibile la barra di stato del lettore esterno in fondo all'area di lavoro su desktop.",
"STRIP_COUNTRY_PREFIX": "Rimuovi i prefissi del paese dai nomi dei canali",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Rimuove i prefissi di paese/gruppo come \"US | \" o \"UK - \" dai nomi dei canali live in tutta l'app.",
"VERSION_DESCRIPTION": "Versione attuale dell'applicazione",
"ABOUT_SUBTITLE": "Versione, link al progetto e modi per supportare IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV integrato",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "デフォルトで字幕を表示/非表示",
"SHOW_EXTERNAL_PLAYBACK_BAR": "再生中フッターを表示",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "デスクトップでワークスペース下部に外部プレーヤーのステータスバーを表示し続けます。",
"STRIP_COUNTRY_PREFIX": "チャンネル名から国名プレフィックスを削除",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "アプリ全体で、ライブチャンネル名から \"US | \" や \"UK - \" のような国/グループのプレフィックスを削除します。",
"VERSION_DESCRIPTION": "アプリケーションの現在のバージョン",
"ABOUT_SUBTITLE": "バージョン、プロジェクトのリンク、IPTVnatorをサポートする方法。",
"EMBEDDED_MPV_NOTICE_TITLE": "組み込みMPVランタイム",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "기본적으로 자막 표시 또는 숨기기",
"SHOW_EXTERNAL_PLAYBACK_BAR": "지금 재생 중 표시줄 표시",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "데스크톱의 작업 공간 하단에 외부 플레이어 상태 표시줄을 계속 표시합니다.",
"STRIP_COUNTRY_PREFIX": "채널 이름에서 국가 접두사 제거",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "라이브 채널 이름에서 \"US | \" 또는 \"UK - \"와 같은 국가/그룹 접두사를 앱 전체에서 제거합니다.",
"VERSION_DESCRIPTION": "애플리케이션의 현재 버전",
"ABOUT_SUBTITLE": "버전, 프로젝트 링크 및 IPTVnator를 후원하는 방법.",
"EMBEDDED_MPV_NOTICE_TITLE": "내장 MPV 런타임",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Standaard ondertiteling weergeven of verbergen",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Voettekst 'nu spelend' tonen",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Houd de statusbalk van de externe speler zichtbaar onderaan de werkruimte op desktop.",
"STRIP_COUNTRY_PREFIX": "Landprefixen uit kanaalnamen verwijderen",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Verwijdert land-/groepsprefixen zoals \"US | \" of \"UK - \" uit de namen van live-kanalen, overal in de app.",
"VERSION_DESCRIPTION": "Huidige versie van de applicatie",
"ABOUT_SUBTITLE": "Versie, projectlinks en manieren om IPTVnator te ondersteunen.",
"EMBEDDED_MPV_NOTICE_TITLE": "Ingebedde MPV-runtime",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Domyślnie pokaż lub ukryj napisy",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Pokaż stopkę „teraz odtwarzane”",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Pozostaw pasek statusu zewnętrznego odtwarzacza widoczny u dołu obszaru roboczego na komputerze.",
"STRIP_COUNTRY_PREFIX": "Usuń prefiksy krajów z nazw kanałów",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Usuwa prefiksy kraju/grupy, takie jak \"US | \" lub \"UK - \", z nazw kanałów na żywo w całej aplikacji.",
"VERSION_DESCRIPTION": "Aktualna wersja aplikacji",
"ABOUT_SUBTITLE": "Wersja, linki do projektu i sposoby wsparcia IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Środowisko wbudowanego MPV",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Mostrar ou ocultar legendas por padrão",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostrar rodapé de reprodução atual",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantém a barra de status do reprodutor externo visível na parte inferior do espaço de trabalho no desktop.",
"STRIP_COUNTRY_PREFIX": "Remover prefixos de país dos nomes dos canais",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Remove prefixos de país/grupo como \"US | \" ou \"UK - \" dos nomes dos canais ao vivo em todo o aplicativo.",
"VERSION_DESCRIPTION": "Versão atual do aplicativo",
"ABOUT_SUBTITLE": "Versão, links do projeto e formas de apoiar o IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV integrado",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Показать или скрыть субтитры по умолчанию.",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Показывать панель «Сейчас воспроизводится»",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Оставлять панель состояния внешнего проигрывателя видимой внизу рабочей области в настольном приложении.",
"STRIP_COUNTRY_PREFIX": "Удалять префиксы стран из названий каналов",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Удаляет префиксы стран/групп, такие как \"US | \" или \"UK - \", из названий каналов прямого эфира во всём приложении.",
"VERSION_DESCRIPTION": "Текущая версия приложения",
"ABOUT_SUBTITLE": "Версия, ссылки на проект и способы поддержать IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Среда выполнения встроенного MPV",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Varsayılan olarak altyazıları göster veya gizle",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Şu an oynatılıyor altbilgisini göster",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Masaüstünde harici oynatıcı durum çubuğunu çalışma alanının altında görünür tutun.",
"STRIP_COUNTRY_PREFIX": "Kanal adlarından ülke ön eklerini kaldır",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Uygulama genelinde canlı kanal adlarından \"US | \" veya \"UK - \" gibi ülke/grup ön eklerini kaldırır.",
"VERSION_DESCRIPTION": "Uygulamanın geçerli sürümü",
"ABOUT_SUBTITLE": "Sürüm, proje bağlantıları ve IPTVnator'u desteklemenin yolları.",
"EMBEDDED_MPV_NOTICE_TITLE": "Gömülü MPV çalışma zamanı",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "默认显示或隐藏字幕",
"SHOW_EXTERNAL_PLAYBACK_BAR": "显示「正在播放」底栏",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "在桌面端工作区底部保持外部播放器状态栏可见。",
"STRIP_COUNTRY_PREFIX": "移除频道名称中的国家前缀",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "从整个应用中的直播频道名称里移除国家/分组前缀,例如 \"US | \" 或 \"UK - \"。",
"VERSION_DESCRIPTION": "应用程序的当前版本",
"ABOUT_SUBTITLE": "版本、项目链接以及支持 IPTVnator 的方式。",
"EMBEDDED_MPV_NOTICE_TITLE": "嵌入式 MPV 运行时",
+2
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "預設顯示或隱藏字幕",
"SHOW_EXTERNAL_PLAYBACK_BAR": "顯示「正在播放」列",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "在桌面工作區底部保留外部播放器狀態列可見。",
"STRIP_COUNTRY_PREFIX": "從頻道名稱中移除國家前綴",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "移除應用程式中直播頻道名稱裡的國家/群組前綴,例如「US | 」或「UK - 」。",
"VERSION_DESCRIPTION": "應用程式目前版本",
"ABOUT_SUBTITLE": "版本、專案連結,以及支持 IPTVnator 的方式。",
"EMBEDDED_MPV_NOTICE_TITLE": "內嵌 MPV 執行環境",
@@ -67,16 +67,14 @@
<app-audio-player
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo!"
[channelName]="
activeChannel.name || activeChannel?.tvg?.name || ''
"
[channelName]="displayChannelName()"
[volume]="volume()"
(volumeChange)="onInlineVolumeChange($event)"
/>
} @else {
<app-web-player-view
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
[title]="embeddedPlayback()?.title ?? ''"
[title]="inlinePlayerTitle()"
[playback]="embeddedPlayback()"
[playerOverride]="playerSettings.player ?? null"
[volume]="volume()"
@@ -191,6 +191,7 @@ describe('VideoPlayerComponent', () => {
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
const showCaptions = signal(false);
const stripCountryPrefix = signal(false);
const epgViewMode = signal<'timeline' | 'list'>('timeline');
const originalElectron = window.electron;
@@ -314,6 +315,7 @@ describe('VideoPlayerComponent', () => {
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
player.set(VideoPlayer.VideoJs);
showCaptions.set(false);
stripCountryPrefix.set(false);
activePlaybackUrl.set(null);
channelsLoading.set(false);
currentEpgProgram.set(null);
@@ -389,6 +391,7 @@ describe('VideoPlayerComponent', () => {
useValue: {
player,
showCaptions,
stripCountryPrefix,
resolvedEpgViewMode: epgViewMode,
},
},
@@ -456,6 +459,26 @@ describe('VideoPlayerComponent', () => {
expect(headerContext.action()).toBeNull();
});
it('strips country prefixes from the timeline and player titles when enabled', () => {
stripCountryPrefix.set(true);
syncStoreState({ ...sampleChannel, name: 'US | CNN' } as Channel);
fixture.detectChanges();
expect(component.timelineChannelName()).toBe('CNN');
expect(component.displayChannelName()).toBe('CNN');
expect(component.inlinePlayerTitle()).toBe('CNN');
});
it('keeps raw channel names while prefix stripping is disabled', () => {
syncStoreState({ ...sampleChannel, name: 'US | CNN' } as Channel);
fixture.detectChanges();
expect(component.timelineChannelName()).toBe('US | CNN');
expect(component.displayChannelName()).toBe('US | CNN');
});
it('renders the inline player with the embedded EPG panel', () => {
syncStoreState(sampleChannel);
player.set(VideoPlayer.VideoJs);
@@ -22,6 +22,7 @@ import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
applyChannelNameStrip,
getM3uArchiveDays,
isM3uCatchupPlaybackSupported,
} from '@iptvnator/shared/m3u-utils';
@@ -182,8 +183,26 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
readonly epgArchiveDays = computed(() =>
getM3uArchiveDays(this.activeChannel())
);
readonly timelineChannelName = computed(
() => this.activeChannel()?.name ?? ''
readonly timelineChannelName = computed(() =>
applyChannelNameStrip(
this.activeChannel()?.name,
this.settingsStore.stripCountryPrefix?.()
)
);
/** Channel name for the radio player header. */
readonly displayChannelName = computed(() => {
const channel = this.activeChannel();
return applyChannelNameStrip(
channel?.name || channel?.tvg?.name,
this.settingsStore.stripCountryPrefix?.()
);
});
/** Display title for the inline web player header. */
readonly inlinePlayerTitle = computed(() =>
applyChannelNameStrip(
this.embeddedPlayback()?.title,
this.settingsStore.stripCountryPrefix?.()
)
);
/** Channel logo from the EPG feed (M3U playlists often lack tvg-logo). */
private readonly epgChannelLogo = toSignal(
@@ -211,6 +211,7 @@
[pageSizeOptions]="pageSizeOptions"
[showPaginator]="false"
[searchTerm]="searchTerm()"
[type]="contentType() ?? ''"
(pageChange)="onPageChange($event)"
/>
</div>
@@ -31,6 +31,7 @@ class MockGridListComponent {
readonly pageSizeOptions = input<number[]>();
readonly showPaginator = input(true);
readonly searchTerm = input<string>('');
readonly type = input<string>('');
readonly itemClicked = output<unknown>();
readonly pageChange = output<unknown>();
}
@@ -1,7 +1,9 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { SettingsStore } from '@iptvnator/services';
import {
formatGridRating,
GridListComponent,
@@ -159,4 +161,78 @@ describe('GridListComponent', () => {
'live_tv'
);
});
it('renders raw titles while prefix stripping is disabled', () => {
fixture.componentRef.setInput('items', [{ name: 'US | CNN' }]);
fixture.componentRef.setInput('type', 'live');
fixture.detectChanges();
const title = fixture.debugElement.query(By.css('.title'));
expect(title.nativeElement.textContent.trim()).toBe('US | CNN');
});
it('falls back to a placeholder title for items without a name', () => {
fixture.componentRef.setInput('items', [{}]);
fixture.detectChanges();
const title = fixture.debugElement.query(By.css('.title'));
expect(title.nativeElement.textContent.trim()).toBe('No name');
});
});
describe('GridListComponent with strip country prefix enabled', () => {
let fixture: ComponentFixture<GridListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GridListComponent],
providers: [
{
provide: SettingsStore,
useValue: { stripCountryPrefix: signal(true) },
},
],
})
.overrideComponent(GridListComponent, {
remove: { imports: [TranslatePipe] },
add: {
imports: [
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(GridListComponent);
});
const renderedTitle = () =>
fixture.debugElement
.query(By.css('.title'))
.nativeElement.textContent.trim();
it('strips prefixes from live grid titles', () => {
fixture.componentRef.setInput('items', [{ name: 'US | CNN' }]);
fixture.componentRef.setInput('type', 'live');
fixture.detectChanges();
expect(renderedTitle()).toBe('CNN');
});
it('keeps VOD titles untouched', () => {
fixture.componentRef.setInput('items', [
{ title: 'US | Some Movie' },
]);
fixture.componentRef.setInput('type', 'vod');
fixture.detectChanges();
expect(renderedTitle()).toBe('US | Some Movie');
});
});
@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
@@ -11,6 +12,8 @@ import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import { SettingsStore } from '@iptvnator/services';
import {
ProgressCapsuleComponent,
WatchedBadgeComponent,
@@ -164,10 +167,9 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
<mat-icon>star</mat-icon>{{ rating }}
</div>
}
@let title = i.title ?? i.o_name ?? i.name;
<mat-card-actions>
<div class="title">
{{ title || 'No name' }}
{{ channelTitle(i) }}
</div>
</mat-card-actions>
</mat-card>
@@ -229,6 +231,7 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
})
export class GridListComponent {
private readonly failedArtworkUrls = signal<ReadonlySet<string>>(new Set());
private readonly settingsStore = inject(SettingsStore);
readonly items = input<GridListItem[]>([]);
readonly isLoading = input<boolean>(false);
@@ -253,6 +256,18 @@ export class GridListComponent {
protected readonly hasActiveSearch = computed(
() => (this.searchTerm() ?? '').trim().length > 0
);
/** Prefix stripping applies to channel grids only, never VOD/series. */
private readonly isLiveGrid = computed(() =>
['live', 'itv', 'radio'].includes(this.type())
);
protected readonly channelTitle = (item: GridListItem): string => {
const raw = item.title ?? item.o_name ?? item.name ?? '';
const stripped = applyChannelNameStrip(
raw,
this.isLiveGrid() && this.settingsStore.stripCountryPrefix?.()
);
return stripped || 'No name';
};
readonly skeletonRows = computed(() => {
const preferredCount = this.limit() ?? 12;
@@ -136,6 +136,7 @@ describe('UnifiedLiveTabComponent', () => {
let component: UnifiedLiveTabComponent;
let player: ReturnType<typeof signal<VideoPlayer>>;
let epgViewMode: ReturnType<typeof signal<'timeline' | 'list'>>;
let stripCountryPrefix: ReturnType<typeof signal<boolean>>;
let streamResolver: {
resolveLiveDetail: jest.Mock;
resolveM3uPlaybackDetail: jest.Mock;
@@ -171,6 +172,7 @@ describe('UnifiedLiveTabComponent', () => {
};
player = signal(VideoPlayer.VideoJs);
epgViewMode = signal<'timeline' | 'list'>('timeline');
stripCountryPrefix = signal(false);
portalPlayer = {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
@@ -196,6 +198,7 @@ describe('UnifiedLiveTabComponent', () => {
useValue: {
openStreamOnDoubleClick: signal(false),
player,
stripCountryPrefix,
resolvedEpgViewMode: epgViewMode,
},
},
@@ -1061,6 +1064,35 @@ describe('UnifiedLiveTabComponent', () => {
);
});
});
describe('timeline channel name', () => {
it('strips the country prefix when the setting is enabled', () => {
stripCountryPrefix.set(true);
component.activeDetail.set({
epgMode: 'portal',
playback: {
streamUrl: 'https://example.com/live.m3u8',
title: 'US | CNN',
},
epgItems: [],
} as never);
expect(component.timelineChannelName()).toBe('CNN');
});
it('prefers the M3U channel name and keeps it raw while disabled', () => {
component.activeDetail.set({
epgMode: 'm3u',
channel: { name: 'US | CNN' },
playback: {
streamUrl: 'https://example.com/live.m3u8',
title: 'Fallback Title',
},
} as never);
expect(component.timelineChannelName()).toBe('US | CNN');
});
});
});
function buildLiveItem(
@@ -15,6 +15,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
applyChannelNameStrip,
getM3uArchiveDays,
isM3uCatchupPlaybackSupported,
resolveM3uCatchupUrl,
@@ -183,11 +184,12 @@ export class UnifiedLiveTabComponent {
? this.currentM3uPrograms()
: this.currentPortalEpgPrograms()
);
readonly timelineChannelName = computed(
() =>
readonly timelineChannelName = computed(() =>
applyChannelNameStrip(
this.currentM3uChannel()?.name ??
this.activeDetail()?.playback?.title ??
''
this.activeDetail()?.playback?.title,
this.settingsStore.stripCountryPrefix?.()
)
);
readonly timelineChannelLogo = computed(
() =>
@@ -136,6 +136,26 @@ describe('SettingsStore dashboard rail settings', () => {
expect(store.getSettings().webPlayerSharedControls).toBe(true);
});
it('defaults strip country prefix to false when the stored field is missing', async () => {
storedSettings = {};
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().stripCountryPrefix).toBe(false);
});
it('restores a persisted true strip country prefix preference', async () => {
storedSettings = {
stripCountryPrefix: true,
};
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().stripCountryPrefix).toBe(true);
});
it('normalizes a persisted string "true" shared web controls preference to false', async () => {
storedSettings = {
webPlayerSharedControls: 'true' as unknown as boolean,
@@ -34,6 +34,7 @@ const DEFAULT_SETTINGS: Settings = {
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
@@ -196,6 +197,9 @@ export const SettingsStore = signalStore(
showExternalPlaybackBar:
store.showExternalPlaybackBar?.() ??
DEFAULT_SETTINGS.showExternalPlaybackBar,
stripCountryPrefix:
store.stripCountryPrefix?.() ??
DEFAULT_SETTINGS.stripCountryPrefix,
theme: store.theme(),
mpvPlayerPath: store.mpvPlayerPath(),
mpvPlayerArguments: store.mpvPlayerArguments(),
@@ -92,6 +92,8 @@ export interface Settings {
startupBehavior: StartupBehavior;
/** Show the desktop footer bar for external playback status */
showExternalPlaybackBar?: boolean;
/** Strip country/group prefixes like "US | " or "UK - " from channel names */
stripCountryPrefix?: boolean;
theme: Theme;
mpvPlayerPath: string;
/**
+1
View File
@@ -1,2 +1,3 @@
export * from './lib/playlist.utils';
export * from './lib/catchup.utils';
export * from './lib/strip-country-prefix.util';
@@ -0,0 +1,107 @@
import {
applyChannelNameStrip,
stripCountryPrefix,
} from './strip-country-prefix.util';
describe('stripCountryPrefix', () => {
describe('pipe separators', () => {
it('strips a "XX | " prefix', () => {
expect(stripCountryPrefix('US | CNN')).toBe('CNN');
});
it('strips a bare-pipe prefix', () => {
expect(stripCountryPrefix('FR|TF1')).toBe('TF1');
});
it('strips a wrapped tag like "|DE| ARD"', () => {
expect(stripCountryPrefix('|DE| ARD')).toBe('ARD');
});
it('strips long prefixes before a pipe', () => {
expect(stripCountryPrefix('US East | CNN')).toBe('CNN');
});
it('only strips up to the first pipe', () => {
expect(stripCountryPrefix('US | CNN | HD')).toBe('CNN | HD');
});
});
describe('dash and colon separators (short-tag prefixes only)', () => {
it('strips a two-letter country tag', () => {
expect(stripCountryPrefix('UK - BBC One')).toBe('BBC One');
});
it('strips a three-letter country tag', () => {
expect(stripCountryPrefix('USA - CNN')).toBe('CNN');
});
it('strips alphanumeric tags like 4K', () => {
expect(stripCountryPrefix('4K - Discovery')).toBe('Discovery');
});
it('strips a colon-separated tag', () => {
expect(stripCountryPrefix('US: CNN')).toBe('CNN');
});
it('only strips the first tag segment', () => {
expect(stripCountryPrefix('ES - A3 - Sports')).toBe('A3 - Sports');
});
it('keeps names whose prefix is not a short uppercase tag', () => {
expect(stripCountryPrefix('Sky - Sports F1')).toBe(
'Sky - Sports F1'
);
expect(stripCountryPrefix('Discovery - Science')).toBe(
'Discovery - Science'
);
expect(stripCountryPrefix('Mission: Impossible - Fallout')).toBe(
'Mission: Impossible - Fallout'
);
});
it('keeps hyphenated names without separator spacing', () => {
expect(stripCountryPrefix('T-Mobile TV')).toBe('T-Mobile TV');
expect(stripCountryPrefix('US-CNN')).toBe('US-CNN');
});
it('keeps single-character prefixes', () => {
expect(stripCountryPrefix('A - Team')).toBe('A - Team');
});
});
describe('edge cases', () => {
it('returns names without separators unchanged', () => {
expect(stripCountryPrefix('BBC One')).toBe('BBC One');
});
it('trims surrounding whitespace', () => {
expect(stripCountryPrefix(' BBC One ')).toBe('BBC One');
});
it('returns the original name when stripping would leave nothing', () => {
expect(stripCountryPrefix('US | ')).toBe('US |');
});
it('handles empty input', () => {
expect(stripCountryPrefix('')).toBe('');
expect(stripCountryPrefix(' ')).toBe('');
});
});
});
describe('applyChannelNameStrip', () => {
it('strips when enabled', () => {
expect(applyChannelNameStrip('US | CNN', true)).toBe('CNN');
});
it('returns the raw name when disabled', () => {
expect(applyChannelNameStrip('US | CNN', false)).toBe('US | CNN');
expect(applyChannelNameStrip('US | CNN', undefined)).toBe('US | CNN');
expect(applyChannelNameStrip('US | CNN', null)).toBe('US | CNN');
});
it('normalizes nullish names to an empty string', () => {
expect(applyChannelNameStrip(null, true)).toBe('');
expect(applyChannelNameStrip(undefined, false)).toBe('');
});
});
@@ -0,0 +1,79 @@
/**
* Dash/colon separators only count when the prefix looks like a short
* country/group tag — they routinely appear inside real channel names
* ("Sky - Sports F1", "Discovery - Science"). Pipes are conventionally
* used only as tag separators, so they always count.
*/
const DASH_OR_COLON_SEPARATORS = [' - ', '- ', ' -', ': '];
const TAG_PREFIX_PATTERN = /^[A-Z0-9]{2,3}$/;
interface SeparatorMatch {
index: number;
length: number;
}
/**
* Strip country / group prefixes from channel names.
*
* Finds the first tag separator and returns everything to its right.
* Only the prefix is removed — if the separator appears multiple times,
* only the first match counts. If stripping would leave an empty name,
* the original (trimmed) name is returned instead.
*
* Examples:
* "US | CNN" → "CNN"
* "UK - BBC One" → "BBC One"
* "FR|TF1" → "TF1"
* "|DE| ARD" → "ARD"
* "US: CNN" → "CNN"
* "ES - A3 - Sports" → "A3 - Sports"
* "Sky - Sports F1" → "Sky - Sports F1" (prefix is not a short tag)
* "BBC One" → "BBC One" (no separator)
* "US | " → "US |" (stripping would leave nothing)
*/
export function stripCountryPrefix(name: string): string {
const trimmed = name.trim();
if (!trimmed) return trimmed;
const match = findTagSeparator(trimmed);
if (!match) return trimmed;
const prefix = trimmed.slice(0, match.index).trim();
const stripped = trimmed.slice(match.index + match.length).trim();
if (!stripped) return trimmed;
// A leading separator ("|DE| ARD") strips nothing by itself — retry on
// the remainder so the actual tag segment is removed too.
return prefix ? stripped : stripCountryPrefix(stripped);
}
/**
* Settings-aware wrapper for display bindings: strips the prefix only when
* the `stripCountryPrefix` setting is enabled, and normalizes nullish names
* to an empty string.
*/
export function applyChannelNameStrip(
name: string | null | undefined,
enabled: boolean | null | undefined
): string {
const raw = name ?? '';
return enabled ? stripCountryPrefix(raw) : raw;
}
function findTagSeparator(name: string): SeparatorMatch | null {
let best: SeparatorMatch | null = null;
const pipeIndex = name.indexOf('|');
if (pipeIndex !== -1) {
best = { index: pipeIndex, length: 1 };
}
for (const separator of DASH_OR_COLON_SEPARATORS) {
const index = name.indexOf(separator);
if (index === -1 || (best && best.index <= index)) continue;
if (!TAG_PREFIX_PATTERN.test(name.slice(0, index).trim())) continue;
best = { index, length: separator.length };
}
return best;
}
@@ -28,7 +28,7 @@
}
</div>
<div class="channel-details">
<div class="channel-name">{{ name() }}</div>
<div class="channel-name">{{ displayName() }}</div>
@if (showEpg()) {
@if (epgProgram(); as program) {
<div class="epg-title">
@@ -1,8 +1,10 @@
import { DatePipe } from '@angular/common';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsStore } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { ChannelListItemComponent } from './channel-list-item.component';
@@ -141,4 +143,48 @@ describe('ChannelListItemComponent', () => {
})
);
});
it('renders the raw channel name while prefix stripping is disabled', () => {
fixture.componentRef.setInput('name', 'US | CNN');
fixture.detectChanges();
expect(
fixture.nativeElement
.querySelector('.channel-name')
.textContent.trim()
).toBe('US | CNN');
});
describe('with strip country prefix enabled', () => {
beforeEach(async () => {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [
ChannelListItemComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialog, useValue: dialog },
{
provide: SettingsStore,
useValue: { stripCountryPrefix: signal(true) },
},
],
}).compileComponents();
fixture = TestBed.createComponent(ChannelListItemComponent);
});
it('strips the country prefix from the rendered channel name', () => {
fixture.componentRef.setInput('name', 'US | CNN');
fixture.detectChanges();
expect(
fixture.nativeElement
.querySelector('.channel-name')
.textContent.trim()
).toBe('CNN');
});
});
});
@@ -3,6 +3,7 @@ import { DatePipe, NgStyle } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
@@ -16,6 +17,8 @@ import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
@Component({
selector: 'app-channel-list-item',
@@ -35,10 +38,17 @@ import { EpgProgram } from '@iptvnator/shared/interfaces';
export class ChannelListItemComponent {
private readonly dialog = inject(MatDialog);
private readonly logoFailed = signal(false);
private readonly settingsStore = inject(SettingsStore);
readonly isDraggable = input(false);
readonly logo = input<string | null | undefined>('');
readonly name = input('');
readonly displayName = computed(() =>
applyChannelNameStrip(
this.name(),
this.settingsStore.stripCountryPrefix?.()
)
);
readonly showFavoriteButton = input(false);
readonly showAuxActionButton = input(false);
readonly showProgramInfoButton = input(true);
@@ -30,7 +30,7 @@
</button>
<div class="external-playback-dock__meta">
<div class="external-playback-dock__title">{{ session().title }}</div>
<div class="external-playback-dock__title">{{ displayTitle() }}</div>
<div class="external-playback-dock__status">
<span class="external-playback-dock__chip">{{ playerLabel() }}</span>
<span
@@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
@@ -6,6 +7,7 @@ import {
TranslateService,
} from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import { SettingsStore } from '@iptvnator/services';
import { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { ExternalPlaybackDockComponent } from './external-playback-dock.component';
@@ -146,4 +148,62 @@ describe('ExternalPlaybackDockComponent', () => {
.nativeElement.textContent.trim()
).toBe('movie');
});
describe('with strip country prefix enabled', () => {
beforeEach(async () => {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [
ExternalPlaybackDockComponent,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: FakeTranslateLoader,
},
}),
],
providers: [
{
provide: SettingsStore,
useValue: { stripCountryPrefix: signal(true) },
},
],
}).compileComponents();
fixture = TestBed.createComponent(ExternalPlaybackDockComponent);
});
it('strips the prefix from live session titles', () => {
fixture.componentRef.setInput('session', {
...session,
title: 'US | CNN',
});
fixture.detectChanges();
expect(
fixture.nativeElement
.querySelector('.external-playback-dock__title')
.textContent.trim()
).toBe('CNN');
});
it('keeps VOD/episode titles untouched', () => {
fixture.componentRef.setInput('session', {
...session,
title: 'US | Some Movie',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'vod',
},
});
fixture.detectChanges();
expect(
fixture.nativeElement
.querySelector('.external-playback-dock__title')
.textContent.trim()
).toBe('US | Some Movie');
});
});
});
@@ -3,6 +3,7 @@ import {
Component,
computed,
effect,
inject,
input,
output,
signal,
@@ -12,6 +13,8 @@ import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
@Component({
selector: 'app-external-playback-dock',
@@ -26,6 +29,8 @@ import { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExternalPlaybackDockComponent {
private readonly settingsStore = inject(SettingsStore);
readonly session = input.required<ExternalPlayerSession>();
readonly compact = input(false);
@@ -33,6 +38,19 @@ export class ExternalPlaybackDockComponent {
readonly artworkClicked = output<void>();
private readonly artworkFailed = signal(false);
/**
* Sessions with contentInfo are tracked VOD/episode playback — their
* titles are movie/episode names, so the channel-prefix strip is
* applied only to sessions without it (live channels).
*/
readonly displayTitle = computed(() =>
applyChannelNameStrip(
this.session().title,
!this.session().contentInfo &&
this.settingsStore.stripCountryPrefix?.()
)
);
readonly playerLabel = computed(() => this.session().player.toUpperCase());
readonly artworkUrl = computed(
() => this.session().thumbnail?.trim() ?? ''
@@ -1,7 +1,8 @@
import { Component, input, output } from '@angular/core';
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsStore } from '@iptvnator/services';
import type { PortalInlinePlayerComponent as PortalInlinePlayerComponentInstance } from './portal-inline-player.component';
jest.unstable_mockModule('video.js', () => ({
@@ -150,4 +151,55 @@ describe('PortalInlinePlayerComponent', () => {
expect(backCount).toBe(1);
expect(closedCount).toBe(0);
});
describe('with strip country prefix enabled', () => {
beforeEach(async () => {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [
PortalInlinePlayerComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: SettingsStore,
useValue: { stripCountryPrefix: signal(true) },
},
],
})
.overrideComponent(PortalInlinePlayerComponent, {
remove: {
imports: [WebPlayerViewComponent],
},
add: {
imports: [StubWebPlayerViewComponent],
},
})
.compileComponents();
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
component = fixture.componentInstance;
});
it('strips the prefix from live playback titles', () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'US | CNN',
isLive: true,
});
fixture.detectChanges();
expect(component.title()).toBe('CNN');
});
it('keeps VOD titles untouched', () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/movie.mp4',
title: 'US | Some Movie',
});
fixture.detectChanges();
expect(component.title()).toBe('US | Some Movie');
});
});
});
@@ -3,6 +3,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
} from '@angular/core';
@@ -15,6 +16,8 @@ import {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component';
import type {
SeriesEpisodeMetadata,
@@ -43,7 +46,16 @@ export class PortalInlinePlayerComponent {
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly episodeMetadata = input<SeriesEpisodeMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly title = computed(() => this.playback()?.title ?? '');
private readonly settingsStore = inject(SettingsStore);
// Strip only live-channel titles — VOD/series titles ("Mission:
// Impossible - Fallout") must never lose their leading segment.
readonly title = computed(() =>
applyChannelNameStrip(
this.playback()?.title,
this.playback()?.isLive &&
this.settingsStore.stripCountryPrefix?.()
)
);
readonly streamUrl = computed(() => this.playback()?.streamUrl ?? '');
readonly startTime = computed(() => this.playback()?.startTime ?? 0);
readonly contentInfo = computed<PlayerContentInfo | undefined>(
@@ -58,7 +58,7 @@
<div
class="rail__card rail__card--channel"
role="listitem"
[attr.title]="card.title"
[attr.title]="cardTitle(card)"
[attr.data-test-id]="
testId() ? testId() + '-card' : null
"
@@ -83,7 +83,7 @@
) {
<img
[src]="card.imageUrl"
[alt]="card.title"
[alt]="cardTitle(card)"
loading="lazy"
decoding="async"
(error)="markFailed(card.id)"
@@ -94,7 +94,7 @@
</div>
<div class="rail__channel-meta">
<span class="rail__channel-name">{{
card.title
cardTitle(card)
}}</span>
<span class="rail__channel-now">
@if (card.nowPlayingTitle) {
@@ -134,7 +134,7 @@
<div
class="rail__card"
role="listitem"
[attr.title]="card.title"
[attr.title]="cardTitle(card)"
[attr.data-test-id]="
testId() ? testId() + '-card' : null
"
@@ -166,7 +166,7 @@
}
<img
[src]="card.imageUrl"
[alt]="card.title"
[alt]="cardTitle(card)"
loading="lazy"
decoding="async"
(error)="markFailed(card.id)"
@@ -191,7 +191,7 @@
<div class="rail__meta">
<span class="rail__card-title">{{
card.title
cardTitle(card)
}}</span>
<span class="rail__card-meta-row">
@if (card.episodeBadge) {
@@ -0,0 +1,72 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { SettingsStore } from '@iptvnator/services';
import {
DashboardRailCard,
DashboardRailComponent,
} from './dashboard-rail.component';
describe('DashboardRailComponent', () => {
const createComponent = async (stripCountryPrefix: boolean) => {
await TestBed.configureTestingModule({
imports: [DashboardRailComponent],
providers: [
{
provide: SettingsStore,
useValue: {
stripCountryPrefix: signal(stripCountryPrefix),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(DashboardRailComponent);
fixture.componentRef.setInput('label', 'Rail');
fixture.componentRef.setInput('items', []);
return fixture.componentInstance as unknown as {
cardTitle(card: DashboardRailCard): string;
};
};
const card = (overrides: Partial<DashboardRailCard>): DashboardRailCard =>
({
id: 'card-1',
title: 'US | CNN',
icon: 'live_tv',
link: ['/workspace'],
...overrides,
}) as DashboardRailCard;
afterEach(() => {
TestBed.resetTestingModule();
});
it('strips the prefix from live card titles when the setting is enabled', async () => {
const component = await createComponent(true);
expect(component.cardTitle(card({ contentType: 'live' }))).toBe('CNN');
});
it('keeps movie and series card titles untouched', async () => {
const component = await createComponent(true);
expect(
component.cardTitle(
card({ title: 'US | Some Movie', contentType: 'movie' })
)
).toBe('US | Some Movie');
expect(
component.cardTitle(
card({ title: 'US | Some Show', contentType: 'series' })
)
).toBe('US | Some Show');
});
it('keeps live card titles untouched while the setting is disabled', async () => {
const component = await createComponent(false);
expect(component.cardTitle(card({ contentType: 'live' }))).toBe(
'US | CNN'
);
});
});
@@ -5,6 +5,7 @@ import {
ElementRef,
OnDestroy,
effect,
inject,
input,
output,
signal,
@@ -15,6 +16,8 @@ import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
export interface DashboardRailAction {
id: string;
@@ -94,6 +97,8 @@ export interface DashboardRailActionSelection {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardRailComponent implements AfterViewInit, OnDestroy {
private readonly settingsStore = inject(SettingsStore);
readonly label = input.required<string>();
readonly items = input.required<DashboardRailCard[]>();
readonly seeAllLink = input<string[] | null>(null);
@@ -164,6 +169,15 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
event.stopPropagation();
}
/** Prefix stripping applies to live-channel cards only, never VOD/series. */
protected cardTitle(card: DashboardRailCard): string {
return applyChannelNameStrip(
card.title,
card.contentType === 'live' &&
this.settingsStore.stripCountryPrefix?.()
);
}
selectAction(
card: DashboardRailCard,
action: DashboardRailAction,