refactor(add-playlist): replace 2-level type×subtype with flat 5-card picker

The Add Playlist dialog used a nested control: pick category
(M3U / Xtream / Stalker) then pick subtype (URL / file / text). That's
9 cells of which only 5 are real — Xtream-via-file and Stalker-via-text
don't exist. Users spent visible time hunting the right path through
the matrix.

Replace it with a flat 5-card method grid that matches the v0.22 mockup
exactly: each card IS a method (M3U URL, M3U file, Xtream credentials,
Stalker portal, Paste raw m3u text), shown side by side with an icon,
label, and one-line description of when to pick it.

Component state collapses from `category` + `m3uSubType` (two signals
with an entangled `playlistType()` mapping) into a single `method`
signal of type PlaylistType. `initFromType` simplifies to one line.
`playlistType()` is kept as a thin alias so the template @switch and
the action-button branches stay untouched.

The selected card is the only place that needs to change for users to
pick a different method — no more "first click type, then click
subtype, then look for the form" two-step.

i18n: 6 new HOME.ADD_PLAYLIST.* keys (subtitle + 5 method subs)
translated across all 17 locales by per-locale agents, with the
existing HOME.TABS.* keys reused for card titles. Placeholder
integrity verified.

Tests: existing clearCurrentForm scenarios still pass after the signal
refactor; added 6 new tests covering the default method (URL) and the
MAT_DIALOG_DATA.type deep-link path for each of the 5 method values.
25/25 in playlist-import-feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-19 01:09:43 +02:00
1 parent fe849b2de2
commit 210baa2e75
22 files changed
+398 -121

No files matched your search

