feat(switcher): rebuild the playlist switcher as an overlay popover

The switcher's mat-menu becomes a CDK connected overlay
(app-playlist-switcher-panel), 440px wide, with a dimmed, blurred
backdrop; only its list scrolls and row menus open above it.

- Header with the source count, Add playlist and All sources.
- Search by name or host, autofocused; the type filter is one segmented
  control (All / M3U / Xtream / Stalker with counts) instead of three
  checkboxes, remembered between openings (the old checkbox setting is
  migrated). The search is no longer kept between openings.
- "Recent" (the three most recently opened, kept across restarts) above
  "All" in the Sources page order, as compact source rows: the current
  one tinted with a check. Each row's ⋯ menu holds Refresh, Playlist
  info, Account info, Check again and Remove (with Undo); the footer's
  info, account and add actions moved there and to the header.
- Keyboard: a listbox driven by aria-activedescendant from the search
  field. Arrow keys move and wrap, Enter opens, Shift+F10 opens the
  row's menu, Escape closes and returns focus to the trigger. Hints in
  the footer.
- Deleting from a row menu moves focus to the toast's Undo, since the
  row (and its focus) left the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 22:34:22 +02:00
1 parent 106414c118
commit 9feab4f83b
49 files changed
+1987 -1813

No files matched your search

