@v4/video-app
The video section's route package: landing, detail, category, collection and playlist pages over ghost-video, read through @v4/media.
Overview
@v4/video-app is the VIDEO domain's route package — a bolt-on domain, composed
on top of the always-present content routes, and the pattern @v4/basketball-app follows.
It ships the /video vertical as per-route factories over @v4/ghost-video,
reads its video data through @v4/media (the platform video SDK boundary), and
is mounted by the composing app through generated shims exactly like
@v4/content-app.
Being a bolt-on is the point: the domain is listed in the composition
(composition.json) and its routes are declared once in routes.json. On this
site it is one of two bolt-on route packages, beside @v4/basketball-app. The
five routes are createVideoLandingRoute, createVideoDetailRoute,
createVideoCategoryRoute, createVideoCollectionRoute and
createVideoPlaylistRoute, each returning a { Page, generateMetadata } object
the shim instantiates once and statically re-exports.
Entry points
| Subpath | What it is |
|---|---|
. |
The five factories and their types, parsePage and VIDEO_PAGE_SIZE (from the pure paging helpers, so a consumer of the constant does not pull in server-only), and the video card adapters videoCardList, videoCardProps and videoCardPlumbing — for a sibling route package that draws the platform's videos in its own rails (basketball's player and team pages) |
./video-landing, ./video-detail, ./video-category, ./video-collection, ./video-playlist |
One per route factory, named by routes.json |
./associated-videos |
What a video is about (club/0045): the videos about a player or a team and the videos that share the most with a given one — ids from Contentful, cards from the platform. Used by @v4/basketball-app's player and coach pages |
./sitemap |
videoSitemapPaths — the video section's addresses for the app's sitemap: /video, each competition video home with a landing of its own, the landings' collections and playlists (and each collection's playlists), every category and every video. A subpath of its own so the sitemap route never loads the page components; read by apps/web/src/lib/sitemaps.ts |
Dependencies
The package's runtime dependencies name its collaborators exactly:
@v4/media— the read path. Every factory callsconfigureMedia(media)and fetches through the media server fetchers (getVideoLanding,getVideoDetail,getPlayback,getCollection,getPlaylist,getLatestVideos, and for the category pagegetVideoPageandgetVideoFilterOptions) and their normalisedMedia*Viewshapes. The package never touches the platform SDK directly — that is@v4/media's single boundary.@v4/ghost-video— the page components (the listing/detail views).@v4/ghost-core— shared primitives.@v4/tokens— icons.@v4/analytics— each page'sPageAnalytics, so itspage_viewedsays what the page is.@v4/model— the dictionary (getDictionary, from the@v4/model/dictionarysubpath) and the model client every route reads through.@v4/content-app— its non-route subpaths:./metadata(the site's one page-metadata contract,pageMetadata, and the structured-data builders, so a video page publishes its title, share image, canonical and cards exactly like every other page;src/lib/metadata.tsbuilds the video fields),./site-sectionsand./placeholder-page(a competition's video home,src/video-detail/),./listing-address(the category page's address shape),./shareand./read-time. Content is always composed, so the dependency is always there.
next, react and react-dom are peer dependencies. Every route's deps in
routes.json are ["client","media","locale"]: the landing resolves
siteSettings.defaultVideoLandingPage and its category block, the category page
reads the category's name, the detail route reads the sections snapshot, and
every route, collection and playlist included, reads the listed videos'
competitions (below).
Competition markers
The platform's card, hero and detail responses carry no competition, so the
CompetitionMarker a video shows is its Video Item's Competition, read from
Contentful by lib/video-competitions.ts — ONE query per list, for every video
in it at once (get-video-competitions.graphql, tagged content:video).
A video with no competition (or Corporate) shows none, and a failed
lookup is no markers, never a failed page.
Owns its queries and codegen
Video's data reads come from the platform API, so its Contentful surface is
small: seven queries in src/graphql/, spreading the video fragments @v4/model
ships where they apply —
| Query file | Reads |
|---|---|
get-video-landing-settings.graphql |
The landing-settings pointer (siteSettings.defaultVideoLandingPage) |
get-video-landing-categories.graphql |
The landing's category block: every category with its newest video |
get-video-category.graphql |
A category's name and meta title and description |
get-video-category-slugs.graphql |
Every category's name and slug, so a page can link the category the platform names only by its label |
get-video-competitions.graphql |
Each listed Video Item's Competition |
get-video-title.graphql |
A video's fallback name: its Mux asset's title, when the platform sends a video with no title |
get-associated-videos.graphql |
The association lookups behind ./associated-videos (club/0045) |
It runs its own graphql-codegen against the vendored schema.graphql, with the
documents glob narrowed to the video fragments so no other domain's ASTs land
in its runtime gql.ts:
pnpm --filter @v4/video-app generate # graphql-codegen → src/__generated__
pnpm --filter @v4/video-app drift # generate + git diff --exit-code
Landing resolution and graceful degradation
The landing factory resolves its slug editors-first —
siteSettings.defaultVideoLandingPage.slug, then the club-configured
media.landingSlug, then "home" — and degrades to a latest-videos rail when
the lookup fails or finds nothing (the why is on
The landing's editor-first resolution).
That degradation discipline runs
through the vertical: a gated detail video degrades to poster + members badge
(full entitlement UI is the auth step), and unsupported landing rails render
nothing rather than breaking the page.
Where to look next
- Routes — the
routes.jsonregistry and the five factories. - Sibling package: @v4/content-app — the CONTENT domain, built the same way.
- @v4/media — the SDK boundary every factory reads through.
- Media and video — how the media package, the video domain and the player fit together; The video section — what fans see.
Routes
routes.json is the VIDEO domain's route registry — the compose-club generator
reads it to emit the composing app's shims (video is the first bolt-on route
package). Each entry names the URL segment, the subpath in the exports
map, the factory to call, the route params, and the deps the shim must
supply. Every subpath must exist in exports, and a segment's [params] must
match the factory's documented invariant.
The registry
| id | segment | factory | params | deps |
|---|---|---|---|---|
video-landing |
video |
createVideoLandingRoute |
— | client, media, locale |
video-collection |
video/collections/[collection] |
createVideoCollectionRoute |
collection |
client, media, locale |
video-playlist |
video/playlists/[playlist] |
createVideoPlaylistRoute |
playlist |
client, media, locale |
video-category |
video/category/[category] |
createVideoCategoryRoute |
category |
client, media, locale |
video-category-address |
video/category/[category]/[...address] |
createVideoCategoryRoute |
category, ...address |
client, media, locale |
video-detail |
video/[idOrSlug] |
createVideoDetailRoute |
idOrSlug |
client, media, locale |
Each factory takes the club's media config (built from club.config), the
model client and locale, and calls configureMedia(media); copy is read per
request inside each Page with await getDictionary(locale), so a published
label reaches the site without a deploy. Every route takes the client because
every listed video's CompetitionMarker comes from its Video Item in Contentful
(lib/video-competitions.ts), and some routes read more through it
(below). All return a { Page, generateMetadata } route object.
createVideoLandingRoute
The /video landing (video segment, no params — the first bolt-on domain
route). Resolves its slug editors-first
(siteSettings.defaultVideoLandingPage → media.landingSlug → "home"),
fetches the landing through getVideoLanding, and maps its rails into hero
slides plus playlist/collection blocks. When no landing is configured or found,
it degrades to a getLatestVideos rail so an unconfigured club still ships a
working page. Needs client in addition to media/locale for the settings
lookup.
A collection placed on the landing arrives from the platform as a rail of its
videos, its playlists flattened. The landing reads the collection itself
(getCollection) and draws it as the ELB Block-collections band: one
collection card per playlist — each a series or a season — opening the
collection page on that tab (/video/collections/<slug>?filter=<value>), and
an "All videos" link to the collection's Show all tab
(lib/collection-cards.ts). A card whose playlist has no tab of its own
opens Show all. Where the collection cannot be read, the rail keeps its video
cards over the collections artwork.
createVideoDetailRoute
The video detail page (video/[idOrSlug]). Loads the video with
getVideoDetail; a non-gated video resolves playback through the anonymous
getPlayback endpoint, while a gated video (or a 401/403) degrades to poster +
members badge (entitlement UI proper is the auth step). Renders @v4/media's
MediaPlayer — the hosted Mux player by default, else YouTube or Brightcove,
by the playback's provider (see the media player) —
passing it the media config's brightcoveAccountId and accentColor, and lists
up-next cards. The route
param is named idOrSlug — the shim must live in a video/[idOrSlug] segment.
A COMPETITION'S slug at that address is its video home, never a lookup for a
video (the site map, 15 Oct 2026): /video is the default competition's (the
landing, so /video/euroleague redirects there, 308) and /video/<competition>
any other's — a TEMPORARY page (PlaceholderPageView, noindex) with every
video home as its page navigation, until clips are tagged by competition. A
section without video (club.config.ts sections.capabilities — the site map's
Corporate) is not found there, and left out of that navigation
(src/video-detail/video-home.ts). That is what the route reads the sections
snapshot for, through client. How competition sections work across the site is on
Competition sections.
createVideoCategoryRoute
A category's videos (video/category/[category], and
video/category/[category]/[...address]), newest first, paged and filtered by
season, team and competition — all in the PATH, the news listings' shape (user
ruling, 15 Oct 2026; lib/category-address.ts):
/video/category/highlights
/video/category/highlights/page/2
/video/category/highlights/season/2025-26/team/zalgiris-kaunas/page/2
The pairs come straight after the category's slug in one fixed order —
season, team, competition (VIDEO_CATEGORY_FILTER_KEYS) — then an
optional page/<n>, with no /filter/ prefix and no query string, so every view
is a static, cacheable route. A value is the choice's NAME as a slug
(Zalgiris Kaunas → zalgiris-kaunas), looked up against the platform's
choices, never the entry id the platform filters by.
- The videos come from the platform through
getVideoPage(/videos/latest?category=<slug>), with the list's totals for "N Videos" and the page numbers;VIDEO_PAGE_SIZEper page. - The filter row (Season, All Teams, the competition) comes from
getVideoFilterOptions— the platform's/ContentData/*lists. A dropdown with no choices is not drawn. - The category itself — its name, meta title and description — comes from
Contentful through
client(get-video-category.graphql, taggedcontent:category), because the platform has no category endpoint.
The page wears the playlist page's look (BasePage-Playlist): the static header
artwork, the title and "N Videos", the list under it.
createVideoCollectionRoute
The collections page (video/collections/[collection]), composed to the Ghost –
Video 2.0 Collections master: back + sponsor header, WATCH LATEST (the ELB header
draws no WATCH FROM BEGINNING), the featured band, series/season tabs and a videoType filter — both
expressed as URL params the API filters server-side. A series tab, and the list
heading under it, reads "Series 1" (video.label.series: a playlist's Series is a
number in Contentful); a season tab shows the editor's text as entered ("2025-26").
The address keeps the bare value (?filter=1). Exports VIDEO_PAGE_SIZE.
Pagination is delegated to the API only in the videoType-filtered branch (the
platform pages videoType-scoped queries server-side, 0-based; without page/size
it silently caps each group at 20, so the paged form is a correctness fix, not an
optimisation); the unfiltered, playlist-grouped view is paged in-process. The
route param is named collection.
createVideoPlaylistRoute
The playlist page (video/playlists/[playlist]). API-paginated: the platform
pages playlists server-side 0-based, while the URL stays 1-based ?page= like
every other listing. Fetches through getPlaylist and renders the shared
VideoListingView. The route param is named playlist.