+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! تم تحديث قوائم التشغيل بنجاح",
"AUTO_REFRESH_ENABLED": "تم تفعيل التحديث التلقائي"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "اختر طريقة الاتصال، ويمكنك إعادة تسميتها لاحقًا.",
"METHOD_URL_SUB": "ألصق رابطًا لملف .m3u أو .m3u8 بعيد",
"METHOD_FILE_SUB": "حمّل قائمة تشغيل من جهازك",
"METHOD_XTREAM_SUB": "اتصل باستخدام المضيف واسم المستخدم وكلمة المرور",
"METHOD_STALKER_SUB": "عنوان MAC ورابط البوابة",
"METHOD_TEXT_SUB": "للقوائم العابرة أو عندما يتوفر لديك النص فقط"
},
"FILE_UPLOAD": {
"DRAG_DROP": "اسحب وأفلت الملفات هنا",
"OR": "أو",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! قوائم التشغيل تحدثات بنجاح",
"AUTO_REFRESH_ENABLED": "التحديث التلقائي مفعل"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "اختار كيفاش بغيتي تربط. تقدر تبدل الاسم من بعد.",
"METHOD_URL_SUB": "لصق رابط ل ملف .m3u ولا .m3u8 بعيد",
"METHOD_FILE_SUB": "زيد قائمة تشغيل من الجهاز ديالك",
"METHOD_XTREAM_SUB": "ربط بالخادم، اسم المستخدم وكلمة المرور",
"METHOD_STALKER_SUB": "عنوان MAC ورابط البوابة",
"METHOD_TEXT_SUB": "للقوائم المؤقتة ولا إلى عندك غير النص"
},
"FILE_UPLOAD": {
"DRAG_DROP": "جر وأفلت الملفات هنا",
"OR": "أو",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "Гатова! Плэйлісты былі паспяхова абноўлены",
"AUTO_REFRESH_ENABLED": "Аўтаабнаўленне ўключана"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Выберыце, як вы хочаце падключыцца. Перайменаваць можна пазней.",
"METHOD_URL_SUB": "Устаўце спасылку на выдалены .m3u або .m3u8",
"METHOD_FILE_SUB": "Загрузіце плэйліст са свайго камп'ютара",
"METHOD_XTREAM_SUB": "Падключэнне праз хост, імя карыстальніка і пароль",
"METHOD_STALKER_SUB": "MAC-адрас і URL партала",
"METHOD_TEXT_SUB": "Для разавых спісаў або калі ў вас ёсць толькі тэкст"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Перацягніце файл у гэтую вобласць",
"OR": "або",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "Erfolg! Die Playlists wurden erfolgreich aktualisiert",
"AUTO_REFRESH_ENABLED": "Auto-Refresh aktiviert"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Wählen Sie aus, wie Sie sich verbinden möchten. Sie können den Namen später ändern.",
"METHOD_URL_SUB": "Fügen Sie einen Link zu einer .m3u- oder .m3u8-Datei ein",
"METHOD_FILE_SUB": "Laden Sie eine Wiedergabeliste von Ihrem Computer hoch",
"METHOD_XTREAM_SUB": "Verbinden mit Host, Benutzername und Passwort",
"METHOD_STALKER_SUB": "MAC-Adresse und Portal-URL",
"METHOD_TEXT_SUB": "Für einmalige Listen oder wenn Sie nur Text haben"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Ziehen Sie Dateien per Drag & Drop",
"OR": "oder",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "Επιτυχία! Οι λίστες αναπαραγωγής ενημερώθηκαν με επιτυχία",
"AUTO_REFRESH_ENABLED": "Η αυτόματη ανανέωση είναι ενεργοποιημένη"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Επιλέξτε τρόπο σύνδεσης. Μπορείτε να τη μετονομάσετε αργότερα.",
"METHOD_URL_SUB": "Επικολλήστε έναν σύνδεσμο σε απομακρυσμένο .m3u ή .m3u8",
"METHOD_FILE_SUB": "Ανεβάστε μια λίστα αναπαραγωγής από τον υπολογιστή σας",
"METHOD_XTREAM_SUB": "Σύνδεση με διακομιστή, όνομα χρήστη και κωδικό πρόσβασης",
"METHOD_STALKER_SUB": "MAC address και URL πύλης",
"METHOD_TEXT_SUB": "Για λίστες μίας χρήσης ή όταν έχετε μόνο κείμενο"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Σύρετε και αποθέστε αρχεία εδώ",
"OR": "or",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "Success! The playlists were successfully updated",
"AUTO_REFRESH_ENABLED": "Auto-refresh enabled"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Pick how you want to connect. You can rename it later.",
"METHOD_URL_SUB": "Paste a link to a remote .m3u or .m3u8",
"METHOD_FILE_SUB": "Upload a playlist from your computer",
"METHOD_XTREAM_SUB": "Connect with host, username and password",
"METHOD_STALKER_SUB": "MAC address and portal URL",
"METHOD_TEXT_SUB": "For one-off lists or when you only have text"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Drop an .m3u or .m3u8 file here",
"OR": "or",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "¡Listo! Las listas de reproducción se actualizaron correctamente",
"AUTO_REFRESH_ENABLED": "Auto-actualización activada"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Elige cómo quieres conectar. Puedes renombrarla más tarde.",
"METHOD_URL_SUB": "Pega un enlace a un archivo .m3u o .m3u8 remoto",
"METHOD_FILE_SUB": "Sube una lista de reproducción desde tu computadora",
"METHOD_XTREAM_SUB": "Conecta con host, usuario y contraseña",
"METHOD_STALKER_SUB": "Dirección MAC y URL del portal",
"METHOD_TEXT_SUB": "Para listas puntuales o cuando solo tienes texto"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Arrastre & suelte archivos aquí",
"OR": "o",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "Succès ! Les listes de lecture ont été mises à jour avec succès",
"AUTO_REFRESH_ENABLED": "Actualisation automatique activée"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Choisissez comment vous souhaitez vous connecter. Vous pourrez renommer la liste plus tard.",
"METHOD_URL_SUB": "Collez un lien vers un fichier .m3u ou .m3u8 distant",
"METHOD_FILE_SUB": "Téléversez une liste de lecture depuis votre ordinateur",
"METHOD_XTREAM_SUB": "Connectez-vous avec hôte, nom d'utilisateur et mot de passe",
"METHOD_STALKER_SUB": "Adresse MAC et URL du portail",
"METHOD_TEXT_SUB": "Pour les listes ponctuelles ou quand vous n'avez que du texte"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Glissez un fichier ici",
"OR": "ou",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "Le playlist sono state aggiornate correttamente",
"AUTO_REFRESH_ENABLED": "Aggiornamento automatico attivo"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Scegli come connetterti. Puoi rinominarla in seguito.",
"METHOD_URL_SUB": "Incolla un link a un file .m3u o .m3u8 remoto",
"METHOD_FILE_SUB": "Carica una playlist dal tuo computer",
"METHOD_XTREAM_SUB": "Connetti con host, nome utente e password",
"METHOD_STALKER_SUB": "Indirizzo MAC e URL del portale",
"METHOD_TEXT_SUB": "Per liste occasionali o quando hai solo il testo"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Trascina e rilascia qui i file",
"OR": "o",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "成功!プレイリストが正常に更新されました",
"AUTO_REFRESH_ENABLED": "自動更新が有効です"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "接続方法を選択してください。名前は後から変更できます。",
"METHOD_URL_SUB": "リモートの.m3uまたは.m3u8へのリンクを貼り付け",
"METHOD_FILE_SUB": "コンピューターからプレイリストをアップロード",
"METHOD_XTREAM_SUB": "ホスト、ユーザー名、パスワードで接続",
"METHOD_STALKER_SUB": "MACアドレスとポータルURL",
"METHOD_TEXT_SUB": "一度限りのリストやテキストしかない場合に"
},
"FILE_UPLOAD": {
"DRAG_DROP": "ここにファイルをドラッグ&ドロップ",
"OR": "または",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "성공! 재생목록이 성공적으로 업데이트되었습니다",
"AUTO_REFRESH_ENABLED": "자동 새로 고침 활성화됨"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "연결 방식을 선택하세요. 이름은 나중에 변경할 수 있습니다.",
"METHOD_URL_SUB": "원격 .m3u 또는 .m3u8 링크를 붙여넣으세요",
"METHOD_FILE_SUB": "내 컴퓨터에서 재생목록을 업로드하세요",
"METHOD_XTREAM_SUB": "호스트, 사용자 이름, 비밀번호로 연결하세요",
"METHOD_STALKER_SUB": "MAC 주소와 포털 URL",
"METHOD_TEXT_SUB": "일회성 목록이나 텍스트만 있을 때 사용하세요"
},
"FILE_UPLOAD": {
"DRAG_DROP": "파일을 드래그 앤 드롭",
"OR": "또는",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "Gelukt! De afspeellijsten zijn succesvol bijgewerkt",
"AUTO_REFRESH_ENABLED": "Automatisch vernieuwen ingeschakeld"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Kies hoe je verbinding wilt maken. Je kunt het later hernoemen.",
"METHOD_URL_SUB": "Plak een link naar een externe .m3u of .m3u8",
"METHOD_FILE_SUB": "Upload een afspeellijst vanaf je computer",
"METHOD_XTREAM_SUB": "Maak verbinding met host, gebruikersnaam en wachtwoord",
"METHOD_STALKER_SUB": "MAC-adres en portal-URL",
"METHOD_TEXT_SUB": "Voor eenmalige lijsten of als je alleen tekst hebt"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Sleep bestanden hierheen",
"OR": "of",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "Sukces! Listy odtwarzania zostały pomyślnie zaktualizowane",
"AUTO_REFRESH_ENABLED": "Automatyczne odświeżanie włączone"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Wybierz sposób połączenia. Nazwę można zmienić później.",
"METHOD_URL_SUB": "Wklej link do zdalnego pliku .m3u lub .m3u8",
"METHOD_FILE_SUB": "Prześlij listę odtwarzania z komputera",
"METHOD_XTREAM_SUB": "Połącz przy użyciu hosta, nazwy użytkownika i hasła",
"METHOD_STALKER_SUB": "Adres MAC i URL portalu",
"METHOD_TEXT_SUB": "Do jednorazowych list lub gdy dostępny jest tylko tekst"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Przeciągnij i upuść pliki tutaj",
"OR": "lub",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "Sucesso! As playlists foram atualizadas com sucesso",
"AUTO_REFRESH_ENABLED": "Atualização automática ativada"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Escolha como você quer se conectar. Você pode renomeá-la depois.",
"METHOD_URL_SUB": "Cole um link para um arquivo .m3u ou .m3u8 remoto",
"METHOD_FILE_SUB": "Envie uma playlist do seu computador",
"METHOD_XTREAM_SUB": "Conecte-se com host, nome de usuário e senha",
"METHOD_STALKER_SUB": "Endereço MAC e URL do portal",
"METHOD_TEXT_SUB": "Para listas pontuais ou quando você só tem o texto"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Arraste e solte os arquivos aqui",
"OR": "ou",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "Успешно! Плейлисты были успешно обновлены",
"AUTO_REFRESH_ENABLED": "Автообновление включено"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Выберите способ подключения. Переименовать плейлист можно позже.",
"METHOD_URL_SUB": "Вставьте ссылку на удалённый файл .m3u или .m3u8",
"METHOD_FILE_SUB": "Загрузите плейлист с вашего компьютера",
"METHOD_XTREAM_SUB": "Подключение через сервер, имя пользователя и пароль",
"METHOD_STALKER_SUB": "MAC-адрес и URL портала",
"METHOD_TEXT_SUB": "Для разовых плейлистов или когда есть только текст"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Перетащите файл в эту область",
"OR": "или",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "Başarılı! Oynatma listeleri başarıyla güncellendi",
"AUTO_REFRESH_ENABLED": "Otomatik yenileme etkin"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Nasıl bağlanmak istediğinizi seçin. Daha sonra yeniden adlandırabilirsiniz.",
"METHOD_URL_SUB": "Uzak bir .m3u veya .m3u8 bağlantısını yapıştırın",
"METHOD_FILE_SUB": "Bilgisayarınızdan bir oynatma listesi yükleyin",
"METHOD_XTREAM_SUB": "Sunucu, kullanıcı adı ve parola ile bağlanın",
"METHOD_STALKER_SUB": "MAC adresi ve portal URL'si",
"METHOD_TEXT_SUB": "Tek seferlik listeler veya yalnızca metniniz olduğunda"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Dosyaları buraya sürükleyip bırakın",
"OR": "veya",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "成功!播放列表已成功更新",
"AUTO_REFRESH_ENABLED": "已启用自动刷新"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "选择您想要的连接方式。稍后可以重命名。",
"METHOD_URL_SUB": "粘贴远程 .m3u 或 .m3u8 的链接",
"METHOD_FILE_SUB": "从您的电脑上传播放列表",
"METHOD_XTREAM_SUB": "使用主机、用户名和密码连接",
"METHOD_STALKER_SUB": "MAC 地址和门户 URL",
"METHOD_TEXT_SUB": "用于一次性列表,或仅有文本时"
},
"FILE_UPLOAD": {
"DRAG_DROP": "将文件拖放到此处",
"OR": "或",
+8
View File
@@ -128,6 +128,14 @@
"AUTO_REFRESH_UPDATE_SUCCESS": "成功!播放清單已成功更新",
"AUTO_REFRESH_ENABLED": "已啟用自動重新整理"
},
"ADD_PLAYLIST": {
"PICK_METHOD": "請選擇您要使用的連線方式,之後仍可重新命名。",
"METHOD_URL_SUB": "貼上遠端 .m3u 或 .m3u8 的連結",
"METHOD_FILE_SUB": "從您的電腦上傳播放清單",
"METHOD_XTREAM_SUB": "使用主機位址、帳號和密碼連線",
"METHOD_STALKER_SUB": "MAC 位址與入口網址",
"METHOD_TEXT_SUB": "適合一次性清單,或您只有文字內容時使用"
},
"FILE_UPLOAD": {
"DRAG_DROP": "在此處拖放檔案",
"OR": "或者",
@@ -1,49 +1,41 @@
<div class="apd">
<h2 mat-dialog-title>
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</h2>
<div mat-dialog-title class="apd-head">
<h2 class="apd-title">
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</h2>
<p class="apd-sub">
{{ 'HOME.ADD_PLAYLIST.PICK_METHOD' | translate }}
</p>
</div>
<mat-dialog-content>
<div
class="apd-segmented"
role="tablist"
aria-label="Playlist category"
class="apd-method-grid"
role="radiogroup"
aria-label="Source method"
>
@for (opt of categoryOptions; track opt.value) {
@for (opt of methodOptions; track opt.value) {
<button
type="button"
role="tab"
[attr.aria-selected]="category() === opt.value"
class="apd-segment"
[class.active]="category() === opt.value"
(click)="category.set(opt.value)"
role="radio"
[attr.aria-checked]="method() === opt.value"
class="apd-method"
[class.active]="method() === opt.value"
(click)="method.set(opt.value)"
>
{{ opt.label }}
<span class="apd-method__icon" aria-hidden="true">
<mat-icon>{{ opt.icon }}</mat-icon>
</span>
<span class="apd-method__title">
{{ opt.labelKey | translate }}
</span>
<span class="apd-method__sub">
{{ opt.subKey | translate }}
</span>
</button>
}
</div>
@if (category() === 'm3u') {
<div
class="apd-segmented apd-segmented--sub"
role="tablist"
aria-label="M3U source"
>
@for (opt of subtypeOptions; track opt.value) {
<button
type="button"
role="tab"
[attr.aria-selected]="m3uSubType() === opt.value"
class="apd-segment"
[class.active]="m3uSubType() === opt.value"
(click)="m3uSubType.set(opt.value)"
>
{{ opt.labelKey | translate }}
</button>
}
</div>
}
<div class="apd-form-area">
@switch (playlistType()) {
@case ('file') {
@@ -17,53 +17,92 @@
);
}
// ─── Segmented control ──────────────────────────────────────────────────────
.apd-segmented {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
background: var(--apd-segment-track);
border-radius: 12px;
padding: 4px;
gap: 2px;
margin-bottom: 14px;
&--sub {
background: color-mix(
in srgb,
var(--apd-segment-track) 85%,
transparent
);
}
// ─── Dialog head (title + helper subtitle) ──────────────────────────────────
.apd-head {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0 0 8px;
padding: 0;
}
.apd-segment {
appearance: none;
border: 0;
background: transparent;
color: var(--apd-body);
padding: 8px 14px;
border-radius: 9px;
font: inherit;
.apd-title {
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--apd-heading);
}
.apd-sub {
margin: 0;
font-size: 13px;
font-weight: 500;
color: var(--apd-body);
line-height: 1.45;
}
// ─── Method grid (5 flat cards instead of 2-level segmented control) ────────
// One card per source method. Replaces the prior 3×3 type×subtype matrix
// which had 9 cells of which only 5 were real (Xtream-via-file, etc. didn't
// exist). Each card is self-describing — icon + label + one-line sub.
.apd-method-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin: 4px 0 16px;
}
.apd-method {
appearance: none;
background: var(--apd-segment-track);
border: 1px solid var(--apd-border);
border-radius: 10px;
padding: 12px 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
line-height: 1.3;
text-align: left;
font: inherit;
color: var(--apd-heading);
min-width: 0;
transition:
background-color 160ms ease,
color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
&:hover:not(.active) {
color: var(--apd-heading);
background: var(--apd-segment-active);
border-color: color-mix(in srgb, var(--apd-accent) 32%, transparent);
.apd-method__icon {
background: color-mix(
in srgb,
var(--apd-accent) 14%,
transparent
);
color: var(--apd-accent);
}
}
&.active {
background: var(--apd-segment-active);
color: var(--apd-heading);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.25),
inset 0 0 0 1px color-mix(in srgb, white 6%, transparent);
background: color-mix(in srgb, var(--apd-accent) 12%, transparent);
border-color: var(--apd-accent);
box-shadow: 0 1px 2px
color-mix(in srgb, var(--apd-accent) 32%, transparent);
.apd-method__icon {
background: color-mix(
in srgb,
var(--apd-accent) 22%,
transparent
);
color: var(--apd-accent);
}
.apd-method__title {
color: var(--apd-accent);
}
}
&:focus-visible {
@@ -72,6 +111,59 @@
}
}
.apd-method__icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 8px;
background: color-mix(
in srgb,
var(--apd-surface-raised) 75%,
transparent
);
color: var(--apd-muted);
transition:
background-color 160ms ease,
color 160ms ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
}
.apd-method__title {
font-size: 13px;
font-weight: 600;
line-height: 1.25;
color: var(--apd-heading);
transition: color 160ms ease;
}
.apd-method__sub {
font-size: 11.5px;
color: var(--apd-muted);
line-height: 1.4;
}
// On narrow widths the 5-column grid wraps to 2 columns to stay legible.
// 480px keeps the dialog usable down to small modals; below that it stacks
// fully.
@media (max-width: 720px) {
.apd-method-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.apd-method-grid {
grid-template-columns: minmax(0, 1fr);
}
}
// ─── Form area ──────────────────────────────────────────────────────────────
.apd-form-area {
min-height: 0;
@@ -178,18 +178,55 @@ describe('AddPlaylistDialogComponent', () => {
expect(component.isClearDisabled()).toBeTruthy();
});
it('defaults to the URL method when no deep-link type is provided', () => {
// Constructor-injected MAT_DIALOG_DATA is null in this spec setup,
// so the dialog should land on its default first-row method.
expect(component.method()).toBe('url');
expect(component.playlistType()).toBe('url');
});
it.each([
['url' as PlaylistType, 'url' as PlaylistType],
['file' as PlaylistType, 'file' as PlaylistType],
['text' as PlaylistType, 'text' as PlaylistType],
['xtream' as PlaylistType, 'xtream' as PlaylistType],
['stalker' as PlaylistType, 'stalker' as PlaylistType],
])(
'opens directly on the %s method when MAT_DIALOG_DATA.type === %s (deep-link from rail / shell action)',
(deepLinkType, expectedMethod) => {
// Rebuild the component with a deep-link to confirm the
// flat-method signal honours the legacy { type } payload shape.
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{ provide: DataService, useValue: dataService },
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: Store, useValue: store },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: {
instant: jest.fn((value: string) => value),
},
},
{
provide: MAT_DIALOG_DATA,
useValue: { type: deepLinkType },
},
],
});
const deepLinked = TestBed.runInInjectionContext(
() => new AddPlaylistDialogComponent()
);
expect(deepLinked.method()).toBe(expectedMethod);
expect(deepLinked.playlistType()).toBe(expectedMethod);
}
);
function selectType(type: PlaylistType): void {
if (type === 'xtream') {
component.category.set('xtream');
return;
}
if (type === 'stalker') {
component.category.set('stalker');
return;
}
component.category.set('m3u');
component.m3uSubType.set(type);
// The dialog now uses a single `method` signal across all 5 source
// types — no more category × subtype matrix.
component.method.set(type);
}
});
@@ -12,6 +12,7 @@ import {
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
@@ -25,17 +26,16 @@ import { TextImportComponent } from '../text-import/text-import.component';
import { UrlUploadComponent } from '../url-upload/url-upload.component';
import { XtreamCodeImportComponent } from '../xtream-code-import/xtream-code-import.component';
type PlaylistCategory = 'm3u' | Extract<PlaylistType, 'xtream' | 'stalker'>;
type M3uSubType = Extract<PlaylistType, 'url' | 'file' | 'text'>;
interface CategoryOption {
value: PlaylistCategory;
label: string;
}
interface SubtypeOption {
value: M3uSubType;
/**
* Flat 5-method option model — replaces the prior category × subtype matrix
* (M3U/Xtream/Stalker × URL/File/Text) which created 9 combinations of which
* only 5 were real. Now each entry IS a method, no nesting.
*/
export interface PlaylistMethodOption {
value: PlaylistType;
icon: string;
labelKey: string;
subKey: string;
}
@Component({
@@ -43,6 +43,7 @@ interface SubtypeOption {
FileUploadComponent,
MatButtonModule,
MatDialogModule,
MatIcon,
StalkerPortalImportComponent,
TextImportComponent,
TranslateModule,
@@ -70,42 +71,53 @@ export class AddPlaylistDialogComponent {
readonly xtreamImport = viewChild(XtreamCodeImportComponent);
readonly stalkerImport = viewChild(StalkerPortalImportComponent);
readonly category = signal<PlaylistCategory>('m3u');
readonly m3uSubType = signal<M3uSubType>('url');
readonly method = signal<PlaylistType>('url');
readonly categoryOptions: CategoryOption[] = [
{ value: 'm3u', label: 'M3U' },
{ value: 'xtream', label: 'Xtream' },
{ value: 'stalker', label: 'Stalker' },
// Order matches the v0.22 mockup left-to-right: URL first (Most common),
// then File, Xtream credentials, Stalker portal, raw text paste. Each
// entry stands on its own — no nested subtypes.
readonly methodOptions: PlaylistMethodOption[] = [
{
value: 'url',
icon: 'public',
labelKey: 'HOME.TABS.URL_UPLOAD',
subKey: 'HOME.ADD_PLAYLIST.METHOD_URL_SUB',
},
{
value: 'file',
icon: 'folder_open',
labelKey: 'HOME.TABS.FILE_UPLOAD',
subKey: 'HOME.ADD_PLAYLIST.METHOD_FILE_SUB',
},
{
value: 'xtream',
icon: 'vpn_key',
labelKey: 'HOME.TABS.XTREME_IMPORT',
subKey: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_SUB',
},
{
value: 'stalker',
icon: 'cast',
labelKey: 'HOME.TABS.STALKER_PORTAL_IMPORT',
subKey: 'HOME.ADD_PLAYLIST.METHOD_STALKER_SUB',
},
{
value: 'text',
icon: 'subject',
labelKey: 'HOME.TABS.TEXT_IMPORT',
subKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_SUB',
},
];
readonly subtypeOptions: SubtypeOption[] = [
{ value: 'url', labelKey: 'HOME.TABS.URL_UPLOAD' },
{ value: 'file', labelKey: 'HOME.TABS.FILE_UPLOAD' },
{ value: 'text', labelKey: 'HOME.TABS.TEXT_IMPORT' },
];
readonly playlistType = computed<PlaylistType>(() => {
const cat = this.category();
if (cat === 'xtream') return 'xtream';
if (cat === 'stalker') return 'stalker';
return this.m3uSubType();
});
/**
* Backwards-compatible alias. The template's @switch and the action
* buttons key off this; keeping the name avoids churn in 5 case branches.
*/
readonly playlistType = computed<PlaylistType>(() => this.method());
constructor() {
if (this.data?.type) {
this.initFromType(this.data.type);
}
}
private initFromType(type: PlaylistType): void {
if (type === 'xtream') {
this.category.set('xtream');
} else if (type === 'stalker') {
this.category.set('stalker');
} else {
this.category.set('m3u');
this.m3uSubType.set(type as M3uSubType);
this.method.set(this.data.type);
}
}