diff --git a/feature/info/README.md b/feature/info/README.md index 9e12ba99b..abd7c88df 100644 --- a/feature/info/README.md +++ b/feature/info/README.md @@ -94,7 +94,7 @@ The page's description sits under the chips, where it is on every show page; tap The spotlight shows the newest full episode while the list is newest first and search is closed, with the list's like, download, queue and play actions, progress and played state (every card says Played in place of the episode's length once it is finished, the newer ones through `brandedDetails`); the list leaves that episode out. Sorting oldest first or searching hides the card and the episode returns to the list. With completed episodes hidden, a finished latest episode hides the card rather than letting an older episode stand in as the latest. A card's picture is the episode's own (`brandedEpisodePicture`): the show's cover counts as none, even at another size or address scheme, while query parameters that name a picture (`?id=`) keep different pictures apart. A card that shows a picture falls back on the show's cover (`brandedShowPicture`; the fallback image when a show has no cover). -`BrandedShowPages` lists the shows that have a page: The Vergecast (ultraviolet and lime, wordmark and sticker, sticker slap on Subscribe), Conspiracy Theories (an evidence board on white paper by day and black by night, both carrying the show's faded typed script: the official wordmark pinned on a scrap of the show's yellow paper, and round it only episodes from the feed, each by the headline before its colon, or after it for a label like "BEST OF 2025" (`boardClues`): a newspaper clipping for the newest, ringed with a red marker loop, a typed index card for the one before, and a handwritten sticky note for the shortest of the next four, each clamped at three lines with an ellipsis; three more clues peek in from the window's edges, clipped as if the board carries on, their threads running off the screen. On a window narrower than the design's 369dp board (a cover screen, a split window) the clues, pins and yarn shrink to fit it (`ConspiracyBoardLayout.boardWidth`) instead of running off its edges. Glossy pushpins hold each scrap by its top edge, and twisted red yarn (plied, a little fuzzy, wavering, with a soft shadow, built once at full length so drawing it on never changes what is drawn) runs only over empty board: an X under the wordmark and a drop to the sticky note. The board's middle stays centred on wide windows, its paper tiled, while the peeking clues keep to the window's edges. When the page first opens the scraps drop in, the pins go in, the yarn draws on and the marker circles the newest clue, then the yarn sways twice and rests (`ConspiracyBoardLayout`). The latest episode is a manila folder with a dated tab, a NEW stamp, the typed title and notes and the file's buttons; "It all connects" on Subscribe: the button becomes a patch of the board, three scraps land and are pinned, the yarn closes a triangle between them and a red marker eye is drawn inside it with one haptic and a small tug, then the board clears into Subscribed), Waveform: The MKBHD Podcast (Signal: a black page with one red, the logo traced as live bars across the top over a faint pattern of the show's gadget icons; the bars breathe while one of its episodes plays and replay the logo intro on Subscribe, with a red and cyan glitch. Its latest-episode card has the episode's waveform as a band across the top, white up to where you are and grey after with no other marker, then "LATEST", the title and two lines of the episode's own description; the band's bars breathe with the logo while the episode plays) and Crime Junkie (Fingerprint: the official splatter mark over a fingerprint drawn in code in the splatter's magenta, violet and blue, faded out before any text; the latest episode as an evidence tag with clipped corners, a tear line and an eyelet (on the left in every language, the text kept clear of them), the case type stamped from the title before its colon ("MURDERED: Name", 99% of the show's titles; any other title shows whole with no stamp), the name in Oswald capitals, the episode's art and two lines of its description, while screen readers hear the episode's own title; a Flashlight moment on Subscribe: the lights go out on the button, a torch beam sweeps across and back, finds the ink splatter with one tap, and the light floods the button into Subscribed) and The Joe Rogan Experience (The studio: the official badge over a red stage curtain drawn in code, lit warm behind the badge and falling into the dark page; the latest episode as its own picture with the template's top strip trimmed off, in a warm raised panel so its black edges stand off the page, with the episode cards' buttons inside the panel; a black strip across the top carries the "Latest episode" label in the template's style, and the picture is scaled to fit between it and the progress bar, a blurred, darkened copy filling the sides, so neither covers the title in the picture; the card keeps a phone's proportions on wide windows, centred under the badge. An episode with no picture of its own gets its number and guest set in Anton on the curtain, darkened behind the guest so the orange name holds 3:1 even on the brightest fold, scaled with the card like the picture it stands in for; the curtain also shows while a picture loads and stays if it never arrives, while screen readers hear the episode's own title; a Third eye moment on Subscribe: the button goes dark, an eye like the badge's opens in it, its dotted rays spread across the button, it pulses once with one haptic and closes into Subscribed, while the badge's own eye lights up on the same clock) and Rotten Mango (Her room: Apple's official show-page photo of Stephanie Soo in her red room, the wordmark on its wall, emerging from the dark under the top bar and fading into the page below her lap, kept at a phone's size and centred with faded sides on wide windows; the latest episode in an old, darkened frame, "Latest episode" in dull red and the title typed in Special Elite up to four lines, with the episode cards' buttons inside the frame and the episode's number on a brass plate hung from it ("Bonus" when the feed gives none); "Bada bing, bada boom" on tape on Subscribe: the button switches on like an old television, "BADA BING" plays grainy and jittery in VT323, a burst of static cuts in, "BADA BOOM" hits with split colours, a slip of the vertical hold and one haptic, and the set switches off to a line before Subscribed lands, about 1.5 seconds, no sound) and The Daily (Front page: newsprint and ink in light mode, a warm charcoal night edition with cream ink in dark mode; the official one-line wordmark large across the top, a double rule, today's dateline (turning over at midnight and checked again when you come back to the page) with the Times' masthead and the ©, and the description and chips set like the paper; the latest episode as the lead story: "Latest episode" in the show's blue with the day it came out and its length on the same line, the title as a serif headline, five lines of the episode's notes beside its photo (the show's cover when it has none of its own, over the show's dawn horizon while either loads), and the episode cards' buttons and progress between two fine rules; "Daybreak" on Subscribe (`DailyDaybreak`, timings in `DailyDawn`): Subscribe sinks away as the show's dawn rises up through the button from its bottom edge, a small sun clears the horizon with one light tap, then daylight washes the sky into the Subscribed colour and Subscribed comes up, about 1.2 seconds; it replaced "Off the press", a steel roller printing a fresh sheet)). Waveform's, The Joe Rogan Experience's and Rotten Mango's identities are dark only, so their pages (`BrandedShowPage.darkOnly`) stay dark with white status bar icons in light mode too. Each page lays out its own header (`BrandedShowPage.Header`) around the shared chip row and description; the © button and its popup are shared (`BrandedCopyrightButton`). The popup opens below the © or, when the window has more room above, above it with its caret flipped; it stays inside the window and scrolls when taller than it. Where a publisher's name sits beside the ©, a long name shortens with an ellipsis so the © keeps its place. The Rest Is History (the gallery wall: the show's claret velvet as a museum wall in both themes, hung with its seven latest episodes' own pictures in antique bronze frames, the newest in the big centre frame, the two before it in ovals beside it and four more small at the sides; David and Churchill, cut from the show's cover with their faces whole, stand at the window's edges with their straight cut on the wall's edge and the show's logo between them, and the Goalhanger logo with the © below. The first time the page opens the gallery starts in the dark and its lights come on one by one, about 1.8 seconds in all (`GalleryWall`, choreography in `HistoryWall`): the room warms, the centre frame's spotlight catches with a flicker, the two ovals' come on together, then the small frames' row by row, each spotlight flaring as it comes on and settling to a soft pool on the velvet; the busts are lit from below, the logo comes up and a gilt glint runs across it. The lights wait until the page has settled and the frames' pictures have arrived (at most 1.2 seconds), with a short dark beat before them, so no picture pops in while they come up; frames have no loading placeholder, only a dark canvas until their picture arrives. By day the page below the wall is ivory and claret and the wall ends in a fine claret line. The latest episode hangs on a piece of the same wall under a picture light: its own picture in the wall's bronze frame with its number on a brass nameplate, "Latest episode", its name and its series and part split from the title (`historyTitle`), while screen readers hear the episode's own title; Play is ivory by night and claret with an ivory ring by day. "Through the ages" on Subscribe: the button becomes velvet, the latest episodes' pictures (shared from the header through `BrandedHeaderMotion.pictures`) rush in from the right in small bronze frames, oldest first and blurred with speed, the newest lands in the middle, a brass picture lamp clicks on over it with one haptic, its light flickers, the frames either side dim, and the wall clears into Subscribed), Darknet Diaries (the show's own flag-bearer art cut out of its black in a red light, dark in both themes: the flag waves while the figure, pole and trident never move (`FlagMesh` warps the art with a bitmap mesh on every Android version the app supports, following `DarknetFlagWave`: a 260-unit travelling wave every 2.8 seconds, its strength read from an amplitude map), light and shade ripple along the cloth, the red light breathes, light shafts sway through the episode illustrations' ink grit and red ink flecks drift through the air; the header stays black until its art is decoded and the page has settled, then the art rises into place with the red light coming up (the first time), and the motion eases in and keeps going for as long as the header is on screen; it stops when you scroll it away or leave, and stays still with animations off or battery saver on. To keep each frame cheap the light shafts are drawn once into a half-resolution picture off the main thread and only turned and screened on each frame (`DarknetRays`), the cloth's light and shade is a masked shader blended straight onto the art (`FlagShade`, one shader moved by its matrices each frame, so the wave allocates nothing), and the art draws in its own layer. The flag carries the show's name, so there is no separate wordmark. The latest episode is a charred card with a scorched top edge and an ember glow, "// LATEST · EP 182", the episode's name in Anton capitals (`darknetTitle`; screen readers hear its own title), when it came out and how long it is, and its illustration taped up beside them like a flyer, tilted, that straightens while you press the card. "The masked host" on Subscribe: the button goes dark, Jack's masked avatar comes up still in the middle, his avatar's red ink burst lights up behind him with a hot core, his glasses flash red with one haptic, film grain runs over it all, and the button clears into Subscribed) and Serial (its logo made real on the plum of its cover art, dark in both themes: the logo is a stack of black cards with white rims, and here each card is a season, the newest on top and six before it stepping down-left behind it, each with its cover and "18 · METRO" under it. Covers of seasons that have ended ship with the app (`SerialSeasons.Bundled`, seasons 12 to 18, each the picture on its trailer, or on episode 1 when it has none); a newer season comes from the feed (`serialDeck`; Serial's header gets the trailers too, `BrandedShowPage.headerKeepsTrailers`). Seasons such as METRO give every episode its own photo and put the season's art on the trailer and episode 1, so a new season's card shows its trailer's own picture, else episode 1's; while neither is loaded, the picture all its loaded episodes share, or else the show's cover (`serialSeasonPicture`). Its name is the one two of its episode titles share before " - ", or "Season 19" until then. When the covers are decoded and the page has settled, the deck deals itself once: the oldest card lands first and each season drops onto the one before with a small bump, 150ms apart, then Serial's own card slides half out from behind the newest season and settles at a tilt (`SerialDeal`, keyframes eased stretch by stretch as on the board, `SerialKeys`); it stands dealt after that, and always with animations off. A tap on the deck deals it again; screen readers skip the deck, and hear SERIAL as the page's heading. Serial's own card is drawn, not pictured: its S is traced from the show's art (`SerialMark`), cream with a red edge and a cream copy just below it, on a black card with the deck's rim (`drawSerialCard`). Under SERIAL sit the Times' T and Serial Productions' S, as the season covers sign them, then the ©. The latest episode is the top card of a stack, its own picture on a rimmed card with two plain cards behind it, "METRO · EPISODE 3" in red capitals and the title without the season's name in front (`serialEpisodeLine`; a title that is only a number after the name stays whole, and screen readers hear the episode's own title). "One more on the stack" on Subscribe: the button goes dark, a small stack of cards waits in its middle, Serial's S card drops onto it with a bump and one haptic, the stack steps down-left to make room, a red glow comes up behind the new top card, and the button clears into Subscribed). No subscribe moment spells out "Subscribed"; the button's own label lands after each. Headers with their own art decode it off the main thread and keep it for the next visit (`rememberBrandedArt`, `BrandedArtCache`; a new set of pictures starts empty, never showing the last set's), so opening a page never waits on decoding. Subscribe moments take their art from the same cache (`brandedArtNow`), which the page warms when it opens, so a tap starts on its first frame, and start their motion only once the page has settled (`awaitPageSettled`: at least 250ms in and six smooth frames in a row, or 1.5 seconds at most). Fonts bundled with a page keep their licence under `licenses/` (Anton, shared by The Vergecast, The Joe Rogan Experience and Darknet Diaries, Barlow, Caveat, Cinzel, Cormorant Garamond, Courier Prime, Libre Franklin, Newsreader, Old Standard TT, Oswald and VT323, SIL OFL 1.1; Caveat, Cinzel, Cormorant Garamond and Old Standard TT are bundled at the single weights the pages draw; Special Elite, shared by Conspiracy Theories and Rotten Mango, Apache 2.0). Oswald and Libre Franklin are bundled as variable fonts and drawn at weights 500, 600 and 700; Newsreader's variable font is drawn at the weight and optical size of each line. They are built with the platform's font builder (`rememberVariableFont`), as the app's Google Sans is, because some devices ignore Compose's font variation settings and draw the default weight. +`BrandedShowPages` lists the shows that have a page: The Vergecast (ultraviolet and lime, wordmark and sticker, sticker slap on Subscribe), Conspiracy Theories (an evidence board on white paper by day and black by night, both carrying the show's faded typed script: the official wordmark pinned on a scrap of the show's yellow paper, and round it only episodes from the feed, each by the headline before its colon, or after it for a label like "BEST OF 2025" (`boardClues`): a newspaper clipping for the newest, ringed with a red marker loop, a typed index card for the one before, and a handwritten sticky note for the shortest of the next four, each clamped at three lines with an ellipsis; three more clues peek in from the window's edges, clipped as if the board carries on, their threads running off the screen. On a window narrower than the design's 369dp board (a cover screen, a split window) the clues, pins and yarn shrink to fit it (`ConspiracyBoardLayout.boardWidth`) instead of running off its edges. Glossy pushpins hold each scrap by its top edge, and twisted red yarn (plied, a little fuzzy, wavering, with a soft shadow, built once at full length so drawing it on never changes what is drawn) runs only over empty board: an X under the wordmark and a drop to the sticky note. The board's middle stays centred on wide windows, its paper tiled, while the peeking clues keep to the window's edges. When the page first opens the scraps drop in, the pins go in, the yarn draws on and the marker circles the newest clue, then the yarn sways twice and rests (`ConspiracyBoardLayout`). The latest episode is a manila folder with a dated tab, a NEW stamp, the typed title and notes and the file's buttons; "It all connects" on Subscribe: the button becomes a patch of the board, three scraps land and are pinned, the yarn closes a triangle between them and a red marker eye is drawn inside it with one haptic and a small tug, then the board clears into Subscribed), Waveform: The MKBHD Podcast (Signal: a black page with one red, the logo traced as live bars across the top over a faint pattern of the show's gadget icons; the bars breathe while one of its episodes plays and replay the logo intro on Subscribe, with a red and cyan glitch. Its latest-episode card has the episode's waveform as a band across the top, white up to where you are and grey after with no other marker, then "LATEST", the title and two lines of the episode's own description; the band's bars breathe with the logo while the episode plays) and Crime Junkie (Fingerprint: the official splatter mark over a fingerprint drawn in code in the splatter's magenta, violet and blue, faded out before any text; the latest episode as an evidence tag with clipped corners, a tear line and an eyelet (on the left in every language, the text kept clear of them), the case type stamped from the title before its colon ("MURDERED: Name", 99% of the show's titles; any other title shows whole with no stamp), the name in Oswald capitals, the episode's art and two lines of its description, while screen readers hear the episode's own title; a Flashlight moment on Subscribe: the lights go out on the button, a torch beam sweeps across and back, finds the ink splatter with one tap, and the light floods the button into Subscribed) and The Joe Rogan Experience (The studio: the official badge over a red stage curtain drawn in code, lit warm behind the badge and falling into the dark page; the latest episode as its own picture with the template's top strip trimmed off, in a warm raised panel so its black edges stand off the page, with the episode cards' buttons inside the panel; a black strip across the top carries the "Latest episode" label in the template's style, and the picture is scaled to fit between it and the progress bar, a blurred, darkened copy filling the sides, so neither covers the title in the picture; the card keeps a phone's proportions on wide windows, centred under the badge. An episode with no picture of its own gets its number and guest set in Anton on the curtain, darkened behind the guest so the orange name holds 3:1 even on the brightest fold, scaled with the card like the picture it stands in for; the curtain also shows while a picture loads and stays if it never arrives, while screen readers hear the episode's own title; a Third eye moment on Subscribe: the button goes dark, an eye like the badge's opens in it, its dotted rays spread across the button, it pulses once with one haptic and closes into Subscribed, while the badge's own eye lights up on the same clock) and Rotten Mango (Her room: Apple's official show-page photo of Stephanie Soo in her red room, the wordmark on its wall, emerging from the dark under the top bar and fading into the page below her lap, kept at a phone's size and centred with faded sides on wide windows; the latest episode in an old, darkened frame, "Latest episode" in dull red and the title typed in Special Elite up to four lines, with the episode cards' buttons inside the frame and the episode's number on a brass plate hung from it ("Bonus" when the feed gives none); "Bada bing, bada boom" on tape on Subscribe: the button switches on like an old television, "BADA BING" plays grainy and jittery in VT323, a burst of static cuts in, "BADA BOOM" hits with split colours, a slip of the vertical hold and one haptic, and the set switches off to a line before Subscribed lands, about 1.5 seconds, no sound) and The Daily (Front page: newsprint and ink in light mode, a warm charcoal night edition with cream ink in dark mode; the official one-line wordmark large across the top, a double rule, today's dateline (turning over at midnight and checked again when you come back to the page) with the Times' masthead and the ©, and the description and chips set like the paper; the latest episode as the lead story: "Latest episode" in the show's blue with the day it came out and its length on the same line, the title as a serif headline, five lines of the episode's notes beside its photo (the show's cover when it has none of its own, over the show's dawn horizon while either loads), and the episode cards' buttons and progress between two fine rules; "Daybreak" on Subscribe (`DailyDaybreak`, timings in `DailyDawn`): Subscribe sinks away as the show's dawn rises up through the button from its bottom edge, a small sun clears the horizon with one light tap, then daylight washes the sky into the Subscribed colour and Subscribed comes up, about 1.2 seconds; it replaced "Off the press", a steel roller printing a fresh sheet)). Waveform's, The Joe Rogan Experience's and Rotten Mango's identities are dark only, so their pages (`BrandedShowPage.darkOnly`) stay dark with white status bar icons in light mode too. Each page lays out its own header (`BrandedShowPage.Header`) around the shared chip row and description; the © button and its popup are shared (`BrandedCopyrightButton`). The popup opens below the © or, when the window has more room above, above it with its caret flipped; it stays inside the window and scrolls when taller than it. Where a publisher's name sits beside the ©, a long name shortens with an ellipsis so the © keeps its place. The Rest Is History (the gallery wall: the show's claret velvet as a museum wall in both themes, hung with its seven latest episodes' own pictures in antique bronze frames, the newest in the big centre frame, the two before it in ovals beside it and four more small at the sides; David and Churchill, cut from the show's cover with their faces whole, stand at the window's edges with their straight cut on the wall's edge and the show's logo between them, and the Goalhanger logo with the © below. The first time the page opens the gallery starts in the dark and its lights come on one by one, about 1.8 seconds in all (`GalleryWall`, choreography in `HistoryWall`): the room warms, the centre frame's spotlight catches with a flicker, the two ovals' come on together, then the small frames' row by row, each spotlight flaring as it comes on and settling to a soft pool on the velvet; the busts are lit from below, the logo comes up and a gilt glint runs across it. The lights wait until the page has settled and the frames' pictures have arrived (at most 1.2 seconds), with a short dark beat before them, so no picture pops in while they come up; frames have no loading placeholder, only a dark canvas until their picture arrives. By day the page below the wall is ivory and claret and the wall ends in a fine claret line. The latest episode hangs on a piece of the same wall under a picture light: its own picture in the wall's bronze frame with its number on a brass nameplate, "Latest episode", its name and its series and part split from the title (`historyTitle`), while screen readers hear the episode's own title; Play is ivory by night and claret with an ivory ring by day. "Through the ages" on Subscribe: the button becomes velvet, the latest episodes' pictures (shared from the header through `BrandedHeaderMotion.pictures`) rush in from the right in small bronze frames, oldest first and blurred with speed, the newest lands in the middle, a brass picture lamp clicks on over it with one haptic, its light flickers, the frames either side dim, and the wall clears into Subscribed), Darknet Diaries (the show's own flag-bearer art cut out of its black in a red light, dark in both themes: the flag waves while the figure, pole and trident never move (`FlagMesh` warps the art with a bitmap mesh on every Android version the app supports, following `DarknetFlagWave`: a 260-unit travelling wave every 2.8 seconds, its strength read from an amplitude map), light and shade ripple along the cloth, the red light breathes, light shafts sway through the episode illustrations' ink grit and red ink flecks drift through the air; the header stays black until its art is decoded and the page has settled, then the art rises into place with the red light coming up (the first time), and the motion eases in and keeps going for as long as the header is on screen; it stops when you scroll it away or leave, and stays still with animations off or battery saver on. To keep each frame cheap the light shafts are drawn once into a half-resolution picture off the main thread and only turned and screened on each frame (`DarknetRays`), the cloth's light and shade is a masked shader blended straight onto the art (`FlagShade`, one shader moved by its matrices each frame, so the wave allocates nothing), and the art draws in its own layer. The flag carries the show's name, so there is no separate wordmark. The latest episode is a charred card with a scorched top edge and an ember glow, "// LATEST · EP 182", the episode's name in Anton capitals (`darknetTitle`; screen readers hear its own title), when it came out and how long it is, and its illustration taped up beside them like a flyer, tilted, that straightens while you press the card. "The masked host" on Subscribe: the button goes dark, Jack's masked avatar comes up still in the middle, his avatar's red ink burst lights up behind him with a hot core, his glasses flash red with one haptic, film grain runs over it all, and the button clears into Subscribed), Serial (its logo made real on the plum of its cover art, dark in both themes: the logo is a stack of black cards with white rims, and here each card is a season, the newest on top and six before it stepping down-left behind it, each with its cover and "18 · METRO" under it. Covers of seasons that have ended ship with the app (`SerialSeasons.Bundled`, seasons 12 to 18, each the picture on its trailer, or on episode 1 when it has none); a newer season comes from the feed (`serialDeck`; Serial's header gets the trailers too, `BrandedShowPage.headerKeepsTrailers`). Seasons such as METRO give every episode its own photo and put the season's art on the trailer and episode 1, so a new season's card shows its trailer's own picture, else episode 1's; while neither is loaded, the picture all its loaded episodes share, or else the show's cover (`serialSeasonPicture`). Its name is the one two of its episode titles share before " - ", or "Season 19" until then. When the covers are decoded and the page has settled, the deck deals itself once: the oldest card lands first and each season drops onto the one before with a small bump, 150ms apart, then Serial's own card slides half out from behind the newest season and settles at a tilt (`SerialDeal`, keyframes eased stretch by stretch as on the board, `SerialKeys`); it stands dealt after that, and always with animations off. A tap on the deck deals it again; screen readers skip the deck, and hear SERIAL as the page's heading. Serial's own card is drawn, not pictured: its S is traced from the show's art (`SerialMark`), cream with a red edge and a cream copy just below it, on a black card with the deck's rim (`drawSerialCard`). Under SERIAL sit the Times' T and Serial Productions' S, as the season covers sign them, then the ©. The latest episode is the top card of a stack, its own picture on a rimmed card with two plain cards behind it, "METRO · EPISODE 3" in red capitals and the title without the season's name in front (`serialEpisodeLine`; a title that is only a number after the name stays whole, and screen readers hear the episode's own title). "One more on the stack" on Subscribe: the button goes dark, a small stack of cards waits in its middle, Serial's S card drops onto it with a bump and one haptic, the stack steps down-left to make room, a red glow comes up behind the new top card, and the button clears into Subscribed), Sherlock Holmes Short Stories (The Strand Magazine, where the stories first ran, dark in both themes: an issue lies on a lamp-lit desk, George Charles Haité's 1891 cover with the show's wordmark on its banner and Hugh Bonneville on its hanging sign. When the art is decoded and the page has settled, the issue slides in and its cover turns over on the spine, opening on Sidney Paget's 1893 Reichenbach plate and a contents page with the wordmark as its masthead; a tap opens it again. The turn is projected by hand at the design board's 900dp perspective (`StrandLeaf`), so it looks the same on every device rather than depending on a layer's camera. The contents list the stories the show has read, newest first and each once, numbered 1, 17, 33… (`SherlockFeed.contents`: a title's story is the part before ":" or " Part ", a leading "The Adventure of " is dropped, and only full episodes count); the page shows as many lines as fit at the text size. On a window narrower than 369dp the issue shrinks to fit, and the desk fills any width. The latest episode is the story's opening page: its own picture as the plate with when it came out and how long it is under it (or Played), Haité's headpiece, the story in small capitals, "Part III. Read by Hugh Bonneville." (`SherlockFeed.part`), a double rule, and the description's first paragraph (`SherlockFeed.firstParagraph`) beside a drop cap, ending in an ellipsis; a story name longer than 17 characters steps down onto two lines and the text gives up a line. On Subscribe the button becomes 221B's green front door with its brass plate and knob: the letterbox flap lifts, the new issue is pushed through, hangs from the slot by its top edge, drops, and the flap snaps shut with a jolt and one haptic (`SherlockLetterbox`, `LetterboxKeys`), then the button clears into Subscribed) and The Desi Crime Podcast (the hosts' crime board from the show's cover, dark in both themes: cork under the studio lamp, crowded with prints of earlier cases. A title reads "212. The case | the people in it" (`DesiCrimeFeed.case`). Each print is a case's own picture cut to the photo band above its DESI CRIME lettering (the picture's top 34%); the three nearest the lamp are captioned with the case's people, or with the case when "|" names a guest ("Ft.") or nobody (`DesiCrimeFeed.caption`). A case whose picture is the show's cover, and an episode without a number (Chai & Chithi), gets no print. In the middle hangs the newest case's picture down to the torn edge every episode picture shares (`DesiCrimeTear`), so its DESI CRIME lettering sits on the board, with the case number on a sticky note; a Hindi paper's cutting, a "सबूत?" tape and two more notes are part of the board's art. Aishwarya and Aryaan peek up over their arms in front, each held to their own edge of the window; the prints keep to their own edge too (`Edge`, `boardX`), and a window 640dp or wider pins six more prints between them. When the page has settled the lamp flickers on, the newest print drops and is pinned, and its number is slapped on; a tap does it again. The latest episode is a sticky note on a strip of the cork: "#212 · newest case" in red pen, the case in their handwriting, and when it came out and how long it is, or Played. On Subscribe, chai and a chithi after the show's segment of listeners' letters: the button becomes a listener's blue inland letter with a stamp and postmark, a cutting-chai glass is set down with one haptic, chai pours in, froths and steams, and the glass lifts away leaving its ring on the letter (`DesiCrimeChithi`, `ChithiKeys`), then the button clears into Subscribed). Both pages run their intro on `rememberBrandedIntro` (once per page, after the art is decoded and the page has settled, at rest after that, after recreation and with animations off) and ease their design boards' keyframes stretch by stretch with `keyframes`. No subscribe moment spells out "Subscribed"; the button's own label lands after each. Headers with their own art decode it off the main thread and keep it for the next visit (`rememberBrandedArt`, `BrandedArtCache`; a new set of pictures starts empty, never showing the last set's), so opening a page never waits on decoding. Subscribe moments take their art from the same cache (`brandedArtNow`), which the page warms when it opens, so a tap starts on its first frame, and start their motion only once the page has settled (`awaitPageSettled`: at least 250ms in and six smooth frames in a row, or 1.5 seconds at most). Fonts bundled with a page keep their licence under `licenses/` (Anton, shared by The Vergecast, The Joe Rogan Experience and Darknet Diaries, Barlow, Caveat, Cinzel, Cormorant Garamond, Courier Prime, Libre Franklin, Newsreader, Old Standard TT, Oswald and VT323, SIL OFL 1.1, and IM Fell English with its italic and small capitals, and Kalam, SIL OFL 1.1, both cut down to Latin text since the pages' Hindi is part of their art; Caveat, Cinzel, Cormorant Garamond and Old Standard TT are bundled at the single weights the pages draw; Special Elite, shared by Conspiracy Theories and Rotten Mango, Apache 2.0). Oswald and Libre Franklin are bundled as variable fonts and drawn at weights 500, 600 and 700; Newsreader's variable font is drawn at the weight and optical size of each line. They are built with the platform's font builder (`rememberVariableFont`), as the app's Google Sans is, because some devices ignore Compose's font variation settings and draw the default weight. ## Dependencies @@ -142,9 +142,9 @@ The spotlight shows the newest full episode while the list is newest first and s - `EpisodeExpandableTitleTest` covers baseline continuity with a larger touch target, different font padding, enlarged text and a wrapping expanded tail. `PodcastEpisodeSearchLogicTest` covers list continuity, empty/loading/recovery states, repository result order and stale-response rejection after clearing, replacing a query or changing shows. The shared `AdaptivePlayerOverlayOffsetTest` covers compact/expanded docking, reverse motion, Classic navigation and absent-player behavior used by the selection toolbar. - Unit tests live under `feature/info/src/test`. -- `BrandedShowPageTest` covers matching a show by id, linked `rss:` copy, GUID and feed URL, and shows that keep the regular page. `BrandedSpotlightTest` covers when the spotlight shows (newest first, search closed, trailers skipped, a finished latest episode hiding the card when completed episodes are hidden), that its episode leaves the list while the rest keep their positions, and that Select visible counts the spotlight's episode (its list key is the episode's ID, never used twice). `EpisodeDetailsLineTest` covers the episode details line and its highlighted release time. `VergecastStickerSlapTest` covers the sticker slap's keyframes, the button's thud, its reveal timing and when the description shows; `ConspiracyPageTest` covers matching the show, contrast on the page and on every scrap and the folder, the headline before the colon (or after a label), which episodes the board pins and a short feed, the board's intro settling and every length of yarn having its pins, the typed details and filed date, and "It all connects" landing before the button settles. `HistoryPageTest` covers matching the show, contrast in both themes (and the top bar's light icons by day only), the card's ivory text on the dimmed velvet, splitting titles into name, series and part, and number, the lights coming on one by one in order (the centre frame's flicker, each spotlight flaring then settling, busts, logo and glint after the frames) and ending fully lit, and the moment's strip starting off the button and landing the newest frame in the middle at any width. `SerialPageTest` covers matching the show by id, feed URL and GUID, the page staying dark and readable, the deck showing the newest seven seasons with their bundled covers, a new season from the feed going on top with its shared name (and waiting for a name), its card taking the trailer's picture, else episode 1's, else one all loaded episodes share, else the show's cover, Serial's header alone getting the trailers, the latest episode's season line and title, keyframes eased stretch by stretch, the deal's order and timing, the moment settling as it ends, and the traced S staying whole inside its card. `DarknetPageTest` covers matching the show, the page staying dark and readable, the card's title and number, the flag's wave (only the cloth moves, it repeats every wavelength and period, and it travels away from the pole), the motion easing in from rest and then running on, when a page counts as settled, the ink flecks being the same every visit, and the masked host's timing. `BrandedSealTest` covers the seal's mark on every foil colour, the faded print staying visible, its 36 teeth, both lines of words sitting between the rings, and the sheen starting and ending off the seal and settling within ten seconds. `WaveformPageTest` covers matching the show, the page staying dark and readable in both modes, the logo reaching the edges of wide windows, the intro starting flat and ending on the logo at rest, the glitch frames, the playing bars' range, the spotlight band lighting its bars up to the listening point with nothing else marking it, its tallest bar fitting the band, and the button landing on Subscribed. `CrimeJunkiePageTest` covers matching the show by id, feed URL and GUID, contrast on the page, tag and popup in both themes, splitting a case title into its stamp and name (and titles that keep no stamp), the typed length and aired day, the fingerprint's ridges and breaks, and the flashlight starting dark, sweeping to the splatter, flooding the button and ending on Subscribed with nothing left over. `JrePageTest` covers matching the show by id, feed URL and GUID, the page staying dark and readable in both modes, the card's label and the popup, splitting a title into the number and guest the pictures show (numbered and MMA Show titles; any other title shows whole), breaking a long guest name where its two lines balance, the guest's name holding 3:1 on the brightest fold of the curtain, and the third eye opening, pulsing once, every dot spreading and falling back, and ending on Subscribed. `BrandedTightTextTest` covers trimming display type set tighter than its face, including faces more than half a line taller. `RottenMangoPageTest` covers matching the show by id, feed URL and GUID, the page staying dark and readable in both modes, the frame's type, the plate and the popup, the set switching on from a bright line and off to one, BADA BING, the static burst and the one BADA BOOM, ending on Subscribed with nothing left over, and the grain being the same every run. `DailyPageTest` covers matching the show by id, feed URL and GUID, contrast in both editions, Daybreak (the first frame is the plain button with the sun and its glow out of sight, the tap as the sun's disc clears the bottom edge, full daylight and Subscribed by the end), and the dateline turning over at the next midnight through clock changes. `BrandedCopyrightPopupTest` covers the © popup opening below or above the ©, taking the roomier side in a short window within its margins, and starting at the top margin when taller than the window. `BrandedSpotlightTest` also covers a card's picture: the episode's own, with the show's cover counting as none and the page's fallback image (often the latest episode's own picture) not, pictures named in the query kept apart, and the cover fallback. `BrandedAccentTest` checks that every show's Subscribe button takes the page's accent and its label, like Play and the other filled buttons, in both themes. `BrandedEpisodeActionsTest` checks every show's card buttons: 3:1 for the icons and the download spinner, 4.5:1 for Play's label. `BrandedSubscribeMomentTest` drives the real toolbar through a subscribe (alerts held back mid-moment, then shown; Unsubscribe never replays it; the header follows the moment's clock and lets go after); `BrandedSubscribeMomentSpeedTest` runs it with animations at half speed and checks alerts stay hidden until the slowed moment has played. `VergecastPageTest` checks 4.5:1 text contrast for every page, chip, button and navbar pair in both themes, the card's text, and that cards and chips keep full-strength text. +- `BrandedShowPageTest` covers matching a show by id, linked `rss:` copy, GUID and feed URL, and shows that keep the regular page. `BrandedSpotlightTest` covers when the spotlight shows (newest first, search closed, trailers skipped, a finished latest episode hiding the card when completed episodes are hidden), that its episode leaves the list while the rest keep their positions, and that Select visible counts the spotlight's episode (its list key is the episode's ID, never used twice). `EpisodeDetailsLineTest` covers the episode details line and its highlighted release time. `VergecastStickerSlapTest` covers the sticker slap's keyframes, the button's thud, its reveal timing and when the description shows; `ConspiracyPageTest` covers matching the show, contrast on the page and on every scrap and the folder, the headline before the colon (or after a label), which episodes the board pins and a short feed, the board's intro settling and every length of yarn having its pins, the typed details and filed date, and "It all connects" landing before the button settles. `HistoryPageTest` covers matching the show, contrast in both themes (and the top bar's light icons by day only), the card's ivory text on the dimmed velvet, splitting titles into name, series and part, and number, the lights coming on one by one in order (the centre frame's flicker, each spotlight flaring then settling, busts, logo and glint after the frames) and ending fully lit, and the moment's strip starting off the button and landing the newest frame in the middle at any width. `SerialPageTest` covers matching the show by id, feed URL and GUID, the page staying dark and readable, the deck showing the newest seven seasons with their bundled covers, a new season from the feed going on top with its shared name (and waiting for a name), its card taking the trailer's picture, else episode 1's, else one all loaded episodes share, else the show's cover, Serial's header alone getting the trailers, the latest episode's season line and title, keyframes eased stretch by stretch, the deal's order and timing, the moment settling as it ends, and the traced S staying whole inside its card. `DarknetPageTest` covers matching the show, the page staying dark and readable, the card's title and number, the flag's wave (only the cloth moves, it repeats every wavelength and period, and it travels away from the pole), the motion easing in from rest and then running on, when a page counts as settled, the ink flecks being the same every visit, and the masked host's timing. `BrandedSealTest` covers the seal's mark on every foil colour, the faded print staying visible, its 36 teeth, both lines of words sitting between the rings, and the sheen starting and ending off the seal and settling within ten seconds. `WaveformPageTest` covers matching the show, the page staying dark and readable in both modes, the logo reaching the edges of wide windows, the intro starting flat and ending on the logo at rest, the glitch frames, the playing bars' range, the spotlight band lighting its bars up to the listening point with nothing else marking it, its tallest bar fitting the band, and the button landing on Subscribed. `CrimeJunkiePageTest` covers matching the show by id, feed URL and GUID, contrast on the page, tag and popup in both themes, splitting a case title into its stamp and name (and titles that keep no stamp), the typed length and aired day, the fingerprint's ridges and breaks, and the flashlight starting dark, sweeping to the splatter, flooding the button and ending on Subscribed with nothing left over. `JrePageTest` covers matching the show by id, feed URL and GUID, the page staying dark and readable in both modes, the card's label and the popup, splitting a title into the number and guest the pictures show (numbered and MMA Show titles; any other title shows whole), breaking a long guest name where its two lines balance, the guest's name holding 3:1 on the brightest fold of the curtain, and the third eye opening, pulsing once, every dot spreading and falling back, and ending on Subscribed. `BrandedTightTextTest` covers trimming display type set tighter than its face, including faces more than half a line taller. `RottenMangoPageTest` covers matching the show by id, feed URL and GUID, the page staying dark and readable in both modes, the frame's type, the plate and the popup, the set switching on from a bright line and off to one, BADA BING, the static burst and the one BADA BOOM, ending on Subscribed with nothing left over, and the grain being the same every run. `DailyPageTest` covers matching the show by id, feed URL and GUID, contrast in both editions, Daybreak (the first frame is the plain button with the sun and its glow out of sight, the tap as the sun's disc clears the bottom edge, full daylight and Subscribed by the end), and the dateline turning over at the next midnight through clock changes. `BrandedCopyrightPopupTest` covers the © popup opening below or above the ©, taking the roomier side in a short window within its margins, and starting at the top margin when taller than the window. `BrandedSpotlightTest` also covers a card's picture: the episode's own, with the show's cover counting as none and the page's fallback image (often the latest episode's own picture) not, pictures named in the query kept apart, and the cover fallback. `BrandedAccentTest` checks that every show's Subscribe button takes the page's accent and its label, like Play and the other filled buttons, in both themes. `BrandedEpisodeActionsTest` checks every show's card buttons: 3:1 for the icons and the download spinner, 4.5:1 for Play's label. `BrandedSubscribeMomentTest` drives the real toolbar through a subscribe (alerts held back mid-moment, then shown; Unsubscribe never replays it; the header follows the moment's clock and lets go after); `BrandedSubscribeMomentSpeedTest` runs it with animations at half speed and checks alerts stay hidden until the slowed moment has played. `SherlockPageTest` covers the Strand page: matching the show, contrast on the page and on the card's printed paper, the story, part and short name rules, the contents (each story once, newest first, trailers and bonus episodes left out, curly and straight apostrophes the same, page numbers), the first paragraph (double-encoded spaces, entities, tags), the issue's intro from closed to open, the letterbox moment (the issue in, hanging, gone, the flap shut and the door gone before Subscribed lands) and `keyframes`. `DesiCrimePageTest` covers the crime board: matching the show, contrast on the page, the sticky notes and the prints, the title and caption rules, which cases the board shows (the newest case's picture, or the show's cover when that is its picture; earlier cases with pictures of their own), the board before the feed loads, pieces keeping to their own edge and the extra prints on a wide window, the torn edge, the newest print's drop, and the chai moment's timings. `VergecastPageTest` checks 4.5:1 text contrast for every page, chip, button and navbar pair in both themes, the card's text, and that cards and chips keep full-strength text. - `BrandedPagesAdaptiveTransitionTest` drives each live Showcase page across the 600dp width, 480dp height and 840dp split gates in both directions and a fold posture change, then recreates it: the open description and the open © popup stay open, the seal stays in the top bar, the spotlight's episode is never repeated, and the popup still closes on Back or a tap outside on compact and tablet windows; the seal's note stays open across the same changes and recreation, then closes on Back. The list of pages is `BrandedShowPages.all`, so a new page is covered as soon as it is listed. The show description's open state is saved with the page, so it also survives recreation on regular show pages. `DarkOnlyPageStatusBarTest` covers Waveform's white status bar icons in light mode and the app's own setting returning when the page leaves. -- **Manual acceptance (Showcase pages).** Open The Vergecast, Conspiracy Theories, Waveform: The MKBHD Podcast, Crime Junkie, The Joe Rogan Experience, Rotten Mango, The Daily, The Rest Is History, Darknet Diaries and Serial in turn. (1) Phone: the page shows its own header, the Showcase seal (tap it: its note opens and closes on Back), the description under the chips and the latest-episode card; tap the description to open it, then the © to open its popup, and rotate twice: both stay open, and the popup closes on Back or a tap outside. (2) Tap Subscribe on a show you don't follow: its moment plays once (sticker slap, it all connects, logo intro, flashlight, third eye, bada bing on tape, daybreak, through the ages, the masked host, one more on the stack), alerts and auto-download appear after it, and Unsubscribe never replays it. With system animations off it never plays. Hold Subscribe, then Subscribed: each takes the pressed shape only while your finger is on it, and returns to its resting shape when you lift or slide off. (3) Play the card's episode: Waveform's band and logo bars breathe and rest again on pause; the card's progress moves as it plays. (4) Long-press the card, then Select visible: the card's episode is counted once. (5) Tablet or desktop window: repeat (1), the Subscribe hold and (4) while resizing across 600dp wide (599 ↔ 600), 480dp tall (479 ↔ 480) and the 840dp landscape split (839 ↔ 840), both ways; The Joe Rogan Experience's card stops growing at 480dp and stays centred on wider windows. (6) Foldable: repeat (1) and the Subscribe hold while folding flat ↔ tabletop, which recreates the activity. (7) Light mode: Waveform, The Joe Rogan Experience, Rotten Mango, Darknet Diaries and Serial stay dark with white status bar icons, and the app's own setting returns when you leave; The Rest Is History keeps its velvet wall with light top bar and status bar icons over it, which turn dark as you scroll it away. (8) Darknet Diaries: the flag keeps waving while the header is on screen, and stays still with battery saver on. (9) Serial: the deck deals itself once when the page opens and stands dealt when you come back or rotate; tap it to deal again. With system animations off it stands dealt from the start. Device runs are not part of CI; the JVM tests below cover the same transitions without a device, and any state not run on a device is a known gap. +- **Manual acceptance (Showcase pages).** Open The Vergecast, Conspiracy Theories, Waveform: The MKBHD Podcast, Crime Junkie, The Joe Rogan Experience, Rotten Mango, The Daily, The Rest Is History, Darknet Diaries, Serial, Sherlock Holmes Short Stories and The Desi Crime Podcast in turn. (1) Phone: the page shows its own header, the Showcase seal (tap it: its note opens and closes on Back), the description under the chips and the latest-episode card; tap the description to open it, then the © to open its popup, and rotate twice: both stay open, and the popup closes on Back or a tap outside. (2) Tap Subscribe on a show you don't follow: its moment plays once (sticker slap, it all connects, logo intro, flashlight, third eye, bada bing on tape, daybreak, through the ages, the masked host, one more on the stack, 221B's letterbox, chai and a chithi), alerts and auto-download appear after it, and Unsubscribe never replays it. With system animations off it never plays. Hold Subscribe, then Subscribed: each takes the pressed shape only while your finger is on it, and returns to its resting shape when you lift or slide off. (3) Play the card's episode: Waveform's band and logo bars breathe and rest again on pause; the card's progress moves as it plays. (4) Long-press the card, then Select visible: the card's episode is counted once. (5) Tablet or desktop window: repeat (1), the Subscribe hold and (4) while resizing across 600dp wide (599 ↔ 600), 480dp tall (479 ↔ 480) and the 840dp landscape split (839 ↔ 840), both ways; The Joe Rogan Experience's card stops growing at 480dp and stays centred on wider windows. (6) Foldable: repeat (1) and the Subscribe hold while folding flat ↔ tabletop, which recreates the activity. (7) Light mode: Waveform, The Joe Rogan Experience, Rotten Mango, Darknet Diaries, Serial, Sherlock Holmes Short Stories and The Desi Crime Podcast stay dark with white status bar icons, and the app's own setting returns when you leave; The Rest Is History keeps its velvet wall with light top bar and status bar icons over it, which turn dark as you scroll it away. (8) Darknet Diaries: the flag keeps waving while the header is on screen, and stays still with battery saver on. (9) Serial: the deck deals itself once when the page opens and stands dealt when you come back or rotate; tap it to deal again. With system animations off it stands dealt from the start. (10) Sherlock Holmes Short Stories: the issue slides onto the desk and its cover turns over once when the page opens, and lies open when you come back or rotate; tap it to open it again; the contents list the show's stories; with system animations off it lies open from the start; on a window under 369dp the issue shrinks to fit. Finish the latest episode: its card says Played under the art. (11) The Desi Crime Podcast: the lamp flickers on, the newest case's print drops and is pinned and its number is slapped on, once; tap the board to play it again; at 840dp the hosts stay at the window's edges and six more prints fill the middle, and back under 640dp the extra prints go; finish the latest episode: its sticky note says Played. Device runs are not part of CI; the JVM tests below cover the same transitions without a device, and any state not run on a device is a known gap. - `BrandedPagesPhoneGoldenTest` (local only) renders each branded page at 369 by 821dp in both themes, and at 840dp wide in dark, for comparison with the design boards, and its subscribe moment every 16ms (checked for one-frame flashes): copy each cover to `build/branded-fixtures/-art.jpg` (and any episode's own picture to `build/branded-fixtures/.jpg`, such as `trih-0.jpg`), then run `./gradlew :feature:info:testDebugUnitTest -PtabletCaptures=after --tests '*BrandedPagesPhoneGoldenTest'`; captures land in `build/reports/branded-captures/`. Its `headerOpening` test captures The Rest Is History's, Darknet Diaries', Conspiracy Theories' and Serial's headers every 50ms from the page's first frame; motion that runs for as long as a header is on screen uses Compose's infinite-animation clock, which tests skip. - `ArtworkInfoThemeHandoffTest` exercises the actual loader/theme wrapper with a controlled Compose clock: inherited first-frame colors, a long content load, isolation from transient shell colors, missing-art fallback and artwork opt-out, including navbar agreement and a fresh Back handoff while the returning composition is still retained. - `ArtworkInfoThemeLayoutTest` uses hermetic Robolectric/Compose JVM rendering to check top-aligned header geometry and visible body pixels after scrolling and making the header opaque. It exercises the actual theme wrapper without an app ViewModel or network. diff --git a/feature/info/licenses/IMFellEnglish-OFL.txt b/feature/info/licenses/IMFellEnglish-OFL.txt new file mode 100644 index 000000000..68632c178 --- /dev/null +++ b/feature/info/licenses/IMFellEnglish-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010, Igino Marini (mail@iginomarini.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/feature/info/licenses/Kalam-OFL.txt b/feature/info/licenses/Kalam-OFL.txt new file mode 100644 index 000000000..6dd809659 --- /dev/null +++ b/feature/info/licenses/Kalam-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2014, Indian Type Foundry (info@indiantypefoundry.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/BrandedIntroClock.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/BrandedIntroClock.kt new file mode 100644 index 000000000..7e1002fe6 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/BrandedIntroClock.kt @@ -0,0 +1,53 @@ +package cx.aswin.boxlore.feature.info.branded + +import android.animation.ValueAnimator +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue + +/** A header intro's clock in milliseconds, read while drawing; [again] plays it once more. */ +@Stable +internal class BrandedIntroClock(private val clock: Animatable, val again: () -> Unit) { + fun millis(): Float = clock.value +} + +/** + * A header's intro, [endMillis] long. It starts once the header's art is [ready] and the page has + * settled, the first time the page opens; it stands at the end after that (also after the page is + * recreated), and always with animations off. A tap may play it again ([BrandedIntroClock.again]). + */ +@Composable +internal fun rememberBrandedIntro(ready: Boolean, endMillis: Float): BrandedIntroClock { + val still = remember { !ValueAnimator.areAnimatorsEnabled() } + var played by rememberSaveable { mutableStateOf(false) } + val clock = remember { Animatable(if (played) endMillis else 0f) } + var replays by remember { mutableIntStateOf(0) } + LaunchedEffect(ready, still, replays) { + if (!ready) return@LaunchedEffect + if (still) { + clock.snapTo(endMillis) + played = true + return@LaunchedEffect + } + if (replays == 0) { + if (played) return@LaunchedEffect + awaitPageSettled() + } + played = true + clock.snapTo(0f) + clock.animateTo(endMillis, tween(endMillis.toInt(), easing = LinearEasing)) + } + return remember(clock, still) { BrandedIntroClock(clock) { if (!still) replays++ } } +} + +/** How far [t] is through a step that starts at [start] and lasts [duration], from 0 to 1. */ +internal fun introProgress(t: Float, start: Float, duration: Float): Float = ((t - start) / duration).coerceIn(0f, 1f) diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/BrandedKeyframes.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/BrandedKeyframes.kt new file mode 100644 index 000000000..7f84e201d --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/BrandedKeyframes.kt @@ -0,0 +1,22 @@ +package cx.aswin.boxlore.feature.info.branded + +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.LinearEasing + +/** One keyframe of a design board's animation: at [at] (0 to 1) the value is [value]; [easing] runs the step after it. */ +internal class Keyframe(val at: Float, val value: Float, val easing: Easing = LinearEasing) + +/** + * The value at [p] (0 to 1) along [frames], easing each step by the easing of the keyframe it + * starts from, as a CSS keyframe's timing function does. Before the first keyframe it holds the + * first value, after the last the last. + */ +internal fun keyframes(p: Float, vararg frames: Keyframe): Float { + if (p <= frames.first().at) return frames.first().value + for (i in 0 until frames.size - 1) { + val from = frames[i] + val to = frames[i + 1] + if (p <= to.at) return from.value + (to.value - from.value) * from.easing.transform((p - from.at) / (to.at - from.at)) + } + return frames.last().value +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/BrandedShowPages.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/BrandedShowPages.kt index 714b63805..7ee8e8dee 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/BrandedShowPages.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/BrandedShowPages.kt @@ -4,14 +4,29 @@ import cx.aswin.boxlore.feature.info.branded.conspiracy.ConspiracyPage import cx.aswin.boxlore.feature.info.branded.crimejunkie.CrimeJunkiePage import cx.aswin.boxlore.feature.info.branded.daily.DailyPage import cx.aswin.boxlore.feature.info.branded.darknet.DarknetPage +import cx.aswin.boxlore.feature.info.branded.desicrime.DesiCrimePage import cx.aswin.boxlore.feature.info.branded.history.HistoryPage import cx.aswin.boxlore.feature.info.branded.jre.JrePage import cx.aswin.boxlore.feature.info.branded.rottenmango.RottenMangoPage import cx.aswin.boxlore.feature.info.branded.serial.SerialPage +import cx.aswin.boxlore.feature.info.branded.sherlock.SherlockPage import cx.aswin.boxlore.feature.info.branded.vergecast.VergecastPage import cx.aswin.boxlore.feature.info.branded.waveform.WaveformPage /** The shows with a branded page. */ internal object BrandedShowPages { - val all: List = listOf(VergecastPage, ConspiracyPage, WaveformPage, CrimeJunkiePage, JrePage, RottenMangoPage, DailyPage, HistoryPage, DarknetPage, SerialPage) + val all: List = listOf( + VergecastPage, + ConspiracyPage, + WaveformPage, + CrimeJunkiePage, + JrePage, + RottenMangoPage, + DailyPage, + HistoryPage, + DarknetPage, + SerialPage, + SherlockPage, + DesiCrimePage, + ) } diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeBoard.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeBoard.kt new file mode 100644 index 000000000..164129e0e --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeBoard.kt @@ -0,0 +1,422 @@ +package cx.aswin.boxlore.feature.info.branded.desicrime + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import cx.aswin.boxlore.core.designsystem.components.OptimizedImage +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.branded.BrandedIntroClock +import cx.aswin.boxlore.feature.info.branded.Keyframe +import cx.aswin.boxlore.feature.info.branded.boxShadow +import cx.aswin.boxlore.feature.info.branded.brandedTextStyle +import cx.aswin.boxlore.feature.info.branded.introProgress +import cx.aswin.boxlore.feature.info.branded.keyframes +import cx.aswin.boxlore.feature.info.branded.rememberBrandedArt +import cx.aswin.boxlore.feature.info.branded.rememberBrandedIntro +import kotlin.math.roundToInt + +/** + * Their crime board, from behind Aryaan and Aishwarya on the show's cover: cork under the studio's + * warm lamp, crowded with prints of past cases ([DesiCrimeFeed.board]: each case's own picture, cut + * by one rule to the photo band above its DESI CRIME lettering, the nearest three captioned with + * the case's people). In the middle, the newest case's picture down to its torn edge, so the DESI + * CRIME lettering sits on the board, with its number on a sticky note; a Hindi paper's cutting, a + * "सबूत?" (evidence?) tape and two more notes. The hosts peek up over their arms in front of it, + * each held to their own edge of the window. When the page has settled the lamp flickers on, the + * newest print drops and is pinned, and its number is slapped on. A tap does it again. + */ +@Composable +internal fun DesiCrimeBoard(board: DesiCrimeBoard, modifier: Modifier = Modifier) { + val art by rememberBrandedArt(R.drawable.desicrime_cork, R.drawable.desicrime_host_her, R.drawable.desicrime_host_him, R.drawable.desicrime_clipping, R.drawable.desicrime_tape) + val intro = rememberBrandedIntro(ready = art != null, endMillis = DesiCrimeArt.END) + val fonts = rememberDesiCrimeFonts() + val statusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val title = stringResource(R.string.desicrime_wordmark_description) + val hosts = stringResource(R.string.desicrime_hosts_description) + BoxWithConstraints(modifier.fillMaxWidth().height(DesiCrimeArt.HEIGHT.dp)) { + val width = maxWidth.value + Spacer(Modifier.fillMaxSize().drawBehind { drawCork(art?.get(0), statusBar.toPx()) }) + val pictures = art + if (pictures != null) { + val slots = DesiCrimeArt.slots(width) + slots.indices.reversed().forEach { i -> + val print = board.prints.getOrNull(i) ?: return@forEach + val slot = slots[i] + Polaroid(print, slot, fonts, Modifier.place(slot.edge, slot.x, slot.y, width).zIndex(DesiCrimeArt.PRINTS_Z - i)) + } + BoardImage(pictures[3], Piece(Edge.Right, 260f, 160f, 84f, 112f), width, DesiCrimeArt.CLIPPING_Z) + NewestPrint(board, intro, Modifier.place(Edge.Centre, DesiCrimeArt.NEWEST_X, DesiCrimeArt.NEWEST_Y, width).zIndex(DesiCrimeArt.NEWEST_Z)) + NewestPin(intro, Modifier.place(Edge.Centre, DesiCrimeArt.PIN_X, DesiCrimeArt.PIN_Y, width).zIndex(DesiCrimeArt.PIN_Z)) + BoardImage(pictures[4], Piece(Edge.Centre, 112f, 186f, 70f, 30f), width, DesiCrimeArt.PIN_Z) + Note(stringResource(R.string.desicrime_note_show), 11.5.sp, fonts, Modifier.place(Edge.Centre, 160f, 266f, width).zIndex(DesiCrimeArt.NOTES_Z).graphicsLayer { rotationZ = 4f }) + Note(stringResource(R.string.desicrime_note_who), 12.sp, fonts, Modifier.place(Edge.Right, 328f, 238f, width).zIndex(DesiCrimeArt.NOTES_Z).graphicsLayer { rotationZ = 7f }) + board.newestNumber?.let { number -> NumberNote(number, fonts, intro, Modifier.place(Edge.Centre, 114f, 98f, width).zIndex(DesiCrimeArt.NUMBER_Z)) } + Spacer(Modifier.fillMaxSize().zIndex(DesiCrimeArt.LIGHT_Z).drawBehind { drawStudioLight(intro.millis(), statusBar.toPx()) }) + Spacer( + Modifier + .fillMaxSize() + .zIndex(DesiCrimeArt.HOSTS_Z) + .semantics { contentDescription = hosts } + .drawBehind { drawHosts(pictures[1], pictures[2]) }, + ) + } + // The board's name for screen readers; the lettering is on the newest print. + Spacer( + Modifier + .offset(0.dp, (DesiCrimeArt.TAP_Y - DesiCrimeArt.STATUS_BAR).dp) + .fillMaxWidth() + .height(DesiCrimeArt.TAP_HEIGHT.dp) + .zIndex(DesiCrimeArt.TAP_Z) + .semantics { + heading() + contentDescription = title + }, + ) + // The board plays again on a tap: a flourish for the eye only, so screen readers skip it. + Spacer( + Modifier + .offset(0.dp, (DesiCrimeArt.TAP_Y - DesiCrimeArt.STATUS_BAR).dp) + .fillMaxWidth() + .height(DesiCrimeArt.TAP_HEIGHT.dp) + .zIndex(DesiCrimeArt.TAP_Z) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = intro.again) + .clearAndSetSemantics {}, + ) + } +} + +/** Which edge of the window a piece keeps its distance from, so a wider window opens space between them. */ +internal enum class Edge { Left, Centre, Right } + +internal data class Piece(val edge: Edge, val x: Float, val y: Float, val width: Float, val height: Float) + +/** Where a piece designed at [x] on the 369dp board sits on a window [width] wide. */ +internal fun boardX(edge: Edge, x: Float, width: Float): Float = when (edge) { + Edge.Left -> x + Edge.Centre -> x + (width - DesiCrimeArt.WIDTH) / 2f + Edge.Right -> x + width - DesiCrimeArt.WIDTH +} + +private fun Modifier.place(edge: Edge, x: Float, y: Float, width: Float): Modifier = + offset { IntOffset((boardX(edge, x, width) * density).roundToInt(), ((y - DesiCrimeArt.STATUS_BAR) * density).roundToInt()) } + +@Composable +private fun BoardImage(bitmap: ImageBitmap, piece: Piece, width: Float, z: Float) { + Spacer( + Modifier + .place(piece.edge, piece.x, piece.y, width) + .size(piece.width.dp, piece.height.dp) + .zIndex(z) + .drawBehind { drawImage(bitmap, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }, + ) +} + +/** An earlier case's print: its photo band in a white border, pinned or taped, captioned where the slot has room. */ +@Composable +private fun Polaroid(print: DesiCrimePrint, slot: DesiCrimeSlot, fonts: DesiCrimeFonts, modifier: Modifier) { + Box(modifier.width(108.dp).graphicsLayer { rotationZ = slot.rotation }) { + Column( + Modifier + .width(108.dp) + .boxShadow(fill = PrintPaperColor, color = DesiCrimeColors.Shadow.copy(alpha = 0.55f), offsetY = 4.dp, blur = 8.dp, cornerRadius = 0.dp) + .background(PrintPaper) + .padding(start = 4.dp, end = 4.dp, top = 4.dp), + ) { + Box(Modifier.size(100.dp, 34.dp).clipToBounds()) { + // The episode picture's photo band: its top 34%, above the DESI CRIME lettering. + OptimizedImage( + url = print.picture, + proxyWidth = 200, + contentDescription = null, + contentScale = ContentScale.Crop, + colorFilter = PrintTone, + modifier = Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).size(100.dp), + ) + Spacer(Modifier.fillMaxSize().background(Brush.linearGradient(0f to Glare, 0.4f to Color.Transparent))) + } + if (slot.captioned) { + Text( + buildAnnotatedString { + withStyle(SpanStyle(color = DesiCrimeColors.CaseNumber)) { append("#${print.number}") } + append(" ") + append(print.caption) + }, + style = brandedTextStyle(fonts.handBold, 9.sp, 10.5.sp), + color = DesiCrimeColors.PrintInk, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().heightIn(min = 22.dp).padding(top = 2.dp, bottom = 1.dp).clearAndSetSemantics {}, + ) + } else { + Spacer(Modifier.height(4.dp)) + } + } + Spacer( + Modifier.matchParentSize().drawBehind { + if (slot.taped) drawTape() else drawBrassPin(Offset(54.dp.toPx(), 1.dp.toPx()), 9.dp.toPx()) + }, + ) + } +} + +/** The newest case's picture down to its torn edge, dropped onto the board as the lamp comes up. */ +@Composable +private fun NewestPrint(board: DesiCrimeBoard, intro: BrandedIntroClock, modifier: Modifier) { + val picture = board.newestPicture ?: return + Box( + modifier + .size(DesiCrimeArt.NEWEST_WIDTH.dp, DesiCrimeArt.NEWEST_HEIGHT.dp) + .graphicsLayer { + val pose = NewestMotion.pose(intro.millis()) + transformOrigin = TransformOrigin(0.5f, 0.04f) + translationX = pose.x.dp.toPx() + translationY = pose.y.dp.toPx() + rotationZ = pose.rotation + scaleX = pose.scale + scaleY = pose.scale + alpha = pose.alpha + } + .drawWithCache { + val shadow = TornShadow() + onDrawBehind { drawTornShadow(shadow, NewestMotion.lift(intro.millis())) } + }, + ) { + OptimizedImage( + url = picture, + fallbackUrls = listOf(board.cover), + proxyWidth = 300, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .wrapContentSize(Alignment.TopStart, unbounded = true) + .size(DesiCrimeArt.NEWEST_WIDTH.dp) + .clip(TornEdge), + ) + } +} + +/** The newest print's own pin, pushed in once it has landed. */ +@Composable +private fun NewestPin(intro: BrandedIntroClock, modifier: Modifier) { + Spacer( + modifier + .size(11.dp) + .graphicsLayer { + val p = PinIn.transform(introProgress(intro.millis(), 760f, 200f)) + alpha = p.coerceIn(0f, 1f) + translationX = -5.dp.toPx() * (1f - p) + translationY = -9.dp.toPx() * (1f - p) + scaleX = 1.9f - 0.9f * p + scaleY = scaleX + } + .drawBehind { drawBrassPin(center, size.width) }, + ) +} + +/** The newest case's number, on a sticky slapped onto its print and underlined in red. */ +@Composable +private fun NumberNote(number: Int, fonts: DesiCrimeFonts, intro: BrandedIntroClock, modifier: Modifier) { + Box( + modifier + .size(46.dp, 40.dp) + .graphicsLayer { + val p = introProgress(intro.millis(), 1_080f, 260f) + val slap = Slap.transform(p) + transformOrigin = TransformOrigin(0.6f, 0.2f) + alpha = (p / 0.6f).coerceIn(0f, 1f) + rotationZ = -24f + 15f * slap + scaleX = 1.7f - 0.7f * slap + scaleY = scaleX + }, + ) { + StickyPaper(Modifier.matchParentSize()) + Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { + Text("#$number", style = brandedTextStyle(fonts.handBold, 16.sp, 16.sp), color = DesiCrimeColors.NoteInk, maxLines = 1) + Spacer( + Modifier + .padding(top = 1.dp) + .size(30.dp, 2.dp) + .graphicsLayer { rotationZ = -3f } + .background(DesiCrimeColors.Underline, RoundedCornerShape(1.dp)), + ) + } + Spacer(Modifier.matchParentSize().drawBehind { drawPushPin(Offset(size.width / 2f, 3.dp.toPx()), 8.dp.toPx()) }) + } +} + +/** A sticky note on the board, in their handwriting. */ +@Composable +private fun Note(text: String, size: TextUnit, fonts: DesiCrimeFonts, modifier: Modifier) { + Box(modifier.size(44.dp, 40.dp).clearAndSetSemantics {}) { + StickyPaper(Modifier.matchParentSize()) + Text( + text, + style = brandedTextStyle(fonts.handBold, size, 12.sp), + color = DesiCrimeColors.NoteInk, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.Center), + ) + Spacer(Modifier.matchParentSize().drawBehind { drawPushPin(Offset(this.size.width / 2f, 3.dp.toPx()), 8.dp.toPx()) }) + } +} + +/** A sticky note's paper: yellow, a little darker towards its foot and under its glued top, lifting off the cork. */ +@Composable +internal fun StickyPaper(modifier: Modifier) { + Spacer( + modifier + .boxShadow(fill = StickyColor, color = DesiCrimeColors.Shadow.copy(alpha = 0.5f), offsetY = 4.dp, blur = 6.dp, cornerRadius = 0.dp) + .background(DesiCrimeColors.Sticky) + .drawBehind { + val band = 9.dp.toPx() + drawRect(Brush.verticalGradient(0f to DesiCrimeColors.StickyTop, 1f to Color.Transparent, endY = band), size = Size(size.width, band)) + }, + ) +} + +/** The newest print's drop: from above and tilted, onto the board, settling at its slant. */ +internal object NewestMotion { + data class Pose(val x: Float, val y: Float, val rotation: Float, val scale: Float, val alpha: Float) + + private val Drop = CubicBezierEasing(0.3f, 0.7f, 0.35f, 1f) + + fun pose(t: Float): Pose { + val p = introProgress(t, 220f, 560f) + return Pose( + x = keyframes(p, Keyframe(0f, 18f, Drop), Keyframe(0.78f, 0f, Drop), Keyframe(1f, 0f)), + y = keyframes(p, Keyframe(0f, -46f, Drop), Keyframe(0.78f, 2f, Drop), Keyframe(1f, 0f)), + rotation = keyframes(p, Keyframe(0f, -11f, Drop), Keyframe(0.78f, -1.6f, Drop), Keyframe(1f, -2.5f)), + scale = keyframes(p, Keyframe(0f, 1.12f, Drop), Keyframe(0.78f, 1f, Drop), Keyframe(1f, 1f)), + alpha = keyframes(p, Keyframe(0f, 0f, Drop), Keyframe(0.55f, 1f), Keyframe(1f, 1f)), + ) + } + + /** How far the print has come down onto the board, 0 to 1, for its shadow. */ + fun lift(t: Float): Float = introProgress(t, 220f, 560f) +} + +/** The board in the design's units (dp), from the top of the status bar, which is 43dp tall there. */ +internal object DesiCrimeArt { + const val WIDTH = 369f + const val STATUS_BAR = 43f + const val HEIGHT = 355f - STATUS_BAR + const val NEWEST_X = 112f + const val NEWEST_Y = 106f + const val NEWEST_WIDTH = 146f + const val NEWEST_HEIGHT = 89f + const val PIN_X = 185f - 5.5f + const val PIN_Y = 110f - 5.5f + const val HOSTS_Y = 183.9f + const val HOSTS_HEIGHT = 170.6f + const val TOP_SHADE = 118f + const val TAP_Y = 102f + const val TAP_HEIGHT = 118f + const val END = 1_340f + const val PRINTS_Z = 10f + const val CLIPPING_Z = 11f + const val NEWEST_Z = 12f + const val PIN_Z = 13f + const val NOTES_Z = 14f + const val NUMBER_Z = 15f + const val LIGHT_Z = 16f + const val HOSTS_Z = 17f + const val TAP_Z = 18f + + /** A window this wide has room between the edges' prints for [WideSlots]. */ + const val WIDE_FROM = 640f + + /** The slots for a window [width] wide: the design's, and on a wide window more between them. */ + fun slots(width: Float): List = if (width >= WIDE_FROM) Slots + WideSlots else Slots + + /** Where earlier cases' prints go, most prominent first; the three nearest the lamp are captioned. */ + val Slots = listOf( + DesiCrimeSlot(Edge.Left, 2f, 104f, -5f, taped = false, captioned = true), + DesiCrimeSlot(Edge.Right, 256f, 104f, 5f, taped = false, captioned = true), + DesiCrimeSlot(Edge.Left, 6f, 168f, 3f, taped = true, captioned = true), + DesiCrimeSlot(Edge.Left, 6f, 50f, 3f, taped = false, captioned = false), + DesiCrimeSlot(Edge.Centre, 130f, 52f, -2f, taped = true, captioned = false), + DesiCrimeSlot(Edge.Right, 254f, 49f, 3f, taped = false, captioned = false), + DesiCrimeSlot(Edge.Left, 2f, 3f, -3f, taped = false, captioned = false), + DesiCrimeSlot(Edge.Centre, 126f, -1f, 2f, taped = true, captioned = false), + DesiCrimeSlot(Edge.Right, 250f, 4f, -2f, taped = false, captioned = false), + ) + + /** On a wide window, more prints either side of the middle, above the hosts. */ + val WideSlots = listOf( + DesiCrimeSlot(Edge.Centre, -40f, 106f, 4f, taped = false, captioned = false), + DesiCrimeSlot(Edge.Centre, 300f, 108f, -3f, taped = true, captioned = false), + DesiCrimeSlot(Edge.Centre, -64f, 52f, -2f, taped = true, captioned = false), + DesiCrimeSlot(Edge.Centre, 326f, 50f, 3f, taped = false, captioned = false), + DesiCrimeSlot(Edge.Centre, -58f, 2f, 2f, taped = false, captioned = false), + DesiCrimeSlot(Edge.Centre, 320f, 0f, -2f, taped = false, captioned = false), + ) + + /** Prints the feed fills at most: every slot on the widest board. */ + val MAX_PRINTS = Slots.size + WideSlots.size +} + +internal data class DesiCrimeSlot(val edge: Edge, val x: Float, val y: Float, val rotation: Float, val taped: Boolean, val captioned: Boolean) + +private val PrintPaperColor = Color(0xFFEBE5D8) +private val PrintPaper = Brush.verticalGradient(0f to Color(0xFFF3EFE5), 1f to Color(0xFFE3DCCC)) +private val StickyColor = Color(0xFFE9E07F) +private val PrintTone = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.85f) }) +private val Glare = Color(0x33FFFFFF) +private val PinIn = CubicBezierEasing(0.2f, 0.8f, 0.3f, 1.4f) +private val Slap = CubicBezierEasing(0.25f, 0.8f, 0.35f, 1.25f) diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeChithi.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeChithi.kt new file mode 100644 index 000000000..cf975b1d5 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeChithi.kt @@ -0,0 +1,341 @@ +package cx.aswin.boxlore.feature.info.branded.desicrime + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.PathMeasure +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.feature.info.branded.BrandedButtonNudge +import cx.aswin.boxlore.feature.info.branded.BrandedSubscribeLabels +import cx.aswin.boxlore.feature.info.branded.BrandedSubscribeMoment +import cx.aswin.boxlore.feature.info.branded.BrandedToolbarStyle +import cx.aswin.boxlore.feature.info.branded.Keyframe +import cx.aswin.boxlore.feature.info.branded.MomentHaptic +import cx.aswin.boxlore.feature.info.branded.introProgress +import cx.aswin.boxlore.feature.info.branded.keyframes +import cx.aswin.boxlore.feature.info.components.SubscriptionLabel +import kotlin.random.Random + +/** + * Chai and a chithi, after the show's own segment of listeners' letters: the button becomes a + * listener's blue inland letter, written edge to edge with a stamp and postmark; a cutting-chai glass + * is set down on it with one haptic, chai pours in from above and steams, and the glass lifts away, + * leaving its ring on the letter. Then the button clears into Subscribed. No words of its own. + * Timings follow the approved design board, in milliseconds from the tap. + */ +internal object DesiCrimeChithi : BrandedSubscribeMoment { + override val durationMillis = 1_400 + override val revealDelayMillis = 1_240 + + /** The glass is set down on the letter. */ + const val SET_DOWN_AT = 430f + + override fun nudge(elapsedMillis: Float): BrandedButtonNudge = BrandedButtonNudge(0.dp, 1f) + + @Composable + override fun Overlay(elapsedMillis: () -> Float, labels: BrandedSubscribeLabels, style: BrandedToolbarStyle, shape: Shape, modifier: Modifier) { + MomentHaptic(elapsedMillis, SET_DOWN_AT) + Box(modifier.clip(shape), contentAlignment = Alignment.Center) { + Spacer( + Modifier.fillMaxSize().drawWithCache { + val letter = Letter(size.width, 1.dp.toPx()) + onDrawBehind { + val t = elapsedMillis() + drawRect(style.subscribedContainer) + drawRect(style.subscribeContainer, alpha = 1f - introProgress(t, 60f, 120f)) + val shown = ChithiKeys.shown(t) + if (shown <= 0f) return@onDrawBehind + translate(left = -1.04f * size.width * (1f - ChithiKeys.slide(t)), top = ChithiKeys.thunk(t) * 1.dp.toPx()) { + with(letter) { draw(t, shown) } + } + } + }, + ) + SubscriptionLabel( + text = labels.subscribe, + compact = labels.compact, + color = style.subscribeContent, + modifier = Modifier.graphicsLayer { alpha = 1f - introProgress(elapsedMillis(), 0f, 80f) }, + ) + SubscriptionLabel( + text = labels.subscribed, + compact = labels.compact, + color = style.subscribedContent, + modifier = Modifier.graphicsLayer { + val land = Land.transform(introProgress(elapsedMillis(), LABEL_AT, 180f)) + alpha = land + scaleX = 1.04f - 0.04f * land + scaleY = scaleX + }, + ) + } + } + + private const val LABEL_AT = 1_220f + private val Land = CubicBezierEasing(0f, 0f, 0.58f, 1f) +} + +/** + * The inland letter over a button [width] wide, drawn in the design's 235 by 48 units ([unit] pixels + * each across its height): what is written on it spreads with the button; the stamp, the postmark + * and the glass keep their size. + */ +private class Letter(width: Float, private val unit: Float) { + private val across = width / (DESIGN_WIDTH * unit) + private fun x(design: Float) = design * across * unit + private fun y(design: Float) = design * unit + + private val scribbles = (0 until 4).map { line -> scribble(56f, if (line < 3) 168f else 134f, 15f + line * 7.5f, 40 + line) } + private val centre = x(GLASS_X + GLASS_WIDTH / 2f) + private val glass = Path().apply { + val half = GLASS_WIDTH / 2f * unit + val base = GLASS_WIDTH * BASE / 2f * unit + moveTo(centre - half, y(GLASS_TOP)) + lineTo(centre + half, y(GLASS_TOP)) + lineTo(centre + base, y(GLASS_TOP + GLASS_HEIGHT)) + lineTo(centre - base, y(GLASS_TOP + GLASS_HEIGHT)) + close() + } + private val stream = Path().apply { + moveTo(centre + unit, -12f * unit) + cubicTo(centre - 0.5f * unit, 0f, centre + 1.5f * unit, 8f * unit, centre, y(GLASS_TOP + GLASS_HEIGHT - 4f)) + } + private val streamMeasure = PathMeasure().apply { setPath(stream, false) } + private val streamPart = Path() + + fun DrawScope.draw(t: Float, alpha: Float) { + drawRect(Brush.linearGradient(0f to PaperLight, 0.55f to PaperMid, 1f to PaperDeep, end = Offset(size.width, size.height)), alpha = alpha) + drawRoundRect( + EdgeBlue, + Offset(3f * unit, 3f * unit), + Size(size.width - 6f * unit, size.height - 6f * unit), + CornerRadius(20f * unit), + alpha = alpha * 0.8f, + style = Stroke(0.7f * unit, pathEffect = PathEffect.dashPathEffect(floatArrayOf(2.2f * unit, 1.6f * unit))), + ) + drawLine(Color.White, Offset(x(140f), y(3f)), Offset(x(140f), size.height - y(3f)), unit, alpha = alpha * 0.55f) + drawLine(Crease, Offset(x(140f) + 1.2f * unit, y(3f)), Offset(x(140f) + 1.2f * unit, size.height - y(3f)), 0.6f * unit, alpha = alpha * 0.4f) + scribbles.forEach { drawPath(it, Handwriting, alpha = alpha * 0.72f, style = Stroke(0.9f * unit, cap = StrokeCap.Round)) } + drawStamp(alpha) + val ring = ChithiKeys.ring(t) * alpha + if (ring > 0f) drawRing(ring) + drawStream(t, alpha) + translate(top = ChithiKeys.glassY(t) * unit) { drawGlass(t, alpha) } + for ((dx, delay) in Wisps) drawWisp(dx, delay, t, alpha) + } + + private fun DrawScope.drawStamp(alpha: Float) { + translate(x(22f), y(8f)) { + drawRect(Stamp, size = Size(13f * unit, 15f * unit), alpha = alpha) + drawRect(StampEdge, size = Size(13f * unit, 15f * unit), alpha = alpha, style = Stroke(1.6f * unit, pathEffect = PathEffect.dashPathEffect(floatArrayOf(1.2f * unit, 0.9f * unit)))) + val figure = Path().apply { + moveTo(4f * unit, 4f * unit) + lineTo(9f * unit, 4f * unit) + lineTo(8.3f * unit, 11f * unit) + lineTo(4.7f * unit, 11f * unit) + close() + } + drawPath(figure, StampEdge, alpha = alpha * 0.9f) + } + val postmark = Stroke(0.7f * unit) + drawCircle(Postmark, 6.5f * unit, Offset(x(38f), y(21f)), alpha = alpha * 0.6f, style = postmark) + for (row in 0 until 3) { + val wave = Path().apply { + var at = x(38f) + 6f * unit + val level = y(18.5f + row * 2.5f) + moveTo(at, level) + repeat(3) { + quadraticTo(at + 1.5f * unit, level - 1.6f * unit, at + 3f * unit, level) + quadraticTo(at + 4.5f * unit, level + 1.6f * unit, at + 6f * unit, level) + at += 6f * unit + } + } + drawPath(wave, Postmark, alpha = alpha * 0.6f, style = postmark) + } + } + + private fun DrawScope.drawRing(alpha: Float) { + val bottom = y(GLASS_TOP + GLASS_HEIGHT - 1f) + val rx = GLASS_WIDTH * BASE / 2f * unit + drawOval(RingDark, Offset(centre - rx - 1.2f * unit, bottom - 3f * unit), Size(2 * (rx + 1.2f * unit), 6f * unit), alpha = alpha * 0.55f, style = Stroke(1.6f * unit, pathEffect = PathEffect.dashPathEffect(floatArrayOf(14f * unit, 3f * unit, 6f * unit, 2f * unit)))) + drawOval(Tea, Offset(centre + 0.6f * unit - rx + 0.6f * unit, bottom + 0.4f * unit - 2.2f * unit), Size(2 * (rx - 0.6f * unit), 4.4f * unit), alpha = alpha * 0.35f, style = Stroke(0.7f * unit)) + } + + private fun DrawScope.drawStream(t: Float, alpha: Float) { + val length = streamMeasure.length + if (length <= 0f) return + val dash = DASH * unit + val offset = ChithiKeys.pourOffset(t) * unit + val from = (-offset).coerceIn(0f, length) + val to = (dash - offset).coerceIn(0f, length) + if (to <= from) return + streamPart.reset() + streamMeasure.getSegment(from, to, streamPart, true) + drawPath(streamPart, Brush.verticalGradient(0f to TeaLight, 1f to Tea), alpha = alpha, style = Stroke(2.6f * unit, cap = StrokeCap.Round)) + } + + private fun DrawScope.drawGlass(t: Float, alpha: Float) { + val top = y(GLASS_TOP) + val bottom = y(GLASS_TOP + GLASS_HEIGHT) + val half = GLASS_WIDTH / 2f * unit + val base = GLASS_WIDTH * BASE / 2f * unit + drawOval(GlassShadow, Offset(centre - base - 1.5f * unit, bottom + 0.6f * unit - 1.6f * unit), Size(2 * (base + 1.5f * unit), 3.2f * unit), alpha = alpha) + clipPath(glass) { + val fill = ChithiKeys.fill(t) + if (fill > 0f) { + scale(scaleX = 1f, scaleY = fill, pivot = Offset(centre, bottom)) { + drawRect(Brush.verticalGradient(0f to TeaLight, 1f to TeaDeep, startY = top + 6f * unit, endY = bottom), Offset(centre - half, top + 6f * unit), Size(2 * half, bottom - top - 6f * unit), alpha = alpha) + } + } + val froth = ChithiKeys.froth(t) + if (froth > 0f) drawRect(Froth, Offset(centre - half, top + 5f * unit), Size(2 * half, 2.6f * unit), alpha = alpha * froth) + } + drawPath(glass, GlassTint, alpha = alpha) + drawPath(glass, GlassEdge, alpha = alpha, style = Stroke(0.9f * unit)) + for (f in Ribs) { + val at = centre - half + 2 * half * f + drawLine(Color.White, Offset(at, top + 2f * unit), Offset(centre + (at - centre) * BASE, bottom - unit), 0.55f * unit, alpha = alpha * 0.32f) + } + drawLine(Color.White, Offset(centre - half + 2.4f * unit, top + 2f * unit), Offset(centre - half + 4.6f * unit, bottom - 2f * unit), 1.3f * unit, alpha = alpha * 0.65f) + drawOval(GlassEdge, Offset(centre - half, top - 1.4f * unit), Size(2 * half, 2.8f * unit), alpha = alpha, style = Stroke(0.8f * unit)) + } + + private fun DrawScope.drawWisp(dx: Float, delay: Float, t: Float, alpha: Float) { + val wisp = ChithiKeys.wisp(t, delay) + if (wisp.alpha <= 0f) return + val foot = Offset(centre + dx * unit, y(GLASS_TOP - 1f)) + translate(top = wisp.rise * unit) { + scale(scaleX = 1f, scaleY = wisp.stretch, pivot = foot) { + val path = Path().apply { + moveTo(foot.x, foot.y) + relativeCubicTo(-2.4f * unit, -3f * unit, 2.4f * unit, -5f * unit, 0f, -9f * unit) + } + drawPath(path, Color.White, alpha = alpha * wisp.alpha, style = Stroke(1.3f * unit, cap = StrokeCap.Round)) + } + } + } + + /** One line of cursive-looking handwriting: small loops and a word gap or two, no letters and no words. */ + private fun scribble(from: Float, to: Float, level: Float, seed: Int): Path { + val random = Random(seed) + val gaps = listOf(random.nextInt((from + 18).toInt(), (to - 30).toInt()), random.nextInt((from + 50).toInt(), (to - 10).toInt())) + return Path().apply { + var at = from + var height = level + moveTo(x(at), y(height)) + while (at < to) { + val step = random.nextDouble(3.4, 5.2).toFloat() + val loop = random.nextDouble(0.9, 2.1).toFloat() + if (gaps.any { kotlin.math.abs(at - it) < 3f }) { + at += step + 3f + moveTo(x(at), y(height)) + continue + } + val drift = random.nextDouble(-0.4, 0.4).toFloat() + quadraticTo(x(at + step / 2f), y(height - loop), x(at + step), y(height + drift)) + at += step + height += drift + } + } + } + + companion object { + const val DESIGN_WIDTH = 235f + const val GLASS_X = 184f + const val GLASS_TOP = 9f + const val GLASS_WIDTH = 26f + const val GLASS_HEIGHT = 34f + + /** The glass narrows to its base. */ + const val BASE = 0.78f + const val DASH = 60f + val Ribs = listOf(0.18f, 0.36f, 0.54f, 0.72f, 0.9f) + val Wisps = listOf(-4f to 780f, 2f to 850f, 6f to 920f) + } +} + +/** The moment's keyframes, from the design board, in milliseconds from the tap. */ +internal object ChithiKeys { + private val Glass = CubicBezierEasing(0.3f, 0.8f, 0.3f, 1f) + private val Out = CubicBezierEasing(0f, 0f, 0.58f, 1f) + + /** The letter over the button: there until just before Subscribed lands. */ + fun shown(t: Float): Float = if (t < 1_150f) 1f else (1f - (t - 1_150f) / 70f).coerceAtLeast(0f) + + /** The letter slides in from the left. */ + fun slide(t: Float): Float = CubicBezierEasing(0.25f, 0.8f, 0.3f, 1f).transform(introProgress(t, 10f, 230f)) + + /** The letter's small jump as the glass is set down, in dp. */ + fun thunk(t: Float): Float = keyframes(introProgress(t, 430f, 160f), Keyframe(0f, 0f, Out), Keyframe(0.35f, 0.8f, Out), Keyframe(0.7f, -0.3f, Out), Keyframe(1f, 0f)) + + /** The glass's height above where it stands, in dp: lowered in, held while it fills, lifted away. */ + fun glassY(t: Float): Float = keyframes( + introProgress(t, 190f, 1_000f), + Keyframe(0f, -58f, Glass), + Keyframe(0.24f, 1f, Glass), + Keyframe(0.3f, 0f, Glass), + Keyframe(0.76f, 0f, Glass), + Keyframe(1f, -64f), + ) + + /** The pour's dash offset along the stream, in dp: the stream reaches down, runs, then its tail falls in after it. */ + fun pourOffset(t: Float): Float = keyframes(introProgress(t, 450f, 470f), Keyframe(0f, 60f), Keyframe(0.3f, 0f), Keyframe(0.75f, 0f), Keyframe(1f, -60f)) + + fun fill(t: Float): Float = Out.transform(introProgress(t, 520f, 360f)) + + fun froth(t: Float): Float = introProgress(t, 860f, 120f) + + fun ring(t: Float): Float = introProgress(t, 1_000f, 160f) + + /** A wisp of steam starting [delay] after the tap: it rises, stretches and fades. */ + fun wisp(t: Float, delay: Float): Wisp { + val p = introProgress(t, delay, 360f) + if (t < delay) return Wisp(0f, 2f, 0.6f) + return Wisp( + alpha = keyframes(p, Keyframe(0f, 0f, Out), Keyframe(0.4f, 0.85f, Out), Keyframe(1f, 0f)), + rise = 2f - 11f * Out.transform(p), + stretch = 0.6f + 0.5f * Out.transform(p), + ) + } + + data class Wisp(val alpha: Float, val rise: Float, val stretch: Float) +} + +private val PaperLight = Color(0xFFDCE9F6) +private val PaperMid = Color(0xFFC6DAF0) +private val PaperDeep = Color(0xFFB3CCE8) +private val EdgeBlue = Color(0xFF7E9CC6) +private val Crease = Color(0xFF6F8DB8) +private val Handwriting = Color(0xFF1D3A6E) +private val Stamp = Color(0xFFEE7112) +private val StampEdge = Color(0xFFF4ECDC) +private val Postmark = Color(0xFF3B4F7A) +private val Tea = Color(0xFFA8642C) +private val TeaLight = Color(0xFFE0A160) +private val TeaDeep = Color(0xFF6A3510) +private val Froth = Color(0xFFEBD6B0) +private val RingDark = Color(0xFF8A4A1A) +private val GlassShadow = Color(0x40141E32) +private val GlassTint = Color(0x29E6F0F8) +private val GlassEdge = Color(0xE6FAFCFF) diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeColors.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeColors.kt new file mode 100644 index 000000000..aa1958cf5 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeColors.kt @@ -0,0 +1,81 @@ +package cx.aswin.boxlore.feature.info.branded.desicrime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.branded.BrandedActionColors +import cx.aswin.boxlore.feature.info.branded.BrandedCopyrightStyle + +/** + * The crime board's colours: a dark studio, the cork under a warm lamp, the show's saffron (the DESI + * of its wordmark) as the accent, the cream of its CRIME, and the yellow of the sticky notes. Dark in + * both themes. + */ +internal object DesiCrimeColors { + val Page = Color(0xFF0E0A08) + val Text = Color(0xFFF3E9D8) + val Muted = Color(0xFFBEB3A3) + val Saffron = Color(0xFFEE7112) + val OnSaffron = Color(0xFF1A0B02) + val Control = Color(0xFF2C2119) + val OnControl = Color(0xFFF3E9D8) + val Card = Color(0xFF1A1410) + val CardControl = Color(0xFF33251B) + val ChipLine = Color(0xFF4A3D32) + val Navigation = Color(0xFF211913) + val NavigationText = Color(0xFFEDE3D3) + + /** Ink on the sticky notes and the prints. */ + val NoteInk = Color(0xFF2A2016) + val PrintInk = Color(0xFF2E2418) + val CaseNumber = Color(0xFF9A4408) + + /** The sticky's "#212 · newest case": a red pen. */ + val RedPen = Color(0xFF98300A) + val Underline = Color(0xFFC2330F) + val NoteDetails = Color(0xFF4E3F2C) + + /** The card's frame round its strip of cork. */ + val Frame = Color(0xFF4A3020) + val Cork = Color(0xFF3A2616) + + /** The sticky notes' yellow, a little deeper towards the foot. */ + val Sticky = Brush.verticalGradient(0f to Color(0xFFF2EC98), 0.55f to Color(0xFFE9E07F), 1f to Color(0xFFDCD06B)) + val StickyTop = Color(0x29786414) + val Tape = Color(0x80F0E8CE) + val Shadow = Color(0xFF0A0400) + + val Actions = BrandedActionColors(toggle = CardControl, onToggle = OnControl, checked = Saffron, onChecked = OnSaffron, play = Saffron, onPlay = OnSaffron) + + /** The © popup: 260dp wide, its caret near its right end, under the © after the hosts' names. */ + val Copyright = BrandedCopyrightStyle( + icon = Muted, + popup = Color(0xFF2C2119), + title = Text, + body = Color(0xFFE6DCCC), + note = Muted, + shadow = Color(0xB3060302), + ring = Color(0x1AFFFFFF), + popupWidth = 260.dp, + caretCentre = 236.dp, + gap = 2.dp, + overhangTop = 3.dp, + overhangBottom = 3.dp, + iconSize = 16.8.dp, + ) +} + +/** The hosts' handwriting on the board: Kalam, bold for notes and captions, regular for the small print. */ +@Immutable +internal data class DesiCrimeFonts(val hand: FontFamily, val handBold: FontFamily) + +@Composable +internal fun rememberDesiCrimeFonts(): DesiCrimeFonts = remember { + DesiCrimeFonts(hand = FontFamily(Font(R.font.desicrime_kalam)), handBold = FontFamily(Font(R.font.desicrime_kalam_bold))) +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeDrawing.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeDrawing.kt new file mode 100644 index 000000000..5e08bdb2a --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeDrawing.kt @@ -0,0 +1,184 @@ +package cx.aswin.boxlore.feature.info.branded.desicrime + +import android.graphics.BlurMaskFilter +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.feature.info.branded.Keyframe +import cx.aswin.boxlore.feature.info.branded.introProgress +import cx.aswin.boxlore.feature.info.branded.keyframes +import kotlin.math.roundToInt + +// The crime board's drawing: the cork, the studio light, the hosts, pins, tape and the torn print's edge and shadow. + +/** The torn print's shadow on the cork: high and soft while it drops, close and darker once it lands. */ +internal fun DrawScope.drawTornShadow(shadow: TornShadow, lift: Float) { + val unit = 1.dp.toPx() + val drop = (22f - 17f * lift) * unit + val paint = shadow.paint(blur = (16f - 10f * lift) * unit * 0.75f, alpha = 0.45f + 0.15f * lift) + drawContext.canvas.nativeCanvas.apply { + save() + translate(0f, drop) + drawPath(tearPath(size.width).asAndroidPath(), paint) + restore() + } +} + +/** The cork fills the board and reaches up behind the status bar, cropped to cover. */ +internal fun DrawScope.drawCork(cork: ImageBitmap?, statusBar: Float) { + val unit = 1.dp.toPx() + val top = -maxOf(statusBar, DesiCrimeArt.STATUS_BAR * unit) + clipRect(top = top, bottom = size.height) { + drawRect(DesiCrimeColors.Cork, topLeft = Offset(0f, top), size = Size(size.width, size.height - top)) + if (cork == null) return@clipRect + val height = size.height - top + val scale = maxOf(size.width / cork.width, height / cork.height) + val srcWidth = (size.width / scale).roundToInt().coerceAtMost(cork.width) + val srcHeight = (height / scale).roundToInt().coerceAtMost(cork.height) + drawImage( + cork, + srcOffset = IntOffset((cork.width - srcWidth) / 2, 0), + srcSize = IntSize(srcWidth, srcHeight), + dstOffset = IntOffset(0, top.roundToInt()), + dstSize = IntSize(size.width.roundToInt(), height.roundToInt()), + ) + } +} + +/** + * The studio light over the board: the lamp's warm pool (flickering on as the board opens), the red + * wall's glow at the left, shadow at the edges, and the dark under the top bar. + */ +internal fun DrawScope.drawStudioLight(t: Float, statusBar: Float) { + val unit = 1.dp.toPx() + val top = -maxOf(statusBar, DesiCrimeArt.STATUS_BAR * unit) + val height = size.height - top + val lamp = keyframes(introProgress(t, 0f, 900f), Keyframe(0f, 0f, LampEase), Keyframe(0.12f, 0.8f, LampEase), Keyframe(0.18f, 0.2f, LampEase), Keyframe(0.3f, 1f, LampEase), Keyframe(1f, 1f)) + clipRect(top = top, bottom = size.height) { + ellipse(Offset(size.width * 0.54f, top + height * 0.44f), size.width * 0.62f, height * 0.52f, top, lamp, BlendMode.Screen, 0f to LampLight, 0.45f to LampLight.copy(alpha = 0.10f), 0.72f to Color.Transparent) + drawRect(Brush.horizontalGradient(0f to WallGlow, 0.22f to Color.Transparent, endX = size.width), topLeft = Offset(0f, top), size = Size(size.width, height), blendMode = BlendMode.Multiply) + ellipse(Offset(size.width * 0.54f, top + height * 0.5f), size.width * 0.95f, height * 0.78f, top, 1f, BlendMode.SrcOver, 0.42f to Color.Transparent, 1f to EdgeShadow) + val dark = 0.35f * (1f - keyframes(introProgress(t, 0f, 900f), Keyframe(0f, 0f, LampEase), Keyframe(0.3f, 1f), Keyframe(1f, 1f))) + if (dark > 0f) drawRect(Studio, topLeft = Offset(0f, top), size = Size(size.width, height), alpha = dark) + val shade = (DesiCrimeArt.TOP_SHADE - DesiCrimeArt.STATUS_BAR) * unit - top + drawRect( + Brush.verticalGradient(0f to TopShade, 0.5f to TopShade.copy(alpha = 0.55f), 1f to Color.Transparent, startY = top, endY = top + shade), + topLeft = Offset(0f, top), + size = Size(size.width, shade), + ) + } +} + +/** An elliptical radial gradient ([rx] by [ry] round [centre]) over the whole board from [top] down, like CSS's radial-gradient. */ +private fun DrawScope.ellipse(centre: Offset, rx: Float, ry: Float, top: Float, alpha: Float, blendMode: BlendMode, vararg stops: Pair) { + if (alpha <= 0f) return + val squash = ry / rx + scale(scaleX = 1f, scaleY = squash, pivot = centre) { + val from = centre.y + (top - centre.y) / squash + val to = centre.y + (size.height - centre.y) / squash + drawRect(Brush.radialGradient(*stops, center = centre, radius = rx), topLeft = Offset(0f, from), size = Size(size.width, to - from), alpha = alpha, blendMode = blendMode) + } +} + +/** Aishwarya held to the window's left edge and Aryaan to its right, peeking up over their arms. */ +internal fun DrawScope.drawHosts(her: ImageBitmap, him: ImageBitmap) { + val unit = 1.dp.toPx() + val top = (DesiCrimeArt.HOSTS_Y - DesiCrimeArt.STATUS_BAR) * unit + val height = DesiCrimeArt.HOSTS_HEIGHT * unit + val herWidth = height * her.width / her.height + val himWidth = height * him.width / him.height + drawImage(her, dstOffset = IntOffset(0, top.roundToInt()), dstSize = IntSize(herWidth.roundToInt(), height.roundToInt())) + drawImage(him, dstOffset = IntOffset((size.width - himWidth).roundToInt(), top.roundToInt()), dstSize = IntSize(himWidth.roundToInt(), height.roundToInt())) +} + +/** A flat brass drawing pin, the kind every stationery shop sells by the box. */ +internal fun DrawScope.drawBrassPin(centre: Offset, size: Float) { + val r = size / 2f + drawCircle(Brush.radialGradient(0f to PinShadow, 1f to Color.Transparent, center = centre + Offset(r * 0.3f, r * 0.55f), radius = r * 1.35f), r * 1.35f, centre + Offset(r * 0.3f, r * 0.55f)) + drawCircle( + Brush.radialGradient(0f to Color(0xFFFFF3C4), 0.26f to Color(0xFFEDC45A), 0.62f to Color(0xFFB9832A), 1f to Color(0xFF714A12), center = centre + Offset(-0.32f * r, -0.4f * r), radius = r * 1.5f), + r, + centre, + ) + drawCircle(PinRing, r - 0.35f, centre, style = Stroke(0.7f * density)) + drawCircle(PinDot, 1.3f * density, centre) +} + +/** A saffron plastic push pin, seen from above. */ +internal fun DrawScope.drawPushPin(centre: Offset, size: Float) { + val r = size / 2f + drawCircle(Brush.radialGradient(0f to PinShadow, 1f to Color.Transparent, center = centre + Offset(r * 0.35f, r * 0.6f), radius = r * 1.4f), r * 1.4f, centre + Offset(r * 0.35f, r * 0.6f)) + drawCircle( + Brush.radialGradient(0f to Color(0xFFFFD3A8), 0.3f to Color(0xFFF58A33), 0.64f to Color(0xFFD2600C), 1f to Color(0xFF8C3A04), center = centre + Offset(-0.28f * r, -0.4f * r), radius = r * 1.5f), + r, + centre, + ) +} + +/** Two strips of tape across a print's top corners. */ +internal fun DrawScope.drawTape() { + val unit = 1.dp.toPx() + val strip = Size(26f * unit, 10f * unit) + rotate(-30f, pivot = Offset(6f * unit, 1f * unit)) { drawRect(DesiCrimeColors.Tape, Offset(-7f * unit, -4f * unit), strip) } + rotate(30f, pivot = Offset(size.width - 6f * unit, 1f * unit)) { drawRect(DesiCrimeColors.Tape, Offset(size.width + 7f * unit - strip.width, -4f * unit), strip) } +} + +/** The torn edge every episode picture shares, as fractions of the picture's width (the art is square). */ +internal val TornEdge = GenericShape { size, _ -> addPath(tearPath(size.width)) } + +private fun tearPath(width: Float): Path = Path().apply { + moveTo(0f, 0f) + lineTo(width, 0f) + var i = DesiCrimeTear.Edge.size - 2 + while (i >= 0) { + lineTo(DesiCrimeTear.Edge[i] * width, DesiCrimeTear.Edge[i + 1] * width) + i -= 2 + } + close() +} + +private val LampLight = Color(0x47FFB260) +private val WallGlow = Color(0x668C160A) +private val EdgeShadow = Color(0x990E0602) +private val Studio = Color(0xFF0E0602) +private val TopShade = Color(0xC70E0703) +private val PinShadow = Color(0x990A0400) +private val PinRing = Color(0xB3462806) +private val PinDot = Color(0xA65A370A) +private val LampEase = CubicBezierEasing(0f, 0f, 0.58f, 1f) + +/** + * The torn print's shadow paint, made once per board: its blur filter is replaced only when the + * blur reaches a new whole pixel, so the drop allocates a handful of filters, not one per frame. + */ +internal class TornShadow { + private val paint = android.graphics.Paint().apply { color = DesiCrimeColors.Shadow.toArgb() } + private var radius = -1f + + fun paint(blur: Float, alpha: Float): android.graphics.Paint { + val whole = blur.roundToInt().coerceAtLeast(1).toFloat() + if (whole != radius) { + radius = whole + paint.maskFilter = BlurMaskFilter(whole, BlurMaskFilter.Blur.NORMAL) + } + paint.alpha = (alpha * 255).roundToInt() + return paint + } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeFeed.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeFeed.kt new file mode 100644 index 000000000..b301d67e4 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeFeed.kt @@ -0,0 +1,65 @@ +package cx.aswin.boxlore.feature.info.branded.desicrime + +import androidx.compose.runtime.Immutable +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.feature.info.branded.brandedShowPicture +import cx.aswin.boxlore.feature.info.branded.episodeOwnPicture + +/** + * The Desi Crime Podcast's feed, read by fixed rules that work on any of its episodes. Its cases + * are numbered: "212. The Most Hated Indian Killer In America | Mundill Mahil & Gagandip Singh". + * The listeners' stories (Chai & Chithi) and specials carry no number. Every episode's picture + * follows one template: the case's photo across the top, the DESI CRIME lettering under it, and a + * torn edge above the hosts. + */ +internal object DesiCrimeFeed { + /** A title's parts: the case number, the case, and what follows "|" (the people in it, or a guest). */ + fun case(title: String): DesiCrimeCase { + val numbered = Numbered.find(title.trim()) + val number = numbered?.groupValues?.get(1)?.toIntOrNull() + val rest = numbered?.groupValues?.get(2) ?: title.trim() + val main = rest.substringBefore('|').trim() + val after = rest.substringAfter('|', "").trim() + return DesiCrimeCase(number, main.ifEmpty { rest.trim() }, after) + } + + /** + * A print's caption: the names after "|" (the case's people), or the case itself when there are + * none, or when "|" names a guest ("Ft. …") rather than the case. + */ + fun caption(case: DesiCrimeCase): String = + case.after.takeUnless { it.isEmpty() || it.startsWith("Ft.", ignoreCase = true) } ?: case.main + + /** The board, newest first: the latest case and its picture, then earlier cases that have pictures of their own. */ + fun board(feed: List, podcast: Podcast, prints: Int = DesiCrimeArt.MAX_PRINTS): DesiCrimeBoard { + val cover = brandedShowPicture(podcast) + val covers = listOf(podcast.imageUrl, podcast.fallbackImageUrl) + val cases = feed.mapNotNull { episode -> case(episode.title).takeIf { it.number != null }?.let { episode to it } } + val newest = cases.firstOrNull() + val earlier = cases.drop(1).mapNotNull { (episode, case) -> + episodeOwnPicture(episode, covers)?.let { picture -> DesiCrimePrint(episode.id, case.number ?: 0, caption(case), picture) } + } + return DesiCrimeBoard( + newestNumber = newest?.second?.number, + // A case whose picture is the show's cover shows the cover's top: the same template. + newestPicture = newest?.first?.let { episodeOwnPicture(it, covers) } ?: cover, + cover = cover, + prints = earlier.take(prints), + ) + } + + private val Numbered = Regex("""^(\d+)\.\s+(.*)$""") +} + +/** One title's parts ([DesiCrimeFeed.case]). */ +@Immutable +internal data class DesiCrimeCase(val number: Int?, val main: String, val after: String) + +/** An earlier case pinned on the board: its number, its caption and its own picture. */ +@Immutable +internal data class DesiCrimePrint(val id: String, val number: Int, val caption: String, val picture: String) + +/** What the board shows from the feed ([DesiCrimeFeed.board]). */ +@Immutable +internal data class DesiCrimeBoard(val newestNumber: Int?, val newestPicture: String?, val cover: String?, val prints: List) diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeHeader.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeHeader.kt new file mode 100644 index 000000000..c338824b1 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeHeader.kt @@ -0,0 +1,90 @@ +package cx.aswin.boxlore.feature.info.branded.desicrime + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.branded.BrandedCopyrightButton +import cx.aswin.boxlore.feature.info.branded.BrandedDescription +import cx.aswin.boxlore.feature.info.branded.BrandedDescriptionExtras +import cx.aswin.boxlore.feature.info.branded.brandedSansStyle +import java.util.Locale + +/** + * Their crime board ([DesiCrimeBoard]), the hosts' names with the © beside them, then the + * description, centred, which opens on tap. The usual chips follow. + */ +@Composable +internal fun DesiCrimeHeader(podcast: Podcast, feed: List, description: BrandedDescription, chips: @Composable () -> Unit, modifier: Modifier) { + val board = remember(feed, podcast) { DesiCrimeFeed.board(feed, podcast) } + Column(modifier) { + DesiCrimeBoard(board) + HostsLine(Modifier.align(Alignment.CenterHorizontally).padding(top = 1.dp)) + if (description.text.isNotEmpty()) { + val toggleState = stringResource(if (description.expanded) R.string.branded_description_expanded else R.string.branded_description_collapsed) + Text( + description.text, + style = brandedSansStyle(14.5.sp, 21.sp), + color = DesiCrimeColors.Muted, + textAlign = TextAlign.Center, + maxLines = if (description.expanded) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(start = 28.dp, end = 28.dp, top = 8.dp) + .fillMaxWidth() + .animateContentSize(spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow)) + .expressiveClickable(shape = RoundedCornerShape(8.dp)) { description.onToggle() } + .semantics { + role = Role.Button + stateDescription = toggleState + }, + ) + } + Column(Modifier.padding(horizontal = 16.dp)) { BrandedDescriptionExtras(description) } + Spacer(Modifier.height(if (description.text.isNotEmpty()) 18.dp else 14.dp)) + chips() + } +} + +/** ARYAAN MISRA & AISHWARYA SINGH, spaced out, with the © after it; the same space before keeps the names centred. */ +@Composable +private fun HostsLine(modifier: Modifier) { + Row(modifier.heightIn(min = 26.dp).padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + Spacer(Modifier.width(30.dp)) + Text( + stringResource(R.string.desicrime_hosts).uppercase(Locale.getDefault()), + style = brandedSansStyle(10.5.sp, 13.sp, weight = 700).copy(letterSpacing = 1.8.sp), + color = DesiCrimeColors.Muted, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f, fill = false), + ) + BrandedCopyrightButton(DesiCrimeColors.Copyright, stringResource(R.string.desicrime_copyright)) + } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimePage.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimePage.kt new file mode 100644 index 000000000..907a783ac --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimePage.kt @@ -0,0 +1,115 @@ +package cx.aswin.boxlore.feature.info.branded.desicrime + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.feature.info.branded.BrandedChipStyle +import cx.aswin.boxlore.feature.info.branded.BrandedDescription +import cx.aswin.boxlore.feature.info.branded.BrandedPageStyle +import cx.aswin.boxlore.feature.info.branded.BrandedShowIds +import cx.aswin.boxlore.feature.info.branded.BrandedShowPage +import cx.aswin.boxlore.feature.info.branded.BrandedSpotlight +import cx.aswin.boxlore.feature.info.branded.BrandedSubscribeMoment +import cx.aswin.boxlore.feature.info.branded.BrandedToolbarStyle + +/** + * The Desi Crime Podcast as the hosts' crime board: the cork wall behind Aryaan and Aishwarya on the + * show's cover, pinned with prints of past cases; the latest case written up on a sticky note; and + * a listener's chai and chithi when you subscribe. Dark in both themes. + */ +internal object DesiCrimePage : BrandedShowPage { + override val ids = BrandedShowIds( + podcastIndexId = "7976624", + podcastGuid = "8ee0469f-e82f-54f1-9736-0e24334c51c4", + feedUrl = "https://feeds.acast.com/public/shows/6a55242c7987e974eaf4ba29", + ) + + // The cork starts under the status bar; it draws itself up behind it. + override val headerTop = 0.dp + override val subscribeMoment: BrandedSubscribeMoment = DesiCrimeChithi + override val darkOnly = true + + override fun headerToButtons(description: BrandedDescription) = 14.dp + + override fun style(base: ColorScheme, dark: Boolean): BrandedPageStyle { + val c = DesiCrimeColors + val colors = base.copy( + primary = c.Saffron, + onPrimary = c.OnSaffron, + primaryContainer = c.Saffron, + onPrimaryContainer = c.OnSaffron, + secondary = c.Saffron, + onSecondary = c.OnSaffron, + secondaryContainer = c.Saffron, + onSecondaryContainer = c.OnSaffron, + background = c.Page, + onBackground = c.Text, + surface = c.Page, + onSurface = c.Text, + // Unique, so Material's matching content colour for cards and chips stays onSurface. + surfaceVariant = c.Control, + onSurfaceVariant = c.Muted, + surfaceDim = c.Page, + surfaceBright = c.Copyright.popup, + surfaceContainerLowest = c.Page, + surfaceContainerLow = c.Card, + surfaceContainer = c.Card, + surfaceContainerHigh = c.Control, + surfaceContainerHighest = c.CardControl, + outline = c.Muted, + outlineVariant = c.ChipLine, + surfaceTint = c.Saffron, + ) + return BrandedPageStyle( + colors = colors, + popup = c.Copyright, + navigationColors = colors.copy( + surfaceContainerHigh = c.Navigation, + onSurfaceVariant = c.NavigationText, + outlineVariant = c.Navigation, + ), + chips = BrandedChipStyle( + shape = RoundedCornerShape(3.dp), + height = 34.dp, + padding = PaddingValues(start = 10.dp, end = 13.dp), + iconSize = 18.dp, + gap = 6.dp, + fontSize = 14.sp, + fontWeight = 500, + container = c.Page, + content = c.Text, + icon = c.Saffron, + genreContainer = c.Saffron, + genreContent = c.OnSaffron, + genreIcon = c.OnSaffron, + outline = c.ChipLine, + ), + toolbar = BrandedToolbarStyle( + subscribeContainer = c.Saffron, + subscribeContent = c.OnSaffron, + subscribedContainer = c.Control, + subscribedContent = c.OnControl, + toggleContainer = c.Control, + toggleContent = c.OnControl, + toggleCheckedContainer = c.Saffron, + toggleCheckedContent = c.OnSaffron, + actionContainer = c.Control, + actionContent = c.OnControl, + ), + episodeTimeColor = c.Saffron, + ) + } + + @Composable + override fun Header(podcast: Podcast, feed: List, description: BrandedDescription, chips: @Composable () -> Unit, modifier: Modifier) = + DesiCrimeHeader(podcast, feed, description, chips, modifier) + + @Composable + override fun Spotlight(spotlight: BrandedSpotlight, modifier: Modifier) = DesiCrimeSpotlight(spotlight, modifier) +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeSpotlight.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeSpotlight.kt new file mode 100644 index 000000000..71cf837cc --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeSpotlight.kt @@ -0,0 +1,127 @@ +package cx.aswin.boxlore.feature.info.branded.desicrime + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.text +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.branded.BrandedEpisodeActions +import cx.aswin.boxlore.feature.info.branded.BrandedSpotlight +import cx.aswin.boxlore.feature.info.branded.brandedDetails +import cx.aswin.boxlore.feature.info.branded.brandedTextStyle +import cx.aswin.boxlore.feature.info.branded.rememberBrandedArt +import cx.aswin.boxlore.feature.info.components.formatEpisodeRelativeDate +import kotlin.math.roundToInt + +/** + * The latest case written up on a sticky note pinned to a strip of their board: its number and + * "newest case" in red pen, the case's title in their hand ([DesiCrimeFeed.case]), and when it came + * out and how long it is, or that it was played. The episode cards' buttons follow on the cork. + */ +@Composable +internal fun DesiCrimeSpotlight(spotlight: BrandedSpotlight, modifier: Modifier) { + val episode = spotlight.episode + val fonts = rememberDesiCrimeFonts() + val case = remember(episode.title) { DesiCrimeFeed.case(episode.title) } + val cork by rememberBrandedArt(R.drawable.desicrime_cork) + val selectLabel = stringResource(R.string.podcast_episode_select) + val shape = RoundedCornerShape(4.dp) + Column( + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .drawBehind { + drawRect(DesiCrimeColors.Cork) + cork?.firstOrNull()?.let { bitmap -> + // The board's cork at its own scale, centred, under the lamp's last warmth and shadow. + val scale = size.width.coerceAtLeast(CORK_WIDTH.dp.toPx()) / bitmap.width + val width = (bitmap.width * scale).roundToInt() + val height = (bitmap.height * scale).roundToInt() + drawImage(bitmap, dstOffset = IntOffset(((size.width - width) / 2f).roundToInt(), ((size.height - height) / 2f).roundToInt()), dstSize = IntSize(width, height)) + } + drawRect(Brush.radialGradient(0f to CardLamp, 1f to CardShadow, center = Offset(size.width / 2f, size.height * 0.2f), radius = size.width * 0.9f)) + drawRect(DesiCrimeColors.Frame, style = Stroke(4.dp.toPx())) + drawRect(FrameShade, topLeft = Offset(2.dp.toPx(), 2.dp.toPx()), size = Size(size.width - 4.dp.toPx(), size.height - 4.dp.toPx()), style = Stroke(1.dp.toPx())) + } + .then(if (spotlight.isSelected) Modifier.border(3.dp, DesiCrimeColors.Text, shape) else Modifier) + .expressiveClickable(shape = shape, onLongClickLabel = selectLabel, onLongClick = spotlight.onLongPress, onClick = spotlight.onOpen) + .padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 11.dp), + ) { + Box( + Modifier + .fillMaxWidth() + .graphicsLayer { rotationZ = -0.8f } + .drawWithContent { + drawContent() + drawBrassPin(Offset(size.width / 2f, 2.dp.toPx()), 11.dp.toPx()) + }, + ) { + StickyPaper(Modifier.matchParentSize()) + Column(Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, top = 9.dp, bottom = 8.dp)) { + Text( + case.number?.let { stringResource(R.string.desicrime_newest_case, it) } ?: stringResource(R.string.desicrime_newest), + style = brandedTextStyle(fonts.handBold, 13.sp, 15.sp), + color = DesiCrimeColors.RedPen, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + case.main, + style = brandedTextStyle(fonts.handBold, 18.5.sp, 20.sp), + color = DesiCrimeColors.NoteInk, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + // Screen readers hear the episode's own title. + modifier = Modifier.padding(top = 3.dp).clearAndSetSemantics { + heading() + text = AnnotatedString(episode.title) + }, + ) + Text( + brandedDetails(formatEpisodeRelativeDate(episode.publishedDate), episode.duration, spotlight.isCompleted, stringResource(R.string.episode_info_played)), + style = brandedTextStyle(fonts.hand, 13.sp, 15.sp), + color = DesiCrimeColors.NoteDetails, + maxLines = 1, + modifier = Modifier.padding(top = 3.dp), + ) + } + } + Spacer(Modifier.height(10.dp)) + BrandedEpisodeActions(spotlight, DesiCrimeColors.Actions, showProgress = true) + } +} + +private const val CORK_WIDTH = 369f +private val CardLamp = Color(0x1AFFAA5A) +private val CardShadow = Color(0x94120802) +private val FrameShade = Color(0x66000000) diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeTear.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeTear.kt new file mode 100644 index 000000000..0f5a88e42 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/desicrime/DesiCrimeTear.kt @@ -0,0 +1,35 @@ +package cx.aswin.boxlore.feature.info.branded.desicrime + +/** + * The torn paper edge across every Desi Crime episode picture, above the hosts: the same template on + * each, traced once from the show's cover. Pairs of x and y, left to right, as fractions of the + * picture's width (the art is square); the newest print shows the picture down to this edge. + */ +internal object DesiCrimeTear { + val Edge = floatArrayOf( + 0.0000f, 0.5352f, 0.0027f, 0.5366f, 0.0081f, 0.5325f, 0.0108f, 0.5014f, 0.0217f, 0.5041f, 0.0271f, 0.5108f, + 0.0325f, 0.5786f, 0.0407f, 0.5759f, 0.0434f, 0.5786f, 0.0461f, 0.5759f, 0.0515f, 0.5786f, 0.0542f, 0.5745f, + 0.0678f, 0.5732f, 0.0759f, 0.5610f, 0.0786f, 0.5596f, 0.0813f, 0.5623f, 0.0867f, 0.5569f, 0.0976f, 0.5542f, + 0.1111f, 0.5420f, 0.1301f, 0.5393f, 0.1382f, 0.5447f, 0.1518f, 0.5474f, 0.1572f, 0.5122f, 0.1599f, 0.5488f, + 0.1897f, 0.5447f, 0.2141f, 0.5501f, 0.2249f, 0.5501f, 0.2304f, 0.5474f, 0.2358f, 0.5515f, 0.2412f, 0.5474f, + 0.2439f, 0.5501f, 0.2466f, 0.5488f, 0.2493f, 0.5271f, 0.2520f, 0.5474f, 0.2575f, 0.5461f, 0.2683f, 0.5501f, + 0.2764f, 0.5461f, 0.2818f, 0.5501f, 0.2846f, 0.5474f, 0.3008f, 0.5488f, 0.3171f, 0.5569f, 0.3198f, 0.5542f, + 0.3306f, 0.5569f, 0.3333f, 0.5542f, 0.3388f, 0.5542f, 0.3469f, 0.5108f, 0.3496f, 0.5515f, 0.3550f, 0.5515f, + 0.3577f, 0.5488f, 0.3659f, 0.5515f, 0.3686f, 0.5461f, 0.3713f, 0.5515f, 0.3794f, 0.5556f, 0.4011f, 0.5569f, + 0.4201f, 0.5623f, 0.4255f, 0.5583f, 0.4282f, 0.5596f, 0.4336f, 0.5528f, 0.4363f, 0.5244f, 0.4390f, 0.5515f, + 0.4553f, 0.5501f, 0.4580f, 0.5542f, 0.4607f, 0.5528f, 0.4661f, 0.5556f, 0.4743f, 0.5515f, 0.4770f, 0.5528f, + 0.4824f, 0.5474f, 0.4932f, 0.5474f, 0.5068f, 0.5542f, 0.5230f, 0.5528f, 0.5312f, 0.5569f, 0.5339f, 0.5257f, + 0.5366f, 0.5515f, 0.5420f, 0.5569f, 0.5583f, 0.5610f, 0.5799f, 0.5528f, 0.5827f, 0.5542f, 0.5881f, 0.5501f, + 0.6043f, 0.5515f, 0.6125f, 0.5488f, 0.6152f, 0.5515f, 0.6179f, 0.5488f, 0.6314f, 0.5488f, 0.6341f, 0.5528f, + 0.6396f, 0.5474f, 0.6423f, 0.5501f, 0.6504f, 0.5461f, 0.6531f, 0.5488f, 0.6612f, 0.5461f, 0.6694f, 0.5515f, + 0.6775f, 0.5488f, 0.6802f, 0.5501f, 0.6829f, 0.5474f, 0.6856f, 0.5501f, 0.6883f, 0.5461f, 0.6911f, 0.5474f, + 0.6992f, 0.5420f, 0.7019f, 0.5447f, 0.7073f, 0.5434f, 0.7127f, 0.5379f, 0.7154f, 0.5407f, 0.7209f, 0.5379f, + 0.7344f, 0.5393f, 0.7371f, 0.5054f, 0.7398f, 0.5393f, 0.7480f, 0.5352f, 0.7561f, 0.5379f, 0.7724f, 0.5379f, + 0.7913f, 0.5271f, 0.7967f, 0.5271f, 0.8103f, 0.5339f, 0.8130f, 0.5312f, 0.8238f, 0.5312f, 0.8293f, 0.5366f, + 0.8482f, 0.5312f, 0.8618f, 0.5312f, 0.8645f, 0.5352f, 0.8916f, 0.5407f, 0.8970f, 0.5461f, 0.9133f, 0.5474f, + 0.9214f, 0.5515f, 0.9404f, 0.5515f, 0.9512f, 0.5556f, 0.9566f, 0.5542f, 0.9593f, 0.5501f, 0.9986f, 0.5528f, + ) + + /** How far down the picture the print reaches: just below the deepest point of the tear. */ + const val DEPTH = 0.607f +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockColors.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockColors.kt new file mode 100644 index 000000000..9329d3fab --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockColors.kt @@ -0,0 +1,89 @@ +package cx.aswin.boxlore.feature.info.branded.sherlock + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.branded.BrandedActionColors +import cx.aswin.boxlore.feature.info.branded.BrandedCopyrightStyle + +/** + * The Strand's colours: a dark study lit by a desk lamp, the issue's pale blue cover (the page's + * accent), its cream paper and the engraver's blue-black ink. Dark in both themes. + */ +internal object SherlockColors { + val Page = Color(0xFF150C0C) + val Text = Color(0xFFF1E9DC) + val Muted = Color(0xFFC4B8AB) + + /** The Strand cover's pale blue: Subscribe, the genre chip, Play. */ + val Blue = Color(0xFFBFD3D8) + val OnBlue = Color(0xFF13202A) + val Control = Color(0xFF2B1F1D) + val OnControl = Color(0xFFF1E9DC) + val Card = Color(0xFF211716) + + /** The latest episode's panel, behind its page. */ + val Panel = Color(0xFF1F1816) + val CardControl = Color(0xFF33251F) + val ChipLine = Color(0xFF4D3F3A) + val Navigation = Color(0xFF261B1A) + val NavigationText = Color(0xFFECE2D6) + + /** The engraving's blue-black ink: the plate's rule, the drop cap. */ + val Ink = Color(0xFF18212E) + + /** Printed text on the page: titles, the body, and the brown of the italic lines. */ + val Print = Color(0xFF1E1712) + val Body = Color(0xFF2E241C) + val Italic = Color(0xFF4A3A2C) + val Rule = Color(0xFF6A5848) + val DropCapLetter = Color(0xFFF6EEDC) + val DropCapOrnament = Color(0xFFE9DEC4) + + /** The page's cream, from its gutter to its open side. */ + val PaperGutter = Color(0xFFD9CCB0) + val PaperEdge = Color(0xFFEEE4CE) + val Paper = Color(0xFFF5EDDB) + val PaperOuter = Color(0xFFF1E7D2) + + /** Under the issue and the card's page. */ + val Shadow = Color(0x73000000) + + val Actions = BrandedActionColors(toggle = Control, onToggle = OnControl, checked = Blue, onChecked = OnBlue, play = Blue, onPlay = OnBlue) + + /** The © popup: 260dp wide, its caret near its right end, under the © at the desk's right edge. */ + val Copyright = BrandedCopyrightStyle( + icon = Muted, + popup = Color(0xFF2B1F1D), + title = Text, + body = Color(0xFFE4D9CB), + note = Muted, + shadow = Color(0xB3060303), + ring = Color(0x1AFFFFFF), + popupWidth = 260.dp, + caretCentre = 236.dp, + gap = 2.dp, + overhangTop = 3.dp, + overhangBottom = 3.dp, + iconSize = 16.8.dp, + ) +} + +/** The Strand's period type: IM Fell English, its italic, and its small capitals. */ +@Immutable +internal data class StrandFonts(val roman: FontFamily, val italic: FontFamily, val smallCaps: FontFamily) + +@Composable +internal fun rememberStrandFonts(): StrandFonts = remember { + StrandFonts( + roman = FontFamily(Font(R.font.sherlock_fell)), + italic = FontFamily(Font(R.font.sherlock_fell_italic, style = FontStyle.Italic)), + smallCaps = FontFamily(Font(R.font.sherlock_fell_sc)), + ) +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockFeed.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockFeed.kt new file mode 100644 index 000000000..4c3b651e1 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockFeed.kt @@ -0,0 +1,97 @@ +package cx.aswin.boxlore.feature.info.branded.sherlock + +import androidx.compose.runtime.Immutable +import cx.aswin.boxlore.core.model.Episode +import java.util.Locale + +/** + * Sherlock Holmes Short Stories' feed, read by fixed rules that work on any of its episodes. Its + * titles name the story and the part: "The Sign of Four: Part Three", "The Adventure of the + * Bruce-Partington Plans: Part Three". + */ +internal object SherlockFeed { + /** The story: the title before its first ":" or " Part ". */ + fun story(title: String): String = title.split(StoryEnd, limit = 2).first().trim() + + /** The part in Roman numerals when the title ends "Part " ("Part Three" is "III"), otherwise null. */ + fun part(title: String): String? { + val word = PartAtEnd.find(title.trim())?.groupValues?.get(1)?.lowercase(Locale.ROOT) ?: return null + val number = Numbers.indexOf(word).takeIf { it > 0 } ?: word.toIntOrNull() ?: return null + return number.takeIf { it in 1..MAX_PART }?.let(::roman) + } + + /** A story's name as the contents and the card show it: a leading "The Adventure of " dropped, the rest capitalised. */ + fun shortStory(story: String): String = story.replaceFirst(AdventureOf, "").replaceFirstChar { it.titlecase(Locale.ROOT) } + + /** Whether the card sets [title] smaller, on up to two lines: longer than one line of its type holds. */ + fun isLongTitle(title: String): Boolean = title.length > LONG_TITLE + + /** + * The contents page: the stories the show has read, newest first, each once (a straight and a + * curly apostrophe count as the same name), [count] at most, numbered as a monthly's pages run + * (1, 17, 33…). Only full episodes count: trailers and bonus episodes are not stories. + */ + fun contents(feed: List, count: Int = CONTENTS_LINES): List { + val seen = HashSet() + return feed.asSequence() + .filter { it.episodeType?.equals("full", ignoreCase = true) != false } + .map { story(it.title) } + .filter { it.isNotBlank() && seen.add(it.replace('\u2019', '\'').lowercase(Locale.ROOT)) } + .take(count) + .mapIndexed { i, name -> StrandContentsLine(shortStory(name), 1 + PAGES_PER_STORY * i) } + .toList() + } + + /** The description's first paragraph: the text before its first blank line or paragraph break, without HTML. */ + fun firstParagraph(description: String): String { + val text = Tag.replace(decodeEntities(decodeEntities(description)), "\n").replace(' ', ' ') + return text.split(BlankLine).map(String::trim).firstOrNull(String::isNotEmpty)?.replace(Space, " ").orEmpty() + } + + internal fun roman(number: Int): String { + var left = number + return buildString { + for ((value, letters) in Numerals) { + while (left >= value) { + append(letters) + left -= value + } + } + } + } + + private fun decodeEntities(text: String): String = Entity.replace(text) { match -> + val name = match.groupValues[1] + when { + name.startsWith("#x") || name.startsWith("#X") -> name.drop(2).toIntOrNull(HEX)?.let(::codePoint) + name.startsWith("#") -> name.drop(1).toIntOrNull()?.let(::codePoint) + else -> NamedEntities[name] + } ?: match.value + } + + private fun codePoint(code: Int): String? = runCatching { String(Character.toChars(code)) }.getOrNull() + + private val StoryEnd = Regex(":| Part ") + private val PartAtEnd = Regex("""Part (\w+)$""") + private val AdventureOf = Regex("""^The Adventure of\s+""") + private val Tag = Regex("<[^>]+>") + private val BlankLine = Regex("""\n\s*\n""") + private val Space = Regex("""\s+""") + private val Entity = Regex("""&(#?[A-Za-z0-9]+);""") + private val Numbers = "zero one two three four five six seven eight nine ten eleven twelve".split(' ') + private val Numerals = listOf(10 to "X", 9 to "IX", 5 to "V", 4 to "IV", 1 to "I") + private val NamedEntities = mapOf( + "amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'", "nbsp" to " ", + "rsquo" to "’", "lsquo" to "‘", "ldquo" to "“", "rdquo" to "”", + "mdash" to "—", "ndash" to "–", "hellip" to "…", + ) + private const val HEX = 16 + private const val MAX_PART = 39 + private const val LONG_TITLE = 17 + private const val CONTENTS_LINES = 9 + private const val PAGES_PER_STORY = 16 +} + +/** One line of the contents page: a story and the page it "starts" on. */ +@Immutable +internal data class StrandContentsLine(val story: String, val page: Int) diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockHeader.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockHeader.kt new file mode 100644 index 000000000..eb022c81a --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockHeader.kt @@ -0,0 +1,77 @@ +package cx.aswin.boxlore.feature.info.branded.sherlock + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.branded.BrandedCopyrightButton +import cx.aswin.boxlore.feature.info.branded.BrandedDescription +import cx.aswin.boxlore.feature.info.branded.BrandedDescriptionExtras +import cx.aswin.boxlore.feature.info.branded.brandedSansStyle + +/** + * The Strand on the desk ([StrandDesk]) with the © at the desk's right edge, then the description, + * centred, which opens on tap. The usual chips follow. + */ +@Composable +@Suppress("UNUSED_PARAMETER") +internal fun SherlockHeader(podcast: Podcast, feed: List, description: BrandedDescription, chips: @Composable () -> Unit, modifier: Modifier) { + val contents = remember(feed) { SherlockFeed.contents(feed) } + Column(modifier) { + Box(Modifier.fillMaxWidth()) { + StrandDesk(contents) + BrandedCopyrightButton( + SherlockColors.Copyright, + stringResource(R.string.sherlock_copyright), + Modifier.align(Alignment.BottomEnd).padding(end = 2.dp), + ) + } + if (description.text.isNotEmpty()) { + val toggleState = stringResource(if (description.expanded) R.string.branded_description_expanded else R.string.branded_description_collapsed) + Text( + description.text, + style = brandedSansStyle(14.5.sp, 21.sp), + color = SherlockColors.Muted, + textAlign = TextAlign.Center, + maxLines = if (description.expanded) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(start = 28.dp, end = 28.dp, top = 2.dp) + .fillMaxWidth() + .animateContentSize(spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow)) + .expressiveClickable(shape = RoundedCornerShape(8.dp)) { description.onToggle() } + .semantics { + role = Role.Button + stateDescription = toggleState + }, + ) + } + Column(Modifier.padding(horizontal = 16.dp)) { BrandedDescriptionExtras(description) } + Spacer(Modifier.height(if (description.text.isNotEmpty()) 18.dp else 14.dp)) + chips() + } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockLetterbox.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockLetterbox.kt new file mode 100644 index 000000000..11345ec48 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockLetterbox.kt @@ -0,0 +1,267 @@ +package cx.aswin.boxlore.feature.info.branded.sherlock + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.branded.BrandedButtonNudge +import cx.aswin.boxlore.feature.info.branded.BrandedSubscribeLabels +import cx.aswin.boxlore.feature.info.branded.BrandedSubscribeMoment +import cx.aswin.boxlore.feature.info.branded.BrandedToolbarStyle +import cx.aswin.boxlore.feature.info.branded.Keyframe +import cx.aswin.boxlore.feature.info.branded.MomentHaptic +import cx.aswin.boxlore.feature.info.branded.brandedArtNow +import cx.aswin.boxlore.feature.info.branded.brandedSansStyle +import cx.aswin.boxlore.feature.info.branded.introProgress +import cx.aswin.boxlore.feature.info.branded.keyframes +import cx.aswin.boxlore.feature.info.components.SubscriptionLabel +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.roundToInt + +/** + * The Strand was a monthly, delivered to subscribers: the button becomes the green front door of + * 221B, with its brass number plate and knob. The letterbox flap lifts, the new issue (the cover the + * header opens) is pushed through, hangs from the slot by its top edge, then drops to the mat; the + * flap snaps shut with a jolt of the door and one haptic. Then the button clears into Subscribed. + * No words of its own. Timings follow the approved design board, in milliseconds from the tap. + */ +internal object SherlockLetterbox : BrandedSubscribeMoment { + override val durationMillis = 1_480 + override val revealDelayMillis = 1_320 + + /** The flap snaps shut behind the dropped issue. */ + const val SNAP_AT = 985f + + override fun nudge(elapsedMillis: Float): BrandedButtonNudge = BrandedButtonNudge(0.dp, 1f) + + @Composable + override fun Overlay(elapsedMillis: () -> Float, labels: BrandedSubscribeLabels, style: BrandedToolbarStyle, shape: Shape, modifier: Modifier) { + MomentHaptic(elapsedMillis, SNAP_AT) + val issue = brandedArtNow(R.drawable.sherlock_cover) + val measurer = rememberTextMeasurer() + // The door's number in plain bold lining figures, as on a brass plate. + val plate = brandedSansStyle(10.sp, 13.sp, weight = 700).merge(TextStyle(color = PlateText, letterSpacing = 0.6.sp)) + val number = remember(plate) { measurer.measure(PLATE_NUMBER, plate) } + Box(modifier.clip(shape), contentAlignment = Alignment.Center) { + Spacer( + Modifier.fillMaxSize().drawBehind { + val t = elapsedMillis() + // Once the door covers the button it is already Subscribed underneath, so it clears straight to it. + drawRect(if (t < DOOR_COVERS_AT) style.subscribeContainer else style.subscribedContainer) + val door = LetterboxKeys.door(t) + if (door <= 0f) return@drawBehind + translate(left = LetterboxKeys.jolt(t) * 1.dp.toPx()) { drawDoor(t, door, issue, number) } + }, + ) + SubscriptionLabel( + text = labels.subscribe, + compact = labels.compact, + color = style.subscribeContent, + modifier = Modifier.graphicsLayer { alpha = 1f - introProgress(elapsedMillis(), 0f, 80f) }, + ) + SubscriptionLabel( + text = labels.subscribed, + compact = labels.compact, + color = style.subscribedContent, + modifier = Modifier.graphicsLayer { + val land = Land.transform(introProgress(elapsedMillis(), LABEL_AT, 180f)) + alpha = land + scaleX = 1.04f - 0.04f * land + scaleY = scaleX + }, + ) + } + } + + private fun DrawScope.drawDoor(t: Float, alpha: Float, issue: ImageBitmap, number: TextLayoutResult) { + val unit = 1.dp.toPx() + drawRect(Brush.verticalGradient(0f to DoorTop, 0.55f to DoorMiddle, 1f to DoorFoot), alpha = alpha) + // The paint's brush marks and the light along the door's top. + var x = 0f + while (x < size.width) { + drawRect(Grain, topLeft = Offset(x, 0f), size = Size(unit, size.height), alpha = alpha) + x += 9f * unit + } + drawRect(Brush.verticalGradient(0f to Sheen, 0.4f to Color.Transparent), alpha = alpha) + for ((left, width) in Panels) drawPanel(left * size.width, width * size.width, unit, alpha) + drawPlate(number, unit, alpha) + drawKnob(unit, alpha) + drawLetterbox(t, unit, alpha, issue) + } + + private fun DrawScope.drawPanel(left: Float, width: Float, unit: Float, alpha: Float) { + val top = 6f * unit + val height = size.height - 12f * unit + val corner = CornerRadius(2f * unit) + drawRoundRect(PanelRing, Offset(left - unit, top - unit), Size(width + 2 * unit, height + 2 * unit), corner, style = Stroke(2f * unit), alpha = alpha) + drawLine(PanelLight, Offset(left, top + 0.5f * unit), Offset(left + width, top + 0.5f * unit), unit, alpha = alpha) + drawLine(PanelLight, Offset(left + 0.5f * unit, top), Offset(left + 0.5f * unit, top + height), unit, alpha = alpha) + drawLine(PanelDark, Offset(left, top + height - 0.5f * unit), Offset(left + width, top + height - 0.5f * unit), unit, alpha = alpha) + drawLine(PanelDark, Offset(left + width - 0.5f * unit, top), Offset(left + width - 0.5f * unit, top + height), unit, alpha = alpha) + } + + /** The brass 221B plate, left of the letterbox. */ + private fun DrawScope.drawPlate(number: TextLayoutResult, unit: Float, alpha: Float) { + val width = number.size.width + 10f * unit + val height = number.size.height + 2f * unit + val left = PLATE_LEFT * size.width + val top = (size.height - height) / 2f + drawRoundRect(Shade, Offset(left, top + unit), Size(width, height), CornerRadius(2f * unit), alpha = alpha * 0.5f) + drawRoundRect(brass(top, height), Offset(left, top), Size(width, height), CornerRadius(2f * unit), alpha = alpha) + drawText(number, topLeft = Offset(left + 5f * unit, top + unit), alpha = alpha) + } + + private fun DrawScope.drawKnob(unit: Float, alpha: Float) { + val radius = 5.5f * unit + val centre = Offset(size.width * (1f - KNOB_RIGHT) - radius, size.height / 2f) + drawCircle(Shade, radius, centre + Offset(0f, 1.5f * unit), alpha = alpha * 0.55f) + drawCircle( + Brush.radialGradient(0f to KnobLight, 0.45f to BrassMid, 1f to BrassDark, center = centre + Offset(-0.15f * radius * 2, -0.2f * radius * 2), radius = radius * 1.6f), + radius, + centre, + alpha = alpha, + ) + } + + /** The letterbox, its slot, the issue coming through it and the flap over it. */ + private fun DrawScope.drawLetterbox(t: Float, unit: Float, alpha: Float, issue: ImageBitmap) { + val box = Offset(size.width / 2f - BOX_LEFT_OF_CENTRE * unit, size.height / 2f - BOX_HEIGHT / 2f * unit) + val boxSize = Size(BOX_WIDTH * unit, BOX_HEIGHT * unit) + drawRoundRect(Shade, box + Offset(0f, 2f * unit), boxSize, CornerRadius(3f * unit), alpha = alpha * 0.55f) + drawRoundRect(brass(box.y, boxSize.height), box, boxSize, CornerRadius(3f * unit), alpha = alpha) + drawRoundRect(BrassEdge, box, boxSize, CornerRadius(3f * unit), style = Stroke(unit), alpha = alpha) + drawRoundRect(Slot, box + Offset(8f * unit, 8f * unit), Size((BOX_WIDTH - 16f) * unit, 6f * unit), CornerRadius(1.5f * unit), alpha = alpha) + // The issue shows only below the slot's top edge, inside the door: it comes in through the slot. + val mail = box + Offset(6f * unit, 8f * unit) + clipRect(left = mail.x, top = mail.y, right = mail.x + (BOX_WIDTH - 12f) * unit, bottom = size.height) { + val drop = LetterboxKeys.issueY(t) * unit + val turn = LetterboxKeys.issueTurn(t) + val issueLeft = mail.x + 5f * unit + translate(issueLeft, mail.y + drop) { + rotate(turn, pivot = Offset(ISSUE_WIDTH / 2f * unit, 0f)) { + drawRect(Shade, Offset(0f, 2f * unit), Size(ISSUE_WIDTH * unit, ISSUE_HEIGHT * unit), alpha = alpha * 0.5f) + drawImage(issue, dstSize = IntSize((ISSUE_WIDTH * unit).roundToInt(), (ISSUE_HEIGHT * unit).roundToInt()), alpha = alpha) + } + } + } + // The flap is hinged along its top: lifted, it shows as a thin strip of brass. + val lift = abs(cos(Math.toRadians(LetterboxKeys.flap(t).toDouble()))).toFloat() + val flapTop = box.y + 6f * unit + drawRoundRect( + Brush.verticalGradient(0f to FlapLight, 0.55f to BrassMid, 1f to FlapDark, startY = flapTop, endY = flapTop + 10f * unit * lift), + Offset(box.x + 6f * unit, flapTop), + Size((BOX_WIDTH - 12f) * unit, maxOf(10f * unit * lift, unit)), + CornerRadius(2f * unit), + alpha = alpha, + ) + } + + private const val DOOR_COVERS_AT = 100f + private const val PLATE_NUMBER = "221B" + private const val LABEL_AT = 1_300f + private const val BOX_WIDTH = 76f + private const val BOX_HEIGHT = 22f + private const val BOX_LEFT_OF_CENTRE = 26f + private const val ISSUE_WIDTH = 50f + private const val ISSUE_HEIGHT = 70f + private const val PLATE_LEFT = 0.09f + private const val KNOB_RIGHT = 0.09f + + /** The door's two panels: left edge and width, as fractions of the button. */ + private val Panels = listOf(0.07f to 0.37f, 0.56f to 0.37f) + private val Land = CubicBezierEasing(0f, 0f, 0.58f, 1f) +} + +/** The moment's keyframes, from the design board, in milliseconds from the tap. */ +internal object LetterboxKeys { + /** The door over the button: in fast, held while the issue is delivered, then gone before Subscribed lands. */ + fun door(t: Float): Float = when { + t < 96f -> t / 96f + t < 1_152f -> 1f + else -> (1f - (t - 1_152f) / 128f).coerceAtLeast(0f) + } + + /** The flap's angle round its hinge: lifts, holds while the issue comes through, snaps shut past closed and settles. */ + fun flap(t: Float): Float = keyframes( + introProgress(t, 140f, 900f), + Keyframe(0f, 0f, CubicBezierEasing(0.3f, 0.7f, 0.3f, 1f)), + Keyframe(0.2f, -80f), + Keyframe(0.88f, -82f, CubicBezierEasing(0.6f, 0f, 0.9f, 0.5f)), + Keyframe(0.94f, 10f), + Keyframe(1f, 0f), + ) + + /** How far the issue has come through the slot, in dp: pushed in, hanging by its top edge, then dropping away. */ + fun issueY(t: Float): Float = keyframes( + introProgress(t, 300f, 700f), + Keyframe(0f, -72f, CubicBezierEasing(0.2f, 0.7f, 0.3f, 1f)), + Keyframe(0.45f, 1.5f), + Keyframe(0.55f, 0f), + Keyframe(0.75f, 0f, CubicBezierEasing(0.55f, 0f, 1f, 0.6f)), + Keyframe(1f, 84f), + ) + + /** The issue's sway as it hangs, and its turn as it falls, in degrees. */ + fun issueTurn(t: Float): Float = keyframes( + introProgress(t, 300f, 700f), + Keyframe(0f, 0f), + Keyframe(0.45f, 0f), + Keyframe(0.55f, -1.6f), + Keyframe(0.65f, 1f), + Keyframe(0.75f, 0f, CubicBezierEasing(0.55f, 0f, 1f, 0.6f)), + Keyframe(1f, 9f), + ) + + /** The door's jolt as the flap snaps, in dp. */ + fun jolt(t: Float): Float = keyframes(introProgress(t, 980f, 160f), Keyframe(0f, 0f), Keyframe(0.35f, 0.8f), Keyframe(0.7f, -0.4f), Keyframe(1f, 0f)) +} + +private val DoorTop = Color(0xFF24473A) +private val DoorMiddle = Color(0xFF1A3729) +private val DoorFoot = Color(0xFF132A1F) +private val Grain = Color(0x06FFFFFF) +private val Sheen = Color(0x12FFFFFF) +private val PanelLight = Color(0x1AFFFFFF) +private val PanelDark = Color(0x59000000) +private val PanelRing = Color(0x1F000000) +private val Shade = Color(0xFF000000) +private val BrassMid = Color(0xFFC9A24A) +private val BrassDark = Color(0xFF7A5A20) +private val BrassEdge = Color(0x80503710) +private val KnobLight = Color(0xFFFBEAB0) +private val FlapLight = Color(0xFFF6DE97) +private val FlapDark = Color(0xFF94702C) +private val Slot = Color(0xFF0B0907) +private val PlateText = Color(0xFF3A2A10) + +/** Polished brass, lit from above, over a plate [height] tall from [top]. */ +private fun brass(top: Float, height: Float): Brush = + Brush.verticalGradient(0f to Color(0xFFF1D68A), 0.38f to BrassMid, 0.72f to Color(0xFF8A6526), 1f to Color(0xFFB89040), startY = top, endY = top + height) diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockPage.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockPage.kt new file mode 100644 index 000000000..deee6344d --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockPage.kt @@ -0,0 +1,115 @@ +package cx.aswin.boxlore.feature.info.branded.sherlock + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.feature.info.branded.BrandedChipStyle +import cx.aswin.boxlore.feature.info.branded.BrandedDescription +import cx.aswin.boxlore.feature.info.branded.BrandedPageStyle +import cx.aswin.boxlore.feature.info.branded.BrandedShowIds +import cx.aswin.boxlore.feature.info.branded.BrandedShowPage +import cx.aswin.boxlore.feature.info.branded.BrandedSpotlight +import cx.aswin.boxlore.feature.info.branded.BrandedSubscribeMoment +import cx.aswin.boxlore.feature.info.branded.BrandedToolbarStyle + +/** + * Sherlock Holmes Short Stories as The Strand Magazine, where the stories first ran: an issue on a + * lamp-lit desk that opens to its contents, the latest story set as its opening page, and the issue + * delivered through 221B's letterbox when you subscribe. Dark in both themes. + */ +internal object SherlockPage : BrandedShowPage { + override val ids = BrandedShowIds( + podcastIndexId = "7131280", + podcastGuid = "0a245e72-7c89-5da6-9e43-0041937f1a7c", + feedUrl = "https://feeds.megaphone.fm/NSR5430340583", + ) + + // The desk starts under the status bar; it draws itself up behind it. + override val headerTop = 0.dp + override val subscribeMoment: BrandedSubscribeMoment = SherlockLetterbox + override val darkOnly = true + + override fun headerToButtons(description: BrandedDescription) = 14.dp + + override fun style(base: ColorScheme, dark: Boolean): BrandedPageStyle { + val c = SherlockColors + val colors = base.copy( + primary = c.Blue, + onPrimary = c.OnBlue, + primaryContainer = c.Blue, + onPrimaryContainer = c.OnBlue, + secondary = c.Blue, + onSecondary = c.OnBlue, + secondaryContainer = c.Blue, + onSecondaryContainer = c.OnBlue, + background = c.Page, + onBackground = c.Text, + surface = c.Page, + onSurface = c.Text, + // Unique, so Material's matching content colour for cards and chips stays onSurface. + surfaceVariant = c.Control, + onSurfaceVariant = c.Muted, + surfaceDim = c.Page, + surfaceBright = c.Copyright.popup, + surfaceContainerLowest = c.Page, + surfaceContainerLow = c.Card, + surfaceContainer = c.Card, + surfaceContainerHigh = c.Control, + surfaceContainerHighest = c.CardControl, + outline = c.Muted, + outlineVariant = c.ChipLine, + surfaceTint = c.Blue, + ) + return BrandedPageStyle( + colors = colors, + popup = c.Copyright, + navigationColors = colors.copy( + surfaceContainerHigh = c.Navigation, + onSurfaceVariant = c.NavigationText, + outlineVariant = c.Navigation, + ), + chips = BrandedChipStyle( + shape = RoundedCornerShape(2.dp), + height = 34.dp, + padding = PaddingValues(start = 10.dp, end = 13.dp), + iconSize = 18.dp, + gap = 6.dp, + fontSize = 14.sp, + fontWeight = 500, + container = c.Page, + content = c.Text, + icon = c.Blue, + genreContainer = c.Blue, + genreContent = c.OnBlue, + genreIcon = c.OnBlue, + outline = c.ChipLine, + ), + toolbar = BrandedToolbarStyle( + subscribeContainer = c.Blue, + subscribeContent = c.OnBlue, + subscribedContainer = c.Control, + subscribedContent = c.OnControl, + toggleContainer = c.Control, + toggleContent = c.OnControl, + toggleCheckedContainer = c.Blue, + toggleCheckedContent = c.OnBlue, + actionContainer = c.Control, + actionContent = c.OnControl, + ), + episodeTimeColor = c.Blue, + ) + } + + @Composable + override fun Header(podcast: Podcast, feed: List, description: BrandedDescription, chips: @Composable () -> Unit, modifier: Modifier) = + SherlockHeader(podcast, feed, description, chips, modifier) + + @Composable + override fun Spotlight(spotlight: BrandedSpotlight, modifier: Modifier) = SherlockSpotlight(spotlight, modifier) +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockSpotlight.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockSpotlight.kt new file mode 100644 index 000000000..dac0ebca6 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockSpotlight.kt @@ -0,0 +1,285 @@ +package cx.aswin.boxlore.feature.info.branded.sherlock + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.text +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.core.designsystem.components.OptimizedImage +import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.branded.BrandedEpisodeActions +import cx.aswin.boxlore.feature.info.branded.BrandedSpotlight +import cx.aswin.boxlore.feature.info.branded.boxShadow +import cx.aswin.boxlore.feature.info.branded.brandedArtNow +import cx.aswin.boxlore.feature.info.branded.brandedDetails +import cx.aswin.boxlore.feature.info.branded.brandedTextStyle +import cx.aswin.boxlore.feature.info.components.formatEpisodeRelativeDate + +/** + * The latest episode as the story's opening page in the Strand: the episode's own picture as its + * plate, with when it came out and how long it is (or that it was played) under it; Haité's + * headpiece, the story in small capitals ([SherlockFeed.shortStory]), the part and the reader in + * italic, a double rule, and the description's first paragraph under a drop cap, cut short with an + * ellipsis. A long story name steps down a size onto two lines and the text gives up a line. The + * episode cards' buttons follow. + */ +@Composable +internal fun SherlockSpotlight(spotlight: BrandedSpotlight, modifier: Modifier) { + val episode = spotlight.episode + val fonts = rememberStrandFonts() + val title = remember(episode.title) { SherlockFeed.shortStory(SherlockFeed.story(episode.title)) } + val part = remember(episode.title) { SherlockFeed.part(episode.title) } + val notes = remember(episode.description) { SherlockFeed.firstParagraph(episode.description) } + val long = SherlockFeed.isLongTitle(title) + val selectLabel = stringResource(R.string.podcast_episode_select) + val shape = RoundedCornerShape(3.dp) + Column( + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(SherlockColors.Panel) + .then(if (spotlight.isSelected) Modifier.border(3.dp, SherlockColors.Text, shape) else Modifier) + .expressiveClickable(shape = shape, onLongClickLabel = selectLabel, onLongClick = spotlight.onLongPress, onClick = spotlight.onOpen) + .padding(10.dp), + ) { + Row( + Modifier + .fillMaxWidth() + .heightIn(min = PAGE_HEIGHT) + .boxShadow(fill = SherlockColors.Paper, color = SherlockColors.Shadow, offsetY = 6.dp, blur = 12.dp, cornerRadius = 0.dp) + .paper(), + ) { + Column(Modifier.width(PLATE_COLUMN).padding(top = 12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Plate(spotlight) + Text( + brandedDetails(formatEpisodeRelativeDate(episode.publishedDate), episode.duration, spotlight.isCompleted, stringResource(R.string.episode_info_played)), + style = brandedTextStyle(fonts.italic, 10.sp, 12.sp), + color = SherlockColors.Italic, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp, start = 4.dp, bottom = 10.dp), + ) + } + Column(Modifier.weight(1f).padding(top = 7.dp, end = 10.dp, bottom = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Image( + painterResource(R.drawable.sherlock_fleuron), + contentDescription = null, + modifier = Modifier.size(56.dp, 21.dp).alpha(FLEURON_ALPHA), + contentScale = ContentScale.FillBounds, + ) + Text( + "$title.", + style = if (long) { + brandedTextStyle(fonts.smallCaps, 15.sp, 17.sp, 1.sp) + } else { + brandedTextStyle(fonts.smallCaps, 18.sp, 20.sp, 1.4.sp) + }, + color = SherlockColors.Print, + textAlign = TextAlign.Center, + maxLines = if (long) 2 else 1, + overflow = TextOverflow.Ellipsis, + // Screen readers hear the episode's own title. + modifier = Modifier.clearAndSetSemantics { + heading() + text = AnnotatedString(episode.title) + }, + ) + Text( + part?.let { stringResource(R.string.sherlock_part_read_by, it) } ?: stringResource(R.string.sherlock_read_by), + style = brandedTextStyle(fonts.italic, 11.sp, 13.sp), + color = SherlockColors.Italic, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 1.dp).clearAndSetSemantics {}, + ) + DoubleRule(Modifier.padding(top = 4.dp).width(120.dp).height(3.dp)) + if (notes.isNotEmpty()) { + DropCapNotes( + notes = notes, + lines = if (long) 2 else 3, + fonts = fonts, + modifier = Modifier.padding(top = 5.dp).fillMaxWidth(), + ) + } + } + } + Spacer(Modifier.height(10.dp)) + BrandedEpisodeActions(spotlight, SherlockColors.Actions, showProgress = true) + } +} + +/** The episode's own picture, tipped in as a plate with a thin ink rule round it. */ +@Composable +private fun Plate(spotlight: BrandedSpotlight) { + Box( + Modifier + .size(PLATE) + .border(0.8.dp, SherlockColors.Ink) + .background(SherlockColors.PaperOuter) + .padding(3.3.dp), + ) { + OptimizedImage( + url = spotlight.episodePicture ?: spotlight.showPicture, + fallbackUrls = listOf(spotlight.showPicture), + proxyWidth = 240, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(PLATE - 6.6.dp), + ) + } +} + +/** + * The description under a drop cap: the cap is the first letter, the rest of the first three words + * follow in small capitals, and the text runs beside the cap, [lines] lines in all, ending in an + * ellipsis when there is more. + */ +@Composable +private fun DropCapNotes(notes: String, lines: Int, fonts: StrandFonts, modifier: Modifier) { + val styled = remember(notes, fonts) { + val words = notes.split(' ') + val lead = words.take(LEAD_WORDS).joinToString(" ").drop(1) + val rest = words.drop(LEAD_WORDS).joinToString(" ") + buildAnnotatedString { + withStyle(SpanStyle(fontFamily = fonts.smallCaps, letterSpacing = 0.4.sp)) { append(lead) } + if (rest.isNotEmpty()) { + append(" ") + append(rest) + } + } + } + // Screen readers hear the paragraph as written. + Row(modifier.clearAndSetSemantics { text = AnnotatedString(notes) }) { + DropCap(notes.take(1), if (lines == 2) 26.dp else 29.dp, fonts, Modifier.padding(top = if (lines == 2) 1.dp else 2.dp, end = CAP_GAP)) + Text( + styled, + style = brandedTextStyle(fonts.roman, 11.6.sp, 14.sp).copy(color = SherlockColors.Body), + maxLines = lines, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** The drop cap: the letter in small capitals, cream on an ink square with a thin flourished frame. */ +@Composable +private fun DropCap(letter: String, size: Dp, fonts: StrandFonts, modifier: Modifier) { + Box( + modifier + .size(size) + .background(SherlockColors.Ink) + .drawBehind { + // The frame and flourishes, drawn in the design's 29-unit box. + val unit = this.size.width / CAP_BOX + val stroke = Stroke(width = 0.7f * unit) + drawRect(SherlockColors.DropCapOrnament, Offset(1.6f * unit, 1.6f * unit), Size(25.8f * unit, 25.8f * unit), alpha = ORNAMENT_ALPHA, style = stroke) + drawPath(capFlourish(unit), SherlockColors.DropCapOrnament, alpha = ORNAMENT_ALPHA, style = stroke) + }, + contentAlignment = Alignment.Center, + ) { + Text( + letter, + style = brandedTextStyle(fonts.smallCaps, (size.value * 0.9f).sp, (size.value * 0.97f).sp), + color = SherlockColors.DropCapLetter, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } +} + +private fun capFlourish(unit: Float): Path = Path().apply { + fun m(x: Float, y: Float) = moveTo(x * unit, y * unit) + fun c(x1: Float, y1: Float, x2: Float, y2: Float, x: Float, y: Float) = cubicTo(x1 * unit, y1 * unit, x2 * unit, y2 * unit, x * unit, y * unit) + m(3f, 25f) + c(6f, 20f, 3f, 16f, 7f, 13f) + c(9f, 11.6f, 10f, 13.4f, 8.6f, 14.4f) + m(26f, 4f) + c(23f, 9f, 26f, 13f, 22f, 16f) + c(20f, 17.4f, 19f, 15.6f, 20.4f, 14.6f) + m(3f, 5f) + c(6f, 5f, 7f, 7f, 6f, 9f) + m(26f, 24f) + c(23f, 24f, 22f, 22f, 23f, 20f) +} + +/** The double rule under the reader's line. */ +@Composable +private fun DoubleRule(modifier: Modifier) { + Spacer( + modifier.drawBehind { + val unit = 1.dp.toPx() + drawLine(SherlockColors.Rule, Offset(0f, 0.4f * unit), Offset(size.width, 0.4f * unit), strokeWidth = 0.8f * unit) + drawLine(SherlockColors.Rule, Offset(0f, size.height - 0.25f * unit), Offset(size.width, size.height - 0.25f * unit), strokeWidth = 0.5f * unit) + }, + ) +} + +/** The page's cream, darker at the gutter, with a fine grain. */ +@Composable +private fun Modifier.paper(): Modifier { + val grain = brandedArtNow(R.drawable.sherlock_paper_grain) + val brush = remember(grain) { ShaderBrush(ImageShader(grain, TileMode.Repeated, TileMode.Repeated)) } + return drawBehind { + drawRect( + Brush.horizontalGradient( + 0f to SherlockColors.PaperGutter, + 0.05f to SherlockColors.PaperEdge, + 0.4f to SherlockColors.Paper, + 1f to SherlockColors.PaperOuter, + ), + ) + drawRect(brush) + } +} + +private val PAGE_HEIGHT = 138.dp +private val PLATE_COLUMN = 96.dp +private val PLATE = 80.dp +private val CAP_GAP = 6.dp +private const val CAP_BOX = 29f +private const val LEAD_WORDS = 3 +private const val FLEURON_ALPHA = 0.85f +private const val ORNAMENT_ALPHA = 0.75f diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockStrand.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockStrand.kt new file mode 100644 index 000000000..636cf3087 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/branded/sherlock/SherlockStrand.kt @@ -0,0 +1,393 @@ +package cx.aswin.boxlore.feature.info.branded.sherlock + +import android.graphics.BlurMaskFilter +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.branded.BrandedIntroClock +import cx.aswin.boxlore.feature.info.branded.brandedTextStyle +import cx.aswin.boxlore.feature.info.branded.introProgress +import cx.aswin.boxlore.feature.info.branded.rememberBrandedArt +import cx.aswin.boxlore.feature.info.branded.rememberBrandedIntro +import kotlin.math.roundToInt + +/** + * A reader's treasured issue of The Strand Magazine, where Doyle's Holmes stories first ran, lying + * on a lamp-lit desk: George Charles Haité's 1891 cover with the show's wordmark on its banner. When + * the page has settled the issue slides in and its cover turns over on the spine, opening on Sidney + * Paget's Reichenbach plate and a contents page with the wordmark as its masthead, listing the + * stories the show has read, newest first ([SherlockFeed.contents]). A tap opens it again. On a + * window narrower than the design the issue shrinks to fit; the desk always fills the window. + */ +@Composable +internal fun StrandDesk(contents: List, modifier: Modifier = Modifier) { + val art by rememberBrandedArt(R.drawable.sherlock_desk, R.drawable.sherlock_cover, R.drawable.sherlock_inside_cover, R.drawable.sherlock_page) + val intro = rememberBrandedIntro(ready = art != null, endMillis = StrandArt.END) + val statusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val title = stringResource(R.string.sherlock_wordmark_description) + BoxWithConstraints(modifier.fillMaxWidth().height(StrandArt.HEIGHT.dp)) { + val fit = (maxWidth.value / StrandArt.WIDTH).coerceAtMost(1f) + Spacer(Modifier.fillMaxSize().drawBehind { drawDesk(art?.get(0), statusBar.toPx()) }) + Box( + Modifier + .align(Alignment.TopCenter) + .requiredWidth(StrandArt.WIDTH.dp) + .height(StrandArt.HEIGHT.dp) + .graphicsLayer { + scaleX = fit + scaleY = fit + transformOrigin = TransformOrigin(0.5f, 0f) + } + .semantics { + heading() + contentDescription = title + }, + ) { + art?.let { Issue(it[1], it[2], it[3], contents, intro) } + // The issue opens again on a tap: a flourish for the eye only, so screen readers skip it. + Spacer( + Modifier + .offset(StrandArt.ISSUE_X.dp, StrandArt.ISSUE_Y.dp) + .size(StrandArt.ISSUE_WIDTH.dp, StrandArt.ISSUE_HEIGHT.dp) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = intro.again) + .clearAndSetSemantics {}, + ) + } + } +} + +/** The issue, tilted on the desk: its shadows, the contents page, and the cover leaf turning on the spine. */ +@Composable +private fun Issue(cover: ImageBitmap, insideCover: ImageBitmap, page: ImageBitmap, contents: List, intro: BrandedIntroClock) { + val fonts = rememberStrandFonts() + Box( + Modifier + .offset(StrandArt.ISSUE_X.dp, StrandArt.ISSUE_Y.dp) + .size(StrandArt.ISSUE_WIDTH.dp, StrandArt.ISSUE_HEIGHT.dp) + .graphicsLayer { + val slide = StrandMotion.slide(intro.millis()) + translationX = (1f - slide) * StrandArt.SLIDE_FROM.dp.toPx() + rotationZ = StrandArt.TILT_FROM + (StrandArt.TILT - StrandArt.TILT_FROM) * slide + } + .drawWithCache { + val shadow = android.graphics.Paint().apply { + color = Color.Black.toArgb() + maskFilter = BlurMaskFilter(StrandArt.SHADOW_BLUR * density, BlurMaskFilter.Blur.NORMAL) + } + onDrawBehind { drawIssueShadows(shadow, StrandMotion.leftShadow(intro.millis())) } + }, + ) { + Spacer( + Modifier + .offset(StrandArt.PAGE.dp, (-StrandArt.LEAF_OVERHANG).dp) + .size(StrandArt.LEAF_WIDTH.dp, StrandArt.LEAF_HEIGHT.dp) + .drawBehind { drawImage(page, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }, + ) + ContentsList( + contents, + fonts, + Modifier + .offset((StrandArt.PAGE + StrandArt.CONTENTS_X).dp, StrandArt.CONTENTS_Y.dp) + .size(StrandArt.CONTENTS_WIDTH.dp, StrandArt.CONTENTS_HEIGHT.dp), + ) + Spacer( + Modifier + .offset(StrandArt.PAGE.dp, 0.dp) + .size(StrandArt.PAGE.dp, StrandArt.ISSUE_HEIGHT.dp) + .drawBehind { + val shade = StrandMotion.shade(intro.millis()) + if (shade > 0f) drawRect(Brush.horizontalGradient(0f to PageShadeNear, 1f to PageShadeFar), alpha = shade) + }, + ) + val leaf = remember { StrandLeaf() } + Spacer(Modifier.matchParentSize().drawBehind { with(leaf) { draw(cover, insideCover, StrandMotion.leafAngle(intro.millis())) } }) + } +} + +/** + * The cover leaf, turning on the spine in perspective as the design's 900dp CSS perspective draws + * it: its free edge lifts towards the reader, and past upright it shows the inside of the cover. + * Projected here rather than with a layer's camera, so it looks the same on every device. + */ +private class StrandLeaf { + private val matrix = android.graphics.Matrix() + private val paint = android.graphics.Paint(android.graphics.Paint.FILTER_BITMAP_FLAG or android.graphics.Paint.ANTI_ALIAS_FLAG) + private val from = FloatArray(8) + private val to = FloatArray(8) + + fun DrawScope.draw(cover: ImageBitmap, insideCover: ImageBitmap, degrees: Float) { + val unit = 1.dp.toPx() + val radians = Math.toRadians(degrees.toDouble()) + val cos = kotlin.math.cos(radians).toFloat() + val sin = kotlin.math.sin(radians).toFloat() + // Edge-on, the leaf has no width to draw. + if (kotlin.math.abs(cos) < EDGE_ON) return + val spine = StrandArt.PAGE * unit + val middle = StrandArt.ISSUE_HEIGHT / 2f * unit + val half = StrandArt.LEAF_HEIGHT / 2f * unit + val width = StrandArt.LEAF_WIDTH * unit + val camera = StrandArt.PERSPECTIVE * unit + // The free edge comes towards the reader (z) as the leaf turns, and looks bigger for it. + val lift = camera / (camera + width * sin) + val freeX = spine + width * cos * lift + val freeTop = middle - half * lift + val freeBottom = middle + half * lift + val front = degrees > -90f + val picture = (if (front) cover else insideCover).asAndroidBitmap() + from[0] = 0f + from[1] = 0f + from[2] = picture.width.toFloat() + from[3] = 0f + from[4] = picture.width.toFloat() + from[5] = picture.height.toFloat() + from[6] = 0f + from[7] = picture.height.toFloat() + // The cover's left edge is on the spine; seen from behind, the inside's right edge is. + val nearX = if (front) spine else freeX + val farX = if (front) freeX else spine + val nearTop = if (front) middle - half else freeTop + val nearBottom = if (front) middle + half else freeBottom + val farTop = if (front) freeTop else middle - half + val farBottom = if (front) freeBottom else middle + half + to[0] = nearX + to[1] = nearTop + to[2] = farX + to[3] = farTop + to[4] = farX + to[5] = farBottom + to[6] = nearX + to[7] = nearBottom + if (!matrix.setPolyToPoly(from, 0, to, 0, 4)) return + drawContext.canvas.nativeCanvas.drawBitmap(picture, matrix, paint) + } + + private companion object { + const val EDGE_ON = 0.01f + } +} + +/** The contents: each story, a dotted leader and its page, as many lines as the page holds at this text size. */ +@Composable +private fun ContentsList(contents: List, fonts: StrandFonts, modifier: Modifier) { + val story = brandedTextStyle(fonts.roman, 8.5.sp, 11.sp).copy(color = SherlockColors.Body) + val number = brandedTextStyle(fonts.roman, 8.sp, 11.sp).copy(color = SherlockColors.Body, textAlign = TextAlign.End) + Layout( + content = { + contents.forEach { line -> + ContentsLine( + story = { Text(line.story, style = story, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + page = { Text(line.page.toString(), style = number, maxLines = 1, modifier = Modifier.width(StrandArt.CONTENTS_NUMBER.dp)) }, + ) + } + }, + modifier = modifier.clearAndSetSemantics {}, + ) { measurables, constraints -> + val rows = measurables.map { it.measure(constraints.copy(minHeight = 0)) } + layout(constraints.maxWidth, constraints.maxHeight) { + var y = 0 + for (row in rows) { + if (y + row.height > constraints.maxHeight) break + row.place(0, y) + y += row.height + } + } + } +} + +/** One contents line: the story (cut short when it runs long), then dots up to its page number. */ +@Composable +private fun ContentsLine(story: @Composable () -> Unit, page: @Composable () -> Unit) { + Layout( + content = { + story() + Spacer(Modifier.drawBehind { drawLeader() }) + page() + }, + ) { measurables, constraints -> + val gap = 2.dp.roundToPx() + val minLeader = StrandArt.LEADER_MIN.dp.roundToPx() + val number = measurables[2].measure(Constraints()) + val title = measurables[0].measure(Constraints(maxWidth = (constraints.maxWidth - number.width - minLeader - 2 * gap).coerceAtLeast(0))) + val leaderWidth = (constraints.maxWidth - title.width - number.width - 2 * gap).coerceAtLeast(0) + val height = maxOf(title.height, number.height) + val leader = measurables[1].measure(Constraints.fixed(leaderWidth, height)) + layout(constraints.maxWidth, height) { + title.place(0, 0) + leader.place(title.width + gap, 0) + number.place(constraints.maxWidth - number.width, height - number.height) + } + } +} + +private fun DrawScope.drawLeader() { + val step = 2.2.dp.toPx() + val radius = 0.45.dp.toPx() + val y = size.height - 3.dp.toPx() + var x = step / 2f + while (x < size.width) { + drawCircle(LeaderDot, radius, Offset(x, y)) + x += step + } +} + +/** The desk fills the header and reaches up behind the status bar, cropped to cover; its foot fades into the page. */ +private fun DrawScope.drawDesk(desk: ImageBitmap?, statusBar: Float) { + val unit = 1.dp.toPx() + val top = -maxOf(statusBar, StrandArt.STATUS_BAR * unit) + clipRect(top = top, bottom = size.height) { + drawRect(DeskFallback, topLeft = Offset(0f, top), size = Size(size.width, size.height - top)) + if (desk != null) { + val height = size.height - top + val scale = maxOf(size.width / desk.width, height / desk.height) + val srcWidth = (size.width / scale).roundToInt().coerceAtMost(desk.width) + val srcHeight = (height / scale).roundToInt().coerceAtMost(desk.height) + drawImage( + desk, + srcOffset = IntOffset((desk.width - srcWidth) / 2, 0), + srcSize = IntSize(srcWidth, srcHeight), + dstOffset = IntOffset(0, top.roundToInt()), + dstSize = IntSize(size.width.roundToInt(), height.roundToInt()), + ) + } + val fade = StrandArt.DESK_FADE * unit + drawRect( + Brush.verticalGradient(0f to SherlockColors.Page.copy(alpha = 0f), 1f to SherlockColors.Page, startY = size.height - fade, endY = size.height), + topLeft = Offset(0f, size.height - fade), + size = Size(size.width, fade), + ) + } +} + +/** The soft shadows the issue casts on the desk: under the right-hand pages, and under the cover once it lies open on the left. */ +private fun DrawScope.drawIssueShadows(paint: android.graphics.Paint, left: Float) { + val unit = 1.dp.toPx() + val width = (StrandArt.PAGE + StrandArt.SHADOW_SPREAD) * unit + val height = (StrandArt.ISSUE_HEIGHT + StrandArt.SHADOW_DROP) * unit + val top = StrandArt.SHADOW_DROP * unit + drawContext.canvas.nativeCanvas.apply { + paint.alpha = (StrandArt.SHADOW_ALPHA * 255).roundToInt() + val right = (StrandArt.PAGE - 2f) * unit + drawRect(right, top, right + width, top + height, paint) + if (left > 0f) { + paint.alpha = (StrandArt.SHADOW_ALPHA * left * 255).roundToInt() + drawRect(-4f * unit, top, -4f * unit + width, top + height, paint) + } + } +} + +/** The issue's intro, from the approved design board, in milliseconds from the start. */ +internal object StrandMotion { + private val Turn = CubicBezierEasing(0.45f, 0.05f, 0.3f, 1f) + + private fun turn(t: Float): Float = Turn.transform(introProgress(t, StrandArt.START, StrandArt.TURN)) + + /** How far the issue has slid into place, 0 to 1. */ + fun slide(t: Float): Float = turn(t) + + /** The cover leaf's angle round the spine: 0 lying closed over the pages, -180 lying open on the left. */ + fun leafAngle(t: Float): Float = -180f * turn(t) + + /** Whether the leaf still shows its front, the cover; past the upright it shows the inside. */ + fun showsFront(t: Float): Boolean = turn(t) < 0.5f + + /** The open cover's shadow on the desk, 0 to 1: it comes in during the second half of the turn. */ + fun leftShadow(t: Float): Float = ((introProgress(t, StrandArt.START, StrandArt.TURN) - 0.45f) / 0.55f).coerceIn(0f, 1f) + + /** The shade over the contents page while the cover is over it, lifting as the cover opens. */ + fun shade(t: Float): Float { + val p = introProgress(t, StrandArt.START, StrandArt.TURN) + return if (p < 0.55f) 1f - 0.5f * (p / 0.55f) else 0.5f * (1f - (p - 0.55f) / 0.45f) + } +} + +/** The header's frame in the design's units (dp), from the top of the status bar, which is 43dp tall there. */ +internal object StrandArt { + const val WIDTH = 369f + const val STATUS_BAR = 43f + const val HEIGHT = 342f - STATUS_BAR + const val ISSUE_X = 18f + const val ISSUE_Y = 104f - STATUS_BAR + const val ISSUE_WIDTH = 324f + const val ISSUE_HEIGHT = 228f + + /** One page's width: the spine runs down the issue's middle. */ + const val PAGE = ISSUE_WIDTH / 2f + + /** The cover leaf is a little bigger than the pages it closes over. */ + const val LEAF_OVERHANG = 2f + const val LEAF_WIDTH = PAGE + 2f + const val LEAF_HEIGHT = ISSUE_HEIGHT + 2 * LEAF_OVERHANG + const val CONTENTS_X = 16f + const val CONTENTS_Y = 97f + const val CONTENTS_WIDTH = PAGE - CONTENTS_X - 10f + const val CONTENTS_HEIGHT = 102f + const val CONTENTS_NUMBER = 15f + const val LEADER_MIN = 6f + const val TILT = -1.2f + const val TILT_FROM = -3f + const val SLIDE_FROM = -81f + const val START = 120f + const val TURN = 1_050f + const val END = START + TURN + + /** The design's CSS perspective, from the spine. */ + const val PERSPECTIVE = 900f + + const val SHADOW_BLUR = 12f + const val SHADOW_SPREAD = 6f + const val SHADOW_DROP = 2f + const val SHADOW_ALPHA = 0.55f + const val DESK_FADE = 17f +} + +private val DeskFallback = Color(0xFF24150E) +private val LeaderDot = Color(0x8C2E241C) +private val PageShadeNear = Color(0x8C0A0604) +private val PageShadeFar = Color(0x260A0604) diff --git a/feature/info/src/main/res/drawable-nodpi/desicrime_clipping.webp b/feature/info/src/main/res/drawable-nodpi/desicrime_clipping.webp new file mode 100644 index 000000000..c7d410f4c Binary files /dev/null and b/feature/info/src/main/res/drawable-nodpi/desicrime_clipping.webp differ diff --git a/feature/info/src/main/res/drawable-nodpi/desicrime_cork.jpg b/feature/info/src/main/res/drawable-nodpi/desicrime_cork.jpg new file mode 100644 index 000000000..4ef518eeb Binary files /dev/null and b/feature/info/src/main/res/drawable-nodpi/desicrime_cork.jpg differ diff --git a/feature/info/src/main/res/drawable-nodpi/desicrime_host_her.webp b/feature/info/src/main/res/drawable-nodpi/desicrime_host_her.webp new file mode 100644 index 000000000..9089bb6b6 Binary files /dev/null and b/feature/info/src/main/res/drawable-nodpi/desicrime_host_her.webp differ diff --git a/feature/info/src/main/res/drawable-nodpi/desicrime_host_him.webp b/feature/info/src/main/res/drawable-nodpi/desicrime_host_him.webp new file mode 100644 index 000000000..711551863 Binary files /dev/null and b/feature/info/src/main/res/drawable-nodpi/desicrime_host_him.webp differ diff --git a/feature/info/src/main/res/drawable-nodpi/desicrime_tape.webp b/feature/info/src/main/res/drawable-nodpi/desicrime_tape.webp new file mode 100644 index 000000000..4bc2f3c81 Binary files /dev/null and b/feature/info/src/main/res/drawable-nodpi/desicrime_tape.webp differ diff --git a/feature/info/src/main/res/drawable-nodpi/sherlock_cover.jpg b/feature/info/src/main/res/drawable-nodpi/sherlock_cover.jpg new file mode 100644 index 000000000..5d3d6487e Binary files /dev/null and b/feature/info/src/main/res/drawable-nodpi/sherlock_cover.jpg differ diff --git a/feature/info/src/main/res/drawable-nodpi/sherlock_desk.jpg b/feature/info/src/main/res/drawable-nodpi/sherlock_desk.jpg new file mode 100644 index 000000000..ca2e6f974 Binary files /dev/null and b/feature/info/src/main/res/drawable-nodpi/sherlock_desk.jpg differ diff --git a/feature/info/src/main/res/drawable-nodpi/sherlock_fleuron.png b/feature/info/src/main/res/drawable-nodpi/sherlock_fleuron.png new file mode 100644 index 000000000..ff53078f1 Binary files /dev/null and b/feature/info/src/main/res/drawable-nodpi/sherlock_fleuron.png differ diff --git a/feature/info/src/main/res/drawable-nodpi/sherlock_inside_cover.jpg b/feature/info/src/main/res/drawable-nodpi/sherlock_inside_cover.jpg new file mode 100644 index 000000000..ccc928acb Binary files /dev/null and b/feature/info/src/main/res/drawable-nodpi/sherlock_inside_cover.jpg differ diff --git a/feature/info/src/main/res/drawable-nodpi/sherlock_page.jpg b/feature/info/src/main/res/drawable-nodpi/sherlock_page.jpg new file mode 100644 index 000000000..8b32463f4 Binary files /dev/null and b/feature/info/src/main/res/drawable-nodpi/sherlock_page.jpg differ diff --git a/feature/info/src/main/res/drawable-nodpi/sherlock_paper_grain.png b/feature/info/src/main/res/drawable-nodpi/sherlock_paper_grain.png new file mode 100644 index 000000000..1990909a4 Binary files /dev/null and b/feature/info/src/main/res/drawable-nodpi/sherlock_paper_grain.png differ diff --git a/feature/info/src/main/res/font/desicrime_kalam.ttf b/feature/info/src/main/res/font/desicrime_kalam.ttf new file mode 100644 index 000000000..a34e8cfee Binary files /dev/null and b/feature/info/src/main/res/font/desicrime_kalam.ttf differ diff --git a/feature/info/src/main/res/font/desicrime_kalam_bold.ttf b/feature/info/src/main/res/font/desicrime_kalam_bold.ttf new file mode 100644 index 000000000..2c315e559 Binary files /dev/null and b/feature/info/src/main/res/font/desicrime_kalam_bold.ttf differ diff --git a/feature/info/src/main/res/font/sherlock_fell.ttf b/feature/info/src/main/res/font/sherlock_fell.ttf new file mode 100644 index 000000000..9185ba6cb Binary files /dev/null and b/feature/info/src/main/res/font/sherlock_fell.ttf differ diff --git a/feature/info/src/main/res/font/sherlock_fell_italic.ttf b/feature/info/src/main/res/font/sherlock_fell_italic.ttf new file mode 100644 index 000000000..d59e77144 Binary files /dev/null and b/feature/info/src/main/res/font/sherlock_fell_italic.ttf differ diff --git a/feature/info/src/main/res/font/sherlock_fell_sc.ttf b/feature/info/src/main/res/font/sherlock_fell_sc.ttf new file mode 100644 index 000000000..5a34728ff Binary files /dev/null and b/feature/info/src/main/res/font/sherlock_fell_sc.ttf differ diff --git a/feature/info/src/main/res/values/branded_strings.xml b/feature/info/src/main/res/values/branded_strings.xml index 0659dcd42..231f84f55 100644 --- a/feature/info/src/main/res/values/branded_strings.xml +++ b/feature/info/src/main/res/values/branded_strings.xml @@ -8,6 +8,9 @@ Latest episode Copyright information Artwork and logos + + Expanded + Collapsed Made by boxlore, in the show\'s own style. The Vergecast The Verge @@ -56,4 +59,23 @@ %1$s · Episode %2$d Episode %1$d © The New York Times Company. Serial is a podcast from Serial Productions and The New York Times. The cards show each season\'s own cover art. + + Sherlock Holmes Short Stories + + Read by Hugh Bonneville. + + Part %1$s.  Read by Hugh Bonneville. + © Noiser. Sherlock Holmes Short Stories is a Noiser podcast. The 1891 Strand Magazine cover by George Charles Haité and Sidney Paget\'s 1893 drawing are in the public domain. + + The Desi Crime Podcast + Aryaan Misra & Aishwarya Singh + Aishwarya Singh and Aryaan Misra + + #%1$d · newest case + + newest episode + © Desi Studios. The Desi Crime Podcast is a Desi Studios podcast. The prints show each case\'s own episode art, and Aryaan and Aishwarya are from the show\'s cover art. + + DESI\nCRIME + kaun\ntha? diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/branded/BrandedPagesPhoneGoldenTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/branded/BrandedPagesPhoneGoldenTest.kt index 1ef5cfb5d..70cdd0316 100644 --- a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/branded/BrandedPagesPhoneGoldenTest.kt +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/branded/BrandedPagesPhoneGoldenTest.kt @@ -102,7 +102,17 @@ class BrandedPagesPhoneGoldenTest(private val show: String, private val dark: Bo private fun theme() = if (dark) "dark" else "light" - private fun fixture(): Pair> = cardVariant() ?: when (show) { + private fun fixture(): Pair> = cardVariant() ?: newerPage() ?: firstPage() + + /** The pages added after the first ten; [firstPage] has the rest. */ + private fun newerPage(): Pair>? = when (show) { + "sherlock" -> sherlock(longTitle = false) + "sherlock-long" -> sherlock(longTitle = true) + "desicrime" -> desiCrime() + else -> null + } + + private fun firstPage(): Pair> = when (show) { "conspiracy" -> conspiracy() "history" -> history() "darknet" -> darknet() @@ -289,6 +299,68 @@ class BrandedPagesPhoneGoldenTest(private val show: String, private val dark: Bo return podcast to episodes } + /** Weekly on Thursdays, like the show: The Sign of Four in parts, then A Study in Scarlet; or a long story name on top. */ + private fun sherlock(longTitle: Boolean): Pair> { + val podcast = TestFixtures.podcast(id = "7131280", title = "Sherlock Holmes Short Stories", artist = "Noiser", imageUrl = ART, genre = "Fiction") + .copy( + notificationsEnabled = true, + autoDownloadEnabled = true, + description = "Sherlock Holmes Short Stories narrated by Hugh Bonneville. A woman struck dead after hearing a haunting whistle. " + + "A series of child-like drawings scrawled throughout a country estate. A prize horse wandering the moors without an owner.", + ) + val now = System.currentTimeMillis() / 1000 + val credits = "\n\nPart three of seven.&nbsp;\n\nA&nbsp;Noiser&nbsp;podcast production." + val latest = if (longTitle) { + "The Adventure of the Bruce-Partington Plans: Part Three" to "As a thick fog settles over London, Holmes and Watson break into a spy’s home " + + "on a quiet street in Kensington. There, they discover a series of cryptic messages, and the true nature of the conspiracy comes to light." + } else { + "The Sign of Four: Part Three" to "The police arrive at Pondicherry Lodge… and promptly begin arresting the wrong suspects.&nbsp;" + } + val titles = listOf(latest.first) + listOf( + "The Sign of Four: Part Two", "The Sign of Four: Part One", "A Study in Scarlet: Part Seven", "A Study in Scarlet: Part Six", + "A Study in Scarlet: Part One", "The Adventure of the Three Garridebs: Part Two", "The Adventure of the Three Garridebs: Part One", + "The Adventure of the Missing Three-Quarter: Part One", "The Adventure of Shoscombe Old Place", "The Adventure of the Golden Pince-Nez", + "The Adventure of the Priory School: Part One", "The Adventure of the Sussex Vampire", "The Adventure of the Empty House", + ) + val prefix = if (longTitle) "sherlocklong" else "sherlock" + val episodes = titles.mapIndexed { i, title -> + episode(podcast, "$prefix-$i", title, (43 - i % 5 * 4) * 60, now - 2 * 86_400L - i * 7L * 86_400, if (i == 0) latest.second + credits else "Holmes takes the case.") + } + return podcast to episodes + } + + /** Weekly, like the show: case 212 (its picture is the show's cover), a Chai & Chithi, then earlier cases with their own pictures. */ + private fun desiCrime(): Pair> { + val podcast = TestFixtures.podcast(id = "7976624", title = "The Desi Crime Podcast", artist = "Desi Studios", imageUrl = ART, genre = "True Crime") + .copy( + notificationsEnabled = true, + autoDownloadEnabled = true, + description = "Desi Killers, Desi Kidnappers, Desi Criminals - find them here. Brought to you by Aryaan Misra and Aishwarya Singh, " + + "powered by The Desi Studios. We are your one stop shop for all things Desi, and all things Crazy.", + ) + val now = System.currentTimeMillis() / 1000 + val titles = listOf( + "212. The Most Hated Indian Killer In America", + "Chai & Chithi 36 | Ghost On A Roller Coaster", + "211. The Trial of Ajmal Kasab | Ft. Ujjwal Nikam", + "210. The Burning Car Murder | Mundill Mahil & Gagandip Singh", + "209. Murder in Room 1701 | Wendy Albano", + "208. Murder at DuPont Plaza Hotel | Krishna Maharaj", + "207. The Chameli Bagh Murders | Oishee Rahman", + "206. A Werewolf in Virginia | Pankaj Bhasin", + "205. Murder Trial Of The Century | Ananda Sathasivam Case", + "204. Murder in Monte Sereno | Raveesh Kumra", + "203. The Orkut Murder | Adnan Patrawala", + "202. The Gangster and the Juror | Peter Gill & Gillian Guess", + ) + val episodes = titles.mapIndexed { i, title -> + episode(podcast, "desicrime-$i", title, (47 - i % 4 * 5) * 60, now - 6 * 86_400L - i * 4L * 86_400, "A case from the board.") + // The newest case and the Chai & Chithi carry the show's cover as their picture. + .let { if (i < 2) it.copy(imageUrl = ART) else it } + } + return podcast to episodes + } + /** Serial's feed as it stands: METRO's three episodes, then The Last 12 Weeks. */ private fun serial(): Pair> { val podcast = TestFixtures.podcast(id = "745392", title = "Serial", artist = "Serial Productions & The New York Times", imageUrl = ART, genre = "News") @@ -489,9 +561,9 @@ class BrandedPagesPhoneGoldenTest(private val show: String, private val dark: Bo companion object { private const val ART = "https://images.example.invalid/art.jpg" - private val Openings = setOf("history", "darknet", "conspiracy", "serial") + private val Openings = setOf("history", "darknet", "conspiracy", "serial", "sherlock", "desicrime") - private val Moments = setOf("vergecast", "conspiracy", "history", "darknet", "serial", "waveform", "crimejunkie", "jre", "jre-nopic", "rottenmango", "rottenmango-long", "daily") + private val Moments = setOf("vergecast", "conspiracy", "history", "darknet", "serial", "waveform", "crimejunkie", "jre", "jre-nopic", "rottenmango", "rottenmango-long", "daily", "sherlock", "sherlock-long", "desicrime") @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "{0}-dark={1}") diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/branded/DesiCrimePageTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/branded/DesiCrimePageTest.kt new file mode 100644 index 000000000..ba83204e3 --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/branded/DesiCrimePageTest.kt @@ -0,0 +1,178 @@ +package cx.aswin.boxlore.feature.info.branded + +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.testing.TestFixtures +import cx.aswin.boxlore.feature.info.branded.desicrime.ChithiKeys +import cx.aswin.boxlore.feature.info.branded.desicrime.DesiCrimeArt +import cx.aswin.boxlore.feature.info.branded.desicrime.DesiCrimeCase +import cx.aswin.boxlore.feature.info.branded.desicrime.DesiCrimeChithi +import cx.aswin.boxlore.feature.info.branded.desicrime.DesiCrimeColors +import cx.aswin.boxlore.feature.info.branded.desicrime.DesiCrimeFeed +import cx.aswin.boxlore.feature.info.branded.desicrime.DesiCrimePage +import cx.aswin.boxlore.feature.info.branded.desicrime.DesiCrimeTear +import cx.aswin.boxlore.feature.info.branded.desicrime.Edge +import cx.aswin.boxlore.feature.info.branded.desicrime.NewestMotion +import cx.aswin.boxlore.feature.info.branded.desicrime.boardX +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class DesiCrimePageTest { + private val cover = "https://images.example.invalid/show-cover.png" + private val podcast = TestFixtures.podcast(id = "7976624", imageUrl = cover) + + @Test + fun `The Desi Crime Podcast gets its crime board page, dark in both themes`() { + assertSame(DesiCrimePage, brandedShowPageFor(TestFixtures.podcast(id = "7976624"))) + assertSame(DesiCrimePage, brandedShowPageFor(TestFixtures.podcast(id = "x").copy(feedUrl = "https://feeds.acast.com/public/shows/6a55242c7987e974eaf4ba29"))) + assertSame(DesiCrimePage, brandedShowPageFor(TestFixtures.podcast(id = "x").copy(podcastGuid = "8EE0469F-E82F-54F1-9736-0E24334C51C4"))) + assertTrue(DesiCrimePage.darkOnly) + assertSame(DesiCrimeChithi, DesiCrimePage.subscribeMoment) + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `page text is readable`(dark: Boolean) { + val style = DesiCrimePage.style(if (dark) darkColorScheme() else lightColorScheme(), dark) + val c = style.colors + assertReadable(c.onSurface, c.surface) + assertReadable(c.onSurfaceVariant, c.surface) + assertReadable(c.onSurface, c.surfaceContainerLow) + assertReadable(c.onPrimary, c.primary) + assertReadable(style.chips.genreContent, style.chips.genreContainer) + assertReadable(style.chips.content, style.chips.container) + assertReadable(style.toolbar.subscribeContent, style.toolbar.subscribeContainer) + assertReadable(style.toolbar.subscribedContent, style.toolbar.subscribedContainer) + assertReadable(style.episodeTimeColor, c.surfaceContainerLow) + assertReadable(style.navigationColors.onSurfaceVariant, style.navigationColors.surfaceContainerHigh) + assertReadable(style.popup.body, style.popup.popup) + assertEquals(c.onSurface, c.contentColorFor(c.surfaceContainerLow)) + // The hosts' names and the description on the page; the handwriting on the sticky notes and prints. + assertReadable(DesiCrimeColors.Muted, DesiCrimeColors.Page) + for (sticky in listOf(Color(0xFFF2EC98), Color(0xFFE9E07F), Color(0xFFDCD06B))) { + assertReadable(DesiCrimeColors.NoteInk, sticky) + assertReadable(DesiCrimeColors.RedPen, sticky) + assertReadable(DesiCrimeColors.NoteDetails, sticky) + } + // The print paper runs from cream to a darker tan; its small text reads on both ends. + for (paper in listOf(Color(0xFFF3EFE5), Color(0xFFE3DCCC))) { + assertReadable(DesiCrimeColors.PrintInk, paper) + assertReadable(DesiCrimeColors.CaseNumber, paper) + } + } + + @Test + fun `a title gives the case number, the case and its people`() { + assertEquals( + DesiCrimeCase(210, "The Burning Car Murder", "Mundill Mahil & Gagandip Singh"), + DesiCrimeFeed.case("210. The Burning Car Murder | Mundill Mahil & Gagandip Singh"), + ) + assertEquals(DesiCrimeCase(212, "The Most Hated Indian Killer In America", ""), DesiCrimeFeed.case("212. The Most Hated Indian Killer In America")) + // The listeners' stories carry no number. + assertEquals(DesiCrimeCase(null, "Chai & Chithi 36", "Ghost On A Roller Coaster"), DesiCrimeFeed.case("Chai & Chithi 36 | Ghost On A Roller Coaster")) + assertEquals(DesiCrimeCase(null, "THUG LIFE", ""), DesiCrimeFeed.case("THUG LIFE")) + } + + @Test + fun `a print's caption is the case's people, or the case when the title names a guest or nobody`() { + assertEquals("Mundill Mahil & Gagandip Singh", DesiCrimeFeed.caption(DesiCrimeFeed.case("210. The Burning Car Murder | Mundill Mahil & Gagandip Singh"))) + assertEquals("The Trial of Ajmal Kasab", DesiCrimeFeed.caption(DesiCrimeFeed.case("211. The Trial of Ajmal Kasab | Ft. Ujjwal Nikam"))) + assertEquals("The Trial of Ajmal Kasab", DesiCrimeFeed.caption(DesiCrimeFeed.case("211. The Trial of Ajmal Kasab | ft. Ujjwal Nikam"))) + assertEquals("The Most Hated Indian Killer In America", DesiCrimeFeed.caption(DesiCrimeFeed.case("212. The Most Hated Indian Killer In America"))) + } + + @Test + fun `the board leads with the newest case and pins earlier cases that have their own pictures`() { + val feed = listOf( + episode("212", "212. The Most Hated Indian Killer In America", cover), + episode("cc36", "Chai & Chithi 36 | Ghost On A Roller Coaster", cover), + episode("211", "211. The Trial of Ajmal Kasab | Ft. Ujjwal Nikam"), + episode("cc35", "Chai & Chithi 35 | Scary Stories"), + episode("210", "210. The Burning Car Murder | Mundill Mahil & Gagandip Singh"), + episode("209", "209. Murder in Room 1701 | Wendy Albano", cover), + episode("208", "208. Murder at DuPont Plaza Hotel | Krishna Maharaj"), + ) + val board = DesiCrimeFeed.board(feed, podcast) + assertEquals(212, board.newestNumber) + // 212's picture is the show's cover, so its print is the cover's top: the same template. + assertEquals(cover, board.newestPicture) + assertEquals(listOf(211, 210, 208), board.prints.map { it.number }) + assertEquals(listOf("The Trial of Ajmal Kasab", "Mundill Mahil & Gagandip Singh", "Krishna Maharaj"), board.prints.map { it.caption }) + assertEquals("https://images.example.invalid/211.jpg", board.prints.first().picture) + // A newest case with its own picture shows that picture. + assertEquals("https://images.example.invalid/211.jpg", DesiCrimeFeed.board(feed.drop(2), podcast).newestPicture) + assertEquals(1, DesiCrimeFeed.board(feed, podcast, prints = 1).prints.size) + } + + @Test + fun `before the feed loads the board shows the cover's print and no number`() { + val board = DesiCrimeFeed.board(emptyList(), podcast) + assertNull(board.newestNumber) + assertEquals(cover, board.newestPicture) + assertTrue(board.prints.isEmpty()) + } + + @Test + fun `pieces keep their distance from their own edge as the window widens`() { + assertEquals(2f, boardX(Edge.Left, 2f, 840f)) + assertEquals(256f + 840f - DesiCrimeArt.WIDTH, boardX(Edge.Right, 256f, 840f)) + assertEquals(112f + (840f - DesiCrimeArt.WIDTH) / 2f, boardX(Edge.Centre, 112f, 840f)) + // At the design's width nothing moves. + for (edge in Edge.entries) assertEquals(130f, boardX(edge, 130f, DesiCrimeArt.WIDTH)) + // A wide window has room for more prints between the edges'. + assertEquals(DesiCrimeArt.Slots, DesiCrimeArt.slots(DesiCrimeArt.WIDTH)) + assertEquals(DesiCrimeArt.MAX_PRINTS, DesiCrimeArt.slots(840f).size) + assertEquals(3, DesiCrimeArt.slots(840f).count { it.captioned }) + } + + @Test + fun `the torn edge runs across the whole picture, inside the print`() { + val xs = DesiCrimeTear.Edge.filterIndexed { i, _ -> i % 2 == 0 } + val ys = DesiCrimeTear.Edge.filterIndexed { i, _ -> i % 2 == 1 } + assertEquals(0f, xs.first()) + assertTrue(xs.last() > 0.99f) + assertTrue(xs.zipWithNext().all { (a, b) -> b > a }) + assertTrue(ys.all { it in 0.45f..DesiCrimeTear.DEPTH }) + } + + @Test + fun `the newest print drops in and settles at its slant`() { + assertEquals(0f, NewestMotion.pose(0f).alpha) + val rest = NewestMotion.pose(DesiCrimeArt.END) + assertEquals(NewestMotion.Pose(0f, 0f, -2.5f, 1f, 1f), rest) + } + + @Test + fun `the glass is set down, filled and lifted away before Subscribed lands`() { + assertEquals(1f, ChithiKeys.slide(240f)) + assertTrue(ChithiKeys.glassY(190f) < -50f) + assertEquals(0f, ChithiKeys.glassY(DesiCrimeChithi.SET_DOWN_AT + 100f), 0.01f) + assertEquals(1f, ChithiKeys.fill(900f)) + assertTrue(ChithiKeys.glassY(1_190f) < -60f) + assertEquals(1f, ChithiKeys.ring(1_200f)) + assertEquals(0f, ChithiKeys.shown(1_220f)) + // The stream is out of sight before the pour and after it. + assertTrue(ChithiKeys.pourOffset(400f) >= 60f) + assertTrue(ChithiKeys.pourOffset(1_000f) <= -60f) + assertTrue(DesiCrimeChithi.revealDelayMillis < DesiCrimeChithi.durationMillis) + } + + private fun episode(id: String, title: String, picture: String = "https://images.example.invalid/$id.jpg"): Episode = + TestFixtures.episode(id = id, title = title, imageUrl = picture) + + private fun assertReadable(foreground: Color, background: Color, minimum: Float = 4.5f) { + val light = maxOf(foreground.luminance(), background.luminance()) + val dark = minOf(foreground.luminance(), background.luminance()) + val ratio = (light + 0.05f) / (dark + 0.05f) + assertTrue(ratio >= minimum, "Contrast $ratio < $minimum for $foreground on $background") + } +} diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/branded/SherlockPageTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/branded/SherlockPageTest.kt new file mode 100644 index 000000000..6f1fc4cba --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/branded/SherlockPageTest.kt @@ -0,0 +1,182 @@ +package cx.aswin.boxlore.feature.info.branded + +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.testing.TestFixtures +import cx.aswin.boxlore.feature.info.branded.sherlock.LetterboxKeys +import cx.aswin.boxlore.feature.info.branded.sherlock.SherlockColors +import cx.aswin.boxlore.feature.info.branded.sherlock.SherlockFeed +import cx.aswin.boxlore.feature.info.branded.sherlock.SherlockLetterbox +import cx.aswin.boxlore.feature.info.branded.sherlock.SherlockPage +import cx.aswin.boxlore.feature.info.branded.sherlock.StrandArt +import cx.aswin.boxlore.feature.info.branded.sherlock.StrandContentsLine +import cx.aswin.boxlore.feature.info.branded.sherlock.StrandMotion +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.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class SherlockPageTest { + @Test + fun `Sherlock Holmes Short Stories gets the Strand page, dark in both themes`() { + assertSame(SherlockPage, brandedShowPageFor(TestFixtures.podcast(id = "7131280"))) + assertSame(SherlockPage, brandedShowPageFor(TestFixtures.podcast(id = "x").copy(feedUrl = "https://feeds.megaphone.fm/NSR5430340583/"))) + assertSame(SherlockPage, brandedShowPageFor(TestFixtures.podcast(id = "x").copy(podcastGuid = "0A245E72-7C89-5DA6-9E43-0041937F1A7C"))) + assertTrue(SherlockPage.darkOnly) + assertSame(SherlockLetterbox, SherlockPage.subscribeMoment) + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `page text is readable`(dark: Boolean) { + val style = SherlockPage.style(if (dark) darkColorScheme() else lightColorScheme(), dark) + val c = style.colors + assertReadable(c.onSurface, c.surface) + assertReadable(c.onSurfaceVariant, c.surface) + assertReadable(c.onSurface, c.surfaceContainerLow) + assertReadable(c.onPrimary, c.primary) + assertReadable(style.chips.genreContent, style.chips.genreContainer) + assertReadable(style.chips.content, style.chips.container) + assertReadable(style.toolbar.subscribeContent, style.toolbar.subscribeContainer) + assertReadable(style.toolbar.subscribedContent, style.toolbar.subscribedContainer) + assertReadable(style.episodeTimeColor, c.surfaceContainerLow) + assertReadable(style.navigationColors.onSurfaceVariant, style.navigationColors.surfaceContainerHigh) + assertReadable(style.popup.body, style.popup.popup) + assertEquals(c.onSurface, c.contentColorFor(c.surfaceContainerLow)) + // The header's description, and the printed page of the latest-episode card at its gutter, middle and edge. + assertReadable(SherlockColors.Muted, SherlockColors.Page) + for (paper in listOf(SherlockColors.PaperGutter, SherlockColors.Paper, SherlockColors.PaperOuter)) { + assertReadable(SherlockColors.Print, paper) + assertReadable(SherlockColors.Body, paper) + assertReadable(SherlockColors.Italic, paper) + } + assertReadable(SherlockColors.DropCapLetter, SherlockColors.Ink) + } + + @Test + fun `a title gives its story and its part in Roman numerals`() { + assertEquals("The Sign of Four", SherlockFeed.story("The Sign of Four: Part Three")) + assertEquals("III", SherlockFeed.part("The Sign of Four: Part Three")) + assertEquals("The Adventure of the Bruce-Partington Plans", SherlockFeed.story("The Adventure of the Bruce-Partington Plans: Part Three")) + assertEquals("VII", SherlockFeed.part("A Study in Scarlet: Part Seven")) + assertEquals("XII", SherlockFeed.part("The Valley of Fear Part 12")) + assertEquals("The Valley of Fear", SherlockFeed.story("The Valley of Fear Part 12")) + // A story told in one episode has no part. + assertEquals("The Adventure of the Empty House", SherlockFeed.story("The Adventure of the Empty House")) + assertNull(SherlockFeed.part("The Adventure of the Empty House")) + assertNull(SherlockFeed.part("A Scandal in Bohemia: Part Zero")) + } + + @Test + fun `the card and the contents drop a leading The Adventure of`() { + assertEquals("The Bruce-Partington Plans", SherlockFeed.shortStory("The Adventure of the Bruce-Partington Plans")) + assertEquals("The Three Garridebs", SherlockFeed.shortStory("The Adventure of the Three Garridebs")) + assertEquals("Shoscombe Old Place", SherlockFeed.shortStory("The Adventure of Shoscombe Old Place")) + assertEquals("The Sign of Four", SherlockFeed.shortStory("The Sign of Four")) + // Up to 17 characters the title sets on one line; longer ones step down onto two. + assertFalse(SherlockFeed.isLongTitle("The Sign of Four")) + assertTrue(SherlockFeed.isLongTitle("The Bruce-Partington Plans")) + } + + @Test + fun `the contents list each story once, newest first, numbered as a monthly's pages run`() { + val feed = listOf( + episode("a", "The Sign of Four: Part Three"), + episode("b", "The Sign of Four: Part Two"), + episode("trailer", "Introducing: The Valley of Fear", type = "trailer"), + episode("bonus", "A Noiser Christmas Special", type = "bonus"), + episode("c", "A Study in Scarlet: Part Seven"), + episode("d", "The Adventure of the Three Garridebs: Part One", type = "full"), + episode("e", "The Adventure of the Dying Detective: Part One"), + episode("f", "The Adventure of the Dying Detective: Part Two"), + // A curly and a straight apostrophe are the same story. + episode("g", "The Adventure of the Lion’s Mane"), + episode("h", "The Adventure of the Lion's Mane"), + ) + assertEquals( + listOf( + StrandContentsLine("The Sign of Four", 1), + StrandContentsLine("A Study in Scarlet", 17), + StrandContentsLine("The Three Garridebs", 33), + StrandContentsLine("The Dying Detective", 49), + StrandContentsLine("The Lion’s Mane", 65), + ), + SherlockFeed.contents(feed), + ) + assertEquals(2, SherlockFeed.contents(feed, count = 2).size) + assertEquals(emptyList(), SherlockFeed.contents(emptyList())) + } + + @Test + fun `the card's text is the description's first paragraph, without HTML`() { + // The feed double-encodes its spaces; the credits after the first paragraph are left out. + val raw = "The police arrive at Pondicherry Lodge… and promptly begin arresting the wrong suspects.&nbsp;\n\n\n\n" + + "Part three of seven.&nbsp;\n\nA&nbsp;Noiser&nbsp;podcast production." + assertEquals("The police arrive at Pondicherry Lodge… and promptly begin arresting the wrong suspects.", SherlockFeed.firstParagraph(raw)) + assertEquals("Holmes & Watson’s case.", SherlockFeed.firstParagraph("

Holmes & Watson’s\n case.

Credits

")) + assertEquals("One line", SherlockFeed.firstParagraph("

One line")) + assertEquals("", SherlockFeed.firstParagraph("")) + assertEquals("&bogus; stays", SherlockFeed.firstParagraph("&bogus; stays")) + } + + @Test + fun `the issue lies closed, slides in and opens on its spine, then rests open`() { + assertEquals(0f, StrandMotion.slide(0f)) + assertEquals(0f, StrandMotion.leafAngle(0f), 0.001f) + assertTrue(StrandMotion.showsFront(0f)) + assertEquals(1f, StrandMotion.shade(0f)) + assertEquals(0f, StrandMotion.leftShadow(0f)) + val end = StrandArt.END + assertEquals(1f, StrandMotion.slide(end)) + assertEquals(-180f, StrandMotion.leafAngle(end)) + assertFalse(StrandMotion.showsFront(end)) + assertEquals(0f, StrandMotion.shade(end), 0.001f) + assertEquals(1f, StrandMotion.leftShadow(end)) + // The leaf turns one way only: it never swings back. + val angles = (0..end.toInt() step 10).map { StrandMotion.leafAngle(it.toFloat()) } + assertTrue(angles.zipWithNext().all { (a, b) -> b <= a }) + } + + @Test + fun `the issue is delivered and the door gone before Subscribed lands`() { + assertEquals(0f, LetterboxKeys.door(0f)) + assertEquals(1f, LetterboxKeys.door(600f)) + // The issue comes in from above the slot, hangs at it, then drops out of sight below the button. + assertTrue(LetterboxKeys.issueY(300f) < -60f) + assertEquals(0f, LetterboxKeys.issueY(800f), 0.01f) + assertTrue(LetterboxKeys.issueY(1_000f) >= 80f) + // The flap is lifted while the issue comes through and shut once it has dropped. + assertTrue(LetterboxKeys.flap(600f) < -75f) + assertEquals(0f, LetterboxKeys.flap(1_100f)) + assertEquals(0f, LetterboxKeys.door(1_300f)) + assertTrue(SherlockLetterbox.SNAP_AT in 900f..1_100f) + assertTrue(SherlockLetterbox.revealDelayMillis < SherlockLetterbox.durationMillis) + } + + @Test + fun `keyframes ease each step from its own keyframe and hold outside them`() { + val frames = arrayOf(Keyframe(0.2f, 10f), Keyframe(0.6f, 30f), Keyframe(1f, 0f)) + assertEquals(10f, keyframes(0f, *frames)) + assertEquals(20f, keyframes(0.4f, *frames), 0.001f) + assertEquals(15f, keyframes(0.8f, *frames), 0.001f) + assertEquals(0f, keyframes(1.2f, *frames)) + } + + private fun episode(id: String, title: String, type: String? = null): Episode = + TestFixtures.episode(id = id, title = title).copy(episodeType = type) + + private fun assertReadable(foreground: Color, background: Color, minimum: Float = 4.5f) { + val light = maxOf(foreground.luminance(), background.luminance()) + val dark = minOf(foreground.luminance(), background.luminance()) + val ratio = (light + 0.05f) / (dark + 0.05f) + assertTrue(ratio >= minimum, "Contrast $ratio < $minimum for $foreground on $background") + } +} diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index da2c7a48f..b3b64831b 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -24,7 +24,7 @@ Announcement title: ## New -- The Rest Is History, Darknet Diaries and Serial have show pages in their own style: a gallery wall hung with the latest episodes, the show's flag-bearer with the flag waving, and Serial's stack of cards dealt from its seasons. Each has its own moment when you subscribe. +- The Rest Is History, Darknet Diaries, Serial, Sherlock Holmes Short Stories and The Desi Crime Podcast have show pages in their own style: a gallery wall hung with the latest episodes, the show's flag-bearer with the flag waving, Serial's stack of cards dealt from its seasons, an 1891 Strand Magazine that opens to the stories read so far, and the hosts' crime board pinned with past cases. Each has its own moment when you subscribe. ## Improved