The content tab shows the studio’s content library: recipes, videos, articles, files and links. Code lives in src/features/content/.

Files

Endpoints

getContentList normalises each item so older responses are safe: isFeatured becomes a strict boolean, tags defaults to [] and unlockedAt defaults to null.

Data model

type describes what the item is. mediaKind describes what its media is. The two are independent: a knowledge item can carry a video, and how an item opens depends mostly on mediaKind.

Categories and chips

Categories are studio-defined rows from the taxonomy endpoint. An item with categoryId: null belongs to a synthetic General bucket with key GENERAL_KEY = 'general'. buildCategoryChips(categories, items) builds the chip row in this order:
  1. A featured chip with key FEATURED_KEY = 'featured', only when at least one item has isFeatured.
  2. One chip per taxonomy category that has at least one item.
  3. The General chip, only when at least one item has no category.
  4. A shopping recommendations chip with key SHOPPING_KEY = 'shopping'. Always present.
While the list is still loading (items is undefined), the occupancy filter is skipped, so the featured chip and every category chip show. ContentScreen starts with category set to FEATURED_KEY. If the selected key is not in the chip list, it falls back to defaultCategoryKey(chips), the first chip. The route param section=shopping forces the shopping chip. Selecting another chip clears that param and resets the tag filter, both search boxes and the shopping filter. ContentCategoryBar keeps the active chip centred. It records each chip’s x and width from onLayout and scrolls so the active chip sits in the middle. The first centring is not animated, later ones are. There is no separate endpoint for featured content. featuredItems(items) filters the loaded list by isFeatured, and the featured chip shows that subset. Because the featured chip is first, it is the default view whenever any featured item exists.

Shopping chip

The shopping chip swaps the grid for ShoppingRecommendationsList from src/features/shopping/, driven by useShoppingSection. It reuses the same search field (debounced with useDebouncedValue) and the same filter sheet, with shopping categories as options. A button opens /shopping-list. The shopping feature itself is outside this page.

Tags and the filter sheet

Tags are studio-wide and come from the taxonomy endpoint. Each item carries its own tags array. buildTagOptions(scopedItems, tags) builds the filter options for the open category:
  • Only tags that appear on at least one item in that category are offered, with a count.
  • An “all” option with key ALL_SUB_KEY = 'all' and the category’s item count is put first.
  • If no tag is in use, the list is empty and the filter button is hidden.
ContentFilterSheet is a bottom sheet in a Modal, with its own search box and one radio row per option. Selecting a row calls onSelect(key), or onSelect(null) for the “all” row, then closes. The search box resets each time the sheet opens. When a tag is selected, a selected chip with the tag name appears under the chip row. Tapping it clears the filter. The funnel button shows when filter options exist and, outside shopping, no search is active. Search matches title only, case-insensitive, using useDeferredValue. It cuts across every category: with a non-empty query, the result replaces the scoped list instead of filtering inside it. The tag filter is ignored during search.

Grid

The list is a two-column FlatList. Column width is derived from the window width capped at sizes.maxContentWidth. Pull to refresh refetches the list. ContentGridCard has a fixed aspect (CARD_RATIO = 1.16) and shows:
  • A poster from posterFor(item): thumbnailUrl, or mediaUrl when mediaKind is image, otherwise a brand gradient.
  • A play glyph for video and youtube media, and an external-link glyph for link items. A card with no poster and neither glyph shows the type icon from categoryIcon(item.type).
  • A category badge from itemMetaLabel(item): the category name or the General label.
  • A “new” badge when isNewItem(item) is true. The reference date is unlockedAt ?? createdAt, and an item is new for NEW_ITEM_DAYS = 14 days.
  • The title, and a type label from itemTypeLabel(item) unless it equals the badge. For type: 'other' with an otherLabel, that label replaces the generic one.

Opening an item

openItem(item) in ContentScreen decides what a tap does: ContentItemScreen repeats the first two rules for deep links. A link item opens the browser and the screen closes itself. A pdf item renders only the PDF viewer. closeContentItem() goes back when possible, otherwise replaces the route with /content.

Item screen

For every other item the screen shows a hero and a body. Hero. ContentMedia draws the poster with a play overlay when the item has playable media. Over it sit a type badge, one badge per tag, and the title. Body.
  • recipe: the description (if any), the ingredients text, StepsFlow for steps, then RecipeMacros.
  • Any other type: an about card with the trimmed description, when there is one.
StepsFlow numbers only entries of type step. A tip entry renders as a highlighted note and does not consume a number.

Video player

ContentVideoPlayer is a full-screen Modal. It is also used by the home banner (see Home screen). parseVideoEmbed(url) classifies the URL: Embeds are loaded with a Referer header and a widget_referrer query set to EMBED_REFERRER. By default the embed URL asks for autoplay, and the direct player calls play() on creation.

Stopping playback on close

Unmounting does not stop audio on either backend, so each player registers a synchronous stop function in a shared ref and every exit path calls it before unmounting:
  • Direct player: player.pause().
  • Embed player: injects a script that pauses every video and audio element in the page.
The same stop function runs when the app goes to background (not inactive). Picture in picture is disabled on the WebView and not enabled on the native view, because it would keep playing after close. The direct player sets audioMixingMode = 'doNotMix' so playback is audible with the iOS silent switch on.

PDF viewer

ContentPdfViewer is a full-screen Modal with a close button and a WebView. The source comes from pdfViewerSource(url, Platform.OS) in src/features/plans/lib/planKind.ts:
  • iOS loads the file URL directly.
  • Android loads https://docs.google.com/gview?embedded=true&url=..., because the Android WebView cannot render a PDF. The file URL must be publicly fetchable for this to work.
A spinner shows until onLoadEnd. A load error replaces the WebView with an error message. The component takes an optional source prop to override the computed one, which src/features/plans/components/FilePlanView.tsx uses to show a file plan full screen.

Recipe macros

RecipeMacros reads ten flat numeric fields on the item: servingGrams, servingKcal, servingProtein, servingCarbs, servingFat and the matching total* fields.
  • A set counts as present when any of its kcal, protein, carbs or fat values is not null. With neither set present the card renders nothing.
  • When both sets exist, a two-tab switch chooses between one serving and the whole recipe. It starts on one serving. With one set, there is no switch.
  • The header shows the scope label and the gram weight when known, and the kcal value rounded to a whole number.
  • Three columns show protein, carbs and fat, rounded to one decimal, each with a bar.
Bar length is each macro’s share of the three. In gram mode the shares are weighted by energy with KCAL_PER_GRAM (protein 4, carbs 4, fat 9). When the studio uses the portion system (useMbp().enabled), the three values are converted with mbpFromMacros(macros, anchors) and shown in portion units with the studio’s portion label. The shares are then computed from the portion numbers directly. See Nutrition overview for the portion system.