Files

399 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# YouTube notes
What the YouTube site files do, and what has been learned about YouTube's pages, data and
player while building StreamXR (October 2026, English YouTube, desktop Chrome). Read this
before touching `src/sites/youtube/`; YouTube changes often, so re-check a fact live when
something stops working. How the code is structured is in
[ARCHITECTURE.md](../ARCHITECTURE.md).
## The site modules (`src/sites/youtube/page/`)
The `youtube-*.js` files are the **only place** that reads, clicks or requests anything
from YouTube. When YouTube's markup or data changes, fix it there. They expose everything
YouTube offers, whether the VR interface uses it yet or not (the unused parts are the
backlogs in [features/](features/)):
- `youtube-data.js` (`ns.youtubeData`): helpers, `watchData()` / `playerResponse()`,
`innertube()` (POST to YouTube's own `/youtubei/v1/`), `pager()`, `cached()`, and the item
readers for the `compactVideoRenderer` / `videoRenderer` and `lockupViewModel` shapes.
- `youtube-player.js` (`ns.youtube`): everything on the `#movie_player` API.
- `youtube-page.js` (`ns.youtubePage`): Up Next (merged data + DOM scraping, load more,
playlists), the controls panel's details, Subscribe / Like / Dislike, in-page navigation.
In-page navigation is a `yt-navigate` event with a watch endpoint, sent from
`ytd-page-manager`: sent from `ytd-app` itself, YouTube ignored it on a channel page (it
worked on the watch page and Home), checked 2026-10-17.
- `youtube-content.js` (`ns.youtubeContent`): metadata, description, owner, chapters,
heatmap, end screen, cards, playlist, comments / replies, transcript, search.
- `youtube-channel.js` (`ns.youtubeChannel`): any channel by id (`currentChannelId()` for the
playing video's), via the browse API so the video keeps playing: header, About, Home
shelves, Videos / Shorts / Live (latest / popular / oldest), Playlists, Shows, Posts,
Store, channel search.
- `youtube-home.js` (`ns.youtubeHome`): the Home feed with its topic chips ("Immersive
videos" = 360/VR content), Explore feeds (Live, News, Gaming, Sports, Music, Podcasts,
Courses, Hype, Memberships), signed-in feeds (unverified), the left menu (`guide()`), your
own channel's id (`myChannelId()`: the menu's "Your channel" entry, else the account menu; verified signed in, 0.74.0) and
search `suggestions()`.
- `youtube-subscriptions.js` (`ns.youtubeSubscriptions`): the Subscriptions feed, the Manage
list (`channels()`), Subscribe / Unsubscribe any channel, the notification bell. Verified
signed in: `checkSignIn()`, `feed()` (first page = 100 videos), `channels()` (in the
Manage list `subscribed` is always true; one channel had only a stub `subscriptionButton`
and no bell), subscribe / unsubscribe / `setNotifications` (they report ok only when
YouTube's answer shows the new state). Filter chips: none on desktop for the tested
account (the documented ones seem app-only). **No community posts** in the desktop feed
(signed in, 2026-10-17: no post data in `FEsubscriptions` at all). The Manage page
(`FEchannels`, seen in the signed-in page's `ytInitialData`, 2026-10-17): the list is split
into **titled shelves** (`shelfRenderer` inside the item section: "Purchased" for channels
you're a member of, then "Subscribed"), and its **sort dropdown** (Most relevant, New
activity, A-Z) is a set of `listItemViewModel`s with `entityKey`
`channel_list_page_sort_chip_entity:<name>`, each tap updating a `chipEntity` (the
dropdown's label) and sending a browse `continuationCommand` that reloads the list in that
order (`sortsOf`, `channelsOn`).
- `youtube-chat.js` (`ns.youtubeChat`): a live stream's chat ("Live chat" below): `info()`
(its views), `open(token)` (a view's chat page), `poll(next)`, and the chat items as plain
messages and changes (add, remove, banner).
The provider (`provider/`) is the only code that calls these modules on the shell's behalf.
The maintainer keeps private, live-probed inventories of what each page offers (Watch,
Channel, Home, Subscriptions); what matters from them is in this file and the backlogs in
`docs/features/`.
## Data and requests
- Newer items show short text ("26M", "1y ago", "8mo ago"); the accessibility label holds
the long form. `longAgo()` expands short dates.
- Signed-in requests need `Authorization: SAPISIDHASH <ts>_<sha1("<ts> <SAPISID cookie>
https://www.youtube.com")>`; `innertube()` sends it (and retries without it on 401/403).
Every response's `responseContext` has a `logged_in` flag; a signed-out Subscribe still
answers 200 but changes nothing.
- **Which identity:** an account can have several YouTube identities (default + brand
channels). Without one, YouTube answers as the account's DEFAULT identity, not the channel
the user browses as (once, the Subscriptions feed showed another identity's channels, and
test subscriptions changed that identity). `innertube()` sends `X-Goog-PageId` +
`context.user.onBehalfOfUser` (ytcfg DELEGATED_SESSION_ID). Check in the page console:
`await __streamxr.youtubeSubscriptions.checkAccount()` -> `sameAccount` must be true.
- Channel tabs (browse API): a tab's first page keeps its "load more" token at the end of its
grid / section; later and re-sorted pages arrive in `onResponseReceivedActions`. Sorting
is a chip token (Videos / Shorts / Live) or the sort menu's params (Playlists). A
signed-in first page can carry other `onResponseReceived*` actions with no items, so
`pageRoot` reads only append / reload continuation lists (Live / Playlists / Shows looked
empty before that; the cause is a theory, the fix works).
- A newer "load more" shape, `continuationItemViewModel` (token in
continuationCommand.innertubeCommand.continuationCommand), is read by `continuationOf` and
`nextPageToken` (playlist pages use it).
- Ad filtering must match a capital "Ad" (`AD_KEY_RE`): a loose `/[aA]dRenderer$/` also
matched "backstagePostThreadRenderer" and silently dropped every channel post.
- Text matching in `youtube-page.js` assumes **English** YouTube.
## Subscribe and Like
- The watch page's subscribe box holds both buttons ("Subscribe" and "Subscribed"), so its
text always matches /subscribed/: `isSubscribed` reads the visible button, else the watch
data's subscribeButtonRenderer.subscribed.
- The page's own button doesn't update after an API change, so
`youtubePage.noteSubscription(channelId, on)` remembers confirmed changes until the next
video; the controls panel and the Channel panel tell each other (`noteSubscribed`).
- The Channel header's own `subscribed` takes the FIRST subscriptionStateEntity in the reply
(signed in, a Home tab may carry others); it only matters when the watch page gives no answer.
- The like count falls back to the button's aria-label.
## Video formats
The formats are in `src/streamxr/shared/formats.js`; YouTube's detection is the provider's
`detectFormat`.
- **Quality names** (0.67.1): the player's `getAvailableQualityLevels()` gives only ids
(`hd1080` ...), with no frame rate, so a 60 fps video showed "1080p". The names now come
from `getAvailableQualityData()` (`qualityLabel`, e.g. "1080p60", or "2160s60" for a 360° /
VR180 video: "s" for spherical) where the player has it
(`youtube-player.js` `quality().labels`); with Premium it also lists "1080p Premium",
kept apart as its own level (0.91.0). Seen signed in with Premium (2026-10-17): the entry
has `formatId` "721", `quality` "hd1080", `paygatedQualityDetails.paygatedIndicatorText`
"Enhanced bitrate", `isPlayable` true, beside the plain 1080p (`formatId` "399"); it's
chosen with `setPlaybackQualityRange(q, q, formatId)` and `getPlaybackQualityLabel()` names
the one playing. **Whether you have Premium:** the top bar's logo,
`ytInitialData.topbar.desktopTopbarRenderer.logo.topbarLogoRenderer.iconImage.iconType`
is `YOUTUBE_PREMIUM_LOGO` (`youtube-player.js` `hasPremium`). **Jump ahead:** the page has
no data for it, and the player's `prefetchJumpAhead()` returns undefined and moves nothing.
YouTube shows a **Jump ahead pill** in the player's "timely actions" overlay at the moments
it applies (`.ytp-timely-actions-overlay ytw-timely-action-view-model button`, its text
"Jump ahead"), and a script's `click()` on it jumps (17 s -> 124 s, 2026-10-17), so
`youtube-player.js` `jumpAhead()` clicks it and `jumpAheadOffered()` tells whether it's up.
A script-sent Ctrl + → keydown on `#movie_player` jumped from the console but did nothing
from VR.
- Desktop YouTube sends **one eye only** for most 3D videos (checked 2026-10-17 with frames
from the page's `<video>`): the "Left Right Test in VR 180" (`kle719aQvsc`, 1920×2160) and
a 3D VR180 tour (`KJ-_LUrGhYE`, 3840×2160, 2160s at most on desktop) hold the left eye
only. **Some arrive side by side** (both eyes in one frame): "VR180 Left/Right"
(`ccU0imlNp5Q`, 3840×2160, LEFT | RIGHT). Nothing in the player response tells them apart
(all `MESH`, `vrConfig.partialSpherical`, no stereo field). The Quest app gets both eyes
and higher resolutions, most likely as a VR client; StreamXR doesn't bypass the page's
player to get them ([decisions/0002](decisions/0002-site-player-only.md)).
- 8K reaches desktop where YouTube offers it, e.g. an 8K 360° video (`tR8ZtyhSDYw`):
`4320s`, 7680×3840, AV1 (itag 571).
- Formats: `flat` (cinema screen), `flatsbs` (the cinema screen in 3D side by side: 0.93.0;
e.g. "Demo 3D[SBS]", `u0hmMt5znX0`, a RECTANGULAR 16:9 frame, each eye squeezed into half),
`360mono`, `360eac`, `180mono`, `180sbs` (3D side by side,
picked by hand: 0.89.0).
- "MESH" 360° videos use an **equi-angular cubemap**, 3×2: top row left | front | right,
bottom row down | back | up; down and up rotated 270° clockwise, back 90° (the same as
ffmpeg's `eac`). Verified against a real frame.
- VR180 vs 360° (both "MESH"): the player response has
`playerConfig.vrConfig.partialSpherical: true` on VR180 and no `vrConfig` on 360°
(checked on 2 of each). "180" in the title and an aspect under 1.6 are fallbacks; the
frame shape alone is unreliable (VR180 streams are often 16:9 below 2160p).
- Card flags: YouTube's card data has its own "VR180" / "360°" badges
(metadataBadgeRenderer.label on older cards, badgeViewModel.badgeText on lockups);
`badgeKind()` reads them first, the title only as a fallback. Up Next's merge keeps a
180 / 360 kind from either the page text or the data.
- VR180 test video: "how TV screens made watching movies worse" by Noodle (`wlUV6y5TUko`):
no "180" in the title, 16:9 below 2160p.
## Ads
Test ad handling in a browser without YouTube Premium (e.g. signed out); ads are rationed,
so long non-music videos with many `adPlacements` work best.
- Detection: `#movie_player.ad-showing`. The ad plays in the SAME `<video>` (element
time / duration = the ad's; the player API = the main video's). Pod text "1 of 2" is in
`.ytp-ad-pod-index`.
- Skip: `button.ytp-skip-ad-button` exists from the start on skippable ads but is hidden for
5 s (no countdown text). Unskippable ads have no button.
- **YouTube ignores script clicks on Skip** (tested: click(), synthetic pointer / mouse /
key events, `player.onAdUxClicked(...)`); it checks the event itself (`isTrusted`), not
user activation. A real click works and skips the rest of the pod. Real input needs the
`debugger` permission, which can't be optional and shows the "Read and change all your
data" warning, so it lives in the separate `skip-helper/` extension (fixed ID
`dbfmdfbpdpphbjnjlembolnmhnicbicm` via its manifest `key`; the private key is not in the
repo). Flow: `skipAd()` -> bridge `SKIP_AD` -> `src/sites/youtube/ui/skip-helper.js` ->
`chrome.runtime.sendMessage(HELPER_ID)` -> the helper finds the visible Skip button itself
(CDP Runtime.evaluate), makes anything covering it click-through (StreamXR's own popup
once covered it), clicks (Input.dispatchMouseEvent) and detaches. Without the helper, VR
shows "Skip on desktop". Checked by the maintainer after 0.78.3 (helper 1.2.0): both work. With
YouTube Premium there are no ads, so the helper isn't needed. The helper is packaged and
published separately from the main extension; once it has a Chrome Web Store ID, add that
ID to `HELPER_IDS` in `src/sites/youtube/ui/skip-helper.js` (next to the unpacked ID).
- Format detection during an ad is unreliable (the `<video>` holds the ad's frames, and
`youtube.info()` leaves the frame size out), so the format is re-detected after every ad
unless one was picked by hand during it.
## Live streams
Checked on a Lofi Girl stream:
- `getVideoData().isLive`; `allowLiveDvr` (rewinding allowed). `getDuration()` is
meaningless (~53 days on a stream rewindable for 12 h). `getProgressState()` has
seekableStart / seekableEnd (the end is the live moment). `isAtLiveHead()` /
`seekToLiveHead()` work.
- While paused, the player stops refreshing seekableEnd and `isAtLiveHead()` STAYS true;
`getProgressState().ingestionTime` freezes, so "behind" also uses (now - ingestionTime)
minus its smallest value for this video. Anything under 12 s behind (`LIVE_SLACK`) counts
as live (the player says "not at head" 15 s behind right after joining).
- No storyboard previews on live streams.
## Live chat
Probed signed in on a members' live stream and a busy public one (2026-10-16, console
snippets); read by `youtube-chat.js` since 0.79.0:
- **The watch page** has a `liveChatRenderer` (in `conversationBar`) naming the views ("Top
chat", "Live chat" in `sortFilterSubMenuRenderer.subMenuItems`), but **its tokens don't
work**: `/live_chat?continuation=<one of them>` answers 200 with an empty page (~79 KB,
`window["ytInitialData"] = <!-- …`, no chat), and `live_chat/get_live_chat` answers
**HTTP 400** (probe 6, 2026-10-16). Use the **chat frame's** tokens (its own view selector:
different tokens, each loading a full chat page, ~455 KB) or, with no frame on the page, the
**pop-out page**'s (`/live_chat?is_popout=1&v=<video id>`: ~452 KB of data, but no
`liveChatContinuation`; its view selector is read wherever it is: to confirm on a real page).
- **The chat page / chat frame** (`#chatframe`, same origin): its `ytInitialData` is shaped like
a chat update, `continuationContents.liveChatContinuation`, with `continuations[0]`
(`invalidationContinuationData`: the token to poll with), `header` (the view selector
again, with its own reload tokens), `ticker`, `actionPanel` (the message box, the emoji picker,
the reactions, the send endpoint `sendLiveChatMessageEndpoint`), `emojis` (the channel's
custom emojis), `participantsList`, `itemList`. Its `actions` are gone once the frame has
shown them (`undefined` when read later). The chat page's HTML writes it as
`window["ytInitialData"] = {…};`: fetching `/live_chat?continuation=<a view's reload token>`
and reading that (brace matching, then `JSON.parse`) works without any frame, and comes
with the recent messages in `actions` (77 on a busy stream). That's how to open the
**Live chat** view (every message) whatever view the page's frame shows (Top chat by default).
- **Polling:** `live_chat/get_live_chat` `{ continuation }` with the invalidation token
answers in ~100-200 ms with `actions` and the next `invalidationContinuationData`
(`timeoutMs: 10000`). YouTube's own frame doesn't wait that long: it is told about new
messages by a push service (`/punctual/v1/chooseServer`, `/punctual/multi-watch/channel`).
**Polling every 2 s works** (20 polls in a row on a busy stream: no errors, new messages
in each, no duplicates; each poll's token is the previous answer's).
- **A message:** `addChatItemAction.item.liveChatTextMessageRenderer`: `message.runs`
(text and emojis), `authorName.simpleText`, `authorPhoto.thumbnails` (32 and 64 px),
`authorExternalChannelId`, `id`, `timestampUsec`, `contextMenuEndpoint` ("Chat actions").
The action also has a `clientId`.
- **Other items seen** (in `addChatItemAction.item`): `liveChatPlaceholderItemRenderer`
(only `id` and `timestampUsec`: a message YouTube holds back; none was filled in within
40 s, so skip them), `liveChatViewerEngagementMessageRenderer` (YouTube's "Welcome to live
chat! Remember to guard your privacy…" notice: `icon`, `message.runs`, a "Learn more"
`actionButton` to support.google.com).
- **Banners:** `addBannerToLiveChatCommand.bannerRenderer.liveChatBannerRenderer` (`contents`,
`actionId`, `bannerType`, `bannerProperties` with `isEphemeral` / `autoCollapseDelay`).
Seen: `LIVE_CHAT_BANNER_TYPE_CHAT_SUMMARY`, an AI "Chat summary" ("Auto-generated
experiment • Quality may vary") with thumbs up / down, gone after 12-30 s. Pinned messages
use the same command (not seen yet).
- Not seen yet: Super Chats / Stickers, memberships, ticker items, deletions, replacements.
- **Custom emojis** (`liveChatContinuation.emojis`, 132 on that channel): `emojiId`
(`<channel id>/<id>`), `shortcuts` (`:_C80:`, `:C80:`), `searchTerms`, `image.thumbnails`
(24 and 48 px on yt3.ggpht.com), `isCustomEmoji`.
- **Reactions** (`reactionControlPanelViewModel.expandedButtons`): ❤ 😄 🎉, each a
`reactionControlPanelButtonViewModel` with `emojiId`, `buttonIcon` (Noto emoji PNG on
fonts.gstatic.com), `a11yLabel` ("Send ❤") and `onTap.innertubeCommand`: a
`feedbackEndpoint` (`feedbackToken`) to `/youtubei/v1/feedback`. They're in the chat page's
`actionPanel` (absent when the stream has no reactions, or signed out). Sent by
`youtubeChat.react()` as YouTube's web client sends a feedback token: `{ feedbackTokens:
[token], isFeedbackTokenUnencrypted: false, shouldMerge: false }`, the answer's
`feedbackResponses[].isProcessed` saying whether it was taken (0.80.0; checked from the
console on a live stream, 2026-10-16: taken). The set varies by stream (that one offered five,
not ❤ 😄 🎉). `emojiId` is written without the
emoji selector (❤ = U+2764 alone), which fonts draw as a plain glyph: add U+FE0F.
- **The message box** (probe 7, 2026-10-16; the chat page's `actionPanel`):
`liveChatMessageInputRenderer` with `inputField.liveChatTextInputFieldRenderer`
(`placeholder` "Chat...", `maxCharacterLimit`, `emojiCharacterCount`: 10, what an emoji
counts for), `sendButton…sendLiveChatMessageEndpoint` (`params`, `clientIdPrefix`, apiUrl
`/youtubei/v1/live_chat/send_message`), `pickers` (`emojiPickerRenderer`,
`liveChatProductPickerPanelViewModel`), `pickerButtons` (an icon toggle, the reactions),
`jewelsGiftsButton`. Where you can't chat, the action panel holds something else with a
`message` (not seen yet).
- **Sending**: `live_chat/send_message` `{ params, clientMessageId, richMessage: { textSegments:
[{ text } | { emojiId }] } }` posted a message (checked from the console: the answer's
`actions` echo it as an `addChatItemAction`, plus a `runAttestationCommand`). A channel
emoji as `{ emojiId }` is how YouTube's own client sends one (not checked yet).
- **Support** ("$", the product picker): its items are `liveChatProductPickerPanelItemViewModel`s
("Gifts", "Super Chat", "Membership gifting", "Free Member Milestone Chat" on a members'
stream). The chat frame's document had no button to click for it (only icon definitions), so
StreamXR doesn't open it: its Support button says to use YouTube's own window.
- **Chat replays** (a finished stream's chat; probe 10, 2026-10-16): the watch page's
`liveChatRenderer.isReplay` is true and its views are "Top chat replay" / "Live chat replay".
The chat frame is `/live_chat_replay`; its data (shown actions already gone, as for live chat)
has `continuations` [`liveChatReplayContinuationData` (`continuation`,
`timeUntilLastMessageMsec`), `playerSeekContinuationData` (`continuation`)] and the same view
selector (reload tokens: `/live_chat_replay?continuation=<token>` reads, ~295 KB, with the
chat). `live_chat/get_live_chat_replay` `{ continuation, currentPlayerState: { playerOffsetMs } }`
is a **cursor**: each answer is the next chunk (~45 messages, minutes of chat) as
`replayChatItemAction` (`videoOffsetTimeMsec`, the message's time in the video, and its
`actions`), whatever position is sent; the **first** token with another position starts
again from there (a seek: asked at 10:35, the chunk started at 10:36). So a reader fetches
ahead and shows each message when the video reaches its time (`youtube-chat.js`
`pollReplay`, `provider/live-chat.js`, 0.87.0). The frame itself made no chat requests while
probed (`/youtubei/v1/att/get` only); `playerSeekContinuationData` isn't used yet.
With YouTube's chat **hidden** on the page (probe 11), the watch page keeps the replay's
`liveChatRenderer` (its `initialDisplayState` still says EXPANDED) and the chat frame element
stays, so the chat is still found. There's **no pop-out replay page**:
`/live_chat_replay?is_popout=1&v=<id>` answers HTTP 400 (the fallback only works for live chat).
- **Top fans** (probes 8 and 9, 2026-10-16):
- The chat header's `viewerLeaderboardEntryPoint.liveViewerLeaderboardChatEntryPointViewModel`:
a `defaultButton` and a `pointsButton` (crown icons, "Top fans"), each tapping a
`showEngagementPanelEndpoint` (tag `PAlive_viewer_leaderboard`, `globalConfiguration.params`),
plus `pointsEntityKey` / `entryPointStateEntityKey`.
- **Your XP** is an entity: `frameworkUpdates.entityBatchUpdate.mutations[]` with that
`entityKey` and `payload.liveViewerLeaderboardPointsEntity` (`pointsCompactText` "0 XP",
`pointsValue`); it comes in the chat page and, when it changes, in the polls' answers.
- **The list**: `get_panel` `{ panelId: 'PAlive_viewer_leaderboard', params }` (with the params
alone, `browse` or other guesses: HTTP 400) answers
`content.engagementPanelSectionListRenderer` with `content.sectionListRenderer.contents[]`
(50 on a busy stream) of `itemSectionRenderer` > `liveLeaderboardItemViewModel`: `rank.content`
("1"), `points.content` ("30 XP"), `avatar.avatarViewModel.image.sources[0].url`,
`content.liveLeaderboardItemChannelContentViewModel` (`displayName.content`, `badge`: the
rank's button). Its `footer` is **your row** (`rank`, `points`, `avatar`, and
`liveLeaderboardItemActionContentViewModel.actionDescription` with a gift button).
- **Rank marks on messages**: `liveChatTextMessageRenderer.beforeContentButtons[]` >
`buttonViewModel` (`iconName` CROWN_FILLED, `title` "#1", `customBackgroundColor` /
`customFontColor`, ARGB; tapping opens the same panel): on the top three's messages.
## Playlists
- The player (getPlaylist / getPlaylistIndex) and the page's sidebar hold only a WINDOW of
~80-200 videos around the playing one, and the player's index counts from the window's
start (a large channel's uploads, ~1,850 videos: at #250 the window was #230-#328, index 20). The
page data has playlist.currentIndex (whole list), localCurrentIndex (in the window) and
totalVideos; the provider works in whole-playlist positions (offset = currentIndex -
localCurrentIndex, else found in the playlist page's order, else remembered).
- The sidebar's cards (playlistPanelVideoRenderer) have only title, channel and length; the
playlist's own page (browse "VL<id>", lockupViewModels, 100 a page) fills in views, age
and badges. Mixes ("RD...") have no playlist page.
- Watch later (browse "VLWL", signed in; 0.35.3) is different: its rows are the older
playlistVideoRenderer, after a first section of its own controls. Views and age are only
in `videoInfo`, short and without the word "views" ("7.4K • 4d ago"); no channel badge.
Liked videos ("VLLL") uses lockupViewModels like other playlists.
- Playing a position outside the window: `openVideo(id, list, index)` (watchEndpoint index).
Next / Previous are the player's nextVideo / previousVideo (they switch in-page).
- A show card has no video id; opened with only `list=`, YouTube played a 1-minute "not
available on this device" placeholder, so `openVideo` looks the first video up first
(`next` with the playlistId -> currentVideoEndpoint.watchEndpoint.videoId).
- **Save (0.77.0, verified signed in):** `playlist/get_add_to_playlist` with `videoIds`
answers YouTube's Save menu: `playlistAddToOptionRenderer`s (`playlistId`, `title`,
`containsSelectedVideos` `ALL` / `NONE`), Watch later (`WL`) first; `browse/edit_playlist`
with `ACTION_ADD_VIDEO` / `ACTION_REMOVE_VIDEO_BY_VIDEO_ID` changes it (`status`
`STATUS_SUCCEEDED`).
- **The top bar and the logo (0.78.0):** the right-hand buttons are `ytd-masthead #end #buttons`
(+ Create first when signed in); the logo is an SVG at `ytd-topbar-logo-renderer #logo-icon
svg` whose colours come from the page's CSS (the wordmark follows the light / dark theme),
so a copy needs its fills written in.
- **Captions (0.77.0):** YouTube draws them in the page (`.ytp-caption-window-container`, one
`.caption-visual-line` per line), never into the video; StreamXR turns a track on with the
player's `setOption('captions', 'track', ...)` and reads those lines each frame to draw them in VR.
## Comments, description, transcript
- Comments arrive as layout plus separate "entities" (`frameworkUpdates`), matched by
`commentKey`. A replies page returns each reply as a commentThreadRenderer (with
subThreads for replies-to-replies); one level of replies is shown.
- The toolbar STATE entity (toolbarStateKey) has heartState / likeState; the comment
entity's toolbar has creatorThumbnailUrl + heartActiveTooltip when hearted, and
likeCountLiked (the count including your like). The toolbar SURFACE entity carries
like / unlike / dislike / undislike commands with performCommentActionEndpoint.action
tokens only when signed in; `rateComment` POSTs comment/perform_comment_action
`{ actions: [token] }` (confirmed signed in).
- The comment count comes from the comments panel header's contextualInfo
(`commentsInfo()`); a transcript exists when the page lists an
`engagement-panel-searchable-transcript` (`hasTranscript()`).
- `descriptionWithoutChapters()` drops lines whose leading time or timestamp LINK matches a
chapter start (± 2 s), plus a heading right before them. Gotchas (freeCodeCamp's Python
course): 0:00 links have NO startTimeSeconds and links to OTHER videos carry 0, so a
timestamp link is recognised by its text; chapter lines can start with emoji or brackets
("⌨️ (1:45) Setup"), so the leading-time regex skips non-letters.
- Transcript: YouTube refuses direct `get_transcript` and timedtext requests (they need a
token), so `transcript()` clicks the description's "Show transcript", reads the panel and
closes it.
- Most Replayed (the heatmap): a fresh page load's data leaves it out, even on videos that
have one; the `next` request (what YouTube sends switching videos in-page) has it, signed
out too, as a `macroMarkersListEntity` of type `MARKER_TYPE_HEATMAP`: 100 markers
(`startMillis`, `durationMillis`, `intensityScoreNormalized` 0..1, the most replayed = 1).
So `mostReplayed()` reads the page's data and otherwise asks `next` once per video
(checked Oct 2026 on a very popular music video). Newer or less watched videos have none.
## Audio, channels, storyboards
- Audio tracks (checked on OLEG ZAYANOV, `DbKDX-AyJIQ`: "English (US)" auto-dub = default,
"Russian original"): the player's track id starts with the stream format ("251;..."), so
tracks are named by their language id ("ru.4"); setAudioTrack switches live.
- Featured video: a channel's trailer is shown to NON-subscribers; subscribers only see a
"featured video for returning subscribers" if the creator set one (so a channel that set none shows none to
a subscriber). That's YouTube's behaviour, not a bug.
- Storyboard sheets only allow canvas use from youtube.com (a localhost page gets a CORS
error). The player's `getStoryboardFrame()` takes a frame index, not a time, and returns
bad sizes, so `youtube.storyboard()` works the tile out from the storyboard spec.
- Thumbnails load from `i.ytimg.com` with `crossOrigin='anonymous'` so canvases can stay
WebGL textures.
- Channel avatars and banners (`yt3.googleusercontent.com`), post pictures
(`yt3.ggpht.com`) and store product pictures (`encrypted-tbn*.gstatic.com`) also load
with CORS, but only for youtube.com: a localhost test page gets errors, so test pages
show placeholders there. Product pictures can be portrait (385 x 512), hence `fit: 'contain'`.
- Channel tabs, checked signed out (0.39, on two large channels; two channels can have very
similar handles, so check the handle):
- About: the header's description "more" token opens `aboutChannelViewModel`: the full
description, country, "Joined Mar 21, 2008", exact views / videos / subscribers, the
channel URL and the external links (title + URL).
- Store: the tab's shelves ("Just dropped", "Featured") repeat products (one channel: 9 cards,
7 products), so `store()` lists each once.
- Posts: 8-10 a page; pictures (one or a multi-image set), attached videos (with views,
age, duration), polls (`pollRenderer`: text choices and "32K votes"; signed out each
choice has a sign-in link; signed in, each carries its vote actions and its share,
verified in 0.40). "See results" is a real choice creators add, not a button.