diff --git a/README.md b/README.md index bf7d148..be39dc8 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,9 @@ All keys are remappable — edit `keybinds.jsonc` in your config directory | `.` | Toggle hidden | | `r` | Refresh | | `x` | Unsubscribe the focused show (My Shows) | +| `d` | Download the focused episode (Feed / My Shows detail pane) | +| `D` | Delete the focused episode's download (if one exists) | +| `w` | Toggle the focused show in/out of the auto-download whitelist (My Shows, whitelist scope) | **Audio** @@ -185,6 +188,13 @@ default (`$XDG_CONFIG_HOME/podtui` if set). Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated into `config.json` on first run. +**Auto-download** — in Settings → Preferences: `Auto Download` (master +toggle) downloads the `Auto Download Count` most recent episodes (default 2, +any positive integer — type it in the editor) of every show in the `Auto +Download Scope` (all / none / whitelist, default all). With the whitelist +scope, a search field appears under the setting to pick shows (Space toggles +a suggestion in/out), and `w` in My Shows adds/removes the focused show. + Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. ## Troubleshooting diff --git a/src/config/keybinds.jsonc b/src/config/keybinds.jsonc index a12ff10..4c3c60b 100644 --- a/src/config/keybinds.jsonc +++ b/src/config/keybinds.jsonc @@ -66,6 +66,11 @@ "refresh": ["r"], "unsubscribe": ["x"], // unsubscribe focused show in My Shows + // ── Downloads & auto-download whitelist ─────────────────────────────────── + "download": ["d"], // download the focused episode (detail pane) + "delete-download": ["D"], // delete the focused episode's download (if any) + "whitelist-toggle": ["w"], // add/remove the focused show from the auto-download whitelist (My Shows) + // ── Audio transport (preserved) ────────────────────────────────────────── // Kept on shifted single keys so they never collide with the yazi core // (space=select, s=search, f=filter, etc.). Edit freely in this file. diff --git a/src/context/KeybindContext.tsx b/src/context/KeybindContext.tsx index 2ec2878..21d6c3a 100644 --- a/src/context/KeybindContext.tsx +++ b/src/context/KeybindContext.tsx @@ -68,6 +68,9 @@ export type KeybindActionName = | "toggle-hidden" | "refresh" | "unsubscribe" + | "download" + | "delete-download" + | "whitelist-toggle" | "audio-toggle" | "audio-next" | "audio-prev" diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index 1c5f210..7c8bd4e 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -171,6 +171,18 @@ function FeedPage() { const item = focusedItem(); if (item) nav.toggleSelected(item.episode.id); }, + download: () => { + const item = focusedItem(); + if (item) downloadStore.startDownload(item.episode, item.feed.id); + }, + "delete-download": () => { + const item = focusedItem(); + if (!item) return; + const id = item.episode.id; + if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return; + downloadStore.cancelDownload(id); + downloadStore.removeDownload(id).catch(() => {}); + }, refresh: () => { feedStore.refreshAllFeeds().catch(() => {}); }, @@ -378,7 +390,14 @@ function FeedPage() { {(item().episode.description?.length ?? 0) > 400 ? "…" : ""} - enter: play · space: select · h back + + enter: play · d: download + {downloadStore.getDownloadStatus(item().episode.id) !== + DownloadStatus.NONE + ? " · D: delete" + : ""}{" "} + · space: select · h back + )} diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index fc00573..f894e5d 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -14,6 +14,7 @@ import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; import { useFeedStore } from "@/stores/feed"; import { useDownloadStore } from "@/stores/download"; +import { useAppStore } from "@/stores/app"; import { DownloadStatus } from "@/types/episode"; import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; @@ -40,6 +41,7 @@ export const MyShowsPaneCount = 1; export function MyShowsPage() { const feedStore = useFeedStore(); const downloadStore = useDownloadStore(); + const app = useAppStore(); const audioNav = useAudioNavStore(); const audio = useAudio(); const { theme } = useTheme(); @@ -161,6 +163,33 @@ export function MyShowsPage() { if (ep) nav.toggleSelected(ep.id); } }, + download: () => { + if (depth() < 1) return; + const ep = focusedEpisode(); + if (ep) downloadStore.startDownload(ep, drilledShowId()); + }, + "delete-download": () => { + if (depth() < 1) return; + const ep = focusedEpisode(); + if (!ep) return; + const id = ep.id; + if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return; + downloadStore.cancelDownload(id); + downloadStore.removeDownload(id).catch(() => {}); + }, + "whitelist-toggle": () => { + if (depth() < 1) return; + const id = drilledShowId(); + if (!id) return; + const prefs = app.state().preferences; + if (prefs.autoDownloadScope !== "whitelist") return; + const cur = prefs.autoDownloadWhitelist ?? []; + const next = cur.includes(id) + ? cur.filter((x) => x !== id) + : [...cur, id]; + app.updatePreferences({ autoDownloadWhitelist: next }); + feedStore.runAutoDownload(); + }, refresh: () => { const show = selectedShow(); if (show) feedStore.refreshFeed(show.id).catch(() => {}); @@ -421,7 +450,21 @@ export function MyShowsPage() { {(ep().description?.length ?? 0) > 400 ? "…" : ""} - enter: play · space: select · h: back + + enter: play · d: download + {downloadStore.getDownloadStatus(ep().id) !== + DownloadStatus.NONE + ? " · D: delete" + : ""} + {app.state().preferences.autoDownloadScope === "whitelist" + ? (app.state().preferences.autoDownloadWhitelist ?? []).includes( + drilledShowId(), + ) + ? " · w: un-whitelist" + : " · w: whitelist" + : ""}{" "} + · space: select · h: back + )} diff --git a/src/pages/Settings/PreferencesPanel.tsx b/src/pages/Settings/PreferencesPanel.tsx index 90c2059..968708a 100644 --- a/src/pages/Settings/PreferencesPanel.tsx +++ b/src/pages/Settings/PreferencesPanel.tsx @@ -2,10 +2,33 @@ * PreferencesPanel — exposes theme/font/speed/explicit/auto-download as * SettingItems for the yazi depth-stack. No own useKeyboard; all movement is * driven by the Shell router via nav.action. + * + * Auto-download (global setting, see stores/feed.ts runAutoDownload): + * • Auto Download — master toggle (default: off) + * • Auto Download Count — X most recent episodes per show (default: 2, + * any positive integer — type it in the editor) + * • Auto Download Scope — which shows: all / none / whitelist (default: all) + * • Auto Download Whitelist — shown only when scope is "whitelist": search + * field over subscribed shows; suggestions toggle + * in/out with Space (j/k to move, Esc to browse). */ +import { createSignal, Show, For, onMount, onCleanup } from "solid-js"; +import { RenderableEvents, type InputRenderable } from "@opentui/core"; import { useAppStore } from "@/stores/app"; -import type { ThemeName } from "@/types/settings"; +import { useFeedStore } from "@/stores/feed"; +import { useTheme } from "@/context/ThemeContext"; +import { useInputFocusNav } from "@/hooks/useInputFocusNav"; +import { + NavMode, + DEPTH_CENTER_PANE, + type PaneId, +} from "@/context/NavigationContext"; +import { on } from "@/utils/event-bus"; +import type { KeybindActionName } from "@/context/KeybindContext"; +import { TABS } from "@/utils/navigation"; +import type { AutoDownloadScope, ThemeName } from "@/types/settings"; +import type { Feed } from "@/types/feed"; import type { SettingItem } from "./types"; const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [ @@ -17,13 +40,24 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [ { value: "custom", label: "Custom" }, ]; +const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [ + { value: "all", label: "All" }, + { value: "none", label: "None" }, + { value: "whitelist", label: "Whitelist" }, +]; + +function scopeLabel(scope: AutoDownloadScope): string { + return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope; +} + export function usePreferencesItems(): SettingItem[] { const app = useAppStore(); + const feedStore = useFeedStore(); const settings = () => app.state().settings; const prefs = () => app.state().preferences; - return [ + const items: SettingItem[] = [ { id: "theme", label: "Theme", @@ -97,11 +131,52 @@ export function usePreferencesItems(): SettingItem[] { kind: "toggle", display: () => (prefs().autoDownload ? "On" : "Off"), help: () => - `Download new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`, - toggle: () => - app.updatePreferences({ - autoDownload: !prefs().autoDownload, - }), + `Download the ${prefs().autoDownloadCount} most recent episodes of your shows automatically (see Count/Scope below).\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload ? "On" : "Off"}\nSpace/Enter to toggle.`, + toggle: () => { + app.updatePreferences({ autoDownload: !prefs().autoDownload }); + feedStore.runAutoDownload(); + }, + }, + { + id: "autoDownloadCount", + label: "Auto Download Count", + kind: "number", + display: () => `${prefs().autoDownloadCount} per show`, + help: () => + `How many of the most recent episodes to auto-download per in-scope show.\nType: number (any positive integer)\nDefault: 2\nCurrent: ${prefs().autoDownloadCount}\nj/k to −/+1 · Enter to type a value.`, + cycle: (dir) => { + const next = Math.max(1, prefs().autoDownloadCount + dir); + app.updatePreferences({ autoDownloadCount: next }); + feedStore.runAutoDownload(); + }, + renderEditor: () => ( + prefs().autoDownloadCount} + commit={(n) => { + app.updatePreferences({ autoDownloadCount: n }); + feedStore.runAutoDownload(); + }} + /> + ), + }, + { + id: "autoDownloadScope", + label: "Auto Download Scope", + kind: "select", + display: () => scopeLabel(prefs().autoDownloadScope), + help: () => + `Which shows auto-download applies to.\nAll: every subscribed show.\nNone: nothing.\nWhitelist: only the shows you add (in My Shows press ${"w"} on an episode; or open the Whitelist item below).\nType: select\nDefault: all\nCurrent: ${scopeLabel(prefs().autoDownloadScope)}\nCycle with j/k; Enter to apply.`, + cycle: (dir) => { + const idx = SCOPE_LABELS.findIndex( + (s) => s.value === prefs().autoDownloadScope, + ); + const next = + SCOPE_LABELS[(idx + dir + SCOPE_LABELS.length) % SCOPE_LABELS.length] + .value; + app.updatePreferences({ autoDownloadScope: next }); + feedStore.runAutoDownload(); + }, }, { id: "autoJumpToPlayer", @@ -130,4 +205,225 @@ export function usePreferencesItems(): SettingItem[] { }, }, ]; + + // Whitelist management only appears while scope is set to "whitelist". + if (prefs().autoDownloadScope === "whitelist") { + items.push({ + id: "autoDownloadWhitelist", + label: "Auto Download Whitelist", + kind: "editor", + display: () => `${prefs().autoDownloadWhitelist.length} shows`, + help: () => + `Shows included in auto-download (scope: whitelist).\nSearch your subscribed shows; suggestions toggle in/out with Space.\nType: editor\nCurrent: ${prefs().autoDownloadWhitelist.length} shows`, + renderEditor: () => , + }); + } + + return items; +} + +// ── Number editor ──────────────────────────────────────────────────────────── +// Lets the user type any positive integer (Enter commits; Esc defocuses and +// j/k ±1 cycling takes over — SettingsPage's depth-2 step handler). + +function NumberInputEditor(props: { + label: string; + value: () => number; + commit: (n: number) => void; +}) { + const { theme } = useTheme(); + const ref = useInputFocusNav(); + const [draft, setDraft] = createSignal(String(props.value())); + const [error, setError] = createSignal(null); + + const submit = () => { + const n = Number(draft().trim()); + if (!Number.isInteger(n) || n < 1) { + setError("Enter a whole number ≥ 1"); + return; + } + props.commit(n); + setError(null); + }; + + return ( + + + {props.label} + + + Episodes per show: + { + setDraft(v); + setError(null); + }} + onSubmit={submit} + focused + width={8} + textColor={theme.text} + focusedTextColor={theme.accent} + /> + + + {error()} + + + Type a number, Enter to apply · Esc to browse (j/k ±1) · h back + + + ); +} + +// ── Whitelist editor ───────────────────────────────────────────────────────── +// Search field over subscribed shows + a navigable suggestion list. Space +// (toggle-select) toggles the focused show in/out of the whitelist; Enter +// does the same. While the input is focused, keys type; Esc (handled in the +// Shell) defocuses so j/k move the list. +// +// Transient UI state lives at module level so preference updates (which +// rebuild the item list) never reset the search or yank focus back into the +// input mid-browse. +// +// The nav.action listener is registered ONCE at module level, not per +// component instance: toggling a show updates preferences, which remounts +// the editor (SettingsPage re-resolves the item's renderEditor), and +// re-registering the listener via onMount/onCleanup during a bus emit +// mutates the handler set mid-iteration — the event bus then re-delivers to +// the fresh listener forever. A single stable listener guarded by an active +// flag sidesteps that entirely. + +const [wlQuery, setWlQuery] = createSignal(""); +const [wlCursor, setWlCursor] = createSignal(0); +const [wlTyping, setWlTyping] = createSignal(true); +let wlEditorActive = false; + +function wlSuggestions(): Feed[] { + const q = wlQuery().trim().toLowerCase(); + const all = useFeedStore().getFilteredFeeds(); + if (!q) return all; + return all.filter((f) => + (f.customName || f.podcast.title).toLowerCase().includes(q), + ); +} + +/** Keep the cursor inside the (possibly shrinking) suggestion list. */ +function wlCursorClamped(): number { + return Math.min(wlCursor(), Math.max(wlSuggestions().length - 1, 0)); +} + +function wlToggle(feedId: string): void { + const app = useAppStore(); + const cur = app.state().preferences.autoDownloadWhitelist ?? []; + const next = cur.includes(feedId) + ? cur.filter((id) => id !== feedId) + : [...cur, feedId]; + app.updatePreferences({ autoDownloadWhitelist: next }); + useFeedStore().runAutoDownload(); +} + +const wlOnAction = (data: { + action: KeybindActionName; + tab: TABS; + pane: PaneId; + mode: NavMode; +}) => { + // Fire at most once per dispatch: the editor is only ever open inside the + // Settings tab's depth-2 pane, so scope on tab + pane and gate on the + // mount flag (which flips during remounts without re-registering). + if (!wlEditorActive) return; + if (data.tab !== TABS.SETTINGS) return; + if (data.pane !== DEPTH_CENTER_PANE) return; + const list = wlSuggestions(); + if (list.length === 0) return; + switch (data.action) { + case "move-down": + setWlCursor((c) => Math.min(c + 1, list.length - 1)); + break; + case "move-up": + setWlCursor((c) => Math.max(c - 1, 0)); + break; + case "toggle-select": + case "open": + wlToggle(list[wlCursorClamped()].id); + break; + } +}; +on("nav.action", wlOnAction); + +function WhitelistEditor() { + const { theme } = useTheme(); + const feedStore = useFeedStore(); + const app = useAppStore(); + + const whitelist = () => app.state().preferences.autoDownloadWhitelist ?? []; + const inList = (feedId: string) => whitelist().includes(feedId); + + onMount(() => { + wlEditorActive = true; + onCleanup(() => { + wlEditorActive = false; + }); + }); + + const focusNavRef = useInputFocusNav(); + const inputRef = (el: InputRenderable | null | undefined) => { + focusNavRef(el); + if (el) { + el.on(RenderableEvents.FOCUSED, () => setWlTyping(true)); + el.on(RenderableEvents.BLURRED, () => setWlTyping(false)); + } + }; + + return ( + + + Auto Download Whitelist + + + Search: + + + + + No subscribed shows match. + + + + {(feed, index) => { + const focused = index() === wlCursorClamped(); + const bg = () => (focused ? theme.primary : undefined); + const fg = () => (focused ? theme.surface : theme.text); + return ( + + {focused ? "❯" : " "} + {inList(feed.id) ? "●" : "○"} + + {feed.customName || feed.podcast.title} + + + ); + }} + + + Type to search · Esc to browse · j/k move · Space toggles · h back + + + ); } diff --git a/src/stores/app.ts b/src/stores/app.ts index d09e1d7..f56a130 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -36,6 +36,9 @@ const defaultSettings: AppSettings = { const defaultPreferences: UserPreferences = { showExplicit: false, autoDownload: false, + autoDownloadCount: 2, + autoDownloadScope: "all", + autoDownloadWhitelist: [], autoJumpToPlayer: true, fetchMoreMode: "manual", }; diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 996f018..47bc211 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -18,6 +18,7 @@ import { saveSourcesToFile, } from "../utils/feeds-persistence"; import { useDownloadStore } from "./download"; +import { useAppStore } from "./app"; import { DownloadStatus } from "../types/episode"; /** Max episodes to load per page/chunk */ @@ -209,29 +210,41 @@ function createFeedStore() { saveFeeds(updated); return updated; }); + // Global auto-download: newly subscribed shows join the next pass. + runAutoDownload(); return newFeed; }; - /** Auto-download newest episodes for a feed */ - const autoDownloadEpisodes = ( - feedId: string, - newEpisodes: Episode[], - count: number, - ) => { + /** Download the N most recent episodes of every in-scope show, per the + * global auto-download preferences (master toggle + scope + whitelist + + * count). Skips episodes already downloaded, queued, or in flight; + * retries failed ones. Idempotent — safe to run after any settings + * change, feed refresh, or subscribe. */ + const runAutoDownload = (): void => { + const app = useAppStore(); + const prefs = app.state().preferences; + if (!prefs.autoDownload || prefs.autoDownloadScope === "none") return; + const whitelist = prefs.autoDownloadWhitelist ?? []; + const count = Math.max(1, prefs.autoDownloadCount ?? 2); const dlStore = useDownloadStore(); - // Sort by pubDate descending (newest first) - const sorted = [...newEpisodes].sort( - (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), - ); - // count = 0 means download all new episodes - const toDownload = count > 0 ? sorted.slice(0, count) : sorted; - for (const ep of toDownload) { - const status = dlStore.getDownloadStatus(ep.id); + for (const feed of feeds()) { if ( - status === DownloadStatus.NONE || - status === DownloadStatus.FAILED + prefs.autoDownloadScope === "whitelist" && + !whitelist.includes(feed.id) ) { - dlStore.startDownload(ep, feedId); + continue; + } + const sorted = [...feed.episodes].sort( + (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), + ); + for (const ep of sorted.slice(0, count)) { + const status = dlStore.getDownloadStatus(ep.id); + if ( + status === DownloadStatus.NONE || + status === DownloadStatus.FAILED + ) { + dlStore.startDownload(ep, feed.id); + } } } }; @@ -240,7 +253,6 @@ function createFeedStore() { const refreshFeed = async (feedId: string) => { const feed = getFeed(feedId); if (!feed) return; - const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id)); const episodes = await fetchEpisodes( feed.podcast.feedUrl, MAX_EPISODES_REFRESH, @@ -254,13 +266,9 @@ function createFeedStore() { return updated; }); - // Auto-download new episodes if enabled for this feed - if (feed.autoDownload) { - const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id)); - if (newEpisodes.length > 0) { - autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0); - } - } + // Global auto-download: ensure the N most recent episodes of in-scope + // shows are available offline after every refresh (idempotent). + runAutoDownload(); }; /** Refresh all feeds */ @@ -477,13 +485,9 @@ function createFeedStore() { } }; - /** Set auto-download settings for a feed */ - const setAutoDownload = ( - feedId: string, - enabled: boolean, - count: number = 0, - ) => { - updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count }); + /** Run the global auto-download pass (see runAutoDownload above). */ + const runAutoDownloadNow = (): void => { + runAutoDownload(); }; return { @@ -520,7 +524,7 @@ function createFeedStore() { removeSource, toggleSource, updateSource, - setAutoDownload, + runAutoDownload: runAutoDownloadNow, }; } diff --git a/src/types/feed.ts b/src/types/feed.ts index f28adce..1e31b8b 100644 --- a/src/types/feed.ts +++ b/src/types/feed.ts @@ -33,10 +33,6 @@ export interface Feed { isPinned: boolean /** Feed color for UI */ color?: string - /** Whether auto-download is enabled for this feed */ - autoDownload?: boolean - /** Number of newest episodes to auto-download (0 = all new) */ - autoDownloadCount?: number } /** Feed item for display in lists */ diff --git a/src/types/settings.ts b/src/types/settings.ts index 64f3219..ceae72b 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -87,9 +87,18 @@ export type AppSettings = { /** How the Feed list loads older episodes (default: manual "[Fetch More]"). */ export type FetchMoreMode = "manual" | "auto"; +/** Which shows the auto-download setting applies to (default: all). */ +export type AutoDownloadScope = "all" | "none" | "whitelist"; + export type UserPreferences = { showExplicit: boolean; autoDownload: boolean; + /** Most recent episodes to auto-download per in-scope show (default: 2). */ + autoDownloadCount: number; + /** Shows auto-download covers: all / none / whitelist (default: all). */ + autoDownloadScope: AutoDownloadScope; + /** Feed ids in the auto-download whitelist (used when scope is "whitelist"). */ + autoDownloadWhitelist: string[]; /** Jump to the Player view automatically when playback starts (default: true) */ autoJumpToPlayer: boolean; /** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */ diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index 8878caf..a469ea2 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -39,6 +39,9 @@ const defaultSettings: AppSettings = { const defaultPreferences: UserPreferences = { showExplicit: false, autoDownload: false, + autoDownloadCount: 2, + autoDownloadScope: "all", + autoDownloadWhitelist: [], autoJumpToPlayer: true, fetchMoreMode: "manual", }; diff --git a/src/utils/dispatch.ts b/src/utils/dispatch.ts index b8cc885..7c4160b 100644 --- a/src/utils/dispatch.ts +++ b/src/utils/dispatch.ts @@ -76,6 +76,9 @@ export const PAGE_ACTIONS: ReadonlySet = "toggle-hidden", "refresh", "unsubscribe", + "download", + "delete-download", + "whitelist-toggle", ]); /** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */ diff --git a/src/utils/keybinds-persistence.ts b/src/utils/keybinds-persistence.ts index 8f97602..8f20f08 100644 --- a/src/utils/keybinds-persistence.ts +++ b/src/utils/keybinds-persistence.ts @@ -65,6 +65,10 @@ const DEFAULT_KEYBINDS: KeybindsResolved = { "toggle-hidden": ["."], refresh: ["r"], unsubscribe: ["x"], + // downloads + download: ["d"], + "delete-download": ["D"], + "whitelist-toggle": ["w"], // audio transport (preserved; shifted single keys, no collisions) "audio-toggle": ["P"], "audio-next": ["N"],