diff --git a/src/api/rss-parser.ts b/src/api/rss-parser.ts index ab846a8..c7fa0dd 100644 --- a/src/api/rss-parser.ts +++ b/src/api/rss-parser.ts @@ -74,21 +74,21 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => { return undefined } -/** Extract the `` blocks from an RSS document (the sync part of - * parsing is bounded to this single regex pass). Exported so the feed store - * can parse episodes incrementally without re-deriving item boundaries. */ +/** Extract the `` blocks from an RSS document. Matches items directly + * on the full XML string — scoping to first is a redundant 5MB + * regex pass that doubles parse cost with no practical benefit (well-formed + * RSS has no items outside ). */ export const getRSSItems = (xml: string): string[] => { - const channel = xml.match(//i)?.[0] ?? xml - return channel.match(//gi) ?? [] + return xml.match(//gi) ?? [] } /** Channel-level artwork: `` (podcasts) or RSS 2.0 - * ``. Exported so the feed store can backfill a feed's - * coverUrl on refresh without re-deriving the channel block. */ -export const parseChannelCoverUrl = (channel: string): string | undefined => { - const itunesHref = getAttr(channel, "itunes:image", "href") + * ``. Works on the full XML — channel-level tags precede + * blocks in RSS, so the first match is the channel image. */ +export const parseChannelCoverUrl = (xml: string): string | undefined => { + const itunesHref = getAttr(xml, "itunes:image", "href") if (itunesHref) return itunesHref - const url = getTagValue(channel, "image").match(/([\s\S]*?)<\/url>/i)?.[1] + const url = getTagValue(xml, "image").match(/([\s\S]*?)<\/url>/i)?.[1] return url?.trim() || undefined } diff --git a/src/pages/Player/ProgressBar.tsx b/src/pages/Player/ProgressBar.tsx index 407808a..55e8341 100644 --- a/src/pages/Player/ProgressBar.tsx +++ b/src/pages/Player/ProgressBar.tsx @@ -45,6 +45,10 @@ export function ProgressBar() { padding={0} flexDirection="row" gap={0} + // The bar's block-char texts are non-selectable below: a drag + // over the bar is a seek gesture, not a text selection — otherwise + // mouse-up would copy █/░ to the clipboard via the global + // selection handler. ref={(el) => { bar = el; }} @@ -58,9 +62,11 @@ export function ProgressBar() { }} > {playedChars() > 0 && ( - {"\u2588".repeat(playedChars())} + + {"\u2588".repeat(playedChars())} + )} - + {"\u2591".repeat(width() - playedChars())} diff --git a/src/stores/feed.ts b/src/stores/feed.ts index a5035df..45a1536 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -51,7 +51,7 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 30; /** Max episodes parsed per chunk before yielding to the event loop — bounds * the synchronous regex work per frame so one huge feed (or a batch of * feeds) can't stall the renderer. */ -const PARSE_CHUNK_SIZE = 25; +const PARSE_CHUNK_SIZE = 5; /** Yield to the event loop (task queue) so the renderer can paint between * parse chunks. MessageChannel instead of setTimeout/setImmediate because @@ -79,6 +79,9 @@ const parseEpisodesIncremental = async ( feedUrl: string, ): Promise => { const items = getRSSItems(xml); + // Yield after the item-extraction regex (which scans the full XML + // synchronously) so the renderer paints before the first parse chunk. + await yieldToUI(); const episodes: Episode[] = new Array(items.length); for (let start = 0; start < items.length; start += PARSE_CHUNK_SIZE) { const end = Math.min(start + PARSE_CHUNK_SIZE, items.length); @@ -340,13 +343,15 @@ function createFeedStore() { "Accept-Encoding": "identity", Accept: "application/rss+xml, application/xml, text/xml, */*", }, - // Hung feeds must not stall a refresh batch (or the background - // refresh loop) indefinitely. + // Hung feeds must not stall a refresh batch (or the + // background refresh loop) indefinitely. signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) return { episodes: null, coverUrl: undefined }; const xml = await response.text(); - const channel = xml.match(//i)?.[0] ?? xml; + // Yield after the network read so the renderer gets a turn + // before the sync regex + parse work begins. + await yieldToUI(); const allEpisodes = sortEpisodesReverseChronological( await parseEpisodesIncremental(xml, feedUrl), ); @@ -359,7 +364,7 @@ function createFeedStore() { return { episodes: allEpisodes.slice(0, limit), - coverUrl: parseChannelCoverUrl(channel), + coverUrl: parseChannelCoverUrl(xml), }; } catch { return { episodes: null, coverUrl: undefined }; @@ -786,6 +791,10 @@ function createFeedStore() { } // Cold-refetch parse output is unsorted; sort and cap it so the // cache and the pagination window stay newest-first and bounded. + // Yield before the sync sort (the parse already yielded before + // this point, but the sort of potentially hundreds of episodes + // is its own sync block). + await yieldToUI(); cached = sortEpisodesReverseChronological(cached); cached = cached.slice(0, MAX_EPISODES_IN_MEMORY); fullEpisodeCache.set(feedId, cached);