2. Online Playback
2. Online Playback
Section titled “2. Online Playback”Where we left off
Section titled “Where we left off”Doc 1 established the mechanism: AES-128 encrypted segments, a key behind
kms.chorki.net, three HMAC-signed headers that unlock it, and one
convenient property of both native players — a single header set applies
to every request they make, key request included.
This doc follows the easy case end to end: a user with a network connection taps a poster and watches a film on their phone. It is the path where everything works without anyone doing anything clever, and it is worth understanding thoroughly, because docs 3 and 4 are both defined by which parts of it stop working.
There is no “get playback URL” call
Section titled “There is no “get playback URL” call”The first thing to unlearn. There is no /play, no /stream, no
/manifest endpoint. Grep the entire datasource layer for one and you
will find nothing.
The HLS master URL is a plain field on the content object, delivered by the same request that populates the content page you are already looking at:
GET https://api-static.chorki.net/v1/<route_uri>?country=..&platform=mobile │ ▼ { "result": { "data": { "title": "...", "url": "https://video.chorkicdn.com/transcoded/<id>/playlist.m3u8", "sprite": "...", "subtitles": [...], "introStart": 12, "introEnd": 95, ... } } } │ ▼ ContentModel.fromJson → ContentEntity.urlBy the time the play button is on screen, the app is already holding the URL. Tapping it costs zero network round trips.
The full chain
Section titled “The full chain” ┌──────────────────────────────────────────────────────────────────┐ │ A tap on any poster, anywhere in the app │ │ (section_widget, continue_watching, search, download screen …) │ └────────────────────────────┬─────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ CastAwareNavigation.play() │ │ │ │ One gate every play tap funnels through. Asks: is a TV already │ │ playing? If yes, send the content there instead and DON'T open │ │ the local player at all. If no, push the player route. │ └────────────────────────────┬─────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ VideoPlayerScreen → loadContent() → _buildMedia() │ └────────────────────────────┬─────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ buildPlayerMedia() → OSMediaModel │ │ │ │ url = content.url │ │ headers = EncryptionHelper().playerHeader() ◄── THE KEY BIT│ │ subtitles, sprites, initialSeekPosition, extras │ │ drmLicenseURL / drmCertificate = never set │ └────────────────────────────┬─────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ OSPlayerController.playNewMedia([media]) (ottstack_player) │ │ → _loadMediaEntry: await media.prepare?.(media) │ │ (the AirPlay hook — doc 4; null on the plain path) │ │ → kController.play(k.Media(url, headers, …)) │ └────────────────────────────┬─────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ kvideo, over a Pigeon bridge → native │ │ │ │ Android: DefaultHttpDataSource.Factory() │ │ .setDefaultRequestProperties(headers) │ │ wrapped in a CacheDataSource, fed to ExoPlayer │ │ │ │ iOS: AVURLAsset(url:, options: [ │ │ "AVURLAssetHTTPHeaderFieldsKey": headers ]) │ └────────────────────────────┬─────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ The native player now runs the whole HLS state machine itself: │ │ │ │ fetch master.m3u8 ─┐ │ │ pick a variant │ all with the three headers attached, │ │ fetch media.m3u8 │ because they were set once on the │ │ see #EXT-X-KEY │ data source / asset, not per request │ │ fetch the KEY ─┘ │ │ fetch segments, decrypt with the key, render │ └──────────────────────────────────────────────────────────────────┘Notice how little of this is app code. From kController.play() onward,
the app is a spectator: it never sees the manifest, never parses
#EXT-X-KEY, never holds the key bytes, and never touches a plaintext
frame. No Dart code in this repository decrypts video.
Two details worth knowing
Section titled “Two details worth knowing”The player is app-wide, not per-screen
Section titled “The player is app-wide, not per-screen”OSPlayerController is constructed exactly once, in main.dart, and
provided to the whole widget tree. VideoPlayerScreen reads it rather
than creating it.
That has a visible consequence: the muted trailer that autoplays on a
content page uses the same controller as the full-screen player. So the
content page has to explicitly stop its trailer before the player screen
loads real content, and the trailer builds its own OSMediaModel inline
instead of going through buildPlayerMedia() — it needs different extras
(isTrailer: true) but the same signed headers.
It is also why AirPlay needs an “ownership” flag: when the remote owns the player, the local screen must not pause it on backgrounding.
Headers are skipped when offline
Section titled “Headers are skipped when offline”headers: headers ?? (internet_available.value ? EncryptionHelper().playerHeader() : const {}),Two overrides are folded into that one line:
internet_available == false→ no headers. There is nothing to authenticate to; the URL will be a loopback address (doc 3).- an explicit
headersargument → used verbatim. This is how the AirPlay swap passesconst {}while pointing the URL at the LAN proxy, because once proxied, signing is the proxy’s job (doc 4).
What the CDN sees
Section titled “What the CDN sees”For a single film, from one phone:
video.chorkicdn.com ──► master.m3u8, media.m3u8, hundreds of .ts (all carrying referer / vpsid / platform)
kms.chorki.net ──► exactly one request, 16 bytes backOne key, fetched once, then reused for every segment in the title. That asymmetry — one tiny key versus gigabytes of segments — is what makes the proxy trick in doc 4 affordable.
Where this lives in the code
Section titled “Where this lives in the code”lib/presentation/widgets/player/cast/cast_aware_navigation.dart:36-81— the singleplay()gate. It checksactiveForPlayback(notactive) so that a merely-visible remote bar does not hijack a tap.lib/presentation/pages/content_pages/video_player_screen.dart—_buildMedia()(L361-381) chooses the URL and wires the AirPlaypreparehook;loadContent()(L410-429) callsplayNewMedia.lib/presentation/widgets/player/player_media_builder.dart:18-45— the onlyOSMediaModelfactory for real content, and the only place the signed headers meet the local player.lib/presentation/widgets/player/chorki_player_controller.dart— the entire localOSPlayerControllersubclass: one override, to skip the background auto-pause while a remote owns playback.lib/main.dart— where that controller is constructed and provided app-wide, with the seek and buffer configuration.lib/presentation/pages/content_pages/single_content_screen.dart:384-421— the trailer path, building its ownOSMediaModelwith the same headers.- Outside this repo (path dependencies under
~/myLab/FlutterProjects/Player/):ottstack_player/lib/controller/os_player_controller.dart:120-191forplayNewMedia/_loadMediaEntry;kVideo/android/.../PlayerController.kt:149-157forsetDefaultRequestProperties;kVideo/ios/kvideo/Sources/kvideo/PlayerController.swift:117-120forAVURLAssetHTTPHeaderFieldsKey.
The takeaway
Section titled “The takeaway”Online playback is three lines of interesting code — build a media model, attach three headers, hand it to the native player — because the platform does the rest. The URL is already in memory, the key request happens without the app knowing, and decryption is entirely native. Every complication in the next two docs comes from removing one of the two things this path takes for granted: the network (doc 3), or the ability to put a header on a request (doc 4).