Function
The lesson's video URL: a project-hosted file, or a YouTube link in any of its forms
It replaces the bare <video controls> this project shipped in v1. The
reason is not the chrome — it is that a native player's controls live in a
closed shadow root, so anything drawn over them is at the browser's mercy in
both z-order and hit-testing. Here the provider, the layout, and any overlay
are siblings in a tree we own.
The overlay is a children slot, not a prop, precisely so it composes
that way: it lands inside the player element, sharing its positioning
context, and the caller styles it against the player box without this
component knowing what the overlay is or when it shows.
ariaLabel is passed through rather than left to Vidstack. The library
otherwise builds "Video Player - <title>" in English, which would be the
one control name on the page that no locale file can reach.
The src is derived from source, not passed through. A lesson's video
is either a file this project hosts or a video published on YouTube, and the
two need different providers. youtubeVideoIdFrom decides which; a YouTube
link becomes Vidstack's youtube/<id> provider form, everything else stays a
direct video/mp4 source. Declaring type: "video/mp4" for a YouTube link —
which is what every lesson used to get — makes the provider read a watch page
as a byte stream and the lesson shows a dead player.
Routing YouTube through the library's provider rather than a hand-written
<iframe> is what keeps the rest of this file true for both kinds of lesson.
The provider drives a YouTube iframe through its IFrame API and exposes it
behind the same MediaPlayerInstance, so currentTime, seekTo and the
media events keep working — which is what the resume overlay and the position
persistence are built on. A bare embed would strand both: its start=
parameter can say where to begin, but nothing can read back where the learner
stopped.
Fullscreen stays the browser's wherever the browser has it. The layout's
own fullscreen button is left in place and a fallback is added after it, so
a browser that can take the player fullscreen behaves exactly as it did
before this control existed. The fallback exists for the case the library
cannot serve: Safari on iPhone exposes no element Fullscreen API, a
YouTube-sourced lesson has no <video> for webkitEnterFullscreen, and the
library hides a button it cannot support, which left an iPhone learner with
no way to enlarge a lesson at all. Exactly one of the two ever paints —
VideoEnlargeButton renders nothing while canFullscreen is true.
Enlarged, the player is pinned to the viewport as the largest 16:9 box that
fits, over a black backdrop, rather than stretched to the viewport's own
shape. That is forced by the YouTube provider: the embed lays its video out
against the iframe's width and the player shows only the middle band, so a
band shorter than width × 9/16 — which is what a landscape viewport would
give — crops the video top and bottom.
The browser's own fullscreen has the same trap and gets the same answer one level down. There the browser makes the player the screen, so on a screen wider than 16:9 — an Android phone in landscape — the stylesheet bounds the provider instead: the video is the largest 16:9 band, centred, while the controls and gestures keep the whole screen.
On an iPhone the viewport itself is the ceiling: Safari's toolbar takes 110
of the 402 landscape points and hides only for a real swipe on the document,
which is why the mode never locks the page's scroll — the swipe has to travel
through the pinned player to the page beneath. While that toolbar is still on
screen a ScrollDownHint is drawn along the top of the box, asking the
learner to move the video upward — the gesture, not the page's answering
scroll, because the backdrop hides the page. useBrowserChromeVisible
decides when, from what the page can measure, and the hint is gone the moment
the viewport reaches the screen's short side.
A single tap on the video means what the pointer's convention says.
The Default Layout's own gestures are switched off (noGestures) and
PlaybackGestures supplies the set, because the layout picks a tap's
meaning with a media query in its stylesheet and this Player needs its own
seek run and speed hold beside it. With a mouse a click toggles playback;
with a finger a tap brings the control bar in or out, as every video app on
a phone does, and only a play/pause control pauses.
That leaves one trap to answer. Safari on iPhone gets YouTube's mobile
skin, which draws a centred play/pause icon through this chrome even with
the embed's controls disabled; the provider's blocker keeps every tap from
reaching it (rightly — the same overlay carries links out of the lesson);
and the icon cannot be hidden from outside a cross-origin frame. The
compact chrome covers it with a centre button of its own, but the full
chrome — the one this Player wears enlarged in landscape — has none, so
VideoCenterPlayButton draws one over the icon while the controls are in
view. A double tap on an edge starts a seek run with an on-screen count, the
YouTube app's convention — the helper's own JSDoc says how the library's
gesture and the run share it.
A press held on the video runs the lesson at double speed, that app's
other thumb convention, and restores the learner's own rate when the finger
lifts. The play/pause key carries the same gesture — useSpeedHold says how
it is taken from the library, which acts on that key's keydown and would
otherwise pause the lesson half a second before a hold could arm. The library has no hold event, so the press is timed against the
player element; PlaybackGestures and useSpeedHold carry the reasoning,
including why a press that drifts is treated as the swipe that hides
Safari's toolbar rather than as a hold.
How far that seek reaches is the learner's to set, from SeekStepMenu
in the layout's settingsMenuItemsEnd slot. It rides the library's gear
menu rather than a control of this app's own precisely because that menu is
already placed, keyboard-navigable and touch-sized in both layouts and
inside the pinned player; the component's own JSDoc has the rest.
The element is never portalled. Moving the player in the tree would remount
the provider <iframe>, reloading the embed and resetting currentTime
under the resume overlay and the position writes; a class costs none of that.
The buffering indicator is the Player's own, in the layout's
bufferingIndicator slot. The Default Layout's ring is hollow, and a
YouTube-sourced lesson's embed paints its own spinner inside it at the very
same moments — the player's waiting is derived from the embed's Buffering
state — so VideoBufferingIndicator draws the same ring over an opaque
core that hides it. For the same reason the centre play/pause control, in
both chromes, is an opaque disc larger than the icon the embed paints under
it; lesson-video-player.css holds that geometry once.
Four of the player's defaults are wrong for this app and are overridden here rather than worked around by callers:
Poster of its own, so a self-hosted lesson with a perfectly
good thumbnail would show a black idle frame. It belongs inside
<MediaProvider>, which is the outlet the provider paints behind the
video. It is rendered whether or not the lesson has a poster, because
the element resolves its own picture — the lesson's, else the thumbnail
the provider discovers — and hides itself when there is neither. For a
YouTube lesson that is not a nicety: the embed paints a red play button
over its thumbnail until the first play, and this element, painted over
the frame until frames roll, is the one thing that hides it.system; this app has its own toggle that ignores the OS, so the chrome
would sit in light mode inside a dark page.isCompactChrome holds the threshold, so the
centre play control asks the same question the layout does.Captions, chapters, thumbnails, quality menus, and Cast are all available from this layout and none are wired up here; adding one is a change to this file, not a change of player.
The lesson player: a Vidstack
<MediaPlayer>with the Default Video Layout.