refactor(workspace): remove standalone-layout branches no route can reach (#1889)

* refactor(workspace): remove standalone-layout branches no route can reach

Every routed view sits under the /workspace route (the other top-level
paths are redirects), so isWorkspaceLayoutRoute() was true for every
caller. Global search became a route on 2026-01-12 and the last route
outside the workspace went with the home component on 2026-03-02;
nothing opens SearchResultsComponent in a MatDialog.

Removed as unreachable:
- isWorkspaceLayoutRoute() and the route's data.layout marker; the
  channel list, Xtream live layout and unified collection read the q
  query param directly, the clear-view command no longer checks a flag
  that was always true, and the M3U EPG guide header action keeps only
  its supportsEpg gate.
- The playback sidebar's playlist header (Home button and playlist
  switcher, showPlaylistHeader, its stylesheet and the macOS padding
  rule). The switcher itself stays: the workspace header uses it.
- Xtream search's dialog mode (MAT_DIALOG_DATA initialQuery, dialogRef,
  close buttons) and inline search input; Back is offered whenever the
  search is not the global one, as before.
- Stalker search's inline-input flag (Back is always offered, as before).
- SearchLayoutComponent's showSearchInput, showCloseButton, closeClick,
  searchTermChange and focusSearchInput, and SearchFormComponent, which
  only it rendered.
- SETTINGS.BACK_TO_HOME and PORTALS.SEARCH in all 19 locales, the
  zoneless checklist entry for the deleted form, the specs that only
  covered these paths, and two Electron E2E assertions that the deleted
  form is absent.

Behaviour of every reachable path is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): drop the macOS host class and switcher rule nothing uses

The app root's `macos-platform` host class only fed the removed sidebar
header's padding rule, so no stylesheet reads it any more. The shared
portal-sidebar partial's `.sidebar-header .switcher` rule matched no
element: the only `switcher` class lived in that removed header, which
used `.current-playlist`, not `.sidebar-header`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:07:57 +02:00
1 parent 7abf479de9
commit c76cf357b7
64 files changed
+37 -723

No files matched your search

