diff --git a/feature/home/README.md b/feature/home/README.md index 457f1fa12..50d795936 100644 --- a/feature/home/README.md +++ b/feature/home/README.md @@ -12,12 +12,12 @@ Owns Home feed presentation, Daily and Offline mixes, and local debug surfaces f - Both chapter and child heading text explicitly use `onSurface`, so an artwork-colored navigation shell cannot leak its foreground into a dark Home preview. `HomeHeaderPaletteTest` renders both levels under an inherited black content color on the JVM. -- `HomeRoute`, `HomeScreen`, `HomeFeed`, `HomeViewModel`, and `HomeViewModelAssembler` for the Home route. `HomeRoute` reports after its initial loaded feed has committed two frames so the app shell can begin nonessential launch animation without competing with first-paint work. Foreground subscription latest-episode sync is `:core:catalog` `SubscriptionForegroundSync` (started from AppRoot after onboarding and the initial screen commits). New PI subscriptions observed by Home request the application-scoped full publisher-feed ingest instead of only refreshing a PI tip. Library Subscriptions calls `requestRefresh` on appear so open-app-to Subscriptions is a live sync, not a cache-only paint. Your Shows filter lists call `PodcastRepository.getEpisodesPaginated`, which already unions cached feed extras for PI shows opted into **Missing episodes?** so the chip stream is not PI-only. Selected-show and serial-oldest episode queries fetch with a bounded safe page limit of 100 to eliminate heap exhaustion on deep catalogs. The selected-chip signal also includes `latestEpisodeId` / `rssHasNewEpisodes` so a direct-feed tip promote reloads that list; a successful launch persist also bumps `rssRefreshVersion` for the open chip (`HomeForegroundSyncLogic`). Same-chip reloads swap the list in place (no skeleton). Home calls `preferFeedPodcast` so launch sync refreshes that show first. Available local subscriptions/resume content can reveal Home before remote trending finishes; discovery keeps its own loading state. The filter-chip skeleton remembers its initial 1-row vs 2-row layout (two rows when the count is unknown) for the loading session. Up to five `home_pinned_podcast_ids` prepend the Your Shows cover order (mixtape stays the first chip when there is more than one show; pin 1 is the first podcast after mixtape). Long-press a cover for a Material 3 **Pin** / **Unpin** menu (does not pin on the long-press itself); a sixth pin shows a snackbar and is not added. Pinned covers show a circular pin badge. New unpinned subs still prepend in the remainder, never ahead of pins. Home and Library Smart share the canonical deterministic Your Shows score and one bounded recency floor from `:core:ranking`. `HomeYourShowsRanker` decides when the cover order, Daily Mix and discovery rows are ranked; playback progress never triggers a pass, because ranking on every tick made Home stutter. A pass ranks on the first assembly, once after synced catalog changes settle (new or serial-next episodes, subscriptions from an import or cloud sync: 1.5 s debounce, at most one pass every 5 s), once when Home starts after an episode was started or finished, and when Home starts on a snapshot at least 30 minutes old. A 0.05 score hysteresis prevents tiny changes from moving covers. Subscription and pin changes still update the covers immediately without scoring: new shows join the front and removed shows leave the covers and Daily Mix; new shows join Daily Mix on the settled pass, which also fills a Daily Mix that was empty because episodes had not synced yet. The selected show's episode list has one writer, the selected-show collector, so base Home updates on playback ticks can no longer swap it with the unfiltered list (the cause of rows jumping while something played). Layout in `LibrarySection` is unchanged (≤4 row, 5–9 two rows of 5, >9 interleaved 2-row grid). -- Because you like sends the listener's languages (`contentLanguagesStream`), so English off holds there too (30 Oct, D9). Its collector keys on the picked show, region and languages (`HomeBecauseYouLikeLogic.requests`), so changing languages alone reloads the row, and that reload skips the saved rows fetched for the old languages (`skipsSavedRows`). +- `HomeRoute`, `HomeScreen`, `HomeFeed`, `HomeViewModel`, and `HomeViewModelAssembler` for the Home route. `HomeRoute` reports after its initial loaded feed has committed two frames so the app shell can begin nonessential launch animation without competing with first-paint work. Foreground subscription latest-episode sync is `:core:catalog` `SubscriptionForegroundSync` (started from AppRoot after onboarding and the initial screen commits). New PI subscriptions observed by Home request the application-scoped full publisher-feed ingest instead of only refreshing a PI tip. Library Subscriptions calls `requestRefresh` on appear so open-app-to Subscriptions is a live sync, not a cache-only paint. Your Shows filter lists call `PodcastRepository.getEpisodesPaginated`, which already unions cached feed extras for PI shows opted into **Missing episodes?** so the chip stream is not PI-only. A selected show's list follows `HomeShowEpisodesLogic`: unfinished episodes among the newest 50, or for a show listened to in order, the episode in progress and the unfinished ones after the furthest one finished, read from the listener's place by `SeriesEpisodesLoader`. With nothing left it shows the latest episodes as caught up; a failed load offers Try again. The selected-chip signal also includes `latestEpisodeId` / `rssHasNewEpisodes` so a direct-feed tip promote reloads that list; a successful launch persist also bumps `rssRefreshVersion` for the open chip (`HomeForegroundSyncLogic`). Same-chip reloads swap the list in place (no skeleton). Home calls `preferFeedPodcast` so launch sync refreshes that show first. Available local subscriptions/resume content can reveal Home before remote trending finishes; discovery keeps its own loading state. The filter-chip skeleton remembers its initial 1-row vs 2-row layout (two rows when the count is unknown) for the loading session. Up to five `home_pinned_podcast_ids` prepend the Your Shows cover order (mixtape stays the first chip when there is more than one show; pin 1 is the first podcast after mixtape). Long-press a cover for a Material 3 **Pin** / **Unpin** menu (does not pin on the long-press itself); a sixth pin shows a snackbar and is not added. Pinned covers show a circular pin badge. New unpinned subs still prepend in the remainder, never ahead of pins. Home and Library Smart share the canonical deterministic Your Shows score and one bounded recency floor from `:core:ranking`. `HomeYourShowsRanker` decides when the cover order, Daily Mix and discovery rows are ranked; playback progress never triggers a pass, because ranking on every tick made Home stutter. A pass ranks on the first assembly, once after synced catalog changes settle (new or serial-next episodes, subscriptions from an import or cloud sync: 1.5 s debounce, at most one pass every 5 s), once when Home starts after an episode was started or finished, and when Home starts on a snapshot at least 30 minutes old. A 0.05 score hysteresis prevents tiny changes from moving covers. Subscription and pin changes still update the covers immediately without scoring: new shows join the front and removed shows leave the covers and Daily Mix; new shows join Daily Mix on the settled pass, which also fills a Daily Mix that was empty because episodes had not synced yet. The selected show's episode list has one writer, the selected-show collector, so base Home updates on playback ticks can't swap it with the unfiltered list. Layout in `LibrarySection` is unchanged (≤4 row, 5–9 two rows of 5, >9 interleaved 2-row grid). +- Because you like sends the listener's languages (`contentLanguagesStream`), so English off holds there too. Its collector keys on the picked show, region and languages (`HomeBecauseYouLikeLogic.requests`), so changing languages alone reloads the row, and that reload skips the saved rows fetched for the old languages (`skipsSavedRows`). - The empty-library setup banner uses an open split canvas with the heading “Let’s get listening!”, no subtext, three neutral raised-surface action tiles; discovery retains the larger asymmetric shape, with the primary accent confined to its sparkle and arrow. It reflows for narrow widths and enlarged text without shrinking or truncating action labels. Corners mirror in RTL and every action has a 48dp-or-larger target. Discovery, Explore search, the shell-managed library import overlay, analytics, eligibility and persisted dismissal retain their existing paths; import uses inbound download iconography (`Icons.Rounded.Download`). - The selected Your Shows header uses `SelectedShowTitle`: a small RSS icon beside the single-line show title, vertically centered with a fixed gap. Long titles truncate before the icon, and the header reserves spacing before the show-info arrow. The icon has a localized accessibility description and appears for RSS source metadata or a canonical `rss:` identity. JVM layout tests resolve the description from its string resource and cover narrow headers, enlarged text and RTL without screenshots or device automation. - `TopControlBar` uses the shared `TopBarUtilityActions` capsule for Feedback and Settings, with matching icons and separate accessible touch targets. Existing header placement/collapse, click and long-press actions, and the Cleaner Home shortcut preference remain screen-owned. -- The Because you like seed card is a solid tertiary-container surface with extra-large corners and a 112dp minimum height. A 148dp round cover sits vertically centred at the end and runs 32dp past the end edge, so the card clips it at the top, end and bottom; the cover mirrors to the start edge in RTL. **Because you like** (labelLarge) sits above the show name (18sp/24sp bold, two lines, ellipsised), both in on-tertiary-container and kept clear of the cover. The swap action is a 40dp tertiary filled icon button with a 48dp touch target, placed on the cover's lower start edge with a 3dp ring in the card colour. Tapping the card opens the show; the swap button keeps its **Change show for recommendations** description and callback. Two faint tertiary 9-sided cookies (Material's expressive shape, as on the Your shows selectors) sit on the text side under the cover: a 128dp cookie peeking from the bottom start and a 72dp one peeking from the top, at 12% opacity. They are fixed in place and mirror in RTL. The tilted cover and heart badge are gone. The **Similar shows** and **Episodes to try** rails below are unchanged. +- The Because you like seed card is a solid tertiary-container surface with extra-large corners and a 112dp minimum height. A 148dp round cover sits vertically centred at the end and runs 32dp past the end edge, so the card clips it at the top, end and bottom; the cover mirrors to the start edge in RTL. **Because you like** (labelLarge) sits above the show name (18sp/24sp bold, two lines, ellipsised), both in on-tertiary-container and kept clear of the cover. The swap action is a 40dp tertiary filled icon button with a 48dp touch target, placed on the cover's lower start edge with a 3dp ring in the card colour. Tapping the card opens the show; the swap button keeps its **Change show for recommendations** description and callback. Two faint tertiary 9-sided cookies (Material's expressive shape, as on the Your shows selectors) sit on the text side under the cover: a 128dp cookie peeking from the bottom start and a 72dp one peeking from the top, at 12% opacity. They are fixed in place and mirror in RTL. The **Similar shows** and **Episodes to try** rails below are unchanged. - Because You Like anchor show selection rotates automatically across three time-of-day slots (Morning, Afternoon, Night) based on an engagement-threshold candidate pool (subscriptions and listening history) and day-part genre affinity (`BecauseYouLikeRotationLogic`). It introduces day-to-day variation so the same show is not repeatedly selected at the same slot across consecutive days when pool strength permits, and caches slot keys in `BoxcastPrefs` to limit server calls to at most 3 per day. Manual user show overrides in `ChangeRecommendationPodcastSheet` remain strictly sticky until reset to auto-detect. - Lower Home discovery explicitly opts into the shared `ExpressivePoster` / `ExpressiveFeatured` card family. Media cards reserve three title-only lines with shorter titles vertically centered (episode title or show name), with font-scaled 14sp/20sp text and curved lower artwork corners. Because You Like rails use 42% of the viewport capped at 176dp; daypart rails use 52% capped at 208dp, both with 16dp gaps. On phone widths the second daypart card is visibly clipped at the viewport edge, making horizontal scrolling clear without extra labels; show-based rails retain their smaller proportions. Loaded and loading daypart rails share the same sizing. Grids retain their layout and counts. Major sections and related rails use measured 32dp / 20dp gaps including parent-grid spacing; headings inside rail/genre groups use 12dp before content, while separate lazy heading items retain the parent grid's 16dp gap. Existing content keys and `LazyListKeyPolicy` protections are unchanged. Your Shows, hero, app bar and briefing are outside this presentation change. - Show-based discovery opens directly with its **Because you like** seed card and related rails; the duplicate **More like this** heading and its browse control are removed. **Picked for you** contains the featured episode and recommendation grid, the existing daypart greeting groups its mood rails, and **Explore shows** keeps a stable title above genre selection and the show grid, without secondary header text. Regional fallback recommendations are named **Popular in your region**, including when the seed group is absent. Major headings use wrapping headlineMedium text and 48dp native browse actions: distinct native Material shapes for each chapter and standard native press feedback. Child rails pair their titleMedium heading with a standalone 28dp icon in the primary or tertiary accent, placed 10dp from the title and mirrored in RTL. No decorative shape sits behind these icons; a 36dp minimum header height keeps the feed rhythm stable. Sections remain open, without dividers or section-wide containers. Remaining browse destinations, analytics contexts, eligibility, item order and caps are unchanged. @@ -30,7 +30,7 @@ Owns Home feed presentation, Daily and Offline mixes, and local debug surfaces f - Spotlight presents the show name (at most three lines) and chart/genre context, then a one-line ellipsized publisher when available. It omits the latest-episode caption and marquee; an absent publisher reserves no row. **Play latest** starts the existing latest-episode playback callback; matching playback retains Pause/Resume. Card and arrow taps explore the podcast rather than its latest episode. Missing-episode cards expose one **Explore podcast** action. The measured supporting line and fitted action label preserve the shared card/skeleton height, artwork budget and enlarged-text layout. No new starting-episode selection or ranking is introduced. - **Cover** cards (`CoverHeroCards.kt`; the style arrives through `LocalHeroCardStyle`) are Home's default hero for anyone who has not picked a style in Settings > Appearance > Home, and replaced the Classic cards. The cover fills the card and fades into a shade behind the words (`coverScrim`: clear 150dp above the copy, solid just below its top, so any title length sits on the shade). The shade follows **Shade behind text** (`LocalHeroCoverShade`, `CoverShade.kt`): Dark is a plain near-black shade, App theme a deep shade of the theme's primary, Artwork a deep shade of the cover's seed from the shared `:core:designsystem` artwork picker (plain for covers with no meaningful colour), all through `ArtworkShade`, so text and buttons stay readable on any cover. A cover already read colours the card from its first frame; otherwise it starts plain and eases into the shade on the page colours' 350ms clock. Copy reuses the solid cards' type: a one-line context (**Resume** · 12m left, **New episode** · 1h 11m, **#1** in US, Trending in **Design**), the measured title (32 to 26sp on up to two lines, else 24sp on three), the show or publisher, wavy progress and the shared Play / details pair (`HeroActions`) in the shade's button colours. Artwork follows the solid cards' sources (episode first; show first for Spotlight). Collection cards are solid surface-container cards with the heading and count, and tiles that each fade into their own shade: four make a 2×2 grid, three put the first across a taller top row, two stack full width (`coverGridRows`); tiles alone in a row use 17sp two-line titles, the rest 13sp three-line titles. Each tile shows Resume progress and its remaining time on one row (the bar takes the free width, the time fits 10–12sp), the total time for new episodes, a new episode's length on a solid chip at the top of its cover (no New tag), and a Play / Pause button on its cover (32dp inside a 48dp target); tapping the tile opens the episode, matching the solid cards. The carousel, height, ordering, analytics bundles and callbacks are shared with the Block cards. `HeroStylePreview` draws Block, Cover, Tide, Duotone, Halo or Marquee cards (Marquee as a slice of Home on a taller canvas, `MarqueePreview.kt`: the cover under a floating top bar with its frost and wordmark, page dots under Play, and Your shows' rounded sheet with a few shows; the frost is framed by each card's own height) (single or Jump back in) scaled into any bounds for Settings > Appearance, from the listener's resume sessions, or bundled design-system covers with sample names when there are none (`HeroStylePreviewTest`). `CoverHeroCardsTest` covers play, pause, details and tile routing in each shade and that Block cards are unchanged when Cover is off; `CoverHeroLayoutTest` covers the shade sources, fallbacks and the 1–4 tile layouts. Resume cards (`HomeHeroLogic.sessionToPodcast`) skip player-only `.collage` artwork links saved by older builds and fall back to the subscribed show's cover (`HomeHeroLogicTest`). - **Duotone** cards (`DuotoneHeroCards.kt`) redraw the whole cover in two tones of one colour (`ArtworkDuotone` from `:core:designsystem`, a colour matrix on the image) and put the Cover copy and actions straight on it: the context line at the top, then the title, show and progress at the bottom, with Play / details in the shade's button colours. The tones follow the same choice as Cover's shade (`duotoneTones`): Dark is black and white, App theme uses the theme's primary and Artwork the cover's seed (black and white for covers with no meaningful colour); they ease between covers on the page colours' clock (`rememberDuotoneLook`). Collection cards keep the solid cards' rows (`HeroGridCard` takes `containerColor`, `contentColor` and a per-cover `artworkColorFilter`): the card takes the first cover's shadow tone and each row's cover is redrawn in its own tones. `DuotoneHeroCardsTest` covers play and details in every colour choice, collection routing and the tone sources. -- **Block** cards (`HeroCard`, `HeroGridCard`) take the same colour choice since 2026-10-17 (`BlockHeroColors.kt`): App theme draws them as before, Artwork builds a Material scheme from each cover's seed the way pod info does (`artworkColorScheme`; a collection uses its first cover; the theme for a cover with no meaningful colour), and Dark uses `BlockDarkColors`, a neutral near-black card in light and dark mode alike. The cards read only a few roles, so swapping the scheme recolours the whole card, and the colours ease in on the page colours' clock once a cover is read. Never picked, Block stays in the app theme (`HeroCoverShade.forStyle`). `BlockHeroColorsTest` covers the unpicked theme, artwork and its fallback, and Dark's near-black card with readable words, Play and Details. +- **Block** cards (`HeroCard`, `HeroGridCard`) take the same colour choice (`BlockHeroColors.kt`): App theme draws them as before, Artwork builds a Material scheme from each cover's seed the way pod info does (`artworkColorScheme`; a collection uses its first cover; the theme for a cover with no meaningful colour), and Dark uses `BlockDarkColors`, a neutral near-black card in light and dark mode alike. The cards read only a few roles, so swapping the scheme recolours the whole card, and the colours ease in on the page colours' clock once a cover is read. Never picked, Block stays in the app theme (`HeroCoverShade.forStyle`). `BlockHeroColorsTest` covers the unpicked theme, artwork and its fallback, and Dark's near-black card with readable words, Play and Details. - **Tide** cards (`TideHeroCards.kt`) show the whole cover across the top, uncropped, and a solid panel in its deep shade (`rememberCoverShade`, shown as **Card color**) rises over its lower part on a wavy edge with the wavy progress indicator's 24dp wavelength. The panel rises only as far as the words need (`tidePanelTop`: 40dp above them, between 65% of the width and 16dp above the cover's foot), so short titles show more of the cover. A Play / Pause disc sits in an 88dp notch on the edge, ringed with Resume progress: a wavy circular indicator that moves while the episode plays, or a plain round one when the listener picks **Circle** under **Progress ring** (`LocalHeroTideProgress`, provided by `HomeScreen` and the Appearance previews; same size, 4dp stroke and colours); the words are Cover's context line (kept clear of the disc), title and show name, and tapping the card opens the episode. `TideCardLayout` places the layers and is shared with the loading card. Collection cards fill the top with the covers (four as a 2×2 square with Resume progress and time left, or a new episode's length, on each; two or three in one row up to 34% of the card's height) under the same wavy panel in the first cover's shade, with the heading and the collection's size and total time ("4 episodes · 1h 53m left"). Each cover opens its episode and plays or pauses from the button in its corner; with two or three, the panel also lists each episode with its progress and time, and each row opens it. `TideHeroCardsTest` covers playing, pausing, progress and details in every colour choice, the disc clear of the words, collection routing and totals at two, three and four episodes, and where the panel's edge sits. - **Marquee** (`MarqueeHeroCards.kt`, with its collection card in `MarqueeCollectionCard.kt` and the top bar's hand-offs, frost and page dots in `MarqueeChrome.kt`) replaces the carousel with one full-width card at a time, from the top of the screen under Home's top bar down to where Your shows' sheet begins with rounded corners (24dp over the hero's foot). With Marquee, `HomeScreen` lets the feed start at the top and floats the top bar over it. Home's body slides up over a still cover: the hero reads how far it has scrolled away (`LocalHeroScrollOffset`, while drawing) and moves its content down by as much, inside its own bounds, so only the sheet's rounded edge rises (`marqueePinned`). The bar never darkens over the cover: it is clear there, with frosted glass behind it that belongs to the bar (`MarqueeBarFrost`, 2026-10-17): Home makes its feed a Haze source and the bar blurs the live page under it (the still cover, the next card sliding in, the body rising), fixed to the top of the screen at the bar's full height, so it never moves with the cards, never splits mid-swipe and never shows a cover sharp while its blur loads. It is soft (20dp blur, a 12% tint toward black under light words or white under dark ones), full behind the bar, then fades from 20dp above the bar's foot to 40dp below it: the blur and tint are drawn on a layer of their own and faded by a Compose mask on that layer (`DstIn`, an S-curve from `marqueeBarFrostStops`, smoothstep): on the user's phone Haze ignored both its own mask and its progressive blur, which left a razor-sharp line at the strip's foot, and fades away with `marqueeBarOverArtwork` as the body passes under the bar. On Home the cards skip their own frost (`LocalMarqueeCardFrost`); the Settings preview and tests keep a card-level copy (`MarqueeTopFrost`, the same blur, tint and tail over a blurred copy of the cover's top). Blur needs Android 12; older phones keep the tint. `MarqueeBarFrostTest` covers the fade's shape (full, then steadily falling, gentle at both ends, no step over a fifth), and Home's scroll stops where the body's rounded edge meets the bar (`MarqueeDetentConnection`, `marqueeDetent`): a drag or fling that starts above that point halts there, so the whole page rests with the edge under the bar and nothing scrolls beneath it, and the next swipe carries the body on under the bar (scrolling back down never stops). The bar stays clear over the cover until then and fills with the body's own colour only as the edge passes under it (`marqueeBarOverArtwork`). The sheet's edge carries a short handle line (`MarqueeSheetEdge`) in the hero, the loading card and the Settings preview. Its wordmark and status icons take the settled card's words colour (`LocalMarqueeTopTone`): dark over light covers in every colour choice (judged by the shared picker's mean colour of the cover itself, since the two tones keep its light and dark parts; a stronger veil when a cover has none), light otherwise; the status icons return to the theme's when Home leaves. The bar's expanded height reaches the hero through `LocalHeroTopInset`, and the hero row bleeds past the feed's 16dp side padding; without heroes a spacer keeps the rest of Home below the bar. The hero is 48dp shorter than the other styles' cards plus the bar's room, and meets Your shows' sheet with no strip between. The background is `MarqueeBackdrop` (reworked 2026-10-17; the square cover used to be cropped and zoomed to fill the tall card): an ambient wash of the cover itself, fetched at 96px, scaled past the card's edges and blurred (56dp on Android 12 and later; elsewhere the tiny image's upscaling keeps it soft), deepened toward the card's shade from the middle down; the cover whole and sharp across the full width from the top, melting into the wash from 58% of its height; and the top bar's frost. Each card clips to its own bounds, since its wash reaches past them: unclipped, the neighbouring card's bright wash drew a raw slice over the current card mid-swipe. Before a swipe, `MarqueeWarmColours` reads every card's palette and prefetches its wash and cover images, so a card arriving mid-swipe draws its two-tone filter, wash and shade from its first frame (it used to show its plain cover until its palette was read). Both images take `rememberMarqueeLook`'s colours (the same choice, shown as **Cover colors**: Full color keeps the cover as it is; Artwork and App theme redraw it in two vivid tones of the cover's or the accent colour, `ArtworkDuotone` with `vivid`: a tone-10 shadow and a tone-80 highlight in the colour's own hue, so the cover keeps about the brightness and contrast it has in Full color). Until 2026-10-30 Marquee used Duotone's dark tones (8 to 38) under the same wash, plus an accent glow to lift them, which left the cover murky and hid the art; Duotone keeps the dark tones because its words sit straight on the cover. The words sit on a long, 90% scrim of the card's deep shade (`MarqueeWordsScrim`, `MarqueeWordsFade`), with Cover's title, show, progress and Play / details at the bottom and page dots (a pill for the current card) between Play and the sheet's edge. Every Marquee label is one solid chip in the card's colours above the title (`MarqueeChips.kt`): "#1" in an accent pill then "in US"; "Trending in" then the genre in bold; the episode's state in bold ("Resume", "New episode") then its time left or length; and on collections the name, the chosen episode's time and a "2/4" counter in the accent. Screen readers hear each chip as one phrase ("Jump back in, 2 of 4, 12m left"). Play keeps one width on every phone (`MarqueeActionsWidth`), and the room beside it shows up to three covers of the next cards, each moving the hero there ("Go to …"); swiping also moves between cards. The hero also moves on by itself (`marqueeDwellMillis`: 6 seconds on a card, or as long as a collection's strip takes to show every episode, 5 seconds each), holding while a finger is anywhere on it (`marqueeTouches`, which watches without taking the touch) and counting again from when it lifts, and resting while it is mostly scrolled away, while Home is off screen, with TalkBack exploring by touch or with animations off (`rememberMotionAllowed`, which the strip follows too). Collection cards show the chosen episode's cover, with its one chip (name, the episode's time, "2/4") above the episode's title, show, Play and details at the bottom (one column, so nothing overlaps), above a strip of covers (88–104dp, bigger with fewer episodes) that picks the episode: while the card is on screen the strip advances every 5 seconds; dragging it holds it, and after a tap or a drag it waits 8 seconds before carrying on (nothing is remembered once the card leaves the screen). The covers keep their own colours in front of the card's shade (no dimming, no two-tone filter); the chosen one is outlined and kept in view, and Resume covers show progress along their foot. `MarqueeHeroCardsTest` covers playing, pausing, progress and details in every colour choice, full colour leaving covers unfiltered, a light cover staying at least half as bright as Full color in Artwork and App theme (the dark tones gave a fifth) with dark top bar words in every choice, the still cover under scrolling and spanning the card's width uncropped, the top bar's frost and the words colour the card reports, light and dark top bar words, the bar filling only once the body passes under it, the scroll stopping at the edge and the next swipe going through (`MarqueeDetentTest`, including flings and a finger landing mid-fling), up-next covers and page dots moving with the hero, the hero moving on by itself and when it rests, and the strip advancing, pausing after a cover is chosen and carrying on, with Play and details using the chosen episode. - **Halo** cards (`HaloHeroCards.kt`) cut the cover into a nine-sided cookie in the top end corner of a card tinted from it (`rememberCoverShade`, so they follow the same colour choice, shown as **Card color**), inside a ring that is the cookie's edge pushed outward (`HaloRing.kt`). `haloGeometry` sizes the cookie at three quarters of the card's width, capped so the ring ends by 55% of the height, and lets it run past the top and end edges; Resume progress fills the part of the ring the card shows, from the top edge round the start side to the end edge; it is drawn from just past the top edge and full progress runs just past the end edge, so its rounded ends never show however the lobes have turned. While the card's episode plays, the cookie and ring turn together like the compact player (`rememberHaloRotation`: one lobe every 2.4 seconds at up to 30fps, read while drawing, paused off screen), and the image stays upright. The solid cards' context block sits in the room beside the ring (across the top, with the cover below it, at text sizes over 130%), and Cover's title, show name and Play / details pair sit at the bottom. Right-to-left layouts mirror the cover and ring. Collection cards take the first cover's shade, with the shared heading (`HeroCollectionHeading`); each episode's cookie cover sits in its own ring, filled clockwise from the top with Resume progress in that cover's accent. Tapping the cover plays or pauses it (a small button on it shows which, drawn whole over the ring; only the ripple stays inside it); the rest of the tile opens the episode. Four make a 2×2 grid with the title and time below each cover; two or three become rows with larger covers and the title beside them. `HaloHeroCardsTest` covers play, progress and details in every colour choice, collection routing at two, three and four episodes, the collection play button's corner over the ring, right-to-left placement, the ring geometry across phone widths, text sizes and turns, and the turning pace. @@ -73,6 +73,8 @@ src/main/java/cx/aswin/boxlore/feature/home/ BecauseYouLikeRotationLogic.kt FeaturedVideoPodcasts.kt HomeEditorialRowsLogic.kt + HomeShowEpisodesLogic.kt # selected-show and Daily mix episode rules + SeriesEpisodesLoader.kt # loads from the listener's place ``` Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel, section-row, and logic files keep UI assembly and behavior testable. @@ -94,11 +96,11 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel - The Daily edition Brief card uses an uncropped landscape, live masthead, a single bottom-left date/story/duration line and a solid themed top-right hide control. The opaque Material 3 base gives the headline, Listen/Open brief controls, progress and story previews a defined bottom edge. Open brief uses an opaque primary-container fill to remain distinct from the card in light and dark modes. The card stays on Home until the listener finishes that day's edition or hides it (for today or always). It stays even when the same brief also appears in a resume hero card or grid (`HomeUiAssemblyLogicTest` covers both). The card shows three headlines by default. Story numbers and headlines align vertically; the last visible story row expands/collapses the list instead of adding a separate All stories button. The Home edition switcher is absent; preferences and temporary region selection remain on the Brief page. Hiding today/all and feedback retain explicit confirmation and analytics. Story titles in the card use 14 sp body type in the on-surface colour for readability, and the story numbers use the app's UI font with tabular digits. - When a Brief answer carries no chapters or transcript link, Home builds it with `:core:catalog` `BriefingLinks` (chapters on `/v2/briefings/chapters/` while v2 discovery is on, transcript on `/briefings/transcript/`); the answer's own signed links are always preferred. Related episodes arrive under the IDs this phone already holds for them. No layout or state changes, so phone, tablet, foldable and resized windows behave as before. - Brief metadata and chapters load independently of Home discovery: the saved default Brief edition is resolved before fetching. Changing it does not reload chart/recommendation flows or change the app region. Home opens the resolved default edition; the Brief page allows temporary edition selection without overwriting that preference. -- Recommendation cache parsing, serialization and scoring run on Default. Discovery rankings are recomputed only when candidates change or on a Your shows ranking pass, using the history and subscriptions current at that pass; history alone changes on every playback tick and no longer invalidates them. The cache resets with region/language/daypart collectors. -- Cold-start reveal: when Home's data arrives together, the skeleton hands over one section per frame (`rememberHomeRevealStage`): the hero first, then Your shows, then the discovery grid. Content already present when Home composes shows at once. Before this, all three composed in one frame, which stalled a cold start for most of a second in debug builds. The shimmer keeps running until the last section is revealed, and the feed only recomposes when the visible-loading answer changes, not on every scroll. +- Recommendation cache parsing, serialization and scoring run on Default. Discovery rankings are recomputed only when candidates change or on a Your shows ranking pass, using the history and subscriptions current at that pass. The cache resets with region/language/daypart collectors. +- Cold-start reveal: when Home's data arrives together, the skeleton hands over one section per frame (`rememberHomeRevealStage`): the hero first, then Your shows, then the discovery grid. Content already present when Home composes shows at once. The shimmer keeps running until the last section is revealed, and the feed only recomposes when the visible-loading answer changes, not on every scroll. - The hero skeleton mirrors the real carousel: one large card at the carousel's width (it aims for 320dp and keeps a 40–56dp peek after a 16dp gap) and the peek beside it, so content settles in place. The large card follows the chosen hero style (`HeroSkeletonCards.kt`), so each style loads on its own structure: Block is a solid surface-container card with context lines, square artwork in the top end corner, two title lines, a show line and the connected Play/Details row; Cover shimmers where the artwork goes, above a solid shade holding the context line, a larger two-line title, the show line and Play/Details at the card's 20dp insets; Duotone shimmers across the whole card, with the context block at the top and the title, show line and Play/Details drawn on it; Halo draws its ring's track and a cookie where the cover goes, placed by `haloGeometry`, with the context lines beside them. Tide's loading card reuses `TideCardLayout` with a shimmering square, the solid wavy panel and the disc; Marquee's is one shimmering card as wide and tall as its hero (with no strip below), with the words and actions at its insets and the sheet's rounded edge over its foot. The peek shimmers like artwork for Cover, Duotone and Tide, and is solid otherwise. Every new hero style adds its own branch to `HeroSkeletonCard`. The skeletons use plain layouts, with no lazy rows, so nothing extra is composed on a cold start. - `HomeCacheRestoreGate` lets the initial local recommendation / Because You Like caches and fallback flag finish restoring before the first assembled Home snapshot or recommendation refresh. Slow cache reads cannot produce a second startup insertion or overwrite a fresh API result. Empty, malformed, or cancelled restoration releases waiters; API refreshes and their cache writes continue normally afterward. This gate waits only for local storage, never for network completion. -- Oldest-first serial lookup waits for usable Home content and shares a two-request limit. Cancellation propagates through recommendation/serial work. +- Oldest-first serial lookup waits for usable Home content and shares a two-request limit. Cancellation propagates through recommendation/serial work. Daily mix's next in-order episode uses the selected-show rules. - Regression coverage: `HomeLoadingRevealTest` (opaque cover/content handoff with a frozen animation clock, draw-only cover fade and disposal, immediate cached content), `HomeArtworkHandoffTest` (transparent successful artwork retains its backing, new subscription covers are immediately visible, B-and-microphone fallback rendering for null/blank URLs at small/large sizes in light/dark modes, and proxy-plus-origin failures), `HomeRefreshPresentationLogicTest` (category result isolation and refresh visibility), `HomeCacheRestoreGateTest` (complete initial snapshot, delayed-cache/fresh-response ordering, empty/error/cancel handling), `HomeShimmerVisibilityTest` (visible loading sections and offscreen pause), `HomeDiscoveryRankingCacheTest` (including refreshed payloads with unchanged IDs and fresh empty responses), `HomeSerialResolutionLimiterTest`, `HomeLoadingGeometryTest`, `HomeShimmerAnimationTest` (JVM pixel changes during initial loading, a visible moving band during a short cached wait, visible navigation entry before STARTED, loading restart, movement late in the sweep, seam-free tall blocks, and unchanged composition/drawing-cache counts across animation frames), and the local/remote readiness cases in `HomeUiAssemblyLogicTest`. - Discovery presentation regressions: `HomeDiscoveryHeaderTest` covers narrow phone openings, 2× text, RTL, standalone leading rail icons, heading semantics, four appearance modes, native press stability and genre selection without secondary header text; `HomeDiscoveryFeedSpacingTest` covers compact group spacing, the absence of a duplicate seed heading, fallback/absent-data names, keyed loading geometry, recommendation order/caps and existing browse callbacks. `HomeDiscoveryCardTest` and `HomeDiscoveryInteractionTest` cover shared three-line card geometry, split featured geometry and curved join, artwork curves, the seed card's breakout cover, theme surfaces and independent media/seed/genre actions. These are JVM tests; no device automation is required. @@ -112,7 +114,7 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel - UI state is exposed through flows and collected by Compose on the main thread. - Network and database operations run through injected suspend APIs. -- **Serial next-up for a show switched to its feed.** `HomeSerialLogic.ongoingEpisodeId` and `lastCompletedEpisodeId` read the listening of every show in `Podcast.listenerShowIds`, and `HomeViewModelSerial` finds that episode in the show's list under the ID the list uses (`PlaybackRepository.episodeGroupTokens`, `HomeSerialLogic.idInList` / `idsInList`), so an oldest-first show the listener switched to its RSS feed continues after the episode its old Podcast Index show was playing (decision D8). The selected show's oldest-first window anchors on that show's last play the same way (`HomeSelectedPodcastLogic.buildSignal`). The catch-up row and the episode playback states look history up under every ID of an episode (`HomeCatchUpLogic.underEveryId` with `PlaybackRepository.episodeAliasGroups`), because the projected history list keeps an episode stored under several IDs under its owner's ID; counts and ranking still read that list, one row per episode. +- **Serial next-up for a show switched to its feed.** `HomeSerialLogic.ongoingEpisodeId` and `lastCompletedEpisodeId` read the listening of every show in `Podcast.listenerShowIds`, and `HomeViewModelSerial` finds that episode in the show's list under the ID the list uses (`PlaybackRepository.episodeGroupTokens`, `HomeSerialLogic.idInList` / `idsInList`), so an oldest-first show the listener switched to its RSS feed continues after the episode its old Podcast Index show was playing (decision D8). The selected show's in-order list finds the listener's place the same way (`HomeShowEpisodesLogic.placeHistoryId`). The catch-up row and the episode playback states look history up under every ID of an episode (`HomeCatchUpLogic.underEveryId` with `PlaybackRepository.episodeAliasGroups`), because the projected history list keeps an episode stored under several IDs under its owner's ID; counts and ranking still read that list, one row per episode. ## Persistence & identity @@ -121,7 +123,7 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel - **Picked for you**: `loadRecommendations` (`HomeViewModelSections.kt`) waits for trending, then calls `getHomeRecommendations` and shows the answer with `applyHomeRecommendations`. Off clears it and marks it loaded, so no skeleton shows and Daily Mix fills only from your shows. A failed request with no last good picks (`HomeRecommendations.failed`) keeps the picks Home shows (restored from its cache or loaded earlier) and doesn't overwrite the cache; a failed launch bootstrap (`HomeBootstrapData.failed`) likewise keeps the trending Home shows. State lives in the view model, so phone, tablet, foldable and resized windows keep the same picks. - **Because you like**: the overridden-show / region collector clears it while off; `fetchBecauseYouLikeRecommendations` now suspends, so switching off cancels a request in flight. - **Time-of-day picks**: `loadEditorialRows` clears the curated rows while off, and `HomeScreen` hides the greeting until the switches are read. The greeting's browse link opens Explore on Top when Picked for you is off. - - Cache restore skips cached recommendations and Because you like for sections that are off. The trending pipeline no longer restarts on language changes, since only these sections used languages. + - Cache restore skips cached recommendations and Because you like for sections that are off. - `HomeDiscoverySectionsFeedTest` keeps off sections hidden and Explore shows visible while the window crosses 600dp width and 480dp height in both directions, toggles them live on phone and tablet layouts, and checks the greeting link. `HomeViewModelBriefingTest` covers the off Brief, switching it back on and daypart repeats. - This module owns no storage files or stable preference keys; Home pins are stored as `home_pinned_podcast_ids` in `:core:prefs`. Toggles go through `UserPreferencesRepository.toggleHomePinnedPodcastId` (one DataStore write). - RSS IDs, ranking database rows, download cache entries, and playback media IDs are owned by core modules. @@ -133,7 +135,7 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel - NPS survey: Home owns it. At a finish moment with nothing playing and no other sheet, `EngagementPromptCoordinator.shouldShowNpsSurvey` (`NpsSurveySchedule` in `:core:prefs`) decides it and `NpsSurveyController` opens it; `NpsSurveyHost` shows it only once nothing plays, drops it silently if it hasn't appeared within the finish moment's 6 hours (the finish comes from `EngagementPromptCoordinator.lastFinishedAtMs`, so one before a restart counts), and it counts as shown (`survey shown`, the schedule used up) only on screen with Home resumed. Steps: a 0–10 score (two staggered rows of same-size circles, "Not likely" over the 0 and "Very likely" under the 10), one follow-up by band (0–6 "What's the one thing we should fix first?", 7–8 "What would turn this up to a 10?", 9–10 "What should we queue up next?" with the ideas tag), then thanks; 9s and 10s get "Rate on Google Play", which opens the store page (`store_review_opened`, `nps_survey`). The score is sent at once as a partial answer and Send or Skip completes the same submission; every send lists all four questions in the survey's order, as PostHog's own SDKs do; closing before a score is `survey dismissed`, closing on the follow-up keeps the score. Answers are capped at 500 characters. Wording can be changed from the `nps-survey-copy` flag's payload (any `NpsSurveyCopy` key; an empty `ideaTag` hides the tag). Long-pressing Home's Feedback icon opens it by hand without using up the schedule, and the debug screen's Flags tab says what the automatic survey still waits for and can reset its timing (last showing, shared cooldown, this session's prompt; `NpsSurveyDebugTest`). Echo stands on the sheet's top edge and plays one clip per step from its manifest (`survey-ask`, `survey-notes`, `survey-think`, `survey-idea`, `survey-thanks-calm`, `survey-thanks`, `survey-celebrate`), then rests with its eyes alive; reduced motion shows the resting still. - NPS survey layout: colours are theme roles only (score circles `primaryContainer`; the sheet white in light themes, `surfaceContainerHigh` in dark; the answer box `surfaceContainerHighest`, its edge and label `primary` when focused), so every preset, custom colour, wallpaper colour and Pure style applies. With the keyboard up the sheet rises above it, Echo shrinks to 132dp and the score chip and helper make room; windows under 600dp tall start Echo at 132dp, and the content scrolls when it still doesn't fit. On expanded and folded tablets it is a centered dialog. Before a score and on the thanks it closes like any sheet; on the follow-up only Skip, Send or Back close it, so a typed answer isn't lost to a stray tap. `NpsSurveySheetAdaptiveTest` keeps one survey, its step and its typed answer through phone ↔ tablet transitions across the 600dp and 480dp gates, 840dp, portrait/landscape and a half-opened fold in both directions with no event sent; checks the outside tap on the dialog before a score and on the follow-up, Back on the dialog's follow-up, the scroll position across 839 → 840dp, a survey waiting while something plays and one dropped after its window, and a short landscape phone reaching every score and Not now. `NpsSurveyControllerTest` covers the events and steps; `NpsSurveySheetPolicyTest` the Echo sequences against the manifest, Echo's sizes and the score circles. - `HomeRecommendationsApplyTest` covers `applyHomeRecommendations`: a failed request keeps the restored picks and saves nothing; fresh picks replace them once per episode ID and title and are saved with their fallback flag; an empty answer that did not fail clears them. -- `HomeSerialLogicTest` covers serial progress IDs and picks, and an oldest-first show switched to its feed continuing after the episode its old show was playing, found under the ID the show lists. `HomeSelectedPodcastLogicTest` checks such a show's last play, and `HomeCatchUpLogicTest` that its latest episode played under the old ID reads as played. +- `HomeSerialLogicTest` covers serial progress IDs and picks, and an oldest-first show switched to its feed continuing after the episode its old show was playing, found under the ID the show lists. `HomeSelectedPodcastLogicTest` checks such a show's place, `HomeShowEpisodesLogicTest` and `SeriesEpisodesLoaderTest` the selected-show rules, and `HomeCatchUpLogicTest` that its latest episode played under the old ID reads as played. - Recommendation entry points share an immutable columns/tablet layout value. Selected-show episode rendering has focused private playback, action and layout inputs; the parent crossfade retains each show's presentation and scroll owner. `HomeDiscoveryFeedSpacingTest` checks recommendation caps, order and episode/browse attribution while switching between the phone grid and tablet rail. `HomeConnectedRailTest` also checks selected-show playback, episode and browse callbacks across sidebar resizing, loading/empty-to-content recovery, and a 200-show library keeping a bounded lazy sidebar where every show stays reachable. `HomeArtworkHandoffTest` also checks that hidden tablet artwork waits, phone round trips keep the tablet target, and artwork sized only by its own content loads. - Tablet Home pairs setup/Your shows with a horizontal Brief card when both are present. Without Brief, the Your shows layout group can span 1200dp: its picker gains cover columns until half width, then grows rows up to four without an inner scroll. Larger libraries scroll inside a lazy four-row picker that composes only visible covers, keeping the module height bounded with the same cover size, order and selection. At the half split, Daily/Offline Mix becomes a vertically scrolling Episode presentation (up to 720dp per card), while horizontal rail cards remain 310dp. Picker and content backgrounds match the taller natural content; the single-show cover keeps pin actions and badges. Movable composition retains Mix scroll owners across the sidebar threshold. Selected episode grids keep bounded cards and a complete footer outside their inner scroller. Featured recommendations start on the left with subsequent posters alongside, preserving order and click attribution. @@ -185,7 +187,7 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel - Related recommendation headers use a paired cover motif: overlapping covers for Similar shows and a tilted episode cover with a play cutout for Episodes to try. Both retain the primary header accent. -- Your shows content follows one grid (`HomeMixLayout`, `YourShowsStructure.kt`) shared with its skeleton: headers, rows and the first mix card align 16 dp in, the same inset as the covers; a hairline divider separates the covers from the content; content has 12 dp above and 16 dp below. Daily Mix and a selected show share `YourShowsContentHeader`: a 22 sp title with a one-line subtitle, and the mode's actions on the same 48 dp line. Daily Mix's subtitle is its episode count and listening time ("6 episodes · 3h 40m", played episodes left out, in-progress episodes counting the time left); Offline keeps its download count. A selected show's name (up to two lines) also opens the show; there is no sort line for the default newest-first order, while serial shows keep a Next episodes line, next to a tonal open button and a tonal clear button. Section and mix titles set `onSurface` explicitly. `MixTimeTest` covers the summary. +- Your shows content follows one grid (`HomeMixLayout`, `YourShowsStructure.kt`) shared with its skeleton: headers, rows and the first mix card align 16 dp in, the same inset as the covers; a hairline divider separates the covers from the content; content has 12 dp above and 16 dp below. Daily Mix and a selected show share `YourShowsContentHeader`: a 22 sp title with a one-line subtitle, and the mode's actions on the same 48 dp line. Daily Mix's subtitle is its episode count and listening time ("6 episodes · 3h 40m", played episodes left out, in-progress episodes counting the time left); Offline keeps its download count. A selected show's name (up to two lines) also opens the show; there is no sort line for the default newest-first order, while serial shows keep a Next episodes line (dropped once caught up), next to a tonal open button and a tonal clear button. Section and mix titles set `onSurface` explicitly. `MixTimeTest` covers the summary. - A selected show's episodes form one connected list (20 dp outer corners, 6 dp joins, 2 dp gaps) ending in the See all episodes footer. Every row is the same height: the title block reserves two lines and one metadata line and centres shorter titles; 48 dp artwork that is the row's Play / Pause button (a 28 dp mark on a small local scrim; tapping the cover plays or pauses that episode, tapping the rest of the row opens it); the date, then the length or, while in progress, the progress bar and time left on that one line; while an episode plays its mark shows Pause and the row sits in the primary container. Fresh episodes show "Just now" instead of "0m ago". - Daily/offline mix uses landscape cards up to 310 × 142 dp, leaving a next-card peek at narrow widths. Artwork occupies 76–86 dp beside up to two episode-title lines and a single show-name line; title blocks stay vertically centered. A 48 dp playback target in the artwork corner draws a smaller translucent disc (40 dp on mix cards) so the cover stays visible, independently of the episode-info action. Solid low surfaces highlight the playing episode with primary container. Card and skeleton heights grow together at enlarged text sizes. diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeDataModels.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeDataModels.kt index f24043011..9c10e9b01 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeDataModels.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeDataModels.kt @@ -8,12 +8,17 @@ import cx.aswin.boxlore.core.model.Podcast internal data class SelectedPodcastSignal( val podcastId: String, - val lastPlayedEpisodeId: String?, + /** Shows listened to in order: the listener's place (history ID); null for newest-first shows. */ + val placeHistoryId: String?, val sort: String, val rssRefreshVersion: Long, val latestEpisodeId: String? = null, val rssHasNewEpisodes: Boolean = false, -) + /** Bumped by Try again after a failed load. */ + val retryVersion: Long = 0L, +) { + val inOrder: Boolean get() = sort == "oldest" +} data class HomeDataWrapper( val trending: List, diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeed.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeed.kt index b576791cb..a1d72a8e9 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeed.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeed.kt @@ -44,6 +44,7 @@ internal data class PodcastFeedContent( val gridItems: StablePodcastList, val recommendations: StableEpisodeList, val selectedPodcastEpisodes: StableEpisodeList, + val selectedShowStatus: SelectedShowStatus = SelectedShowStatus(), ) @androidx.compose.runtime.Stable diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedYourShows.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedYourShows.kt index 415cb2d5a..3bdfa7f10 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedYourShows.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedYourShows.kt @@ -80,6 +80,8 @@ private fun YourShowsFeedContent( isSelectedPodcastLoading = loadingState.isSelectedPodcastLoading, isSelectedRssRefreshing = loadingState.isSelectedRssRefreshing, episodePlaybackState = playback.episodePlaybackState, + selectedShowStatus = content.selectedShowStatus, + onRetrySelectedShow = callbacks.onRetrySelectedShow, softExpireProgressEpisodeIds = playback.softExpireProgressEpisodeIds, currentPlayingEpisodeId = playback.player.currentPlayingEpisodeId, isPlaying = playback.player.isPlaying, diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt index 5ebea18df..4b12759aa 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt @@ -145,6 +145,7 @@ data class HomeFeedCallbacks( val onFeedbackClick: () -> Unit, val onToggleHomePin: (String) -> Unit = {}, val onDismissFeaturedVideoShowcaseForever: () -> Unit = {}, + val onRetrySelectedShow: () -> Unit = {}, ) @androidx.compose.runtime.Stable @@ -346,6 +347,7 @@ fun HomeRoute( onFeedbackClick = onFeedbackClick ?: viewModel::triggerFeedback, onToggleHomePin = viewModel::toggleHomePin, onDismissFeaturedVideoShowcaseForever = viewModel::dismissFeaturedVideoShowcaseForever, + onRetrySelectedShow = viewModel::retrySelectedShow, ), onNavigateToSettings = onNavigateToSettings, onForceReviewPrompt = viewModel::forceReviewPrompt, @@ -440,6 +442,7 @@ private fun HomeScreenFeedContent( gridItems = StablePodcastList(uiState.discoverPodcasts), recommendations = StableEpisodeList(uiState.recommendations), selectedPodcastEpisodes = StableEpisodeList(uiState.selectedPodcastEpisodes), + selectedShowStatus = uiState.selectedShowStatus, ), feedState = PodcastFeedUiState( diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeUiModels.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeUiModels.kt index 4fd962396..d79292e82 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeUiModels.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeUiModels.kt @@ -21,6 +21,17 @@ data class SmartHeroItem( enum class HeroType { RESUME, RESUME_GRID, JUMP_BACK_IN, NEW_EPISODES_GRID, SPOTLIGHT } +/** How the selected show's episode list on Home stands, beside its episodes. */ +@Immutable +data class SelectedShowStatus( + /** Nothing is left to hear, so the list holds the show's latest episodes. */ + val caughtUp: Boolean = false, + /** The last load failed with nothing to show. */ + val loadFailed: Boolean = false, + /** The show's two newest episodes, the only ones that can carry the NEW tag. */ + val newestEpisodeIds: Set = emptySet(), +) + enum class HomeEditorialIcon { HEADLINES, UPLIFTING, @@ -62,6 +73,7 @@ data class HomeUiState( val isError: Boolean = false, val selectedPodcastId: String? = null, val selectedPodcastEpisodes: List = emptyList(), + val selectedShowStatus: SelectedShowStatus = SelectedShowStatus(), val isSelectedPodcastLoading: Boolean = false, val isSelectedRssRefreshing: Boolean = false, val episodePlaybackState: Map> = emptyMap(), diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt index dff3b27a7..dd569ea83 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt @@ -348,8 +348,10 @@ class HomeViewModel( internal var activeRegion = "us" internal val _selectedPodcastId = MutableStateFlow(null) - internal val _selectedPodcastEpisodes = MutableStateFlow>(emptyList()) + internal val _selectedShowPool = MutableStateFlow(null) internal val _isSelectedPodcastLoading = MutableStateFlow(false) + internal val _selectedShowLoadFailed = MutableStateFlow(false) + internal val _selectedShowRetryVersion = MutableStateFlow(0L) internal val _isSelectedRssRefreshing = MutableStateFlow(false) internal val _rssRefreshVersion = MutableStateFlow(0L) internal val rssFeedsRefreshedThisSession = @@ -358,6 +360,9 @@ class HomeViewModel( internal val _resolvedSerialEpisodes = MutableStateFlow>(emptyMap()) internal val inFlightResolutions = java.util.Collections.synchronizedSet(mutableSetOf()) + /** Shows listened to in order that have nothing left to suggest, by the place and newest episode they were checked at. */ + internal val serialCaughtUpKeys = java.util.concurrent.ConcurrentHashMap() + // True when the current filter selection was applied automatically (single-show state) // rather than by an explicit user tap. Used to decide whether to fall back to the mix // when the subscription set grows past one show. @@ -367,6 +372,11 @@ class HomeViewModel( applySelection(podcastId, isAuto = false) } + /** Try again after the selected show's episodes failed to load. */ + fun retrySelectedShow() { + _selectedShowRetryVersion.value += 1L + } + private fun applySelection( podcastId: String?, isAuto: Boolean, diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSelected.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSelected.kt index d82d626e2..bd33ecabd 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSelected.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSelected.kt @@ -6,101 +6,67 @@ import cx.aswin.boxlore.core.playback.completedEpisodeIds import cx.aswin.boxlore.core.playback.episodeGroupTokens import cx.aswin.boxlore.feature.home.logic.HomeForegroundSyncLogic import cx.aswin.boxlore.feature.home.logic.HomeSelectedPodcastLogic -import cx.aswin.boxlore.feature.home.logic.HomeSerialLogic +import cx.aswin.boxlore.feature.home.logic.HomeShowEpisodesLogic import cx.aswin.boxlore.feature.home.logic.HomeYourShowsSignals +import cx.aswin.boxlore.feature.home.logic.SelectedShowLoadState +import cx.aswin.boxlore.feature.home.logic.SelectedShowLoadTransitions +import cx.aswin.boxlore.feature.home.logic.SeriesEpisodesLoader +import cx.aswin.boxlore.feature.home.logic.ShowEpisodesList +import cx.aswin.boxlore.feature.home.logic.ShowEpisodesPool import cx.aswin.boxlore.feature.home.logic.settledCatalogChanges import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch internal fun HomeViewModel.observeSelectedPodcast() { viewModelScope.launch { - val historySignal = + val signal = combine( _selectedPodcastId, allHomeHistory, subscriptionRepository.subscribedPodcasts, _rssRefreshVersion, - ) { podcastId, allHistory, subs, rssRefreshVersion -> + _selectedShowRetryVersion, + ) { podcastId, allHistory, subs, rssRefreshVersion, retryVersion -> HomeSelectedPodcastLogic.buildSignal( podcastId = podcastId, allHistory = allHistory, subs = subs, rssRefreshVersion = rssRefreshVersion, + retryVersion = retryVersion, ) }.distinctUntilChanged() var previouslyLoadedPodcastId: String? = null - historySignal.collectLatest { info -> + var state = SelectedShowLoadTransitions.cleared + fun publish(next: SelectedShowLoadState) { + state = next + _selectedShowPool.value = next.pool + _isSelectedPodcastLoading.value = next.loading + _selectedShowLoadFailed.value = next.failed + } + signal.collectLatest { info -> if (info == null) { - android.util.Log.d("HomeViewModelFilteredView", "historySignal collected: null (clearing selected podcast episodes)") - _selectedPodcastEpisodes.value = emptyList() - _isSelectedPodcastLoading.value = false + publish(SelectedShowLoadTransitions.cleared) previouslyLoadedPodcastId = null - } else { - val (podcastId, lastPlayedEpisodeId, sort) = info - android.util.Log.d( - "HomeViewModelFilteredView", - "historySignal collected: podcastId=$podcastId, lastPlayedEpisodeId=$lastPlayedEpisodeId, sort=$sort", - ) - val switchingPodcast = podcastId != previouslyLoadedPodcastId - if (switchingPodcast) { - _selectedPodcastEpisodes.value = emptyList() - previouslyLoadedPodcastId = podcastId - } - val showLoading = - HomeSelectedPodcastLogic.shouldShowLoading( - switchingPodcast = switchingPodcast, - listEmpty = _selectedPodcastEpisodes.value.isEmpty(), - ) - _isSelectedPodcastLoading.value = showLoading - try { - if (sort == "oldest") { - val page = - podcastRepository.getEpisodesPaginated( - podcastId, - limit = 100, - offset = 0, - sort = "oldest", - ) - // History keeps an episode under its owner ID; find it under the ID the list uses. - val tokens = playbackRepository.episodeGroupTokens(page.episodes.map { it.id } + listOfNotNull(lastPlayedEpisodeId)) - val selectedRaw = - HomeSelectedPodcastLogic.oldestSortWindow( - allEpisodes = page.episodes, - lastPlayedEpisodeId = HomeSerialLogic.idInList(lastPlayedEpisodeId, page.episodes, tokens), - ) - android.util.Log.d( - "HomeViewModelFilteredView", - "Oldest sort resolution for podcastId=$podcastId: " + - "totalEpisodesFetched=${page.episodes.size}, " + - "rawEpisodesSelectedCount=${selectedRaw.size}", - ) - _selectedPodcastEpisodes.value = selectedRaw - } else { - val page = - podcastRepository.getEpisodesPaginated( - podcastId, - limit = 25, - offset = 0, - sort = "newest", - ) - _selectedPodcastEpisodes.value = page.episodes - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e("HomeViewModelFilteredView", "Failed to fetch episodes for filter: $podcastId", e) - if (showLoading) { - _selectedPodcastEpisodes.value = emptyList() - } - } finally { - _isSelectedPodcastLoading.value = false - } + return@collectLatest + } + val switchingPodcast = info.podcastId != previouslyLoadedPodcastId + previouslyLoadedPodcastId = info.podcastId + publish(SelectedShowLoadTransitions.started(state, switchingPodcast)) + try { + publish(SelectedShowLoadTransitions.succeeded(loadSelectedShowPool(info))) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.e("HomeViewModelFilteredView", "Failed to fetch episodes for filter: ${info.podcastId}", e) + publish(SelectedShowLoadTransitions.failed(state)) } } } @@ -118,32 +84,106 @@ internal fun HomeViewModel.observeSelectedPodcast() { } viewModelScope.launch { + val frame = + combine( + _selectedPodcastId, + _selectedShowPool, + _isSelectedPodcastLoading, + _selectedShowLoadFailed, + ) { id, pool, loading, failed -> + // A pool still held from the previous show never pairs with the new one. + SelectedShowFrame(id, pool?.takeIf { it.podcastId == id }, loading, failed) + } combine( - _selectedPodcastId, - _selectedPodcastEpisodes, - _isSelectedPodcastLoading, + frame, userPrefs.hideCompletedInHomeStream, playbackRepository.completedEpisodeIds, - ) { id, eps, loading, hideCompleted, completedIds -> - val filteredEps = - HomeSelectedPodcastLogic.filterCompletedIfNeeded( - episodes = eps, - hideCompleted = hideCompleted, - completedIds = completedIds, - ) - Triple(id, filteredEps, loading) - }.collect { (id, eps, loading) -> - _uiState.update { - it.copy( - selectedPodcastId = id, - selectedPodcastEpisodes = eps, - isSelectedPodcastLoading = loading, - ) + ) { current, hideCompleted, completedIds -> + val list = + current.pool?.let { HomeShowEpisodesLogic.build(it, completedIds, hideCompleted) } + ?: ShowEpisodesList(emptyList()) + current to list + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + .collect { (current, list) -> + _uiState.update { + it.copy( + selectedPodcastId = current.podcastId, + selectedPodcastEpisodes = list.episodes, + selectedShowStatus = + SelectedShowStatus( + caughtUp = list.caughtUp, + loadFailed = current.loadFailed && list.episodes.isEmpty(), + newestEpisodeIds = list.newestIds, + ), + isSelectedPodcastLoading = current.loading, + ) + } } - } } } +private data class SelectedShowFrame( + val podcastId: String?, + val pool: ShowEpisodesPool?, + val loading: Boolean, + val loadFailed: Boolean, +) + +/** The selected show's episodes to build Home's list from: from the listener's place for a show listened to in order, else its newest. */ +private suspend fun HomeViewModel.loadSelectedShowPool(info: SelectedPodcastSignal): ShowEpisodesPool { + if (!info.inOrder) { + val newest = + podcastRepository.getEpisodesPaginated( + info.podcastId, + limit = HomeShowEpisodesLogic.NEWEST_POOL_SIZE, + offset = 0, + sort = "newest", + ).episodes + return ShowEpisodesPool(podcastId = info.podcastId, inOrder = false, episodes = newest) + } + val completedIds = playbackRepository.completedEpisodeIds.first() + val loaded = seriesEpisodesLoader(info.podcastId).load(info.placeHistoryId, completedIds) + val newest = + try { + podcastRepository.getEpisodesPaginated( + info.podcastId, + limit = HomeShowEpisodesLogic.NEWEST_FOR_IN_ORDER, + offset = 0, + sort = "newest", + ).episodes + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Only the NEW tags and the caught-up list use these; the list itself still stands. + android.util.Log.w("HomeViewModelFilteredView", "Newest episodes failed for ${info.podcastId}", e) + emptyList() + } + return ShowEpisodesPool( + podcastId = info.podcastId, + inOrder = true, + episodes = loaded.episodes, + placeId = loaded.placeId, + newest = newest, + ) +} + +/** Reads a show listened to in order from the listener's place on; shared by the selected-show list and Daily mix. */ +internal fun HomeViewModel.seriesEpisodesLoader(podcastId: String): SeriesEpisodesLoader = SeriesEpisodesLoader( + window = { aroundId -> podcastRepository.getEpisodeWindow(podcastId, aroundEpisodeId = aroundId, sort = "oldest") }, + oldestPage = { offset -> + val page = + podcastRepository.getEpisodesPaginated( + podcastId, + limit = SeriesEpisodesLoader.PAGE_SIZE, + offset = offset, + sort = "oldest", + ) + SeriesEpisodesLoader.Page(page.episodes, hasMore = page.hasMore, advance = page.sourceCount) + }, + groupTokens = { ids -> playbackRepository.episodeGroupTokens(ids) }, +) + internal fun HomeViewModel.observeDirectFeedRefresh() { viewModelScope.launch { val sync = SharedAppDependenciesHolder.instance?.subscriptionForegroundSync ?: return@launch diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSerial.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSerial.kt index 76513ac00..f120ebd8c 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSerial.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSerial.kt @@ -1,12 +1,13 @@ package cx.aswin.boxlore.feature.home import androidx.lifecycle.viewModelScope +import cx.aswin.boxlore.core.model.Episode import cx.aswin.boxlore.core.model.Podcast import cx.aswin.boxlore.core.model.listenerShowIds import cx.aswin.boxlore.core.playback.completedEpisodeIds import cx.aswin.boxlore.core.playback.episodeGroupTokens import cx.aswin.boxlore.feature.home.logic.HomeSerialLogic -import cx.aswin.boxlore.feature.home.logic.resolveNextSerialEpisode +import cx.aswin.boxlore.feature.home.logic.HomeShowEpisodesLogic import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -43,37 +44,25 @@ internal fun HomeViewModel.startOldestSortResolution() { resolutionLimiter.resolve { try { // A show switched to its feed reads its old show's listening. - val ongoingId = - HomeSerialLogic.ongoingEpisodeId(allHistory, pod.listenerShowIds) - val lastCompletedId = - HomeSerialLogic.lastCompletedEpisodeId(allHistory, pod.listenerShowIds) - + val place = HomeShowEpisodesLogic.placeHistoryId(allHistory, pod.listenerShowIds) android.util.Log.d( "HomeViewModelResolve", - "Resolving pod=${pod.title} id=${pod.id} ongoingId=$ongoingId lastCompletedId=$lastCompletedId", + "Resolving pod=${pod.title} id=${pod.id} place=$place", ) - - val page = - podcastRepository.getEpisodesPaginated( - pod.id, - limit = 100, - offset = 0, - sort = "oldest", - ) + val loaded = seriesEpisodesLoader(pod.id).load(place, progress.completed) // History keeps an episode under its owner ID; find it under the ID the list uses. val tokens = playbackRepository.episodeGroupTokens( - page.episodes.map { it.id } + listOfNotNull(ongoingId, lastCompletedId) + progress.inProgress, + loaded.episodes.map { it.id } + progress.inProgress, ) val nextEp = - resolveNextSerialEpisode( - allEpisodes = page.episodes, - ongoingId = HomeSerialLogic.idInList(ongoingId, page.episodes, tokens), - lastCompletedId = HomeSerialLogic.idInList(lastCompletedId, page.episodes, tokens), - completedEpIdsForResolve = progress.completed, - inProgressEpIdsForResolve = HomeSerialLogic.idsInList(progress.inProgress, page.episodes, tokens), + HomeShowEpisodesLogic.nextUp( + episodes = loaded.episodes, + placeId = loaded.placeId, + completedIds = progress.completed, + inProgressIds = HomeSerialLogic.idsInList(progress.inProgress, loaded.episodes, tokens), ) - if (nextEp != null) pod.id to nextEp else null + SerialResolution(pod.id, nextEp, HomeSerialLogic.resolveKey(pod, allHistory)) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -89,8 +78,16 @@ internal fun HomeViewModel.startOldestSortResolution() { } val results = deferredList.awaitAll().filterNotNull() + results.forEach { result -> + if (result.next == null) serialCaughtUpKeys[result.podcastId] = result.key else serialCaughtUpKeys.remove(result.podcastId) + } if (results.isNotEmpty()) { - _resolvedSerialEpisodes.update { current -> current + results } + // A show with nothing left drops its old suggestion; Daily mix then falls back to its newest episode. + _resolvedSerialEpisodes.update { current -> + results.fold(current) { acc, result -> + if (result.next != null) acc + (result.podcastId to result.next) else acc - result.podcastId + } + } } } finally { serialPodsToResolve.forEach { inFlightResolutions.remove(it.id) } @@ -111,4 +108,8 @@ internal fun HomeViewModel.findPendingSerialPodcasts( completedEpisodeIds = completedEpisodeIds, resolvedSerial = _resolvedSerialEpisodes.value, inFlightResolutions = inFlightResolutions, + caughtUpKeys = serialCaughtUpKeys, ) + +/** One show's resolution: its next episode, or null when it has nothing left, at the [key] it was checked at. */ +private data class SerialResolution(val podcastId: String, val next: Episode?, val key: String) diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySection.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySection.kt index 0f14b85e8..e5f7efccb 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySection.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySection.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -54,6 +55,7 @@ import cx.aswin.boxlore.core.model.Episode import cx.aswin.boxlore.core.model.PlaybackEntryPoint import cx.aswin.boxlore.core.model.Podcast import cx.aswin.boxlore.feature.home.R +import cx.aswin.boxlore.feature.home.SelectedShowStatus import cx.aswin.boxlore.feature.home.StableCompletedDownloadList import cx.aswin.boxlore.feature.home.StableEpisodeList import cx.aswin.boxlore.feature.home.StablePlaybackStateMap @@ -73,6 +75,8 @@ fun YourShowsSection( isSelectedPodcastLoading: Boolean, isSelectedRssRefreshing: Boolean, episodePlaybackState: StablePlaybackStateMap, + selectedShowStatus: SelectedShowStatus = SelectedShowStatus(), + onRetrySelectedShow: () -> Unit = {}, softExpireProgressEpisodeIds: Set = emptySet(), currentPlayingEpisodeId: String? = null, isPlaying: Boolean = false, @@ -215,7 +219,13 @@ fun YourShowsSection( } val frame = retainExitingShowsFrame( active = activeId == contentTarget, - current = SelectedShowPresentation(selectedPodcastEpisodes, isSelectedPodcastLoading, isSelectedRssRefreshing), + current = + SelectedShowPresentation( + selectedPodcastEpisodes, + isSelectedPodcastLoading, + isSelectedRssRefreshing, + selectedShowStatus, + ), ) val selectedPodcast = subscribedPodcasts.list.find { it.id == activeId } @@ -241,6 +251,7 @@ fun YourShowsSection( ) { SelectedShowHeader( podcast = selectedPodcast, + showsNextEpisodes = !frame.status.caughtUp, canClear = subscribedPodcasts.list.size > 1, onOpenShow = { onPodcastClick(selectedPodcast) }, onClear = { onPodcastSelected(null) }, @@ -251,7 +262,7 @@ fun YourShowsSection( selectedPodcast = selectedPodcast, frame = frame, playback = SelectedShowPlayback(episodePlaybackState, currentPlayingEpisodeId, isPlaying), - actions = SelectedShowActions(onPodcastClick, onEpisodeClick, onPlayEpisode), + actions = SelectedShowActions(onPodcastClick, onEpisodeClick, onPlayEpisode, onRetrySelectedShow), layout = SelectedShowLayout(tablet, sidebar), scrollState = filteredScrollState, scrollConnection = filteredScrollConnection, @@ -277,6 +288,7 @@ private data class SelectedShowActions( val onPodcastClick: (Podcast) -> Unit, val onEpisodeClick: (Episode, Podcast, String) -> Unit, val onPlayEpisode: (Episode, Podcast, PlaybackEntryPoint) -> Unit, + val onRetry: () -> Unit, ) @androidx.compose.runtime.Immutable @@ -305,20 +317,20 @@ private fun SelectedShowEpisodes( BoxLoreLoader.Expressive(size = 48.dp) } } else if (frame.episodes.list.isEmpty()) { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = Alignment.Center, - ) { + SelectedShowMessage( + failed = frame.status.loadFailed, + onRetry = actions.onRetry, + onSeeAll = { actions.onPodcastClick(selectedPodcast) }, + ) + } else { + if (frame.status.caughtUp) { Text( - text = stringResource(R.string.home_shows_no_episodes), - style = MaterialTheme.typography.bodyMedium, + text = stringResource(R.string.home_shows_caught_up), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp).testTag("home_filtered_caught_up"), ) } - } else { Column( modifier = Modifier @@ -338,14 +350,6 @@ private fun SelectedShowEpisodes( ) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) } - val latestTwoIds = - remember(frame.episodes) { - frame.episodes.list - .sortedByDescending { it.publishedDate } - .take(2) - .map { it.id } - .toSet() - } val visible = frame.episodes.list.take(8) // The rows and the See all footer form one connected list. val segments = visible.size + 1 @@ -374,7 +378,7 @@ private fun SelectedShowEpisodes( currentPlayingEpisodeId = playback.currentPlayingEpisodeId, isPlaying = playback.isPlaying, ), - isEligibleForNewTag = episode.id in latestTwoIds, + isEligibleForNewTag = episode.id in frame.status.newestEpisodeIds, ) } if (!layout.tablet) SeeAllEpisodesRow(shape = yourShowsListShape(segments - 1, segments)) { actions.onPodcastClick(selectedPodcast) } @@ -386,6 +390,33 @@ private fun SelectedShowEpisodes( } } +/** A selected show with no rows: why, Try again after a failed load, and the way to the show page. */ +@Composable +private fun SelectedShowMessage( + failed: Boolean, + onRetry: () -> Unit, + onSeeAll: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().testTag("home_filtered_message")) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(if (failed) R.string.home_shows_load_failed else R.string.home_shows_no_episodes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (failed) { + TextButton(onClick = onRetry, modifier = Modifier.testTag("home_filtered_retry")) { + Text(stringResource(R.string.home_shows_try_again)) + } + } + } + SeeAllEpisodesRow(shape = yourShowsListShape(0, 1), onClick = onSeeAll) + } +} + /** The last segment of a selected show's list; opens the show page. */ @Composable private fun SeeAllEpisodesRow(shape: androidx.compose.ui.graphics.Shape, onClick: () -> Unit) { diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/YourShowsMotion.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/YourShowsMotion.kt index a1031e41b..83f097b01 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/YourShowsMotion.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/YourShowsMotion.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import cx.aswin.boxlore.feature.home.SelectedShowStatus import cx.aswin.boxlore.feature.home.StableEpisodeList internal object YourShowsMotion { @@ -43,6 +44,7 @@ internal data class SelectedShowPresentation( val episodes: StableEpisodeList, val loading: Boolean, val refreshing: Boolean, + val status: SelectedShowStatus = SelectedShowStatus(), ) /** An exiting show's content retains its last displayed frame instead of adopting the next show. */ diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/YourShowsSelectors.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/YourShowsSelectors.kt index 657385329..bfcb8ca8c 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/YourShowsSelectors.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/YourShowsSelectors.kt @@ -198,6 +198,7 @@ private val TitlePressInset = 6.dp internal fun SelectedShowHeader( podcast: Podcast, canClear: Boolean, + showsNextEpisodes: Boolean = true, onOpenShow: () -> Unit, onClear: () -> Unit, ) { @@ -223,8 +224,9 @@ internal fun SelectedShowHeader( verticalArrangement = Arrangement.spacedBy(2.dp), ) { SelectedShowTitle(podcast.title, podcast.isRss || podcast.id.startsWith("rss:"), maxLines = 2, style = YourShowsText.title()) - // Newest first is the default and needs no label; serial shows say they continue in order. - if (podcast.preferredSort == "oldest") { + // Newest first is the default and needs no label; serial shows say they continue in order, + // except once caught up, when the list holds the latest episodes instead. + if (podcast.preferredSort == "oldest" && showsNextEpisodes) { Text( text = stringResource(R.string.home_shows_next_episodes), style = YourShowsText.subtitle(), diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeRefreshPresentationLogic.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeRefreshPresentationLogic.kt index f7f3d718b..55e90cd2d 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeRefreshPresentationLogic.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeRefreshPresentationLogic.kt @@ -19,6 +19,7 @@ internal fun HomeUiState.withCurrentCategoryDiscovery(previous: HomeUiState): Ho internal fun HomeUiState.withSelectedShowFrom(previous: HomeUiState): HomeUiState = copy( selectedPodcastId = previous.selectedPodcastId, selectedPodcastEpisodes = previous.selectedPodcastEpisodes, + selectedShowStatus = previous.selectedShowStatus, isSelectedPodcastLoading = previous.isSelectedPodcastLoading, isSelectedRssRefreshing = previous.isSelectedRssRefreshing, ) diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeSelectedPodcastLogic.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeSelectedPodcastLogic.kt index ff91d1dbe..43b806fb3 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeSelectedPodcastLogic.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeSelectedPodcastLogic.kt @@ -1,6 +1,5 @@ package cx.aswin.boxlore.feature.home.logic -import cx.aswin.boxlore.core.model.Episode import cx.aswin.boxlore.core.model.Podcast import cx.aswin.boxlore.core.model.listenerShowIds import cx.aswin.boxlore.feature.home.HomeListeningHistoryItem @@ -12,57 +11,26 @@ internal object HomeSelectedPodcastLogic { allHistory: List, subs: List, rssRefreshVersion: Long, + retryVersion: Long = 0L, ): SelectedPodcastSignal? { if (podcastId == null) return null val podcast = subs.find { it.id == podcastId } // A show switched to its feed reads the listening its old Podcast Index show keeps. val showIds = podcast?.listenerShowIds ?: setOf(podcastId) - val lastPlayed = - allHistory - .filter { it.podcastId in showIds } - .maxByOrNull { it.lastPlayedAt } val preferredSort = podcast?.preferredSort ?: "newest" + // Only a show listened to in order lists from the listener's place, so only it reloads when the place moves. + val place = if (preferredSort == "oldest") HomeShowEpisodesLogic.placeHistoryId(allHistory, showIds) else null return SelectedPodcastSignal( podcastId = podcastId, - lastPlayedEpisodeId = lastPlayed?.episodeId, + placeHistoryId = place, sort = preferredSort, rssRefreshVersion = rssRefreshVersion, latestEpisodeId = podcast?.latestEpisode?.id, rssHasNewEpisodes = podcast?.rssHasNewEpisodes == true, + retryVersion = retryVersion, ) } - fun oldestSortWindow( - allEpisodes: List, - lastPlayedEpisodeId: String?, - windowSize: Int = 15, - lookback: Int = 2, - ): List { - val lastPlayedIndex = - if (lastPlayedEpisodeId != null) { - allEpisodes.indexOfFirst { it.id == lastPlayedEpisodeId } - } else { - -1 - } - val offset = - if (lastPlayedIndex != -1) { - (lastPlayedIndex - lookback).coerceAtLeast(0) - } else { - 0 - } - return allEpisodes.drop(offset).take(windowSize) - } - - fun filterCompletedIfNeeded( - episodes: List, - hideCompleted: Boolean, - completedIds: Set, - ): List = if (hideCompleted) { - episodes.filter { it.id !in completedIds } - } else { - episodes - } - /** * Skeleton only when switching chips or the stream is still empty. * Same-chip tip / extras refresh must swap in place. @@ -72,3 +40,33 @@ internal object HomeSelectedPodcastLogic { listEmpty: Boolean, ): Boolean = switchingPodcast || listEmpty } + +/** The selected show's load state: the episodes loaded for it, whether the list shows a skeleton, and whether the last load failed with nothing to show. */ +internal data class SelectedShowLoadState( + val pool: ShowEpisodesPool? = null, + val loading: Boolean = false, + val failed: Boolean = false, +) + +/** How the selected show's load state moves as loads start, succeed and fail. */ +internal object SelectedShowLoadTransitions { + val cleared = SelectedShowLoadState() + + /** + * A load starts. Switching shows drops the old show's episodes. The skeleton shows only when + * nothing is on screen, and then an earlier failure is cleared, so Try again shows the skeleton. + */ + fun started( + state: SelectedShowLoadState, + switchingPodcast: Boolean, + ): SelectedShowLoadState { + val pool = if (switchingPodcast) null else state.pool + val loading = HomeSelectedPodcastLogic.shouldShowLoading(switchingPodcast, listEmpty = pool == null) + return SelectedShowLoadState(pool = pool, loading = loading, failed = state.failed && !loading) + } + + fun succeeded(pool: ShowEpisodesPool): SelectedShowLoadState = SelectedShowLoadState(pool = pool) + + /** A failed refresh keeps the list on screen; only a show with nothing to show says it failed. */ + fun failed(state: SelectedShowLoadState): SelectedShowLoadState = state.copy(loading = false, failed = state.pool == null) +} diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeSerialLogic.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeSerialLogic.kt index 5f6bc920e..17f17522a 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeSerialLogic.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeSerialLogic.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.feature.home.logic import cx.aswin.boxlore.core.model.Episode import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.core.model.listenerShowIds import cx.aswin.boxlore.feature.home.HomeListeningHistoryItem internal data class SerialProgressIds( @@ -61,12 +62,23 @@ internal object HomeSerialLogic { return ids + episodes.filter { (tokens[it.id] ?: it.id) in wanted }.map { it.id } } + /** + * What a show's next episode depends on: the listener's place and the show's newest episode. A show + * found to have nothing left at a key isn't checked again until the key changes, so playback + * progress can't reload a finished series on every update. + */ + fun resolveKey( + pod: Podcast, + allHistory: List, + ): String = "${HomeShowEpisodesLogic.placeHistoryId(allHistory, pod.listenerShowIds)}|${pod.latestEpisode?.id}" + fun findPendingSerialPodcasts( subs: List, allHistory: List, completedEpisodeIds: Set, resolvedSerial: Map, inFlightResolutions: Set, + caughtUpKeys: Map = emptyMap(), ): List { val progress = progressIds(allHistory, completedEpisodeIds) return subs.filter { (it.preferredSort ?: "newest") == "oldest" }.filter { pod -> @@ -75,7 +87,8 @@ internal object HomeSerialLogic { currentResolved == null || currentResolved.id in progress.completed || currentResolved.id in progress.inProgress - needsResolve && pod.id !in inFlightResolutions + val caughtUpHere = currentResolved == null && caughtUpKeys[pod.id]?.let { it == resolveKey(pod, allHistory) } == true + needsResolve && !caughtUpHere && pod.id !in inFlightResolutions } } } diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeShowEpisodesLogic.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeShowEpisodesLogic.kt new file mode 100644 index 000000000..ac0ca856d --- /dev/null +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeShowEpisodesLogic.kt @@ -0,0 +1,154 @@ +package cx.aswin.boxlore.feature.home.logic + +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.model.isTrailer +import cx.aswin.boxlore.feature.home.HomeListeningHistoryItem + +/** What Home's list for one selected show is built from. */ +internal data class ShowEpisodesPool( + val podcastId: String, + /** True for a show listened to in order (oldest first); false for newest first. */ + val inOrder: Boolean, + /** In order: the show from around the listener's place, in listening order. Otherwise its newest episodes, newest first. */ + val episodes: List, + /** In order: the listener's place as an ID in [episodes]; null when they haven't started the show. */ + val placeId: String? = null, + /** The show's newest episodes, newest first. */ + val newest: List = episodes, +) + +/** The rows Home shows for a selected show. */ +internal data class ShowEpisodesList( + val episodes: List, + /** Nothing is left to hear, so [episodes] are the show's latest episodes. */ + val caughtUp: Boolean = false, + /** The show's two newest episodes, the only ones that can carry the NEW tag. */ + val newestIds: Set = emptySet(), +) + +/** + * Which episodes Home lists for a selected show, and which one Daily mix suggests next for a show + * listened to in order. Both read the listener's place the same way, so they never disagree. + * + * - Newest first: unfinished episodes among the newest [NEWEST_POOL_SIZE], newest at the top. + * - In order: the episode the listener is in the middle of, then the next unfinished episodes after + * the furthest one they finished (replaying an early episode doesn't move the list back). Trailers + * are skipped. + * - When nothing is left, the show's latest episodes, marked as caught up, instead of an empty list. + */ +internal object HomeShowEpisodesLogic { + const val VISIBLE_ROWS = 8 + const val NEWEST_POOL_SIZE = 50 + const val NEWEST_FOR_IN_ORDER = 10 + private const val CONTEXT_BEFORE = 2 + private const val NEW_TAG_COUNT = 2 + + /** + * Listening order: publish date, with season then episode number deciding between episodes + * published at the same moment (whole seasons released at once). Numbers only break ties, so a + * feed that numbers some episodes and not others can't scramble the order. + */ + private val listeningOrderComparator: Comparator = + compareBy { it.publishedDate } + .thenBy(nullsLast()) { it.seasonNumber } + .thenBy(nullsLast()) { it.episodeNumber } + + fun listeningOrder(episodes: List): List = episodes.distinctBy { it.id }.sortedWith(listeningOrderComparator) + + /** The listener's place in a show listened to in order: the episode they're in the middle of, else the one they finished last. */ + fun placeHistoryId( + history: List, + showIds: Set, + ): String? = HomeSerialLogic.ongoingEpisodeId(history, showIds) ?: HomeSerialLogic.lastCompletedEpisodeId(history, showIds) + + /** + * Where the next episodes start in [episodes] (listening order): the place, moved on past the + * furthest finished episode in the list. + */ + fun startIndex( + episodes: List, + placeId: String?, + completedIds: Set, + ): Int { + val placeIndex = placeId?.let { id -> episodes.indexOfFirst { it.id == id } } ?: -1 + val furthestFinished = episodes.indexOfLast { it.id in completedIds } + return maxOf(placeIndex, furthestFinished + 1, 0) + } + + /** The unfinished, non-trailer episodes from the start of the next episodes on. */ + fun ahead( + episodes: List, + placeId: String?, + completedIds: Set, + ): List = episodes + .drop(startIndex(episodes, placeId, completedIds)) + .filter { !it.isTrailer && it.id !in completedIds } + + /** The next episode Daily mix suggests in a show listened to in order: the first one ahead that isn't started. */ + fun nextUp( + episodes: List, + placeId: String?, + completedIds: Set, + inProgressIds: Set, + ): Episode? = ahead(episodes, placeId, completedIds).firstOrNull { it.id !in inProgressIds } + + fun build( + pool: ShowEpisodesPool, + completedIds: Set, + hideCompleted: Boolean, + ): ShowEpisodesList { + val newestIds = pool.newest.take(NEW_TAG_COUNT).mapTo(HashSet()) { it.id } + return if (pool.inOrder) { + buildInOrder(pool, completedIds, hideCompleted, newestIds) + } else { + buildNewestFirst(pool.episodes, completedIds, hideCompleted, newestIds) + } + } + + private fun buildNewestFirst( + episodes: List, + completedIds: Set, + hideCompleted: Boolean, + newestIds: Set, + ): ShowEpisodesList { + val newest = episodes.distinctBy { it.id } + val unfinished = newest.filter { it.id !in completedIds } + if (unfinished.isEmpty()) return caughtUp(newest, newestIds) + val shown = if (hideCompleted) unfinished else newest + return ShowEpisodesList(shown.take(VISIBLE_ROWS), caughtUp = false, newestIds = newestIds) + } + + private fun buildInOrder( + pool: ShowEpisodesPool, + completedIds: Set, + hideCompleted: Boolean, + newestIds: Set, + ): ShowEpisodesList { + val episodes = pool.episodes + val ahead = ahead(episodes, pool.placeId, completedIds) + if (ahead.isEmpty()) { + return caughtUp(pool.newest.ifEmpty { episodes.asReversed() }, newestIds) + } + val start = startIndex(episodes, pool.placeId, completedIds) + val placeIndex = pool.placeId?.let { id -> episodes.indexOfFirst { it.id == id } } ?: -1 + val shown = + if (hideCompleted) { + // A replayed episode left behind the furthest point still leads the list while it's unfinished. + val resumed = episodes.getOrNull(placeIndex)?.takeIf { placeIndex < start && it.id !in completedIds } + listOfNotNull(resumed) + ahead + } else { + val from = (start - CONTEXT_BEFORE).coerceAtLeast(0) + val resumed = episodes.getOrNull(placeIndex)?.takeIf { placeIndex < from && it.id !in completedIds } + listOfNotNull(resumed) + episodes.drop(from).filter { !it.isTrailer } + } + return ShowEpisodesList(shown.take(VISIBLE_ROWS), caughtUp = false, newestIds = newestIds) + } + + private fun caughtUp( + newestFirst: List, + newestIds: Set, + ): ShowEpisodesList { + val latest = newestFirst.distinctBy { it.id }.take(VISIBLE_ROWS) + return ShowEpisodesList(latest, caughtUp = latest.isNotEmpty(), newestIds = newestIds) + } +} diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/SerialEpisodeLogic.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/SerialEpisodeLogic.kt deleted file mode 100644 index 20e115f53..000000000 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/SerialEpisodeLogic.kt +++ /dev/null @@ -1,37 +0,0 @@ -package cx.aswin.boxlore.feature.home.logic - -import cx.aswin.boxlore.core.model.Episode - -internal fun resolveNextSerialEpisode( - allEpisodes: List, - ongoingId: String?, - lastCompletedId: String?, - completedEpIdsForResolve: Set, - inProgressEpIdsForResolve: Set, -): Episode? { - val nextEp = - when { - ongoingId != null -> { - val ongoingIndex = allEpisodes.indexOfFirst { it.id == ongoingId } - if (ongoingIndex != -1 && ongoingIndex < allEpisodes.lastIndex) { - allEpisodes[ongoingIndex + 1] - } else { - null - } - } - lastCompletedId != null -> { - val completedIndex = allEpisodes.indexOfFirst { it.id == lastCompletedId } - if (completedIndex != -1 && completedIndex < allEpisodes.lastIndex) { - allEpisodes[completedIndex + 1] - } else { - null - } - } - else -> { - allEpisodes.firstOrNull() - } - } - return nextEp ?: allEpisodes.firstOrNull { ep -> - ep.id !in completedEpIdsForResolve && ep.id !in inProgressEpIdsForResolve - } -} diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/SeriesEpisodesLoader.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/SeriesEpisodesLoader.kt new file mode 100644 index 000000000..699d7c089 --- /dev/null +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/SeriesEpisodesLoader.kt @@ -0,0 +1,131 @@ +package cx.aswin.boxlore.feature.home.logic + +import cx.aswin.boxlore.core.model.Episode +import kotlinx.coroutines.CancellationException + +/** + * Loads a show listened to in order from the listener's place onwards, in listening order, for + * Home's selected-show list and Daily mix's next episode. + * + * A followed show's saved copy answers with the episodes from the place on, however deep in the show + * it is. When that can't find the place (no saved copy yet, so only the server's newest episodes; the + * place stored under another ID; or episodes released at the same moment, which the copy can cut + * through), the show is read oldest first a page at a time until the place turns up, keeping only + * the page it's on and the next. If every loaded episode after the place is finished, loading moves + * on from the last one, so a long run of finished episodes can't end the list early. + */ +internal class SeriesEpisodesLoader( + /** The show from [aroundId] on (the saved copy), or its newest episodes when it can't. */ + private val window: suspend (aroundId: String) -> List, + /** The show oldest first, from [offset]. */ + private val oldestPage: suspend (offset: Int) -> Page, + /** One token per episode, shared by every ID it's stored under. */ + private val groupTokens: suspend (ids: Collection) -> Map, + private val maxPages: Int = MAX_PAGES, +) { + data class Page( + val episodes: List, + val hasMore: Boolean, + /** How far the next page's offset moves; feed extras merged into a page don't count. */ + val advance: Int = episodes.size, + ) + + data class Loaded( + /** Listening order. */ + val episodes: List, + /** The place as an ID in [episodes]; null when there's no place or it wasn't found. */ + val placeId: String?, + ) + + suspend fun load( + placeHistoryId: String?, + completedIds: Set, + ): Loaded { + var loaded = around(placeHistoryId) + var moves = 0 + while (moves < MAX_MOVES && endsBeforeNext(loaded, completedIds)) { + val last = loaded.episodes.last() + val further = around(last.id) + if (further.episodes.none { it.id != last.id }) break + loaded = further + moves++ + } + return loaded + } + + private fun endsBeforeNext( + loaded: Loaded, + completedIds: Set, + ): Boolean = loaded.episodes.size >= PAGE_SIZE && + HomeShowEpisodesLogic.ahead(loaded.episodes, loaded.placeId, completedIds).isEmpty() + + private suspend fun around(placeHistoryId: String?): Loaded { + if (placeHistoryId == null) return fromStart() + val fromWindow = HomeShowEpisodesLogic.listeningOrder(readWindow(placeHistoryId)) + val tokens = groupTokens(fromWindow.map { it.id } + placeHistoryId) + val placeInWindow = idIn(placeHistoryId, fromWindow, tokens) + if (placeInWindow != null && !sharesMomentWithPlace(fromWindow, placeInWindow)) { + return Loaded(fromWindow, placeInWindow) + } + return scan(placeHistoryId) + ?: placeInWindow?.let { Loaded(fromWindow, it) } + ?: fromStart() + } + + private suspend fun readWindow(aroundId: String): List = try { + window(aroundId) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // The pages below can still find the place. + emptyList() + } + + private suspend fun fromStart(): Loaded = Loaded(HomeShowEpisodesLogic.listeningOrder(oldestPage(0).episodes), placeId = null) + + /** Reads the show oldest first until [placeHistoryId] turns up; null when it doesn't within [maxPages]. */ + private suspend fun scan(placeHistoryId: String): Loaded? { + var offset = 0 + var previous: List = emptyList() + repeat(maxPages) { + val page = oldestPage(offset) + if (page.episodes.isEmpty()) return null + val tokens = groupTokens(page.episodes.map { it.id } + placeHistoryId) + val found = idIn(placeHistoryId, page.episodes, tokens) + if (found != null) { + val next = if (page.hasMore) oldestPage(offset + page.advance).episodes else emptyList() + val episodes = HomeShowEpisodesLogic.listeningOrder(previous.takeLast(PREVIOUS_KEPT) + page.episodes + next) + return Loaded(episodes, found) + } + if (!page.hasMore || page.advance <= 0) return null + previous = page.episodes + offset += page.advance + } + return null + } + + /** True when another episode was published at the same moment as the place; the saved copy may have cut that group. */ + private fun sharesMomentWithPlace( + episodes: List, + placeId: String, + ): Boolean { + val moment = episodes.firstOrNull { it.id == placeId }?.publishedDate ?: return false + return episodes.count { it.publishedDate == moment } > 1 + } + + private fun idIn( + id: String, + episodes: List, + tokens: Map, + ): String? { + val token = tokens[id] ?: id + return episodes.firstOrNull { (tokens[it.id] ?: it.id) == token }?.id + } + + companion object { + const val PAGE_SIZE = 100 + const val MAX_PAGES = 20 + private const val MAX_MOVES = 5 + private const val PREVIOUS_KEPT = 10 + } +} diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index 13efdb60e..6d65c2b49 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -83,7 +83,10 @@ Clear show filter Show info See all episodes - No episodes available + No episodes yet + You’re all caught up + Couldn’t load episodes + Try again Refreshing RSS episodes… Pin show Unpin show diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeConnectedRailTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeConnectedRailTest.kt index f069d1182..15d50d3dd 100644 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeConnectedRailTest.kt +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeConnectedRailTest.kt @@ -44,6 +44,7 @@ import cx.aswin.boxlore.core.downloads.CompletedDownloadItem import cx.aswin.boxlore.core.model.EpisodeStatus import cx.aswin.boxlore.core.model.PlaybackEntryPoint import cx.aswin.boxlore.core.testing.TestFixtures +import cx.aswin.boxlore.feature.home.SelectedShowStatus import cx.aswin.boxlore.feature.home.StableCompletedDownloadList import cx.aswin.boxlore.feature.home.StableEpisodeList import cx.aswin.boxlore.feature.home.StablePlaybackStateMap @@ -63,13 +64,14 @@ import org.robolectric.annotation.GraphicsMode @GraphicsMode(GraphicsMode.Mode.NATIVE) class HomeConnectedRailTest { @get:Rule val composeRule = createComposeRule() - private data class Scenario(val count: Int = 3, val selected: String? = null, val width: Int = 353, val scale: Float = 1f, val rtl: Boolean = false, val empty: Boolean = false, val mode: HomeMixMode = HomeMixMode.DAILY, val progressed: Boolean = true, val distinctEpisodes: Boolean = false, val episodeCount: Int = 1, val parentScrollHost: Boolean = false, val tablet: Boolean = false, val densityMultiplier: Float = 1f, val paneWidth: Int? = null) + private data class Scenario(val count: Int = 3, val selected: String? = null, val width: Int = 353, val scale: Float = 1f, val rtl: Boolean = false, val empty: Boolean = false, val mode: HomeMixMode = HomeMixMode.DAILY, val progressed: Boolean = true, val distinctEpisodes: Boolean = false, val episodeCount: Int = 1, val parentScrollHost: Boolean = false, val tablet: Boolean = false, val densityMultiplier: Float = 1f, val paneWidth: Int? = null, val status: SelectedShowStatus = SelectedShowStatus()) private val state = mutableStateOf(Scenario()) private val parentScroll = ScrollState(0) private var infoClicks = 0 private var plays = 0 private var episodeClicks = 0 private val selectedLoading = mutableStateOf(false) + private var retries = 0 private val showEvents = mutableListOf() private val episodeEvents = mutableListOf>() private val playEvents = mutableListOf>() @@ -469,18 +471,21 @@ class HomeConnectedRailTest { state.value = Scenario(count = 3, selected = "show-0", width = 1_200, tablet = true, episodeCount = 0) selectedLoading.value = true render() - composeRule.onNodeWithText("No episodes available").assertDoesNotExist() + composeRule.onNodeWithText("No episodes yet").assertDoesNotExist() composeRule.onNodeWithTag("home_filtered_episodes").assertDoesNotExist() composeRule.onNodeWithTag("home_filtered_see_all").assertDoesNotExist() selectedLoading.value = false composeRule.waitForIdle() - composeRule.onNodeWithText("No episodes available").assertExists() - composeRule.onNodeWithTag("home_filtered_see_all").assertDoesNotExist() + composeRule.onNodeWithText("No episodes yet").assertExists() + // An empty show still leads to its page. + composeRule.onNodeWithTag("home_filtered_see_all").performClick() + assertEquals(listOf("show-0"), showEvents) + showEvents.clear() state.value = state.value.copy(episodeCount = 3) composeRule.waitForIdle() - composeRule.onNodeWithText("No episodes available").assertDoesNotExist() + composeRule.onNodeWithText("No episodes yet").assertDoesNotExist() composeRule.onNodeWithTag("home_filtered_episodes").assertExists() composeRule.onNodeWithTag("home_mix_play_episode", useUnmergedTree = true).performClick() composeRule.onNodeWithTag("home_filtered_see_all").performClick() @@ -488,6 +493,40 @@ class HomeConnectedRailTest { assertEquals(listOf("show-0"), showEvents) } + @Test fun `failed and caught-up show states keep their actions across phone and tablet widths`() { + state.value = Scenario(count = 3, selected = "show-0", episodeCount = 0, status = SelectedShowStatus(loadFailed = true)) + render() + composeRule.onNodeWithText("Couldn’t load episodes").assertExists() + composeRule.onNodeWithText("No episodes yet").assertDoesNotExist() + composeRule.onNodeWithTag("home_filtered_retry").performClick() + assertEquals(1, retries) + // Widening to the tablet sidebar keeps the failure, its Try again and the way to the show page. + state.value = state.value.copy(width = 1_200, tablet = true) + composeRule.waitForIdle() + composeRule.onNodeWithTag("home_filtered_retry").performClick() + assertEquals(2, retries) + composeRule.onNodeWithTag("home_filtered_see_all").performClick() + assertEquals(listOf("show-0"), showEvents) + + // Caught up: the latest episodes, the line saying so, and no "Next episodes" promise. + state.value = state.value.copy(episodeCount = 2, status = SelectedShowStatus(caughtUp = true)) + composeRule.waitForIdle() + composeRule.onNodeWithTag("home_filtered_caught_up").assertExists() + composeRule.onNodeWithTag("home_filtered_episodes").assertExists() + composeRule.onNodeWithTag("home_filtered_retry").assertDoesNotExist() + state.value = state.value.copy(width = 353, tablet = false) + composeRule.waitForIdle() + composeRule.onNodeWithTag("home_filtered_caught_up").assertExists() + composeRule.onNodeWithTag("home_filtered_see_all").performClick() + assertEquals(listOf("show-0", "show-0"), showEvents) + + // Back to episodes left to hear: the line goes. + state.value = state.value.copy(status = SelectedShowStatus()) + composeRule.waitForIdle() + composeRule.onNodeWithTag("home_filtered_caught_up").assertDoesNotExist() + composeRule.onNodeWithTag("home_filtered_episodes").assertExists() + } + private fun render() { composeRule.setContent { val scenario = state.value @@ -521,6 +560,8 @@ class HomeConnectedRailTest { isSelectedPodcastLoading = selectedLoading.value, isSelectedRssRefreshing = false, episodePlaybackState = StablePlaybackStateMap(emptyMap()), + selectedShowStatus = scenario.status, + onRetrySelectedShow = { retries++ }, onPodcastSelected = { state.value = state.value.copy(selected = it) }, onPodcastClick = { podcast -> infoClicks++ diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeRefreshPresentationLogicTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeRefreshPresentationLogicTest.kt index 61462f725..a088ed46f 100644 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeRefreshPresentationLogicTest.kt +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeRefreshPresentationLogicTest.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.feature.home.logic import cx.aswin.boxlore.core.testing.TestFixtures import cx.aswin.boxlore.feature.home.HomeUiState +import cx.aswin.boxlore.feature.home.SelectedShowStatus import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -71,6 +72,17 @@ class HomeRefreshPresentationLogicTest { } } + @Test + fun `base Home updates keep the selected show's caught-up, failed and NEW-tag status`() { + val status = SelectedShowStatus(caughtUp = true, loadFailed = true, newestEpisodeIds = setOf("ep-9", "ep-8")) + val previous = HomeUiState(heroItems = emptyList(), discoverPodcasts = emptyList(), selectedPodcastId = "show", selectedShowStatus = status) + + // A playback-driven rebuild starts from the default status; the collector's status must win. + val tick = HomeUiState(heroItems = emptyList(), discoverPodcasts = emptyList(), selectedPodcastId = "show").withSelectedShowFrom(previous) + + assertEquals(status, tick.selectedShowStatus) + } + @Test fun `usable cards remain visible during refresh but initial or empty states do not`() { assertTrue(showHomeDiscoveryContent(initialLoading = false, hasContent = true)) diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeSelectedPodcastLogicTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeSelectedPodcastLogicTest.kt index 2b0a2e71d..f208b7ea6 100644 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeSelectedPodcastLogicTest.kt +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeSelectedPodcastLogicTest.kt @@ -3,6 +3,7 @@ package cx.aswin.boxlore.feature.home.logic import cx.aswin.boxlore.core.testing.TestFixtures import cx.aswin.boxlore.feature.home.HomeListeningHistoryItem import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -20,7 +21,7 @@ class HomeSelectedPodcastLogicTest { } @Test - fun `build signal uses last played episode and preferred sort`() { + fun `build signal of a show listened to in order carries the place and preferred sort`() { val subs = listOf(TestFixtures.podcast(id = "pod-1").copy(preferredSort = "oldest")) val history = listOf( @@ -57,7 +58,8 @@ class HomeSelectedPodcastLogicTest { ) assertEquals("pod-1", signal?.podcastId) - assertEquals("ep-2", signal?.lastPlayedEpisodeId) + // Both are in progress; the place is the one played last. + assertEquals("ep-2", signal?.placeHistoryId) assertEquals("oldest", signal?.sort) assertEquals(3L, signal?.rssRefreshVersion) assertEquals(subs[0].latestEpisode?.id, signal?.latestEpisodeId) @@ -71,9 +73,9 @@ class HomeSelectedPodcastLogicTest { val signal = HomeSelectedPodcastLogic.buildSignal(podcastId = "rss:feed", allHistory = listOf(played), subs = listOf(feedShow), rssRefreshVersion = 0L) - assertEquals("pi-2", signal?.lastPlayedEpisodeId) + assertEquals("pi-2", signal?.placeHistoryId) // Another show's rows never count. - assertNull(HomeSelectedPodcastLogic.buildSignal(podcastId = "rss:feed", allHistory = listOf(played.copy(podcastId = "other")), subs = listOf(feedShow), rssRefreshVersion = 0L)?.lastPlayedEpisodeId) + assertNull(HomeSelectedPodcastLogic.buildSignal(podcastId = "rss:feed", allHistory = listOf(played.copy(podcastId = "other")), subs = listOf(feedShow), rssRefreshVersion = 0L)?.placeHistoryId) } @Test @@ -98,34 +100,24 @@ class HomeSelectedPodcastLogicTest { } @Test - fun `oldest sort window centers near last played with lookback`() { - val episodes = (0..9).map { TestFixtures.episode(id = "ep-$it") } - val window = - HomeSelectedPodcastLogic.oldestSortWindow( - allEpisodes = episodes, - lastPlayedEpisodeId = "ep-5", - windowSize = 4, - lookback = 2, - ) + fun `newest-first shows don't reload when another episode is played`() { + val subs = listOf(TestFixtures.podcast(id = "pod-1").copy(preferredSort = "newest")) + val history = listOf(HomeListeningHistoryItem("ep-1", "pod-1", "Show", null, 1L, 10L, false, false, 5L)) + + val signal = HomeSelectedPodcastLogic.buildSignal(podcastId = "pod-1", allHistory = history, subs = subs, rssRefreshVersion = 0L) - assertEquals(listOf("ep-3", "ep-4", "ep-5", "ep-6"), window.map { it.id }) + assertNull(signal?.placeHistoryId) + assertEquals(false, signal?.inOrder) } @Test - fun `filter completed if needed removes completed ids`() { - val episodes = listOf(TestFixtures.episode(id = "a"), TestFixtures.episode(id = "b")) - assertEquals( - listOf("a"), - HomeSelectedPodcastLogic - .filterCompletedIfNeeded(episodes, hideCompleted = true, completedIds = setOf("b")) - .map { it.id }, - ) - assertEquals( - listOf("a", "b"), - HomeSelectedPodcastLogic - .filterCompletedIfNeeded(episodes, hideCompleted = false, completedIds = setOf("b")) - .map { it.id }, - ) + fun `try again changes the signal so the show loads again`() { + val subs = listOf(TestFixtures.podcast(id = "pod-1")) + + val first = HomeSelectedPodcastLogic.buildSignal("pod-1", emptyList(), subs, rssRefreshVersion = 0L, retryVersion = 0L) + val retried = HomeSelectedPodcastLogic.buildSignal("pod-1", emptyList(), subs, rssRefreshVersion = 0L, retryVersion = 1L) + + assertNotEquals(first, retried) } @Test diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeSerialLogicTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeSerialLogicTest.kt index 35ae4ff5e..1fe39ebcf 100644 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeSerialLogicTest.kt +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeSerialLogicTest.kt @@ -40,6 +40,31 @@ class HomeSerialLogicTest { assertEquals(listOf("old"), pending.map { it.id }) } + @Test + fun `a series with nothing left isn't checked again until the place or its newest episode changes`() { + val series = TestFixtures.podcast(id = "old").copy(preferredSort = "oldest", latestEpisode = TestFixtures.episode(id = "ep-9")) + val history = listOf(item("ep-9", completed = true, progressMs = 0L, podcastId = "old")) + val caughtUp = mapOf("old" to HomeSerialLogic.resolveKey(series, history)) + + fun pending( + pod: cx.aswin.boxlore.core.model.Podcast, + rows: List, + ) = HomeSerialLogic.findPendingSerialPodcasts( + subs = listOf(pod), + allHistory = rows, + completedEpisodeIds = emptySet(), + resolvedSerial = emptyMap(), + inFlightResolutions = emptySet(), + caughtUpKeys = caughtUp, + ) + + // Playback updates with the same place and newest episode: nothing to load. + assertEquals(emptyList(), pending(series, history).map { it.id }) + // A new episode arrives, or the listener plays another one: check again. + assertEquals(listOf("old"), pending(series.copy(latestEpisode = TestFixtures.episode(id = "ep-10")), history).map { it.id }) + assertEquals(listOf("old"), pending(series, history + item("ep-3", completed = false, progressMs = 5L, podcastId = "old", lastPlayedAt = 9L)).map { it.id }) + } + @Test fun `ongoing and last completed pick latest by lastPlayedAt`() { val history = @@ -74,12 +99,11 @@ class HomeSerialLogicTest { assertEquals("-2", HomeSerialLogic.idInList(ongoing, episodes, tokens)) assertEquals("-1", HomeSerialLogic.idInList(lastCompleted, episodes, tokens)) val next = - resolveNextSerialEpisode( - allEpisodes = episodes, - ongoingId = HomeSerialLogic.idInList(ongoing, episodes, tokens), - lastCompletedId = HomeSerialLogic.idInList(lastCompleted, episodes, tokens), - completedEpIdsForResolve = emptySet(), - inProgressEpIdsForResolve = HomeSerialLogic.idsInList(setOf("pi-2"), episodes, tokens), + HomeShowEpisodesLogic.nextUp( + episodes = episodes, + placeId = HomeSerialLogic.idInList(HomeShowEpisodesLogic.placeHistoryId(history, feedShow.listenerShowIds), episodes, tokens), + completedIds = HomeSerialLogic.idsInList(setOf("pi-1"), episodes, tokens), + inProgressIds = HomeSerialLogic.idsInList(setOf("pi-2"), episodes, tokens), ) assertEquals("-3", next?.id) // Without the old show's rows the show would start over at its first episode. diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeShowEpisodesLogicTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeShowEpisodesLogicTest.kt new file mode 100644 index 000000000..9396dc470 --- /dev/null +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeShowEpisodesLogicTest.kt @@ -0,0 +1,302 @@ +package cx.aswin.boxlore.feature.home.logic + +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.testing.TestFixtures +import cx.aswin.boxlore.feature.home.HomeListeningHistoryItem +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class HomeShowEpisodesLogicTest { + /** A show of [count] episodes, ep-1 the oldest, one day apart. */ + private fun show(count: Int): List = (1..count).map { episode(it) } + + private fun episode( + number: Int, + publishedDate: Long = number * DAY, + ): Episode = TestFixtures.episode(id = "ep-$number", publishedDate = publishedDate) + + private fun ids(episodes: List) = episodes.map { it.id } + + private fun ids(vararg numbers: Int) = numbers.map { "ep-$it" } + + private fun completed(vararg numbers: Int) = numbers.mapTo(HashSet()) { "ep-$it" } + + private fun completedRange(range: IntRange) = range.mapTo(HashSet()) { "ep-$it" } + + private fun inOrderPool( + episodes: List, + placeId: String?, + newest: List = episodes.asReversed(), + ) = ShowEpisodesPool("pod-1", inOrder = true, episodes = episodes, placeId = placeId, newest = newest) + + private fun newestPool(newestFirst: List) = ShowEpisodesPool("pod-1", inOrder = false, episodes = newestFirst) + + // --- Newest first --- + + @Test + fun `newest first lists unfinished episodes newest at the top`() { + val list = HomeShowEpisodesLogic.build(newestPool(show(12).asReversed()), completed(12, 10), hideCompleted = true) + + assertEquals(ids(11, 9, 8, 7, 6, 5, 4, 3), ids(list.episodes)) + assertFalse(list.caughtUp) + } + + @Test + fun `newest first with every episode finished shows the latest ones caught up instead of nothing`() { + val episodes = show(12).asReversed() + + val list = HomeShowEpisodesLogic.build(newestPool(episodes), completedRange(1..12), hideCompleted = true) + + assertTrue(list.caughtUp) + assertEquals(ids(12, 11, 10, 9, 8, 7, 6, 5), ids(list.episodes)) + } + + @Test + fun `newest first looks past finished recent episodes to older unfinished ones`() { + // The newest 30 are finished; the old 25-episode limit left two rows at most, here none. + val episodes = show(50).asReversed() + + val list = HomeShowEpisodesLogic.build(newestPool(episodes), completedRange(21..50), hideCompleted = true) + + assertFalse(list.caughtUp) + assertEquals(ids(20, 19, 18, 17, 16, 15, 14, 13), ids(list.episodes)) + } + + @Test + fun `newest first with hiding off keeps finished episodes and says caught up only when all are`() { + val episodes = show(10).asReversed() + + val some = HomeShowEpisodesLogic.build(newestPool(episodes), completed(10), hideCompleted = false) + val all = HomeShowEpisodesLogic.build(newestPool(episodes), completedRange(1..10), hideCompleted = false) + + assertEquals("ep-10", some.episodes.first().id) + assertFalse(some.caughtUp) + assertTrue(all.caughtUp) + } + + @Test + fun `a show with no episodes is empty, not caught up`() { + val list = HomeShowEpisodesLogic.build(newestPool(emptyList()), emptySet(), hideCompleted = true) + + assertTrue(list.episodes.isEmpty()) + assertFalse(list.caughtUp) + } + + // --- In order --- + + @Test + fun `in order lists the episode in progress then the next ones`() { + val list = HomeShowEpisodesLogic.build(inOrderPool(show(20), placeId = "ep-5"), completedRange(1..4), hideCompleted = true) + + assertEquals(ids(5, 6, 7, 8, 9, 10, 11, 12), ids(list.episodes)) + assertFalse(list.caughtUp) + } + + @Test + fun `in order continues after the last finished episode`() { + val list = HomeShowEpisodesLogic.build(inOrderPool(show(20), placeId = "ep-4"), completedRange(1..4), hideCompleted = true) + + assertEquals("ep-5", list.episodes.first().id) + } + + @Test + fun `in order starts at the beginning when nothing is played`() { + val list = HomeShowEpisodesLogic.build(inOrderPool(show(20), placeId = null), emptySet(), hideCompleted = true) + + assertEquals("ep-1", list.episodes.first().id) + } + + @Test + fun `finishing the whole series shows the latest episodes caught up instead of nothing`() { + val episodes = show(20) + + val list = HomeShowEpisodesLogic.build(inOrderPool(episodes, placeId = "ep-20"), completedRange(1..20), hideCompleted = true) + + assertTrue(list.caughtUp) + assertEquals(ids(20, 19, 18, 17, 16, 15, 14, 13), ids(list.episodes)) + } + + @Test + fun `replaying an early episode does not move the list back`() { + // Finished 1 to 40, then finished episode 2 again: the place by time is ep-2. + val list = HomeShowEpisodesLogic.build(inOrderPool(show(60), placeId = "ep-2"), completedRange(1..40), hideCompleted = true) + + assertEquals("ep-41", list.episodes.first().id) + } + + @Test + fun `an early episode being replayed leads the list, then the episodes after the furthest one finished`() { + val finished = completedRange(1..40) - "ep-2" + + val list = HomeShowEpisodesLogic.build(inOrderPool(show(60), placeId = "ep-2"), finished, hideCompleted = true) + + assertEquals(ids(2, 41, 42), ids(list.episodes).take(3)) + } + + @Test + fun `trailers are skipped in order`() { + val episodes = show(6).map { if (it.id == "ep-3") it.copy(episodeType = "trailer", duration = 60) else it } + + val list = HomeShowEpisodesLogic.build(inOrderPool(episodes, placeId = "ep-1"), completed(1), hideCompleted = true) + + assertEquals(ids(2, 4, 5, 6), ids(list.episodes)) + } + + @Test + fun `a long episode marked as a trailer still counts as an episode`() { + val episodes = show(4).map { if (it.id == "ep-2") it.copy(episodeType = "trailer", duration = 3_600) else it } + + val list = HomeShowEpisodesLogic.build(inOrderPool(episodes, placeId = "ep-1"), completed(1), hideCompleted = true) + + assertEquals(ids(2, 3, 4), ids(list.episodes)) + } + + @Test + fun `in order with hiding off shows two finished episodes before the place`() { + val list = HomeShowEpisodesLogic.build(inOrderPool(show(20), placeId = "ep-10"), completedRange(1..9), hideCompleted = false) + + assertEquals(ids(8, 9, 10, 11, 12, 13, 14, 15), ids(list.episodes)) + } + + @Test + fun `caught up falls back to the loaded episodes newest first when the newest ones failed to load`() { + val list = + HomeShowEpisodesLogic.build( + inOrderPool(show(3), placeId = "ep-3", newest = emptyList()), + completedRange(1..3), + hideCompleted = true, + ) + + assertTrue(list.caughtUp) + assertEquals(ids(3, 2, 1), ids(list.episodes)) + } + + // --- NEW tag --- + + @Test + fun `only the show's two newest episodes can carry the NEW tag`() { + val episodes = show(30) + val newest = episodes.asReversed().take(10) + + val list = HomeShowEpisodesLogic.build(inOrderPool(episodes, placeId = "ep-5", newest = newest), completedRange(1..4), hideCompleted = true) + + assertEquals(setOf("ep-30", "ep-29"), list.newestIds) + assertTrue(list.episodes.none { it.id in list.newestIds }) + } + + // --- Listening order --- + + @Test + fun `episodes released at the same moment play in season and episode order`() { + val sameMoment = 5 * DAY + val season = + listOf(3, 1, 4, 2).map { number -> + TestFixtures.episode(id = "s1e$number", publishedDate = sameMoment).copy(seasonNumber = 1, episodeNumber = number) + } + val earlier = TestFixtures.episode(id = "trailer-ish", publishedDate = DAY) + + val ordered = HomeShowEpisodesLogic.listeningOrder(season + earlier) + + assertEquals(listOf("trailer-ish", "s1e1", "s1e2", "s1e3", "s1e4"), ids(ordered)) + } + + @Test + fun `episode numbers never override publish dates`() { + // A feed that numbers episodes out of order keeps its publish order. + val first = TestFixtures.episode(id = "a", publishedDate = DAY).copy(episodeNumber = 9) + val second = TestFixtures.episode(id = "b", publishedDate = 2 * DAY).copy(episodeNumber = 1) + val unnumbered = TestFixtures.episode(id = "c", publishedDate = 3 * DAY) + + assertEquals(listOf("a", "b", "c"), ids(HomeShowEpisodesLogic.listeningOrder(listOf(unnumbered, second, first)))) + } + + @Test + fun `listening order drops duplicate rows`() { + val episodes = show(3) + + assertEquals(ids(1, 2, 3), ids(HomeShowEpisodesLogic.listeningOrder(episodes + episodes.first()))) + } + + // --- Daily mix's next episode --- + + @Test + fun `daily mix suggests the episode after the one in progress`() { + val next = HomeShowEpisodesLogic.nextUp(show(10), placeId = "ep-4", completedIds = completedRange(1..3), inProgressIds = setOf("ep-4")) + + assertEquals("ep-5", next?.id) + } + + @Test + fun `daily mix suggests the episode after the last finished one`() { + val next = HomeShowEpisodesLogic.nextUp(show(10), placeId = "ep-4", completedIds = completedRange(1..4), inProgressIds = emptySet()) + + assertEquals("ep-5", next?.id) + } + + @Test + fun `daily mix skips finished episodes ahead of the one in progress`() { + val next = HomeShowEpisodesLogic.nextUp(show(10), placeId = "ep-4", completedIds = completed(1, 2, 3, 5), inProgressIds = setOf("ep-4")) + + // ep-5 was heard already; the list and Daily mix agree on ep-6. + assertEquals("ep-6", next?.id) + val list = HomeShowEpisodesLogic.build(inOrderPool(show(10), placeId = "ep-4"), completed(1, 2, 3, 5), hideCompleted = true) + assertEquals("ep-6", list.episodes.first { it.id != "ep-4" }.id) + } + + @Test + fun `daily mix and the list agree after a replay`() { + val finished = completedRange(1..40) + + val next = HomeShowEpisodesLogic.nextUp(show(60), placeId = "ep-2", completedIds = finished, inProgressIds = emptySet()) + val list = HomeShowEpisodesLogic.build(inOrderPool(show(60), placeId = "ep-2"), finished, hideCompleted = true) + + assertEquals("ep-41", next?.id) + assertEquals(next?.id, list.episodes.first().id) + } + + @Test + fun `daily mix has nothing to suggest for a finished series`() { + assertNull(HomeShowEpisodesLogic.nextUp(show(10), placeId = "ep-10", completedIds = completedRange(1..10), inProgressIds = emptySet())) + } + + // --- The place --- + + @Test + fun `the place is the episode in progress, else the last one finished`() { + val history = + listOf( + item("ep-3", completed = true, lastPlayedAt = 30L), + item("ep-4", completed = false, progressMs = 10L, lastPlayedAt = 20L), + item("other", completed = false, progressMs = 10L, lastPlayedAt = 99L, podcastId = "pod-2"), + ) + + assertEquals("ep-4", HomeShowEpisodesLogic.placeHistoryId(history, setOf("pod-1"))) + assertEquals("ep-3", HomeShowEpisodesLogic.placeHistoryId(history.filter { it.isCompleted }, setOf("pod-1"))) + assertNull(HomeShowEpisodesLogic.placeHistoryId(emptyList(), setOf("pod-1"))) + } + + private fun item( + episodeId: String, + completed: Boolean, + progressMs: Long = 0L, + lastPlayedAt: Long = 0L, + podcastId: String = "pod-1", + ) = HomeListeningHistoryItem( + episodeId = episodeId, + podcastId = podcastId, + podcastName = "Show", + podcastImageUrl = null, + progressMs = progressMs, + durationMs = 100L, + isCompleted = completed, + isLiked = false, + lastPlayedAt = lastPlayedAt, + ) + + private companion object { + const val DAY = 86_400L + } +} diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/SelectedShowLoadTransitionsTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/SelectedShowLoadTransitionsTest.kt new file mode 100644 index 000000000..e9531eae5 --- /dev/null +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/SelectedShowLoadTransitionsTest.kt @@ -0,0 +1,76 @@ +package cx.aswin.boxlore.feature.home.logic + +import cx.aswin.boxlore.core.testing.TestFixtures +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SelectedShowLoadTransitionsTest { + private val pool = ShowEpisodesPool("show-a", inOrder = false, episodes = listOf(TestFixtures.episode(id = "ep-1"))) + private val other = ShowEpisodesPool("show-b", inOrder = false, episodes = listOf(TestFixtures.episode(id = "ep-2"))) + + @Test + fun `a failed first load shows the failure instead of an empty show`() { + val loading = SelectedShowLoadTransitions.started(SelectedShowLoadTransitions.cleared, switchingPodcast = true) + assertTrue(loading.loading) + assertFalse(loading.failed) + + val failed = SelectedShowLoadTransitions.failed(loading) + + assertNull(failed.pool) + assertFalse(failed.loading) + assertTrue(failed.failed) + } + + @Test + fun `a failed refresh keeps the list on screen and doesn't say it failed`() { + val shown = SelectedShowLoadTransitions.succeeded(pool) + + val refreshing = SelectedShowLoadTransitions.started(shown, switchingPodcast = false) + // Same show with a list: refreshed in place, no skeleton. + assertEquals(pool, refreshing.pool) + assertFalse(refreshing.loading) + + val failed = SelectedShowLoadTransitions.failed(refreshing) + assertEquals(pool, failed.pool) + assertFalse(failed.failed) + } + + @Test + fun `try again after a failure shows the skeleton, then the episodes`() { + val failed = SelectedShowLoadTransitions.failed(SelectedShowLoadTransitions.started(SelectedShowLoadTransitions.cleared, switchingPodcast = true)) + + val retrying = SelectedShowLoadTransitions.started(failed, switchingPodcast = false) + assertTrue(retrying.loading) + assertFalse(retrying.failed) + + val recovered = SelectedShowLoadTransitions.succeeded(pool) + assertEquals(pool, recovered.pool) + assertFalse(recovered.loading) + assertFalse(recovered.failed) + } + + @Test + fun `a retry that fails again says so again`() { + val failed = SelectedShowLoadTransitions.failed(SelectedShowLoadTransitions.started(SelectedShowLoadTransitions.cleared, switchingPodcast = true)) + + val failedAgain = SelectedShowLoadTransitions.failed(SelectedShowLoadTransitions.started(failed, switchingPodcast = false)) + + assertTrue(failedAgain.failed) + } + + @Test + fun `switching shows drops the old show's episodes and its failure`() { + val shown = SelectedShowLoadTransitions.succeeded(pool) + + val switching = SelectedShowLoadTransitions.started(shown, switchingPodcast = true) + assertNull(switching.pool) + assertTrue(switching.loading) + + assertEquals(other, SelectedShowLoadTransitions.succeeded(other).pool) + val afterFailure = SelectedShowLoadTransitions.started(SelectedShowLoadTransitions.failed(switching), switchingPodcast = true) + assertFalse(afterFailure.failed) + } +} diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/SerialEpisodeLogicTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/SerialEpisodeLogicTest.kt deleted file mode 100644 index 85b75d1e1..000000000 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/SerialEpisodeLogicTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package cx.aswin.boxlore.feature.home.logic - -import cx.aswin.boxlore.core.testing.TestFixtures -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Test - -class SerialEpisodeLogicTest { - @Test - fun `resolve next serial episode advances from ongoing episode`() { - val episodes = - listOf( - TestFixtures.episode(id = "ep-1"), - TestFixtures.episode(id = "ep-2"), - TestFixtures.episode(id = "ep-3"), - ) - - val next = - resolveNextSerialEpisode( - allEpisodes = episodes, - ongoingId = "ep-1", - lastCompletedId = null, - completedEpIdsForResolve = emptySet(), - inProgressEpIdsForResolve = emptySet(), - ) - - assertEquals("ep-2", next?.id) - } - - @Test - fun `resolve next serial episode advances from last completed when no ongoing`() { - val episodes = - listOf( - TestFixtures.episode(id = "ep-1"), - TestFixtures.episode(id = "ep-2"), - ) - - val next = - resolveNextSerialEpisode( - allEpisodes = episodes, - ongoingId = null, - lastCompletedId = "ep-1", - completedEpIdsForResolve = setOf("ep-1"), - inProgressEpIdsForResolve = emptySet(), - ) - - assertEquals("ep-2", next?.id) - } - - @Test - fun `resolve next serial episode skips completed and in progress when at end of chain`() { - val episodes = - listOf( - TestFixtures.episode(id = "ep-1"), - TestFixtures.episode(id = "ep-2"), - TestFixtures.episode(id = "ep-3"), - ) - - val next = - resolveNextSerialEpisode( - allEpisodes = episodes, - ongoingId = "ep-3", - lastCompletedId = null, - completedEpIdsForResolve = setOf("ep-1"), - inProgressEpIdsForResolve = setOf("ep-3"), - ) - - assertEquals("ep-2", next?.id) - } - - @Test - fun `resolve next serial episode returns null when all episodes are consumed`() { - val episodes = - listOf( - TestFixtures.episode(id = "ep-1"), - TestFixtures.episode(id = "ep-2"), - ) - - val next = - resolveNextSerialEpisode( - allEpisodes = episodes, - ongoingId = "ep-2", - lastCompletedId = null, - completedEpIdsForResolve = setOf("ep-1", "ep-2"), - inProgressEpIdsForResolve = setOf("ep-2"), - ) - - assertNull(next) - } -} diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/SeriesEpisodesLoaderTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/SeriesEpisodesLoaderTest.kt new file mode 100644 index 000000000..5f60c1abf --- /dev/null +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/SeriesEpisodesLoaderTest.kt @@ -0,0 +1,174 @@ +package cx.aswin.boxlore.feature.home.logic + +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.testing.TestFixtures +import java.io.IOException +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SeriesEpisodesLoaderTest { + /** + * A show of [count] episodes, ep-1 the oldest. Its saved copy answers a window from a known + * episode on (200 at most); for an ID it doesn't hold it gives [unknownWindow], as the saved copy + * gives the show's start and the server its newest. + */ + private class FakeShow( + val episodes: List, + private val unknownWindow: (List) -> List = { it.take(WINDOW) }, + private val aliases: Map = emptyMap(), + ) { + var windowReads = 0 + var pageReads = 0 + var windowFails = false + var pagesFail = false + + suspend fun window(aroundId: String): List { + windowReads++ + if (windowFails) throw IOException("window") + val index = episodes.indexOfFirst { it.id == aroundId } + return if (index == -1) unknownWindow(episodes) else episodes.drop(index).take(WINDOW) + } + + suspend fun page(offset: Int): SeriesEpisodesLoader.Page { + pageReads++ + if (pagesFail) throw IOException("page") + val page = episodes.drop(offset).take(SeriesEpisodesLoader.PAGE_SIZE) + return SeriesEpisodesLoader.Page(page, hasMore = offset + page.size < episodes.size) + } + + /** Every ID of an episode shares one token; an alias stands for the episode it names. */ + suspend fun tokens(ids: Collection): Map = ids.associateWith { aliases[it] ?: it } + + fun loader() = SeriesEpisodesLoader(window = ::window, oldestPage = ::page, groupTokens = ::tokens) + } + + private fun show(count: Int): List = (1..count).map { TestFixtures.episode(id = "ep-$it", publishedDate = it * DAY) } + + private fun finished(range: IntRange) = range.mapTo(HashSet()) { "ep-$it" } + + @Test + fun `a place deep in a long series is read from the saved copy, past the old 100 limit`() = runTest { + val show = FakeShow(show(300)) + + val loaded = show.loader().load("ep-150", finished(1..149)) + + assertEquals("ep-150", loaded.placeId) + assertEquals("ep-150", loaded.episodes.first().id) + assertEquals(1, show.windowReads) + assertEquals(0, show.pageReads) + assertEquals("ep-150", HomeShowEpisodesLogic.ahead(loaded.episodes, loaded.placeId, finished(1..149)).first().id) + } + + @Test + fun `a place stored under another ID is found page by page`() = runTest { + val show = FakeShow(show(1_000), aliases = mapOf("owner-650" to "ep-650")) + + val loaded = show.loader().load("owner-650", finished(1..650)) + + assertEquals("ep-650", loaded.placeId) + assertEquals( + "ep-651", + HomeShowEpisodesLogic.ahead(loaded.episodes, loaded.placeId, finished(1..650)).first().id, + ) + // Seven pages to find it and the next one; the earlier pages aren't kept. + assertEquals(8, show.pageReads) + assertTrue(loaded.episodes.size <= 2 * SeriesEpisodesLoader.PAGE_SIZE + 10) + } + + @Test + fun `a show with only the server's newest episodes finds an early place page by page`() = runTest { + // No saved copy yet, so the window is the server's newest 200, newest first. + val show = FakeShow(show(1_000), unknownWindow = { it.takeLast(WINDOW).asReversed() }, aliases = mapOf("pi-90" to "ep-90")) + + val loaded = show.loader().load("pi-90", finished(1..90)) + + assertEquals("ep-90", loaded.placeId) + assertEquals("ep-91", HomeShowEpisodesLogic.ahead(loaded.episodes, loaded.placeId, finished(1..90)).first().id) + } + + @Test + fun `a season released at one moment is read whole and played in episode order`() = runTest { + val sameMoment = 50 * DAY + val season = listOf(3, 1, 4, 2, 5).map { TestFixtures.episode(id = "s2e$it", publishedDate = sameMoment).copy(seasonNumber = 2, episodeNumber = it) } + val episodes = show(10) + season + // The saved copy reads on from the place in its own storage order, so it can cut the season. + val show = FakeShow(episodes) + + val loaded = show.loader().load("s2e4", setOf("s2e1", "s2e2", "s2e3", "s2e4") + finished(1..10)) + + assertEquals("s2e4", loaded.placeId) + assertEquals(listOf("s2e1", "s2e2", "s2e3", "s2e4", "s2e5"), loaded.episodes.filter { it.id.startsWith("s2") }.map { it.id }) + assertEquals("s2e5", HomeShowEpisodesLogic.ahead(loaded.episodes, loaded.placeId, setOf("s2e1", "s2e2", "s2e3", "s2e4") + finished(1..10)).single().id) + } + + @Test + fun `a show not started yet is read from its start`() = runTest { + val show = FakeShow(show(300)) + + val loaded = show.loader().load(null, emptySet()) + + assertNull(loaded.placeId) + assertEquals("ep-1", loaded.episodes.first().id) + assertEquals(0, show.windowReads) + assertEquals(1, show.pageReads) + } + + @Test + fun `a place that can't be found falls back to the start of the show`() = runTest { + val show = FakeShow(show(150)) + + val loaded = show.loader().load("gone", emptySet()) + + assertNull(loaded.placeId) + assertEquals("ep-1", loaded.episodes.first().id) + } + + @Test + fun `a long run of finished episodes after the place doesn't end the list early`() = runTest { + // Finished 1 to 450, then replayed episode 10 to the end: the place by time is ep-10. + val show = FakeShow(show(600)) + val done = finished(1..450) + + val loaded = show.loader().load("ep-10", done) + + assertEquals("ep-451", HomeShowEpisodesLogic.ahead(loaded.episodes, loaded.placeId, done).first().id) + } + + @Test + fun `a finished series stops reading at its end`() = runTest { + val show = FakeShow(show(250)) + val done = finished(1..250) + + val loaded = show.loader().load("ep-250", done) + + assertTrue(HomeShowEpisodesLogic.ahead(loaded.episodes, loaded.placeId, done).isEmpty()) + assertTrue(show.windowReads <= 2) + } + + @Test + fun `a failed saved-copy read still finds the place page by page`() = runTest { + val show = FakeShow(show(300)).apply { windowFails = true } + + val loaded = show.loader().load("ep-220", finished(1..219)) + + assertEquals("ep-220", loaded.placeId) + } + + @Test + fun `a failed page read reaches the caller`() { + val show = FakeShow(show(300)).apply { pagesFail = true } + + assertThrows(IOException::class.java) { + kotlinx.coroutines.runBlocking { show.loader().load(null, emptySet()) } + } + } + + private companion object { + const val DAY = 86_400L + const val WINDOW = 200 + } +} diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index 76ab30c8a..32984030e 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -26,4 +26,8 @@ Announcement title: ## Improved +- On Home, a show you listen to oldest first now picks up exactly where you left off, even deep into a long series, and skips trailers. Daily mix suggests the same next episode. + ## Fixed + +- On Home, a show you've heard every episode of now lists its latest episodes instead of an empty list, and a show whose episodes can't load lets you try again.