@@ -245,44 +245,41 @@ test.describe('Electron Playlist Switcher', () => {
await waitForM3uCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(0);
await openPlaylistRecent(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-recent$/
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
@@ -319,7 +316,9 @@ test.describe('Electron Playlist Switcher', () => {
username: xtreamStressUsername,
});
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
const overlay = app.mainWindow.locator(
'.workspace-loading-overlay'
);
await expect(overlay).toBeVisible({ timeout: 15000 });
await waitForDbOperationEvent(app.mainWindow, {
operation: 'save-content',
@@ -428,17 +427,116 @@ test.describe('Electron Playlist Switcher', () => {
await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
);
await expect(
app.mainWindow.locator('.xtream-content-gate')
).toHaveCount(0);
app = await restartElectronApp(app, dataDir);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
await expect(
app.mainWindow.locator('.xtream-content-gate')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('runs from the keyboard: Cmd/Ctrl+P, filter, arrows, Enter and Escape', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: 'Switcher Local A',
url: 'https://streams.example.test/switcher-local-a.m3u8',
},
]);
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
{
groupTitle: 'Sports',
name: 'Switcher Local B',
url: 'https://streams.example.test/switcher-local-b.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await importM3uPlaylistFromNativeDialog(app, localBPath);
await addXtreamPortal(page, { name: xtreamAName });
await waitForXtreamCatalog(page);
const trigger = page.getByTestId('playlist-switcher-trigger');
const panel = page.locator('app-playlist-switcher-panel');
const search = panel.locator('input[role="combobox"]');
await expect(playlistSwitcherTitle(page)).toContainText(
xtreamAName
);
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
// No list shows a credential: the Xtream row names the account
// on purpose, never its password.
await expect(panel).not.toContainText(defaultXtreamPassword);
await expect(panel).not.toContainText('password=');
await page.keyboard.type('switcher-local');
await expect(switcherOptions(page)).toHaveCount(2);
// A search highlights its first match; ↓ moves on, ↑ wraps.
const options = switcherOptions(page);
const firstId = (await options.nth(0).getAttribute('id')) ?? '';
const secondId = (await options.nth(1).getAttribute('id')) ?? '';
await expect(search).toHaveAttribute(
'aria-activedescendant',
firstId
);
await page.keyboard.press('ArrowDown');
await expect(search).toHaveAttribute(
'aria-activedescendant',
secondId
);
await page.keyboard.press('ArrowDown');
await expect(search).toHaveAttribute(
'aria-activedescendant',
firstId
);
await page.keyboard.press('ArrowUp');
await expect(search).toHaveAttribute(
'aria-activedescendant',
secondId
);
const targetName =
(
await options
.nth(1)
.locator('.source-row__name')
.textContent()
)?.trim() ?? '';
await page.keyboard.press('Enter');
await expect(panel).toHaveCount(0);
await waitForM3uCatalog(page);
await expect(playlistSwitcherTitle(page)).toContainText(targetName);
// Escape closes and puts focus back on the trigger.
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
await openSources(page);
await expect(
page.locator('app-workspace-sources app-source-row')
).toHaveCount(3);
await expect(
page.locator('app-workspace-sources')
).not.toContainText(defaultXtreamPassword);
} finally {
await closeElectronApp(app);
}
@@ -446,12 +544,12 @@ test.describe('Electron Playlist Switcher', () => {
});
async function openPlaylistSwitcher(page: Page): Promise<void> {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.click();
await expect(
page.locator('.cdk-overlay-pane .playlist-item').first()
).toBeVisible();
await page.getByTestId('playlist-switcher-trigger').click();
await expect(switcherOptions(page).first()).toBeVisible();
}
function switcherOptions(page: Page) {
return page.locator('app-playlist-switcher-panel [role="option"]');
}
async function switchPlaylistFromHeader(
@@ -459,16 +557,11 @@ async function switchPlaylistFromHeader(
title: string
): Promise<void> {
await openPlaylistSwitcher(page);
const item = page
.locator('.cdk-overlay-pane .playlist-item')
.filter({ hasText: title })
.first();
const item = switcherOptions(page).filter({ hasText: title }).first();
await expect(item).toBeVisible();
await item.click();
await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount(
0
);
await expect(page.locator('app-playlist-switcher-panel')).toHaveCount(0);
}
async function expectPathname(page: Page, pattern: RegExp): Promise<void> {
@@ -503,16 +596,21 @@ async function selectSettingsOption(
}
async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
await expect(
page.getByTestId('xtream-offline-warning')
).toBeVisible({ timeout: 20000 });
await expect(page.getByTestId('xtream-offline-warning')).toBeVisible({
timeout: 20000,
});
}
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = channelItemByTitle(page, title).first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/);
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -175,4 +175,69 @@ test.describe('Electron Sources keyboard', () => {
await closeElectronApp(app);
}
});
test('@m3u @electron deletes from the row menu and undoes from the toast, keyboard only', async ({
dataDir,
}) => {
const alpha = writeTemporaryM3uFile(dataDir, 'undo-alpha.m3u', [
{
groupTitle: 'News',
name: 'Undo Alpha News',
url: 'https://streams.example.test/undo-alpha.m3u8',
},
]);
const bravo = writeTemporaryM3uFile(dataDir, 'undo-bravo.m3u', [
{
groupTitle: 'Sports',
name: 'Undo Bravo Sports',
url: 'https://streams.example.test/undo-bravo.m3u8',
},
]);
const app = await launchElectronApp(dataDir, {
appArgs: [alpha, bravo],
});
const page = app.mainWindow;
const rows = page.locator('app-workspace-sources app-source-row');
try {
await page.waitForURL(/\/workspace\/playlists\/.+/, {
timeout: 30_000,
});
await openSources(page);
await expect(rows).toHaveCount(2, { timeout: 30_000 });
const before = await rows
.locator('.source-row__name')
.allTextContents();
// Focus the first row's ⋯ and open its menu from the keyboard.
const row = rows.first();
await row.locator('.source-row__more').focus();
await page.keyboard.press('Enter');
const remove = page.getByRole('menuitem', {
name: 'Remove playlist',
});
await expect(remove).toBeVisible();
await page.keyboard.press('End');
await expect(remove).toBeFocused();
await page.keyboard.press('Enter');
// The row leaves at once; Undo takes the focus the row had.
await expect(rows).toHaveCount(1);
const undo = page.locator(
'mat-snack-bar-container.app-toast .source-removal-toast__undo'
);
await expect(undo).toBeFocused();
await page.keyboard.press('Enter');
await expect(rows).toHaveCount(2);
expect(
await rows.locator('.source-row__name').allTextContents()
).toEqual(before);
await expect(
page.locator('mat-snack-bar-container.app-toast')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});
+12 -3
View File
@@ -785,11 +785,20 @@ https://streams.example.test/original-url.m3u8
try {
await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath);
await waitForM3uCatalog(app.mainWindow);
// Refresh for the active source lives in its switcher row menu.
await app.mainWindow.getByTestId('playlist-switcher-trigger').click();
const currentRow = app.mainWindow.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"]'
);
await currentRow.locator('.source-row__more').click();
await expect(
app.mainWindow.locator(
'app-playlist-switcher .trigger-refresh-button'
)
app.mainWindow.getByRole('menuitem', { name: 'Refresh playlist' })
).toBeVisible();
await app.mainWindow.keyboard.press('Escape');
await app.mainWindow.keyboard.press('Escape');
await expect(
app.mainWindow.locator('app-playlist-switcher-panel')
).toHaveCount(0);
await goToDashboard(app.mainWindow);
const sourceCard = app.mainWindow
@@ -80,7 +80,7 @@ export async function importPlaylist(page: Page): Promise<string> {
.getByRole('button', { name: 'Add playlist', exact: true })
.click(),
]);
await expect(page.getByText('1 channels')).toBeVisible();
await expect(page.getByText('1 channel', { exact: false })).toBeVisible();
return page.url();
}
+7 -4
View File
@@ -182,12 +182,15 @@ test('@stalker account info dialog shows subscription facts from the portal', as
}) => {
await addStalkerPortal(page);
// Open the header playlist switcher and its "Account info" entry for
// the active stalker portal.
await page.locator('.playlist-switcher-trigger').click();
// Open the header playlist switcher and the "Account info" entry in the
// active stalker portal's row menu.
await page.locator('[data-test-id="playlist-switcher-trigger"]').click();
await page
.locator('.context-action-item', { hasText: 'Account info' })
.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more'
)
.click();
await page.getByRole('menuitem', { name: 'Account info' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
+4 -7
View File
@@ -61,18 +61,15 @@ async function openPlaylistDetailsDialog(page: Page, title: string) {
await playlistSettingsButton.click();
} else {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.locator('[data-test-id="playlist-switcher-trigger"]')
.click();
const switcherMenu = page.getByRole('menu').filter({ hasText: title });
const sourceRow = switcherMenu
.locator('.playlist-item')
const sourceRow = page
.locator('app-playlist-switcher-panel [role="option"]')
.filter({ hasText: title })
.first();
await expect(sourceRow).toBeVisible();
await sourceRow
.getByRole('button', { name: 'Playlist actions' })
.click();
await sourceRow.locator('.source-row__more').click();
await page.getByRole('menuitem', { name: 'Playlist info' }).click();
}
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "منتهي",
"EDIT": "تعديل…",
"COPY_URL": "نسخ الرابط",
"OPEN": "فتح"
"OPEN": "فتح",
"CHANNELS_ONE": "قناة واحدة"
},
"STATUS": "الحالة",
"STATUS_ANY": "أي حالة",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "إعادة تعيين عوامل التصفية",
"REMOVED": "تمت إزالة «{{name}}»",
"REMOVED_SWITCHED": "تمت إزالة «{{name}}»، والانتقال إلى «{{next}}»",
"REMOVE_FAILED": "تعذّرت إزالة «{{name}}»"
"REMOVE_FAILED": "تعذّرت إزالة «{{name}}»",
"ADD_SOURCE": "إضافة مصدر"
},
"CONTEXT": {
"CATEGORIES": "الفئات",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "أضف قائمة تشغيل وسيظهر سجل المشاهدة هنا.",
"NO_ITEMS_TITLE": "لا توجد مشاهدات بعد",
"NO_ITEMS_BODY": "ابدأ بمشاهدة أي محتوى من مكتبتك وسيظهر هنا."
},
"SWITCHER": {
"SEARCH": "ابحث عن قائمة تشغيل…",
"RECENT": "الأخيرة",
"NO_MATCHES": "لم يُعثر على شيء",
"ALL_SOURCES": "كل المصادر",
"HINT_SELECT": "تحديد",
"HINT_OPEN": "فتح",
"HINT_CLOSE": "إغلاق"
}
},
"LOADING": "جارٍ التحميل",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "سالات",
"EDIT": "بدّل…",
"COPY_URL": "نسخ الرابط",
"OPEN": "حلّ"
"OPEN": "حلّ",
"CHANNELS_ONE": "قناة وحدة"
},
"STATUS": "الحالة",
"STATUS_ANY": "أي حالة",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "رجّع الفلاتر",
"REMOVED": "تحيّدات «{{name}}»",
"REMOVED_SWITCHED": "تحيّدات «{{name}}»، دوّزنا لـ «{{next}}»",
"REMOVE_FAILED": "ما قدرناش نحيّدو «{{name}}»"
"REMOVE_FAILED": "ما قدرناش نحيّدو «{{name}}»",
"ADD_SOURCE": "زيد مصدر"
},
"CONTEXT": {
"CATEGORIES": "الفئات",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "زيد قائمة تشغيل وسجل التفرج ديالك غادي يبان هنا.",
"NO_ITEMS_TITLE": "ما تشاف حتى شي حاجة دابا",
"NO_ITEMS_BODY": "بدا تفرج فشي حاجة من المكتبة ديالك وغادي تبان هنا."
},
"SWITCHER": {
"SEARCH": "قلّب على ليستة…",
"RECENT": "الجداد",
"NO_MATCHES": "ما لقينا والو",
"ALL_SOURCES": "كاع المصادر",
"HINT_SELECT": "ختار",
"HINT_OPEN": "حلّ",
"HINT_CLOSE": "سدّ"
}
},
"LOADING": "كيتشارجا",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Скончыўся",
"EDIT": "Змяніць…",
"COPY_URL": "Скапіяваць URL",
"OPEN": "Адкрыць"
"OPEN": "Адкрыць",
"CHANNELS_ONE": "1 канал"
},
"STATUS": "Статус",
"STATUS_ANY": "Любы",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Скінуць фільтры",
"REMOVED": "Выдалены «{{name}}»",
"REMOVED_SWITCHED": "Выдалены «{{name}}», адкрыты «{{next}}»",
"REMOVE_FAILED": "Не ўдалося выдаліць «{{name}}»"
"REMOVE_FAILED": "Не ўдалося выдаліць «{{name}}»",
"ADD_SOURCE": "Дадаць крыніцу"
},
"CONTEXT": {
"CATEGORIES": "Катэгорыі",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Дадайце плэйліст, і гісторыя прагляду з’явіцца тут.",
"NO_ITEMS_TITLE": "Праглядаў пакуль няма",
"NO_ITEMS_BODY": "Пачніце глядзець што-небудзь з бібліятэкі, і гэта з’явіцца тут."
},
"SWITCHER": {
"SEARCH": "Знайсці плэйліст…",
"RECENT": "Нядаўнія",
"NO_MATCHES": "Нічога не знойдзена",
"ALL_SOURCES": "Усе крыніцы",
"HINT_SELECT": "выбар",
"HINT_OPEN": "адкрыць",
"HINT_CLOSE": "закрыць"
}
},
"LOADING": "Загрузка",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Abgelaufen",
"EDIT": "Bearbeiten…",
"COPY_URL": "URL kopieren",
"OPEN": "Öffnen"
"OPEN": "Öffnen",
"CHANNELS_ONE": "1 Sender"
},
"STATUS": "Status (Erreichbarkeit)",
"STATUS_ANY": "Alle",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Filter zurücksetzen",
"REMOVED": "„{{name}}“ entfernt",
"REMOVED_SWITCHED": "„{{name}}“ entfernt, zu „{{next}}“ gewechselt",
"REMOVE_FAILED": "„{{name}}“ konnte nicht entfernt werden"
"REMOVE_FAILED": "„{{name}}“ konnte nicht entfernt werden",
"ADD_SOURCE": "Quelle hinzufügen"
},
"CONTEXT": {
"CATEGORIES": "Kategorien",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Füge eine Wiedergabeliste hinzu, und dein Wiedergabeverlauf erscheint hier.",
"NO_ITEMS_TITLE": "Noch nichts angesehen",
"NO_ITEMS_BODY": "Beginne, etwas aus deiner Bibliothek anzusehen, dann erscheint es hier."
},
"SWITCHER": {
"SEARCH": "Playlist suchen…",
"RECENT": "Zuletzt geöffnet",
"NO_MATCHES": "Nichts gefunden",
"ALL_SOURCES": "Alle Quellen",
"HINT_SELECT": "auswählen",
"HINT_OPEN": "öffnen",
"HINT_CLOSE": "schließen"
}
},
"LOADING": "Laden",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Έληξε",
"EDIT": "Επεξεργασία…",
"COPY_URL": "Αντιγραφή URL",
"OPEN": "Άνοιγμα"
"OPEN": "Άνοιγμα",
"CHANNELS_ONE": "1 κανάλι"
},
"STATUS": "Κατάσταση",
"STATUS_ANY": "Οποιαδήποτε",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Επαναφορά φίλτρων",
"REMOVED": "Καταργήθηκε το «{{name}}»",
"REMOVED_SWITCHED": "Καταργήθηκε το «{{name}}», μετάβαση στο «{{next}}»",
"REMOVE_FAILED": "Δεν ήταν δυνατή η κατάργηση του «{{name}}»"
"REMOVE_FAILED": "Δεν ήταν δυνατή η κατάργηση του «{{name}}»",
"ADD_SOURCE": "Προσθήκη πηγής"
},
"CONTEXT": {
"CATEGORIES": "Κατηγορίες",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Προσθέστε μια λίστα αναπαραγωγής και το ιστορικό προβολής σας θα εμφανιστεί εδώ.",
"NO_ITEMS_TITLE": "Δεν έχει παρακολουθηθεί τίποτα ακόμη",
"NO_ITEMS_BODY": "Ξεκινήστε να παρακολουθείτε οτιδήποτε από τη βιβλιοθήκη σας και θα εμφανιστεί εδώ."
},
"SWITCHER": {
"SEARCH": "Εύρεση λίστας…",
"RECENT": "Πρόσφατα",
"NO_MATCHES": "Δεν βρέθηκε τίποτα",
"ALL_SOURCES": "Όλες οι πηγές",
"HINT_SELECT": "επιλογή",
"HINT_OPEN": "άνοιγμα",
"HINT_CLOSE": "κλείσιμο"
}
},
"LOADING": "Φόρτωση",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Expired",
"EDIT": "Edit…",
"COPY_URL": "Copy URL",
"OPEN": "Open"
"OPEN": "Open",
"CHANNELS_ONE": "1 channel"
},
"STATUS": "Status",
"STATUS_ANY": "Any",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Reset filters",
"REMOVED": "Removed “{{name}}”",
"REMOVED_SWITCHED": "Removed “{{name}}”, switched to “{{next}}”",
"REMOVE_FAILED": "Could not remove “{{name}}”"
"REMOVE_FAILED": "Could not remove “{{name}}”",
"ADD_SOURCE": "Add a source"
},
"CONTEXT": {
"CATEGORIES": "Categories",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Add a playlist and your watching history will appear here.",
"NO_ITEMS_TITLE": "Nothing watched yet",
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
},
"SWITCHER": {
"SEARCH": "Find a playlist…",
"RECENT": "Recent",
"NO_MATCHES": "Nothing found",
"ALL_SOURCES": "All sources",
"HINT_SELECT": "select",
"HINT_OPEN": "open",
"HINT_CLOSE": "close"
}
},
"LOADING": "Loading",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Caducada",
"EDIT": "Editar…",
"COPY_URL": "Copiar URL",
"OPEN": "Abrir"
"OPEN": "Abrir",
"CHANNELS_ONE": "1 canal"
},
"STATUS": "Estado",
"STATUS_ANY": "Cualquiera",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Restablecer filtros",
"REMOVED": "Se eliminó «{{name}}»",
"REMOVED_SWITCHED": "Se eliminó «{{name}}» y se cambió a «{{next}}»",
"REMOVE_FAILED": "No se pudo eliminar «{{name}}»"
"REMOVE_FAILED": "No se pudo eliminar «{{name}}»",
"ADD_SOURCE": "Añadir una fuente"
},
"CONTEXT": {
"CATEGORIES": "Categorías",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Añade una lista de reproducción y tu historial de visualización aparecerá aquí.",
"NO_ITEMS_TITLE": "Aún no has visto nada",
"NO_ITEMS_BODY": "Empieza a ver cualquier contenido de tu biblioteca y aparecerá aquí."
},
"SWITCHER": {
"SEARCH": "Buscar una lista…",
"RECENT": "Recientes",
"NO_MATCHES": "No se encontró nada",
"ALL_SOURCES": "Todas las fuentes",
"HINT_SELECT": "elegir",
"HINT_OPEN": "abrir",
"HINT_CLOSE": "cerrar"
}
},
"LOADING": "Cargando",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Expiré",
"EDIT": "Modifier…",
"COPY_URL": "Copier l’URL",
"OPEN": "Ouvrir"
"OPEN": "Ouvrir",
"CHANNELS_ONE": "1 chaîne"
},
"STATUS": "État",
"STATUS_ANY": "Tous",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Réinitialiser les filtres",
"REMOVED": "« {{name}} » supprimée",
"REMOVED_SWITCHED": "« {{name}} » supprimée, passage à « {{next}} »",
"REMOVE_FAILED": "Impossible de supprimer « {{name}} »"
"REMOVE_FAILED": "Impossible de supprimer « {{name}} »",
"ADD_SOURCE": "Ajouter une source"
},
"CONTEXT": {
"CATEGORIES": "Catégories",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Ajoutez une playlist et votre historique de visionnage apparaîtra ici.",
"NO_ITEMS_TITLE": "Rien regardé pour le moment",
"NO_ITEMS_BODY": "Commencez à regarder un contenu de votre bibliothèque et il apparaîtra ici."
},
"SWITCHER": {
"SEARCH": "Trouver une playlist…",
"RECENT": "Récentes",
"NO_MATCHES": "Aucun résultat",
"ALL_SOURCES": "Toutes les sources",
"HINT_SELECT": "choisir",
"HINT_OPEN": "ouvrir",
"HINT_CLOSE": "fermer"
}
},
"LOADING": "Chargement",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Lejárt",
"EDIT": "Szerkesztés…",
"COPY_URL": "URL másolása",
"OPEN": "Megnyitás"
"OPEN": "Megnyitás",
"CHANNELS_ONE": "1 csatorna"
},
"STATUS": "Állapot",
"STATUS_ANY": "Bármelyik",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Szűrők törlése",
"REMOVED": "„{{name}}” eltávolítva",
"REMOVED_SWITCHED": "„{{name}}” eltávolítva, váltás erre: „{{next}}”",
"REMOVE_FAILED": "„{{name}}” nem távolítható el"
"REMOVE_FAILED": "„{{name}}” nem távolítható el",
"ADD_SOURCE": "Forrás hozzáadása"
},
"CONTEXT": {
"CATEGORIES": "Kategóriák",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Adjon hozzá egy lejátszási listát, és megtekintési előzményei itt fognak megjelenni.",
"NO_ITEMS_TITLE": "Még nincs megtekintett tartalom",
"NO_ITEMS_BODY": "Indítson el bármit a médiatárából, és itt meg fog jelenni."
},
"SWITCHER": {
"SEARCH": "Lejátszási lista keresése…",
"RECENT": "Legutóbbiak",
"NO_MATCHES": "Nincs találat",
"ALL_SOURCES": "Összes forrás",
"HINT_SELECT": "kijelölés",
"HINT_OPEN": "megnyitás",
"HINT_CLOSE": "bezárás"
}
},
"LOADING": "Betöltés",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Scaduto",
"EDIT": "Modifica…",
"COPY_URL": "Copia URL",
"OPEN": "Apri"
"OPEN": "Apri",
"CHANNELS_ONE": "1 canale"
},
"STATUS": "Stato",
"STATUS_ANY": "Qualsiasi",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Reimposta filtri",
"REMOVED": "«{{name}}» rimossa",
"REMOVED_SWITCHED": "«{{name}}» rimossa, passaggio a «{{next}}»",
"REMOVE_FAILED": "Impossibile rimuovere «{{name}}»"
"REMOVE_FAILED": "Impossibile rimuovere «{{name}}»",
"ADD_SOURCE": "Aggiungi una fonte"
},
"CONTEXT": {
"CATEGORIES": "Categorie",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Aggiungi una playlist e la tua cronologia di visione apparirà qui.",
"NO_ITEMS_TITLE": "Ancora nessun contenuto guardato",
"NO_ITEMS_BODY": "Inizia a guardare qualsiasi contenuto della tua libreria e apparirà qui."
},
"SWITCHER": {
"SEARCH": "Trova una playlist…",
"RECENT": "Recenti",
"NO_MATCHES": "Nessun risultato",
"ALL_SOURCES": "Tutte le fonti",
"HINT_SELECT": "scegli",
"HINT_OPEN": "apri",
"HINT_CLOSE": "chiudi"
}
},
"LOADING": "Caricamento",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "期限切れ",
"EDIT": "編集…",
"COPY_URL": "URLをコピー",
"OPEN": "開く"
"OPEN": "開く",
"CHANNELS_ONE": "1 チャンネル"
},
"STATUS": "状態",
"STATUS_ANY": "すべて",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "フィルターをリセット",
"REMOVED": "「{{name}}」を削除しました",
"REMOVED_SWITCHED": "「{{name}}」を削除し、「{{next}}」に切り替えました",
"REMOVE_FAILED": "「{{name}}」を削除できませんでした"
"REMOVE_FAILED": "「{{name}}」を削除できませんでした",
"ADD_SOURCE": "ソースを追加"
},
"CONTEXT": {
"CATEGORIES": "カテゴリー",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "プレイリストを追加すると、視聴履歴がここに表示されます。",
"NO_ITEMS_TITLE": "視聴履歴はまだありません",
"NO_ITEMS_BODY": "ライブラリ内のコンテンツを視聴し始めると、ここに表示されます。"
},
"SWITCHER": {
"SEARCH": "プレイリストを検索…",
"RECENT": "最近",
"NO_MATCHES": "見つかりませんでした",
"ALL_SOURCES": "すべてのソース",
"HINT_SELECT": "選択",
"HINT_OPEN": "開く",
"HINT_CLOSE": "閉じる"
}
},
"LOADING": "読み込み中",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "만료됨",
"EDIT": "편집…",
"COPY_URL": "URL 복사",
"OPEN": "열기"
"OPEN": "열기",
"CHANNELS_ONE": "채널 1개"
},
"STATUS": "상태",
"STATUS_ANY": "전체",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "필터 초기화",
"REMOVED": "'{{name}}' 삭제됨",
"REMOVED_SWITCHED": "'{{name}}' 삭제됨, '{{next}}'(으)로 전환됨",
"REMOVE_FAILED": "'{{name}}'을(를) 삭제할 수 없습니다"
"REMOVE_FAILED": "'{{name}}'을(를) 삭제할 수 없습니다",
"ADD_SOURCE": "소스 추가"
},
"CONTEXT": {
"CATEGORIES": "카테고리",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "플레이리스트를 추가하면 시청 기록이 여기에 표시됩니다.",
"NO_ITEMS_TITLE": "아직 시청 기록이 없습니다",
"NO_ITEMS_BODY": "라이브러리의 콘텐츠를 시청하기 시작하면 여기에 표시됩니다."
},
"SWITCHER": {
"SEARCH": "재생목록 찾기…",
"RECENT": "최근",
"NO_MATCHES": "찾은 항목 없음",
"ALL_SOURCES": "모든 소스",
"HINT_SELECT": "선택",
"HINT_OPEN": "열기",
"HINT_CLOSE": "닫기"
}
},
"LOADING": "로드 중",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Verlopen",
"EDIT": "Bewerken…",
"COPY_URL": "URL kopiëren",
"OPEN": "Openen"
"OPEN": "Openen",
"CHANNELS_ONE": "1 zender"
},
"STATUS": "Bereikbaarheid",
"STATUS_ANY": "Alle",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Filters wissen",
"REMOVED": "‘{{name}}’ verwijderd",
"REMOVED_SWITCHED": "‘{{name}}’ verwijderd, overgeschakeld naar ‘{{next}}’",
"REMOVE_FAILED": "‘{{name}}’ kon niet worden verwijderd"
"REMOVE_FAILED": "‘{{name}}’ kon niet worden verwijderd",
"ADD_SOURCE": "Bron toevoegen"
},
"CONTEXT": {
"CATEGORIES": "Categorieën",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Voeg een afspeellijst toe en uw kijkgeschiedenis verschijnt hier.",
"NO_ITEMS_TITLE": "Nog niets bekeken",
"NO_ITEMS_BODY": "Begin iets uit uw bibliotheek te kijken en het verschijnt hier."
},
"SWITCHER": {
"SEARCH": "Afspeellijst zoeken…",
"RECENT": "Onlangs geopend",
"NO_MATCHES": "Niets gevonden",
"ALL_SOURCES": "Alle bronnen",
"HINT_SELECT": "kiezen",
"HINT_OPEN": "openen",
"HINT_CLOSE": "sluiten"
}
},
"LOADING": "Laden",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Wygasło",
"EDIT": "Edytuj…",
"COPY_URL": "Kopiuj URL",
"OPEN": "Otwórz"
"OPEN": "Otwórz",
"CHANNELS_ONE": "1 kanał"
},
"STATUS": "Stan",
"STATUS_ANY": "Dowolny",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Wyczyść filtry",
"REMOVED": "Usunięto „{{name}}”",
"REMOVED_SWITCHED": "Usunięto „{{name}}”, przełączono na „{{next}}”",
"REMOVE_FAILED": "Nie udało się usunąć „{{name}}”"
"REMOVE_FAILED": "Nie udało się usunąć „{{name}}”",
"ADD_SOURCE": "Dodaj źródło"
},
"CONTEXT": {
"CATEGORIES": "Kategorie",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Dodaj listę odtwarzania, a historia oglądania pojawi się tutaj.",
"NO_ITEMS_TITLE": "Jeszcze nic nie oglądano",
"NO_ITEMS_BODY": "Zacznij oglądać cokolwiek ze swojej biblioteki, a pojawi się tutaj."
},
"SWITCHER": {
"SEARCH": "Znajdź playlistę…",
"RECENT": "Ostatnie",
"NO_MATCHES": "Nic nie znaleziono",
"ALL_SOURCES": "Wszystkie źródła",
"HINT_SELECT": "wybór",
"HINT_OPEN": "otwórz",
"HINT_CLOSE": "zamknij"
}
},
"LOADING": "Ładowanie",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Expirada",
"EDIT": "Editar…",
"COPY_URL": "Copiar URL",
"OPEN": "Abrir"
"OPEN": "Abrir",
"CHANNELS_ONE": "1 canal"
},
"STATUS": "Estado",
"STATUS_ANY": "Qualquer",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Repor filtros",
"REMOVED": "«{{name}}» removida",
"REMOVED_SWITCHED": "«{{name}}» removida, mudou para «{{next}}»",
"REMOVE_FAILED": "Não foi possível remover «{{name}}»"
"REMOVE_FAILED": "Não foi possível remover «{{name}}»",
"ADD_SOURCE": "Adicionar uma fonte"
},
"CONTEXT": {
"CATEGORIES": "Categorias",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Adicione uma playlist e seu histórico de visualização aparecerá aqui.",
"NO_ITEMS_TITLE": "Nada assistido ainda",
"NO_ITEMS_BODY": "Comece a assistir qualquer item da sua biblioteca e ele aparecerá aqui."
},
"SWITCHER": {
"SEARCH": "Procurar uma lista…",
"RECENT": "Recentes",
"NO_MATCHES": "Nada encontrado",
"ALL_SOURCES": "Todas as fontes",
"HINT_SELECT": "escolher",
"HINT_OPEN": "abrir",
"HINT_CLOSE": "fechar"
}
},
"LOADING": "A carregar",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Истёк",
"EDIT": "Изменить…",
"COPY_URL": "Копировать URL",
"OPEN": "Открыть"
"OPEN": "Открыть",
"CHANNELS_ONE": "1 канал"
},
"STATUS": "Статус",
"STATUS_ANY": "Любой",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Сбросить фильтры",
"REMOVED": "Удалён «{{name}}»",
"REMOVED_SWITCHED": "Удалён «{{name}}», открыт «{{next}}»",
"REMOVE_FAILED": "Не удалось удалить «{{name}}»"
"REMOVE_FAILED": "Не удалось удалить «{{name}}»",
"ADD_SOURCE": "Добавить источник"
},
"CONTEXT": {
"CATEGORIES": "Категории",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Добавьте плейлист, и история просмотра появится здесь.",
"NO_ITEMS_TITLE": "Пока ничего не просмотрено",
"NO_ITEMS_BODY": "Начните смотреть любой контент из библиотеки, и он появится здесь."
},
"SWITCHER": {
"SEARCH": "Найти плейлист…",
"RECENT": "Недавние",
"NO_MATCHES": "Ничего не найдено",
"ALL_SOURCES": "Все источники",
"HINT_SELECT": "выбор",
"HINT_OPEN": "открыть",
"HINT_CLOSE": "закрыть"
}
},
"LOADING": "Загрузка",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "Süresi doldu",
"EDIT": "Düzenle…",
"COPY_URL": "URL'yi kopyala",
"OPEN": "Aç"
"OPEN": "Aç",
"CHANNELS_ONE": "1 kanal"
},
"STATUS": "Durum",
"STATUS_ANY": "Tümü",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "Filtreleri sıfırla",
"REMOVED": "“{{name}}” kaldırıldı",
"REMOVED_SWITCHED": "“{{name}}” kaldırıldı, “{{next}}” açıldı",
"REMOVE_FAILED": "“{{name}}” kaldırılamadı"
"REMOVE_FAILED": "“{{name}}” kaldırılamadı",
"ADD_SOURCE": "Kaynak ekle"
},
"CONTEXT": {
"CATEGORIES": "Kategoriler",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "Bir oynatma listesi ekleyin; izleme geçmişiniz burada görünecek.",
"NO_ITEMS_TITLE": "Henüz hiçbir şey izlenmedi",
"NO_ITEMS_BODY": "Kitaplığınızdaki herhangi bir şeyi izlemeye başlayın, burada görünecek."
},
"SWITCHER": {
"SEARCH": "Oynatma listesi bul…",
"RECENT": "Son açılanlar",
"NO_MATCHES": "Hiçbir şey bulunamadı",
"ALL_SOURCES": "Tüm kaynaklar",
"HINT_SELECT": "seç",
"HINT_OPEN": "aç",
"HINT_CLOSE": "kapat"
}
},
"LOADING": "Yükleniyor",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "已过期",
"EDIT": "编辑…",
"COPY_URL": "复制 URL",
"OPEN": "打开"
"OPEN": "打开",
"CHANNELS_ONE": "1 个频道"
},
"STATUS": "状态",
"STATUS_ANY": "全部",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "重置筛选",
"REMOVED": "已删除“{{name}}”",
"REMOVED_SWITCHED": "已删除“{{name}}”,已切换到“{{next}}”",
"REMOVE_FAILED": "无法删除“{{name}}”"
"REMOVE_FAILED": "无法删除“{{name}}”",
"ADD_SOURCE": "添加来源"
},
"CONTEXT": {
"CATEGORIES": "分类",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "添加播放列表后,您的观看历史会显示在这里。",
"NO_ITEMS_TITLE": "还没有观看记录",
"NO_ITEMS_BODY": "开始观看媒体库中的任意内容后,它会显示在这里。"
},
"SWITCHER": {
"SEARCH": "查找播放列表…",
"RECENT": "最近",
"NO_MATCHES": "未找到",
"ALL_SOURCES": "所有来源",
"HINT_SELECT": "选择",
"HINT_OPEN": "打开",
"HINT_CLOSE": "关闭"
}
},
"LOADING": "加载中",
+13 -2
View File
@@ -1917,7 +1917,8 @@
"EXPIRED": "已過期",
"EDIT": "編輯…",
"COPY_URL": "複製 URL",
"OPEN": "開啟"
"OPEN": "開啟",
"CHANNELS_ONE": "1 個頻道"
},
"STATUS": "狀態",
"STATUS_ANY": "全部",
@@ -1927,7 +1928,8 @@
"RESET_FILTERS": "重設篩選",
"REMOVED": "已移除「{{name}}」",
"REMOVED_SWITCHED": "已移除「{{name}}」,已切換到「{{next}}」",
"REMOVE_FAILED": "無法移除「{{name}}」"
"REMOVE_FAILED": "無法移除「{{name}}」",
"ADD_SOURCE": "新增來源"
},
"CONTEXT": {
"CATEGORIES": "類別",
@@ -2020,6 +2022,15 @@
"NO_PLAYLISTS_BODY": "新增播放清單後,您的觀看歷史會顯示在這裡。",
"NO_ITEMS_TITLE": "還沒有觀看記錄",
"NO_ITEMS_BODY": "開始觀看媒體庫中的任意內容後,它會顯示在這裡。"
},
"SWITCHER": {
"SEARCH": "尋找播放清單…",
"RECENT": "最近",
"NO_MATCHES": "找不到",
"ALL_SOURCES": "所有來源",
"HINT_SELECT": "選擇",
"HINT_OPEN": "開啟",
"HINT_CLOSE": "關閉"
}
},
"LOADING": "載入中",