@@ -580,9 +580,6 @@ test.describe('Electron Workspace Search', () => {
xtreamQueries
);
await expectWorkspaceSearchScope(app.mainWindow, 'Advanced search');
await expect(
app.mainWindow.locator('app-search-layout app-search-form')
).toHaveCount(0);
await expect(
xtreamSearchResultCards(app.mainWindow).first()
).toBeVisible({ timeout: 20000 });
@@ -901,9 +898,6 @@ test.describe('Electron Workspace Search', () => {
/\/workspace\/stalker\/[^/]+\/search$/
);
await expectWorkspaceSearchScope(app.mainWindow, 'Advanced search');
await expect(
app.mainWindow.locator('app-search-layout app-search-form')
).toHaveCount(0);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
-1
View File
@@ -94,7 +94,6 @@ describe('AppComponent', () => {
beforeEach(waitForAsync(() => {
runtimeCapabilities = {
isElectron: true,
isMacOS: false,
};
epgBridge = {
checkFreshness: jest.fn().mockResolvedValue({
-4
View File
@@ -1,7 +1,6 @@
import {
Component,
effect,
HostBinding,
inject,
OnInit,
signal,
@@ -66,9 +65,6 @@ const debugAppComponent = createDevLogger('AppComponent');
})
export class AppComponent implements OnInit {
readonly routeReady = signal(false);
@HostBinding('class.macos-platform') get isMacOS() {
return this.runtime.isMacOS;
}
get usesCustomWindowControls() {
return this.runtime.usesCustomWindowControls;
}
-3
View File
@@ -77,9 +77,6 @@ export const routes: Routes = [
},
{
path: 'workspace',
data: {
layout: 'workspace',
},
resolve: {
settingsReady: settingsReadyResolver,
},
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "حفظ",
"UNSAVED_DIALOG_DISCARD": "تجاهل",
"UNSAVED_DIALOG_INVALID_HINT": "بعض الحقول غير صالحة، لذلك لا يمكن حفظ التغييرات بعد.",
"BACK_TO_HOME": "العودة للرئيسية",
"NEW_VERSION_AVAILABLE": "إصدار جديد متاح",
"LATEST_VERSION": "أنت تستخدم الإصدار الأحدث",
"STREAM_FORMAT": "تنسيق البث",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "البث المباشر",
"MOVIES": "الأفلام",
"SERIES": "المسلسلات",
"SEARCH": "بحث",
"NO_FAVORITES": "لا توجد مفضلة بعد",
"NO_RECENT_ITEMS": "لا توجد عناصر تمت مشاهدتها مؤخرًا",
"REMOVE_FROM_RECENT": "إزالة من العناصر التي تمت مشاهدتها مؤخرًا",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "حفظ",
"UNSAVED_DIALOG_DISCARD": "تجاهل",
"UNSAVED_DIALOG_INVALID_HINT": "شي خانات ماشي صحيحين، علاش مايمكنش تسجل التغييرات دابا.",
"BACK_TO_HOME": "رجوع",
"NEW_VERSION_AVAILABLE": "كاين إصدار جديد متوفر",
"LATEST_VERSION": "راك تستعمل أحدث إصدار",
"STREAM_FORMAT": "صيغة البث",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "البث المباشر",
"MOVIES": "الأفلام",
"SERIES": "السلاسل",
"SEARCH": "بحث",
"NO_FAVORITES": "مازال ما كاينش مفضلة",
"NO_RECENT_ITEMS": "ما كاينش عناصر متشافة مؤخراً",
"REMOVE_FROM_RECENT": "حيد من المتشاف مؤخراً",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Захаваць",
"UNSAVED_DIALOG_DISCARD": "Не захоўваць",
"UNSAVED_DIALOG_INVALID_HINT": "Некаторыя палі запоўнены няправільна, таму змены пакуль нельга захаваць.",
"BACK_TO_HOME": "Назад",
"NEW_VERSION_AVAILABLE": "Даступная новая версія праграмы",
"LATEST_VERSION": "Вы выкарыстоўваеце апошнюю версію праграмы",
"STREAM_FORMAT": "Фармат патоку",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "Прамы эфір",
"MOVIES": "Фільмы",
"SERIES": "Серыялы",
"SEARCH": "Пошук",
"NO_FAVORITES": "Пакуль няма выбранага",
"NO_RECENT_ITEMS": "Няма нядаўна праглянутых элементаў",
"REMOVE_FROM_RECENT": "Выдаліць з нядаўна праглянутага",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Speichern",
"UNSAVED_DIALOG_DISCARD": "Verwerfen",
"UNSAVED_DIALOG_INVALID_HINT": "Einige Felder sind ungültig, daher können die Änderungen noch nicht gespeichert werden.",
"BACK_TO_HOME": "Zurück",
"NEW_VERSION_AVAILABLE": "EIne neue Version der Anwendung ist verfügbar",
"LATEST_VERSION": "Sie nutzen die neueste Version der Anwendung",
"STREAM_FORMAT": "Stream-Format",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "Live-TV",
"MOVIES": "Filme",
"SERIES": "Serien",
"SEARCH": "Suche",
"NO_FAVORITES": "Noch keine Favoriten",
"NO_RECENT_ITEMS": "Keine zuletzt angesehenen Inhalte",
"REMOVE_FROM_RECENT": "Aus „Zuletzt angesehen“ entfernen",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Αποθήκευση",
"UNSAVED_DIALOG_DISCARD": "Απόρριψη",
"UNSAVED_DIALOG_INVALID_HINT": "Ορισμένα πεδία δεν είναι έγκυρα, επομένως οι αλλαγές δεν μπορούν να αποθηκευτούν ακόμη.",
"BACK_TO_HOME": "Πίσω",
"NEW_VERSION_AVAILABLE": "Υπάρχει διαθέσιμη μια νέα έκδοση",
"LATEST_VERSION": "Χρησιμοποιείτε την πιο πρόσφατη έκδοση",
"STREAM_FORMAT": "Μορφή Stream",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "Ζωντανή TV",
"MOVIES": "Ταινίες",
"SERIES": "Σειρές",
"SEARCH": "Αναζήτηση",
"NO_FAVORITES": "Δεν υπάρχουν ακόμα αγαπημένα",
"NO_RECENT_ITEMS": "Δεν υπάρχουν πρόσφατα προβληθέντα στοιχεία",
"REMOVE_FROM_RECENT": "Αφαίρεση από τα πρόσφατα προβληθέντα",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Save",
"UNSAVED_DIALOG_DISCARD": "Discard",
"UNSAVED_DIALOG_INVALID_HINT": "Some fields are invalid, so the changes cannot be saved yet.",
"BACK_TO_HOME": "Back",
"NEW_VERSION_AVAILABLE": "There is a new version available",
"LATEST_VERSION": "You are using the latest version",
"STREAM_FORMAT": "Stream Format",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "Live TV",
"MOVIES": "Movies",
"SERIES": "Series",
"SEARCH": "Search",
"NO_FAVORITES": "No favorites yet",
"NO_RECENT_ITEMS": "No recently viewed items",
"REMOVE_FROM_RECENT": "Remove from recently viewed",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Guardar",
"UNSAVED_DIALOG_DISCARD": "Descartar",
"UNSAVED_DIALOG_INVALID_HINT": "Algunos campos no son válidos, por lo que los cambios aún no se pueden guardar.",
"BACK_TO_HOME": "Volver al inicio",
"NEW_VERSION_AVAILABLE": "Hay una nueva versión disponible",
"LATEST_VERSION": "Estás usando la versión reciente",
"STREAM_FORMAT": "Formato de transmision",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "TV en vivo",
"MOVIES": "Películas",
"SERIES": "Series",
"SEARCH": "Buscar",
"NO_FAVORITES": "Aún no hay favoritos",
"NO_RECENT_ITEMS": "No hay elementos vistos recientemente",
"REMOVE_FROM_RECENT": "Quitar de vistos recientemente",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Enregistrer",
"UNSAVED_DIALOG_DISCARD": "Ne pas enregistrer",
"UNSAVED_DIALOG_INVALID_HINT": "Certains champs sont invalides, les modifications ne peuvent donc pas encore être enregistrées.",
"BACK_TO_HOME": "Retourner à l'accueil",
"NEW_VERSION_AVAILABLE": "Une nouvelle version est disponible",
"LATEST_VERSION": "Vous utilisez la dernière version",
"STREAM_FORMAT": "Format du flux",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "TV en direct",
"MOVIES": "Films",
"SERIES": "Séries",
"SEARCH": "Rechercher",
"NO_FAVORITES": "Aucun favori pour le moment",
"NO_RECENT_ITEMS": "Aucun élément récemment consulté",
"REMOVE_FROM_RECENT": "Retirer des éléments récemment consultés",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Mentés",
"UNSAVED_DIALOG_DISCARD": "Elvetés",
"UNSAVED_DIALOG_INVALID_HINT": "Néhány mező érvénytelen, ezért a módosítások még nem menthetők.",
"BACK_TO_HOME": "Vissza",
"NEW_VERSION_AVAILABLE": "Új verzió érhető el",
"LATEST_VERSION": "A legújabb verziót használja",
"STREAM_FORMAT": "Adatfolyam formátuma",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "Élő tévéadás",
"MOVIES": "Filmek",
"SERIES": "Sorozatok",
"SEARCH": "Keresés",
"NO_FAVORITES": "Még nincs kedvenc tartalom",
"NO_RECENT_ITEMS": "Még nincs megtekintési előzmény",
"REMOVE_FROM_RECENT": "Eltávolítás a megtekintési előzményekből",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Salva",
"UNSAVED_DIALOG_DISCARD": "Non salvare",
"UNSAVED_DIALOG_INVALID_HINT": "Alcuni campi non sono validi, quindi le modifiche non possono ancora essere salvate.",
"BACK_TO_HOME": "Indietro",
"NEW_VERSION_AVAILABLE": "È disponibile una nuova versione",
"LATEST_VERSION": "Questa versione è aggiornata",
"STREAM_FORMAT": "Formato streaming",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "TV in diretta",
"MOVIES": "Film",
"SERIES": "Serie",
"SEARCH": "Cerca",
"NO_FAVORITES": "Ancora nessun preferito",
"NO_RECENT_ITEMS": "Nessun elemento visto di recente",
"REMOVE_FROM_RECENT": "Rimuovi dai visti di recente",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "保存",
"UNSAVED_DIALOG_DISCARD": "破棄",
"UNSAVED_DIALOG_INVALID_HINT": "一部の項目が無効なため、変更をまだ保存できません。",
"BACK_TO_HOME": "戻る",
"NEW_VERSION_AVAILABLE": "新しいバージョンが利用可能です",
"LATEST_VERSION": "最新バージョンを使用中です",
"STREAM_FORMAT": "ストリーム形式",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "ライブTV",
"MOVIES": "映画",
"SERIES": "シリーズ",
"SEARCH": "検索",
"NO_FAVORITES": "まだお気に入りがありません",
"NO_RECENT_ITEMS": "最近表示した項目はありません",
"REMOVE_FROM_RECENT": "最近表示した項目から削除",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "저장",
"UNSAVED_DIALOG_DISCARD": "저장 안 함",
"UNSAVED_DIALOG_INVALID_HINT": "일부 항목이 유효하지 않아 변경 사항을 아직 저장할 수 없습니다.",
"BACK_TO_HOME": "홈으로",
"NEW_VERSION_AVAILABLE": "새로운 버전이 있습니다",
"LATEST_VERSION": "최신 버전을 사용하고 있습니다",
"STREAM_FORMAT": "스트림 형식",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "라이브 TV",
"MOVIES": "영화",
"SERIES": "시리즈",
"SEARCH": "검색",
"NO_FAVORITES": "아직 즐겨찾기가 없습니다",
"NO_RECENT_ITEMS": "최근 본 항목이 없습니다",
"REMOVE_FROM_RECENT": "최근 본 항목에서 제거",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Opslaan",
"UNSAVED_DIALOG_DISCARD": "Verwerpen",
"UNSAVED_DIALOG_INVALID_HINT": "Sommige velden zijn ongeldig, dus de wijzigingen kunnen nog niet worden opgeslagen.",
"BACK_TO_HOME": "Terug",
"NEW_VERSION_AVAILABLE": "Een nieuwe versie is beschikbaar",
"LATEST_VERSION": "Je gebruikt de nieuwste versie",
"STREAM_FORMAT": "Streamformaat",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "Live tv",
"MOVIES": "Films",
"SERIES": "Series",
"SEARCH": "Zoeken",
"NO_FAVORITES": "Nog geen favorieten",
"NO_RECENT_ITEMS": "Geen recent bekeken items",
"REMOVE_FROM_RECENT": "Verwijderen uit recent bekeken",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Zapisz",
"UNSAVED_DIALOG_DISCARD": "Odrzuć",
"UNSAVED_DIALOG_INVALID_HINT": "Niektóre pola są nieprawidłowe, więc zmian nie można jeszcze zapisać.",
"BACK_TO_HOME": "Wróć",
"NEW_VERSION_AVAILABLE": "Dostępna jest nowa wersja",
"LATEST_VERSION": "Używasz najnowszej wersji",
"STREAM_FORMAT": "Format strumienia",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "Telewizja na żywo",
"MOVIES": "Filmy",
"SERIES": "Seriale",
"SEARCH": "Szukaj",
"NO_FAVORITES": "Brak ulubionych",
"NO_RECENT_ITEMS": "Brak ostatnio oglądanych",
"REMOVE_FROM_RECENT": "Usuń z ostatnio oglądanych",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Salvar",
"UNSAVED_DIALOG_DISCARD": "Descartar",
"UNSAVED_DIALOG_INVALID_HINT": "Alguns campos são inválidos, então as alterações ainda não podem ser salvas.",
"BACK_TO_HOME": "Voltar",
"NEW_VERSION_AVAILABLE": "Há uma nova versão disponível",
"LATEST_VERSION": "Você está usando a versão mais recente",
"STREAM_FORMAT": "Formato de stream",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "TV ao vivo",
"MOVIES": "Filmes",
"SERIES": "Séries",
"SEARCH": "Pesquisar",
"NO_FAVORITES": "Nenhum favorito ainda",
"NO_RECENT_ITEMS": "Nenhum item visualizado recentemente",
"REMOVE_FROM_RECENT": "Remover dos visualizados recentemente",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Сохранить",
"UNSAVED_DIALOG_DISCARD": "Не сохранять",
"UNSAVED_DIALOG_INVALID_HINT": "Некоторые поля заполнены неверно, поэтому изменения пока нельзя сохранить.",
"BACK_TO_HOME": "Назад",
"NEW_VERSION_AVAILABLE": "Доступна новая версия приложения",
"LATEST_VERSION": "Вы используете последнюю версию приложения",
"STREAM_FORMAT": "Формат потока",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "Live TV",
"MOVIES": "Фильмы",
"SERIES": "Сериалы",
"SEARCH": "Поиск",
"NO_FAVORITES": "Пока нет избранного",
"NO_RECENT_ITEMS": "Нет недавно просмотренных элементов",
"REMOVE_FROM_RECENT": "Удалить из недавно просмотренных",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "Kaydet",
"UNSAVED_DIALOG_DISCARD": "Kaydetme",
"UNSAVED_DIALOG_INVALID_HINT": "Bazı alanlar geçersiz olduğundan değişiklikler henüz kaydedilemiyor.",
"BACK_TO_HOME": "Ana sayfaya dön",
"NEW_VERSION_AVAILABLE": "Yeni bir sürüm mevcut",
"LATEST_VERSION": "En son sürümü kullanıyorsunuz",
"STREAM_FORMAT": "Yayın Formatı",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "Canlı TV",
"MOVIES": "Filmler",
"SERIES": "Diziler",
"SEARCH": "Ara",
"NO_FAVORITES": "Henüz favori yok",
"NO_RECENT_ITEMS": "Son izlenen öğe yok",
"REMOVE_FROM_RECENT": "Son izlenenlerden kaldır",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "保存",
"UNSAVED_DIALOG_DISCARD": "放弃",
"UNSAVED_DIALOG_INVALID_HINT": "部分字段无效,因此暂时无法保存更改。",
"BACK_TO_HOME": "回到主页",
"NEW_VERSION_AVAILABLE": "有新版本可用 ",
"LATEST_VERSION": "您使用的是最新版本",
"STREAM_FORMAT": "流格式",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "直播电视",
"MOVIES": "电影",
"SERIES": "剧集",
"SEARCH": "搜索",
"NO_FAVORITES": "暂无收藏",
"NO_RECENT_ITEMS": "没有最近观看的项目",
"REMOVE_FROM_RECENT": "从最近观看中移除",
-2
View File
@@ -394,7 +394,6 @@
"UNSAVED_DIALOG_SAVE": "儲存",
"UNSAVED_DIALOG_DISCARD": "捨棄",
"UNSAVED_DIALOG_INVALID_HINT": "部分欄位無效,因此暫時無法儲存變更。",
"BACK_TO_HOME": "返回主頁",
"NEW_VERSION_AVAILABLE": "有新版本可用",
"LATEST_VERSION": "您正在使用最新版本",
"STREAM_FORMAT": "串流格式",
@@ -1278,7 +1277,6 @@
"LIVE_TV": "直播電視",
"MOVIES": "電影",
"SERIES": "影集",
"SEARCH": "搜尋",
"NO_FAVORITES": "尚無最愛項目",
"NO_RECENT_ITEMS": "沒有最近觀看的項目",
"REMOVE_FROM_RECENT": "從最近觀看中移除",
-4
View File
@@ -102,10 +102,6 @@
letter-spacing: 0;
}
app-root.macos-platform .current-playlist {
padding-top: 30px;
}
// Windows/Linux frameless title bar: reserve room in top-aligned drag
// regions for the renderer-drawn window controls (3 × 46px buttons).
// Body-level class so it also reaches cdk-overlay content (dialogs, menus).
+7 -7
View File
@@ -65,19 +65,19 @@ Compatibility redirect:
Provider route integration:
1. `apps/web/src/app/app.routes.ts` marks the `/workspace` root route with
`data.layout = 'workspace'`.
2. `isWorkspaceLayoutRoute(...)` treats that layout marker as inherited route
state for all descendants.
3. Xtream and Stalker parent routes attach route-scoped session providers that
1. Every routed view renders inside the workspace shell (the only top-level
paths outside `/workspace` are redirects), so views keep no standalone
layout branches: the shell header owns search, Back and the playlist
switcher.
2. Xtream and Stalker parent routes attach route-scoped session providers that
bootstrap the active playlist, sync provider section state, and clean up
provider-local state when the route is destroyed.
4. Xtream route bootstrap is DB-first for already imported Electron playlists:
3. Xtream route bootstrap is DB-first for already imported Electron playlists:
if the requested section has persisted categories and content, the route
hydrates from SQLite even when the portal status probe reports unavailable,
expired, or inactive. Fresh/no-cache Xtream routes still use the status probe
to block remote imports before the loading overlay starts.
5. Workspace routes no longer rely on nested provider shell components for
4. Workspace routes no longer rely on nested provider shell components for
hidden local chrome.
## Shell Structure
-1
View File
@@ -149,7 +149,6 @@ picker), so each marks itself on the form's `events` through
### libs/portal (8)
- [x] `libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts`
- [x] `libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts`
- [x] `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts`
- [x] `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts`
- [x] `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts`
@@ -116,7 +116,6 @@ describe('VideoPlayerComponent — catch-up timeline segments', () => {
params: of({ id: playlistId(), view: 'all' }),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
@@ -100,7 +100,6 @@ describe('VideoPlayerComponent — guide gating', () => {
params: of({ id: playlistId(), view: 'all' }),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
@@ -153,7 +153,6 @@ describe('VideoPlayerComponent — M3U movie recognition gate', () => {
params: of({ id: playlistId(), view: 'all' }),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
@@ -91,7 +91,6 @@ describe('VideoPlayerComponent — recently viewed history', () => {
params: of({ id: playlistId(), view: 'all' }),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
@@ -171,7 +171,6 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
params: routeParams$.asObservable(),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
@@ -144,7 +144,6 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => {
params: of({ id: 'playlist-1', view: 'all' }),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
@@ -36,7 +36,6 @@
<app-sidebar
[channels]="(channels$ | async)!"
[channelsLoading]="channelsLoading()"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)"
@@ -132,7 +132,6 @@ describe('VideoPlayerComponent', () => {
params: of({ id: playlistId(), view: 'all' }),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
@@ -87,7 +87,6 @@ import {
getAdjacentChannelItem,
getChannelItemByNumber,
isTypingInInput,
isWorkspaceLayoutRoute,
LiveEpgPanelState,
LiveLayoutSidebarStateService,
persistLiveEpgPanelState,
@@ -719,7 +718,6 @@ export class VideoPlayerComponent
readonly isDesktop = this.runtime.isElectron;
readonly supportsEpg = this.runtime.supportsEpg;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
@@ -1750,7 +1748,7 @@ export class VideoPlayerComponent
}
private registerHeaderShortcut(): void {
if (!this.isWorkspaceLayout || !this.supportsEpg) {
if (!this.supportsEpg) {
return;
}
-1
View File
@@ -9,7 +9,6 @@ export * from './lib/components/playlist-error-view/playlist-error-view.componen
export { ProgressCapsuleComponent } from '@iptvnator/ui/components';
export * from './lib/components/portal-empty-state/portal-empty-state.component';
export * from './lib/components/channel-list-hidden-state/channel-list-hidden-state.component';
export * from './lib/components/search-form/search-form.component';
export * from './lib/components/search-layout/search-layout.component';
export { WatchedBadgeComponent } from '@iptvnator/ui/components';
export * from './lib/components/unified-collection/unified-collection-page.component';
@@ -1,42 +0,0 @@
<div class="search-container">
<div class="recent-search">
<mat-icon>search</mat-icon>
<input
#searchInput
type="search"
spellcheck="false"
autocomplete="off"
[placeholder]="placeholder || ('PORTALS.SEARCH' | translate)"
[ngModel]="searchTerm()"
(ngModelChange)="onSearchTermChange()"
/>
@if (searchTerm()) {
<button
mat-icon-button
type="button"
class="clear-btn"
[attr.aria-label]="'CLEAR_SEARCH' | translate"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
@if (filterConfig.length > 0) {
<div class="type-filters">
@for (filter of filterConfig; track filter.key) {
<mat-checkbox
[(ngModel)]="filters[filter.key]"
(ngModelChange)="onFilterChange(filter.key)"
>
@if (filter.translationKey) {
{{ filter.translationKey | translate }}
} @else {
{{ filter.label }}
}
</mat-checkbox>
}
</div>
}
</div>
@@ -1,129 +0,0 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
model,
Output,
ViewChild,
ChangeDetectionStrategy,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
export interface SearchFilters {
[key: string]: boolean;
}
export interface SearchFilter {
key: string;
label: string;
translationKey?: string;
}
@Component({
selector: 'app-search-form',
standalone: true,
imports: [
FormsModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatCheckboxModule,
MatButtonModule,
TranslatePipe,
],
templateUrl: './search-form.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
.search-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.recent-search {
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
height: 42px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.recent-search mat-icon {
opacity: 0.6;
font-size: 18px;
width: 18px;
height: 18px;
}
.recent-search input {
width: 100%;
border: 0;
background: transparent;
color: inherit;
font: inherit;
opacity: 0.95;
}
.recent-search input::placeholder {
opacity: 0.5;
}
.clear-btn {
width: 30px;
height: 30px;
min-width: 30px;
}
`,
],
})
export class SearchFormComponent {
@ViewChild('searchInput') searchInput!: ElementRef;
/** Empty means the generic "Search" label */
@Input() placeholder = '';
@Input() filters: SearchFilters = {};
@Input() filterConfig: SearchFilter[] = [];
@Input() singleSelection = false; // Add this new input
searchTerm = model<string>('');
@Output() filtersChange = new EventEmitter<SearchFilters>();
@Output() search = new EventEmitter<void>();
onFilterChange(changedKey: string) {
if (this.singleSelection) {
// Reset all other filters when in single selection mode
Object.keys(this.filters).forEach((key) => {
if (key !== changedKey) {
this.filters[key] = false;
}
});
}
this.filtersChange.emit(this.filters);
this.search.emit();
}
onSearchTermChange() {
setTimeout(() => {
this.searchTerm.set(this.searchInput.nativeElement.value);
}, 500);
}
clearSearch() {
this.searchInput.nativeElement.value = '';
this.searchTerm.set('');
}
focusSearchInput() {
this.searchInput.nativeElement.focus();
}
}
@@ -24,30 +24,11 @@
}}
</div>
}
@if (showCloseButton()) {
<button
mat-icon-button
type="button"
[attr.aria-label]="'CLOSE' | translate"
(click)="onCloseClick()"
>
<mat-icon>close</mat-icon>
</button>
}
<!-- Extra header actions slot -->
<ng-content select="[header-actions]" />
</div>
</div>
@if (showSearchInput()) {
<div class="search-input-container">
<app-search-form
[searchTerm]="searchTerm()"
(searchTermChange)="onSearchTermChange($event)"
/>
</div>
}
<!-- Subheader slot (info notes, secondary controls) -->
<ng-content select="[subheader]" />
</div>
@@ -84,10 +84,6 @@
align-items: center;
}
}
.search-input-container {
margin-bottom: 0;
}
}
.results-container {
@@ -6,13 +6,11 @@ import {
output,
viewChild,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
import { InfiniteScrollDirective } from '../../directives/infinite-scroll.directive';
import { SearchFormComponent } from '../search-form/search-form.component';
@Component({
selector: 'app-search-layout',
@@ -20,9 +18,7 @@ import { SearchFormComponent } from '../search-form/search-form.component';
imports: [
InfiniteScrollDirective,
MatIcon,
MatIconButton,
MatProgressSpinner,
SearchFormComponent,
TranslatePipe,
],
templateUrl: './search-layout.component.html',
@@ -30,7 +26,6 @@ import { SearchFormComponent } from '../search-form/search-form.component';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchLayoutComponent {
private readonly searchFormComponent = viewChild(SearchFormComponent);
private readonly resultsContainer =
viewChild<ElementRef<HTMLElement>>('resultsContainer');
@@ -46,9 +41,6 @@ export class SearchLayoutComponent {
/** Whether search is in progress */
readonly isLoading = input<boolean>(false);
/** Whether to show the close button (for dialog mode) */
readonly showCloseButton = input<boolean>(false);
/**
* Whether the page offers Back (a nested search reached via navigation).
* It is the workspace header's Back, registered while the results show;
@@ -59,9 +51,6 @@ export class SearchLayoutComponent {
/** Whether to show results count */
readonly showResultsCount = input<boolean>(true);
/** Whether to show the inline search input */
readonly showSearchInput = input<boolean>(true);
/** Whether inline details are active and should replace the search body */
readonly showDetails = input<boolean>(false);
@@ -105,12 +94,6 @@ export class SearchLayoutComponent {
'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION'
);
/** Emitted when search term changes */
readonly searchTermChange = output<string>();
/** Emitted when close button is clicked */
readonly closeClick = output<void>();
/** Emitted by the header's Back while `backAvailable` is set */
readonly backClick = output<void>();
@@ -128,11 +111,6 @@ export class SearchLayoutComponent {
});
}
/** Focus the search input */
focusSearchInput(): void {
this.searchFormComponent()?.focusSearchInput();
}
/**
* Scroll handoff for hosts whose inline detail replaces the results
* (`showDetails`): the container is destroyed with the detail open and
@@ -146,14 +124,6 @@ export class SearchLayoutComponent {
this.resultsContainer()?.nativeElement.scrollTo?.({ top: scrollTop });
}
onSearchTermChange(term: string): void {
this.searchTermChange.emit(term);
}
onCloseClick(): void {
this.closeClick.emit();
}
/** Check if we should show the "no results" state */
get showNoResults(): boolean {
return (
@@ -84,8 +84,6 @@ export function createClearCollectionAction(options: {
}
export interface ClearCollectionViewCommandHost {
/** Only the workspace shell renders a command palette to register into. */
isWorkspaceLayout: boolean;
mode: Signal<CollectionMode>;
selectedContentType: Signal<CollectionContentType>;
/** Rows the command would clear; with none there is nothing to offer. */
@@ -110,7 +108,7 @@ export function setupClearCollectionViewCommand(
});
effect((onCleanup) => {
if (!host.isWorkspaceLayout || host.currentTypeItems().length === 0) {
if (host.currentTypeItems().length === 0) {
return;
}
@@ -203,7 +203,6 @@ describe('UnifiedCollectionPageComponent', () => {
const workspaceRoute = {
snapshot: {
data: { layout: 'workspace' },
paramMap: convertToParamMap({}),
params: {},
},
@@ -672,7 +671,6 @@ describe('UnifiedCollectionPageComponent', () => {
it('registers a workspace command when the current view has actionable items', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.isWorkspaceLayout).toBe(true);
fixture.componentInstance.allItems.set([
{
@@ -31,7 +31,6 @@ import {
CollectionScope,
FavoritesChannelSortMode,
getOpenLiveCollectionItemState,
isWorkspaceLayoutRoute,
isTypingInInput,
LiveLayoutSidebarStateService,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
@@ -110,11 +109,8 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
LiveLayoutSidebarStateService
);
readonly detailTemplate = contentChild(UnifiedCollectionDetailDirective);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
'q',
(value) => (value ?? '').trim()
readonly workspaceSearchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim()
);
private readonly routePlaylistId = routeParamSignal<string | undefined>(
this.route,
@@ -124,9 +120,6 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
readonly playlistId = computed(
() => this.playlistIdInput() ?? this.routePlaylistId()
);
readonly workspaceSearchTerm = computed(() =>
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
);
private readonly viewStateHistory = new CollectionViewStateHistory();
readonly isLoading = this.data.isLoading;
@@ -247,7 +240,6 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
constructor() {
setupClearCollectionViewCommand({
isWorkspaceLayout: this.isWorkspaceLayout,
mode: this.mode,
selectedContentType: this.selectedContentType,
currentTypeItems: this.currentTypeItems,
@@ -17,7 +17,6 @@ import { BehaviorSubject } from 'rxjs';
import {
extractPortalPlaylistId,
extractPortalSection,
isWorkspaceLayoutRoute,
queryParamSignal,
routeParamSignal,
resolveCurrentPortalPlaylistId,
@@ -26,48 +25,6 @@ import {
} from './portal-route.utils';
describe('portal-route.utils', () => {
it('detects workspace layout from route data', () => {
const route = {
snapshot: {
data: {
layout: 'workspace',
},
},
} as unknown as ActivatedRoute;
expect(isWorkspaceLayoutRoute(route)).toBe(true);
});
it('detects workspace layout from ancestor route data', () => {
const parentRoute = {
snapshot: {
data: {
layout: 'workspace',
},
},
} as unknown as ActivatedRoute;
const childRoute = {
snapshot: {
data: {},
},
pathFromRoot: [
{
snapshot: {
data: {},
},
},
parentRoute,
{
snapshot: {
data: {},
},
},
],
} as unknown as ActivatedRoute;
expect(isWorkspaceLayoutRoute(childRoute)).toBe(true);
});
it('extracts Xtream sections from workspace and local URLs', () => {
expect(extractPortalSection('/xtreams/123/search', 'xtreams')).toBe(
'search'
@@ -25,17 +25,6 @@ const PORTAL_SECTIONS = new Set<PortalRailSection>([
'vod',
]);
export function isWorkspaceLayoutRoute(route: ActivatedRoute): boolean {
const routeChain =
Array.isArray(route.pathFromRoot) && route.pathFromRoot.length > 0
? route.pathFromRoot
: [route];
return routeChain.some(
(currentRoute) => currentRoute.snapshot.data['layout'] === 'workspace'
);
}
export function queryParamSignal<T = string>(
route: ActivatedRoute,
key: string,
@@ -3,14 +3,12 @@
[resultsCount]="resultsCount"
[isLoading]="isInitialSearchLoading()"
[showResultsCount]="true"
[showSearchInput]="!isWorkspaceLayout"
[backAvailable]="isWorkspaceLayout"
[backAvailable]="true"
[showDetails]="showingDetails"
[nearEndHasMore]="searchHasMore()"
[nearEndAppending]="isAppendingSearchResults()"
[nearEndRenderedCount]="resultsCount"
[nearEndResetKey]="searchScrollResetKey()"
(searchTermChange)="updateSearchTerm($event)"
(backClick)="goBack()"
(nearEnd)="loadMoreSearchResults()"
>
@@ -37,7 +37,6 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import {
isWorkspaceLayoutRoute,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
@@ -117,8 +116,6 @@ export class StalkerSearchComponent {
series: false,
vod: true,
});
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
readonly filterConfig: StalkerFilter[] = [
{
key: 'vod',
@@ -279,10 +276,6 @@ export class StalkerSearchComponent {
return this.searchResults().length;
}
updateSearchTerm(term: string) {
this.searchTerm.set(term);
}
updateFilter(key: StalkerSearchContentType, value: boolean) {
if (value) {
// Single selection mode - set clicked filter, disable others
@@ -114,9 +114,6 @@ describe('Xtream live auto-open playback queue', () => {
queryParamMap: convertToParamMap({}),
},
queryParamMap: query,
pathFromRoot: [
{ snapshot: { data: { layout: 'workspace' } } },
],
},
},
{ provide: Router, useValue: { events, navigate: jest.fn() } },
@@ -128,9 +128,6 @@ describe('LiveStreamLayoutComponent catch-up timeline segments', () => {
queryParamMap: convertToParamMap({}),
},
queryParamMap: of(convertToParamMap({})),
pathFromRoot: [
{ snapshot: { data: { layout: 'workspace' } } },
],
},
},
{
@@ -206,13 +206,6 @@ describe('LiveStreamLayoutComponent', () => {
queryParamMap: convertToParamMap({}),
},
queryParamMap: routeQueryParamMap.asObservable(),
pathFromRoot: [
{
snapshot: {
data: { layout: 'workspace' },
},
},
],
},
},
{
@@ -47,7 +47,6 @@ import {
getAdjacentChannelItem,
getChannelItemByNumber,
isTypingInInput,
isWorkspaceLayoutRoute,
LiveEpgPanelState,
persistLiveEpgPanelState,
persistPortalChannelSortMode,
@@ -216,14 +215,8 @@ export class LiveStreamLayoutComponent
);
readonly isElectron = this.runtime.isElectron;
readonly supportsEpg = this.runtime.supportsEpg;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
'q',
(value) => (value ?? '').trim()
);
readonly workspaceSearchTerm = computed(() =>
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
readonly workspaceSearchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim()
);
readonly showLiveChannelSidebar = computed(
() => !!this.selectedCategoryId() || !!this.workspaceSearchTerm()
@@ -113,9 +113,6 @@ describe('LiveStreamLayoutComponent parental lock', () => {
queryParamMap: convertToParamMap({}),
},
queryParamMap: of(convertToParamMap({})),
pathFromRoot: [
{ snapshot: { data: { layout: 'workspace' } } },
],
},
},
{
@@ -159,9 +159,6 @@ describe('LiveStreamLayoutComponent sidebar levels', () => {
queryParamMap: convertToParamMap({}),
},
queryParamMap: of(convertToParamMap({})),
pathFromRoot: [
{ snapshot: { data: { layout: 'workspace' } } },
],
},
},
{
@@ -2,18 +2,14 @@
[searchTerm]="searchTerm()"
[resultsCount]="xtreamStore.searchResults().length"
[isLoading]="xtreamStore.isSearching()"
[showCloseButton]="false"
[backAvailable]="backAvailable"
[showResultsCount]="!isGlobalSearch || !groupByPlaylist()"
[showSearchInput]="showInlineSearchInput"
[minSearchLength]="minSearchLength"
[initialDescriptionKey]="initialDescriptionKey"
[nearEndHasMore]="hasMoreSearchResults()"
[nearEndAppending]="isAppendingSearchResults()"
[nearEndRenderedCount]="renderedSearchResultsCount()"
[nearEndResetKey]="searchResetIdentity()"
(searchTermChange)="updateSearchTerm($event)"
(closeClick)="onCloseDialog()"
(backClick)="goBack()"
(nearEnd)="onResultsNearEnd()"
>
@@ -40,16 +36,6 @@
{{ 'PORTALS.SEARCH_VIEW.VISIBLE_CATEGORIES_ONLY' | translate }}
</mat-checkbox>
}
@if (showGlobalCloseButton) {
<button
mat-icon-button
type="button"
[attr.aria-label]="'CLOSE' | translate"
(click)="onCloseDialog()"
>
<mat-icon>close</mat-icon>
</button>
}
</ng-container>
<ng-container subheader>
@@ -1,6 +1,5 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { DatabaseService } from '@iptvnator/services';
@@ -17,10 +16,6 @@ jest.mock('@iptvnator/portal/shared/ui', () => ({
SearchLayoutComponent: class {},
}));
/** The component is opened as a route, not a dialog: `@Optional()` resolves nothing. */
const NO_DIALOG_REF =
undefined as unknown as MatDialogRef<SearchResultsComponent>;
const DEFAULT_SEARCH_FILTERS: SearchFilters = {
live: true,
movie: true,
@@ -78,7 +73,7 @@ class MockXtreamStore {
setSelectedContentType = jest.fn();
}
describe('SearchResultsComponent initialQuery contract', () => {
describe('SearchResultsComponent global search', () => {
let routerNavigateMock: jest.Mock;
beforeEach(() => {
@@ -102,7 +97,6 @@ describe('SearchResultsComponent initialQuery contract', () => {
snapshot: {
data: {
isGlobalSearch: true,
layout: 'workspace',
},
queryParamMap: convertToParamMap({ q: '' }),
},
@@ -119,60 +113,24 @@ describe('SearchResultsComponent initialQuery contract', () => {
});
});
it('applies initialQuery when opened as global search', () => {
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
initialQuery: 'matrix',
},
NO_DIALOG_REF
)
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
expect(store.setSearchTerm).toHaveBeenCalledWith('matrix');
expect(component.searchTerm()).toBe('matrix');
});
it('does not force search term when initialQuery is not provided', () => {
TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
NO_DIALOG_REF
)
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
expect(store.setSearchTerm).not.toHaveBeenCalled();
});
it('clears the workspace global search term when the route q param is empty', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.searchTerm.set('matrix');
const component = TestBed.runInInjectionContext(
() => new SearchResultsComponent(null, NO_DIALOG_REF)
() => new SearchResultsComponent()
);
TestBed.flushEffects();
expect(store.setSearchTerm).toHaveBeenCalledWith('');
expect(component.searchTerm()).toBe('');
expect(component.isGlobalSearch).toBe(true);
expect(component.backAvailable).toBe(false);
});
it('navigates global search selection to workspace xtream route', () => {
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
NO_DIALOG_REF
)
() => new SearchResultsComponent()
);
component.selectItem(
@@ -191,13 +149,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
it('navigates M3U global search selection to the M3U player with route state', () => {
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
NO_DIALOG_REF
)
() => new SearchResultsComponent()
);
const channel = {
id: 'channel-news',
@@ -262,13 +214,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
.mockResolvedValueOnce(freshResults);
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
NO_DIALOG_REF
)
() => new SearchResultsComponent()
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
@@ -309,13 +255,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
.mockResolvedValueOnce(secondPage);
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
NO_DIALOG_REF
)
() => new SearchResultsComponent()
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
@@ -362,13 +302,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
.mockReturnValueOnce(freshSearch.promise);
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
NO_DIALOG_REF
)
() => new SearchResultsComponent()
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.searchResults.set([createSearchItem()]);
@@ -409,13 +343,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
databaseService.globalSearchContent.mockResolvedValueOnce(nextPage);
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
NO_DIALOG_REF
)
() => new SearchResultsComponent()
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.searchResults.set([createSearchItem({ id: 1, xtream_id: 1 })]);
@@ -465,13 +393,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
databaseService.globalSearchContent.mockResolvedValueOnce(firstPage);
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
NO_DIALOG_REF
)
() => new SearchResultsComponent()
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
@@ -505,7 +427,7 @@ describe('SearchResultsComponent in-portal result window', () => {
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { layout: 'workspace' },
data: {},
queryParamMap: convertToParamMap({ q: '' }),
},
queryParamMap: of(convertToParamMap({ q: '' })),
@@ -524,7 +446,7 @@ describe('SearchResultsComponent in-portal result window', () => {
it('windows the full result set, reveals further chunks, and resets on a new result set', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
const component = TestBed.runInInjectionContext(
() => new SearchResultsComponent(null, NO_DIALOG_REF)
() => new SearchResultsComponent()
);
const items = Array.from({ length: 130 }, (_, index) =>
createSearchItem({
@@ -536,6 +458,7 @@ describe('SearchResultsComponent in-portal result window', () => {
store.searchResults.set(items);
expect(component.isGlobalSearch).toBe(false);
expect(component.backAvailable).toBe(true);
expect(component.visibleInPortalResults()).toHaveLength(60);
expect(component.renderedSearchResultsCount()).toBe(60);
expect(component.hasMoreSearchResults()).toBe(true);
@@ -563,7 +486,7 @@ describe('SearchResultsComponent in-portal result window', () => {
it('changes the reset identity on filter-only transitions', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
const component = TestBed.runInInjectionContext(
() => new SearchResultsComponent(null, NO_DIALOG_REF)
() => new SearchResultsComponent()
);
const initialIdentity = component.searchResetIdentity();
@@ -5,20 +5,11 @@ import {
computed,
effect,
inject,
Inject,
linkedSignal,
Optional,
signal,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { ActivatedRoute, Router } from '@angular/router';
@@ -29,7 +20,6 @@ import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import {
buildXtreamNavigationTarget,
isWorkspaceLayoutRoute,
queryParamSignal,
workspacePortalCommands,
} from '@iptvnator/portal/shared/util';
@@ -45,11 +35,6 @@ import {
isM3uGlobalSearchResult,
} from '@iptvnator/shared/interfaces';
interface SearchResultsData {
isGlobalSearch: boolean;
initialQuery?: string;
}
interface GlobalSearchResultGroup {
playlistId: string;
playlistName: string;
@@ -92,9 +77,7 @@ function groupResultsByPlaylistId(
ContentCardComponent,
FormsModule,
MatCheckboxModule,
MatDialogModule,
MatIcon,
MatIconButton,
MatProgressSpinner,
SearchLayoutComponent,
TranslatePipe,
@@ -104,14 +87,12 @@ function groupResultsByPlaylistId(
styleUrls: ['./search-results.component.scss'],
})
export class SearchResultsComponent implements AfterViewInit {
readonly searchLayoutComponent = viewChild(SearchLayoutComponent);
readonly xtreamStore = inject(XtreamStore);
readonly router = inject(Router);
readonly activatedRoute = inject(ActivatedRoute);
readonly databaseService = inject(DatabaseService);
private readonly backNavigation = inject(WorkspaceBackNavigationService);
private readonly logger = createLogger('XtreamSearchResults');
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
readonly routeSearchTerm = queryParamSignal(
this.activatedRoute,
'q',
@@ -137,7 +118,8 @@ export class SearchResultsComponent implements AfterViewInit {
private static readonly TYPE_FILTERS_STORAGE_KEY =
'global-search-type-filters';
isGlobalSearch = false;
readonly isGlobalSearch =
this.activatedRoute.snapshot.data?.['isGlobalSearch'] === true;
/** Whether to group global search results by playlist */
readonly groupByPlaylist = signal(
@@ -246,15 +228,7 @@ export class SearchResultsComponent implements AfterViewInit {
}
}
constructor(
@Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData | null,
@Optional() public dialogRef: MatDialogRef<SearchResultsComponent>
) {
this.isGlobalSearch =
data?.isGlobalSearch ||
this.activatedRoute.snapshot.data?.['isGlobalSearch'] === true;
const initialQuery = (data?.initialQuery ?? '').trim();
constructor() {
if (this.isGlobalSearch) {
const savedFilters = localStorage.getItem(
SearchResultsComponent.TYPE_FILTERS_STORAGE_KEY
@@ -273,10 +247,6 @@ export class SearchResultsComponent implements AfterViewInit {
// Ignore malformed storage value and keep defaults.
}
}
if (initialQuery) {
this.xtreamStore.setSearchTerm(initialQuery);
}
}
effect((onCleanup) => {
@@ -290,10 +260,6 @@ export class SearchResultsComponent implements AfterViewInit {
});
effect(() => {
if (!this.isWorkspaceLayout) {
return;
}
const queryTerm = this.routeSearchTerm();
if (queryTerm === this.searchTerm()) {
return;
@@ -305,11 +271,6 @@ export class SearchResultsComponent implements AfterViewInit {
ngAfterViewInit() {
this.xtreamStore.setSelectedContentType('vod');
if (this.showInlineSearchInput) {
setTimeout(() => {
this.searchLayoutComponent()?.focusSearchInput();
});
}
}
executeSearch() {
@@ -330,13 +291,6 @@ export class SearchResultsComponent implements AfterViewInit {
}
}
/**
* Update search term in the store
*/
updateSearchTerm(term: string) {
this.xtreamStore.setSearchTerm(term);
}
/**
* Update a single filter in the store
*/
@@ -526,19 +480,11 @@ export class SearchResultsComponent implements AfterViewInit {
imageUrl: item.poster_url,
});
if (this.isGlobalSearch) {
this.dialogRef?.close();
}
void this.router.navigate(navigation.link, {
state: navigation.state,
});
}
onCloseDialog() {
this.dialogRef?.close();
}
/** History Back; the portal's default section when it opened the session. */
goBack(): void {
this.backNavigation.back(() =>
@@ -569,25 +515,13 @@ export class SearchResultsComponent implements AfterViewInit {
return this.groupedResults();
}
get showInlineSearchInput(): boolean {
return !this.isWorkspaceLayout;
}
get showGlobalCloseButton(): boolean {
return this.isGlobalSearch && !this.isWorkspaceLayout;
}
/**
* The in-portal search is a nested view (reached from the toolbar
* search box or an actor page), so it offers the header's Back; the
* global search is a top-level sidebar destination, and the dialog has
* its own close button — a Back there would navigate the page behind
* the open dialog.
* global search is a top-level sidebar destination.
*/
get backAvailable(): boolean {
return (
this.isWorkspaceLayout && !this.isGlobalSearch && !this.dialogRef
);
return !this.isGlobalSearch;
}
get minSearchLength(): number {
@@ -618,10 +552,6 @@ export class SearchResultsComponent implements AfterViewInit {
return;
}
if (this.isGlobalSearch) {
this.dialogRef?.close();
}
void this.router.navigate(
['/workspace', 'playlists', item.playlist_id, 'all'],
{
@@ -106,7 +106,6 @@ describe('ChannelListContainerComponent', () => {
const route = {
snapshot: {
data: { layout: 'workspace' },
paramMap: convertToParamMap({}),
queryParamMap: convertToParamMap({}),
params: {},
@@ -115,7 +114,6 @@ describe('ChannelListContainerComponent', () => {
pathFromRoot: [
{
snapshot: {
data: { layout: 'workspace' },
paramMap: convertToParamMap({}),
params: {},
},
@@ -22,10 +22,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { queryParamSignal } from '@iptvnator/portal/shared/util';
import {
ChannelActions,
FavoritesActions,
@@ -219,7 +216,6 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
readonly sidebarToggleRequested = output<void>();
/** Groups view only: the group whose channels are currently listed. */
readonly selectedGroupChange = output<string | null>();
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
'q',
@@ -230,7 +226,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
if (hostTerm !== null) {
return foldSearchText(hostTerm.trim());
}
return this.isWorkspaceLayout ? this.routeSearchTerm() : '';
return this.routeSearchTerm();
});
readonly currentUrl = toSignal(
@@ -1,23 +1,3 @@
@if (showPlaylistHeader()) {
<div class="current-playlist">
<button
mat-icon-button
routerLink="/"
[matTooltip]="'SETTINGS.BACK_TO_HOME' | translate"
[attr.aria-label]="'SETTINGS.BACK_TO_HOME' | translate"
class="home-btn"
>
<mat-icon>home</mat-icon>
</button>
<app-playlist-switcher
class="switcher"
[currentTitle]="playlistTitle()"
[subtitle]="subtitle()"
/>
</div>
}
<app-channel-list-container
[channelList]="channels()"
[channelsLoading]="channelsLoading()"
@@ -1,22 +0,0 @@
.current-playlist {
display: flex;
align-items: center;
padding: 8px;
gap: 4px;
app-region: drag;
button {
app-region: no-drag;
}
.switcher {
flex: 1;
min-width: 0;
}
}
@media only screen and (max-width: 480px) {
.current-playlist {
padding: 6px;
}
}
@@ -1,37 +0,0 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { SidebarComponent } from './sidebar.component';
describe('SidebarComponent', () => {
it('re-words the channel count after a runtime language switch', () => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SidebarComponent],
providers: [
{
provide: PlaylistContextFacade,
useValue: { activePlaylist: signal(null) },
},
],
}).overrideComponent(SidebarComponent, {
set: { template: '', imports: [] },
});
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
HOME: { PLAYLISTS: { CHANNELS: 'channels' } },
});
translate.setTranslation('ru', {
HOME: { PLAYLISTS: { CHANNELS: 'каналов' } },
});
translate.use('en');
const fixture = TestBed.createComponent(SidebarComponent);
fixture.componentRef.setInput('channels', [{}, {}]);
expect(fixture.componentInstance.subtitle()).toBe('2 channels');
translate.use('ru');
expect(fixture.componentInstance.subtitle()).toBe('2 каналов');
});
});
@@ -1,18 +1,10 @@
import {
Component,
computed,
inject,
input,
output,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { injectTranslationTick } from '@iptvnator/services';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { Channel } from '@iptvnator/shared/interfaces';
import { ChannelListContainerComponent } from '@iptvnator/ui/components';
@@ -20,22 +12,12 @@ import { ChannelListContainerComponent } from '@iptvnator/ui/components';
@Component({
selector: 'app-sidebar',
templateUrl: './sidebar.component.html',
styleUrls: ['./sidebar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelListContainerComponent,
MatIcon,
MatIconButton,
MatTooltip,
PlaylistSwitcherComponent,
RouterLink,
TranslatePipe,
],
imports: [ChannelListContainerComponent],
})
export class SidebarComponent {
readonly channels = input<Channel[]>([]);
readonly channelsLoading = input(false);
readonly showPlaylistHeader = input(true);
readonly activeView = input<string>('all');
readonly sidebarWidth = input<number | null>(null);
readonly sidebarWidthRequested = output<number>();
@@ -45,32 +27,8 @@ export class SidebarComponent {
readonly selectedGroupChange = output<string | null>();
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly translate = inject(TranslateService);
private readonly languageTick = injectTranslationTick();
readonly activePlaylist = this.playlistContext.activePlaylist;
readonly playlistTitle = computed(() => {
const playlist = this.activePlaylist();
return (
playlist?.title ||
playlist?.filename ||
playlist?.url ||
playlist?.portalUrl ||
'Untitled playlist'
);
});
/** Worded per language: the tick re-runs this after a switch. */
readonly subtitle = computed(() => {
this.languageTick();
if (this.channelsLoading()) {
return this.translate.instant('CHANNELS.LOADING');
}
const count = this.channels()?.length ?? 0;
return `${count} ${this.translate.instant('HOME.PLAYLISTS.CHANNELS')}`;
});
onSidebarWidthRequested(width: number): void {
this.sidebarWidthRequested.emit(width);
-5
View File
@@ -42,11 +42,6 @@
app-region: no-drag;
}
.switcher {
flex: 1;
min-width: 0;
}
.manage-categories-btn {
margin-left: auto;
}