Skip to content

feat(mediaplayer): split-stream, on-demand delivery, and optional page-URL resolution#871

Draft
towneh wants to merge 17 commits into
BasisVR:developerfrom
towneh:feat/mediaplayer-ytdlp-resolution
Draft

feat(mediaplayer): split-stream, on-demand delivery, and optional page-URL resolution#871
towneh wants to merge 17 commits into
BasisVR:developerfrom
towneh:feat/mediaplayer-ytdlp-resolution

Conversation

@towneh

@towneh towneh commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Several related additions to the media player, plus an optional integration package, that together let it play high-resolution adaptive sources (YouTube/Twitch and similar) — not just the live broadcast URLs it handles today.

1. Split-stream playback. Adaptive sources above ~360p serve video and audio as separate streams. basis_media_open_dual runs a second demux thread for an audio-only URL into the same decoder, so both present in sync on one clock. Exposed via BasisMediaSource.AudioUri (null = single muxed stream, unchanged).

2. Live vs on-demand, auto-detected. BasisMediaSource.Delivery (Auto/Live/OnDemand) selects the playback clock. Auto resolves at open: a finite, byte-range-seekable HTTP body (known Content-Length + Accept-Ranges) or an HLS playlist with EXT-X-ENDLIST is treated as on-demand; an open-ended response or a non-HTTP transport is live. On-demand throttles delivery and presents on a fixed 1× clock with a compressed read-ahead buffer, so VOD that arrives faster than real time no longer fast-forwards — and the caller doesn't have to flag it.

3. Optional page-URL resolution. BasisMediaUrlRouter is a small seam the player consults before loading a URL. BasisMediaPlayerStreaming routes its StreamUrl through it: a directly-playable URL (transport scheme, or an HTTP URL with a media extension) loads straight through; a page URL (no media extension) goes to an installed resolver. With no resolver installed, a page URL reports that the resolver package is needed instead of failing silently.

The new com.basis.integration.ytdlp package registers a yt-dlp-based resolver into that seam — turning a YouTube/Twitch page URL into the playable stream(s) (a split avc1+mp4a pair for VOD, a single HLS stream for live).

4. Smooth live HLS. Live HLS (Twitch and similar) is paced by the engine's per-AU clock rather than a byte-rate token bucket, so variable-bitrate streams stay smooth through busy scenes while still presenting at — and converging to — the live edge.

5. Multiplayer-aware sync. BasisMediaPlayerNetworking syncs the page URL, not the resolved CDN URL (which is per-client and expiring), so every client resolves it independently; direct stream URLs sync as before.

Why a new seam (BasisMediaUrlRouter)

No existing player hook carries "resolve this URL before loading it," and the player package deliberately can't reference the integration package (removability — deleting the integration leaves the player intact). Dependency inversion via a registration seam is the only way to let the existing StreamUrl field steer page URLs without coupling the player to the resolver. The seam isn't an orphan extension point: its consumer (BasisMediaPlayerStreaming.TryResolveAndLoad) ships in this PR; the integration registers a second consumer. IsDirectlyPlayable lives on the player as the single classifier, and the resolver defers to it so the live-vs-resolve rule has one home.

Scope / prerequisite

  • The dlp-native plugin (com.yewnyx.ytdlp) is intentionally not in this PR — how it enters the project is a separate vendoring decision, and it's a large binary package. The integration is gated #if BASIS_MEDIAPLAYER_EXISTS && YTDLP_EXISTS, so it compiles to nothing until that plugin is present. This PR is therefore safe to land without it — the resolver simply stays dormant until the plugin lands, then activates.
  • Windows backend. Split-stream and the auto-detect run on the Windows (Media Foundation) decode path. Other platform backends are follow-ups.

Known limitations (documented in the package READMEs)

  • Codec ceiling is H.264 + AAC, ~1080p — 4K YouTube is VP9/AV1-only, which the player can't decode, so resolution caps the chosen video at 1080p avc1.
  • Steering gap: a direct HTTP stream with no file extension can't be told apart from a page URL; with a resolver installed it's sent to the resolver and fails rather than loading directly. Use a media extension or a transport scheme to avoid it.
  • On-demand presents on a fixed internal buffer; BufferMilliseconds/BufferMode tune the live clock only.
  • Progressive muxed VOD (a single demux thread) can intermittently starve audio; HLS and split-stream playback are clean

Demo

End-to-end: loading a range of sources through the same BasisMediaPlayerStreaming URL field — VRCDN live streams (direct), plus Twitch and YouTube (resolved via the yt-dlp integration) — each resolving and playing.

basis_pr871_video.mp4

3981a9f landing also fixes the above video's observed stutter on the initial A State Of Trance test HLS stream.

basis_pr871_hls_stutter_fix.mp4

What I need help with confirming?

Speaking to @dooly123 we need some consensus on how dlp-native gets embedded in to the client either dynamically vs static package, which needs comments from the maintainer (@yewnyx).

By that I mean either static — vendoring com.yewnyx.ytdlp (the native plugin plus its bundled CPython runtime) into the repo as an embedded package, so it ships with the client and works on a plain checkout, at the cost of a sizable binary blob in git — or dynamic, keeping those binaries out of the tree and pulling them at build/install time, which keeps the repo lean but adds a fetch step and a version-pinning/provenance story. It's largely a repo-weight vs build-pipeline trade-off, so we're after your steer on how you'd prefer the package to be consumed.

To be clear though, this isn't a blocker and there's no pressure on @yewnyx to land a particular answer quickly. The integration package is asmdef-guarded (#if BASIS_MEDIAPLAYER_EXISTS && YTDLP_EXISTS), so with dlp-native absent it compiles to nothing and the URL handling just falls back to the media player directly: a directly-playable URL loads exactly as it does today, and a YouTube/Twitch page URL surfaces a clear BasisDebug message that a resolver package is needed rather than failing silently or breaking the load path. So this PR is safe to land as-is with the resolver dormant — the embedding decision can be taken on its own timeline, and nothing here regresses without it.

Required checks

All boxes below must be ticked before this PR can merge. If a check is genuinely N/A, tick it anyway and explain under Notes.

  • Tested — I built and ran this locally. The change works in the editor and (where relevant) in a built player.
  • Transform access is combined and limited — In hot paths, transform reads/writes go through TransformAccessArray or are otherwise batched. I have not added per-frame transform.position / transform.rotation / transform.localPosition calls inside loops. Whenever I need both position and rotation, I use the combined APIs — SetPositionAndRotation / SetLocalPositionAndRotation for writes, GetPositionAndRotation / GetLocalPositionAndRotation for reads — instead of two separate property accesses; the combined call does one local-to-world matrix traversal instead of two.
  • Addressables used for asset/memory loading — Any new asset loads go through Addressables. No new Resources.Load, no direct asset references that pull large content into memory on scene load.
  • No new GetComponent / AddComponent where avoidable — Where unavoidable, the result is cached on a field, and any GetComponent<T> is replaced with TryGetComponent<T>(out var x) — bare GetComponent will be denied. TryGetComponent is the modern API (Unity 2019.2+) and skips the Editor-only GC allocation GetComponent causes when a component is missing: Unity wraps the null return in a managed "fake null" object so its overloaded == operator can still detect destroyed C++ objects, and constructing that wrapper allocates; TryGetComponent returns a bool plus out parameter and never builds the wrapper. None of these calls run inside Update, LateUpdate, FixedUpdate, jobs, or other per-frame code paths.
  • Per-frame work is scheduled through BasisEventDriver — Any new per-frame work hooks into BasisEventDriver rather than adding standalone Update / LateUpdate / FixedUpdate callbacks on a MonoBehaviour.
  • Anything added to BasisEventDriver is bulletproof, or guarded by try/catchBasisEventDriver runs the single per-frame tick that drives the whole framework (network apply, local player sim, blendshapes, JigglePhysics, nameplates, and more) as one sequential chain. An unhandled exception anywhere in that chain aborts the rest of the tick, so every step after the throwing one is silently skipped for that frame. New work added to the driver must either be guaranteed not to throw, or be wrapped in a try/catch that contains the failure and surfaces it through BasisDebug — logged once / rate-limited, never every frame (see the existing HVRBasisBuiltInAddresses.Simulate() guard for the pattern). Expect this to be scrutinized closely in review.
  • Considered jobification — I asked whether this work can be moved to a Unity Job (Burst-compiled where possible). If it can, it is. If it cannot, the reason is in Notes.
  • No needless { get; set; } properties or access lockdowns — Public fields are fine; Basis is meant to be read and modified freely, so don't wall things off private/internal without a real reason. Don't wrap a field in { get; set; } when the accessors do nothing — property accessors have a real performance cost vs direct field access, and the lead maintainer prefers plain fields (or a method / setter-only property when only the setter needs logic) over a noop-getter pair. For .Instance singletons, callers reassigning Type.Instance is allowed; if that would break your code, log a warning or throw — don't block the assignment. Locking down access is not your call.
  • Camera access goes through BasisLocalCameraDriver — Code that needs the local camera (transform, projection, rig data, etc.) pulls it from BasisLocalCameraDriver rather than looking one up itself. Don't roll a separate camera discovery path.
  • Logging uses BasisDebug — All new logging calls go through BasisDebug.Log / BasisDebug.LogWarning / BasisDebug.LogError (with an appropriate LogTag) instead of UnityEngine.Debug.Log / Debug.LogWarning / Debug.LogError. BasisDebug routes through Basis's tagged, color-coded logger and respects the project-wide LoggingDisabled toggle so logging can be killed at runtime; bare Debug.Log calls bypass that and will be denied.
  • No scene-wide discovery for dependencies — New code is architected so it does not need FindObjectOfType / FindObjectsOfType / GameObject.Find / FindGameObjectsWithTag to locate what it depends on. References are wired in — registered through an existing manager/driver, injected at init, or passed in by the caller — rather than discovered by scanning the scene at runtime. If a scene scan is genuinely unavoidable, justify it under Notes.
  • No allocations in hot paths — Per-frame code (Update / LateUpdate / FixedUpdate, simulation loops, jobs, anything called once per frame or more) does not allocate. No new on reference types, no LINQ, no string concatenation/interpolation, no boxing, no foreach over interface-typed collections. Allocate once at init and reuse the buffer.
  • No debugging in hot paths — No log calls of any kind on per-frame paths, including BasisDebug. Hot-path logging floods the console and incurs cost on every frame regardless of whether the message is filtered out downstream. If a hot-path log is needed while iterating, gate it behind #if UNITY_EDITOR and remove (or leave gated) before merge.
  • Hot-path collection access is optimized — Cache .Count (lists) / .Length (arrays) into a local int before the loop instead of re-reading the property each iteration. Prefer T[] (with a separate length int when the array is over-sized) over List<T> where the data is hot — Unity's mono BCL doesn't expose CollectionsMarshal.AsSpan(List<T>), so a list can't be fed into Span<T> / unsafe paths cleanly. Where the perf justifies it, drop into Span<T> / ref locals / Unsafe.As / unsafe pointer code to skip bounds checks and copies, and call out the invariants you're relying on under Notes so reviewers can sanity-check them.

Testing details

Tick the platforms you actually tested on. Leave the rest unticked — these are informational and do not block merge.

  • Windows
  • Linux
  • Android
  • iOS
  • macOS

Input / control mode coverage:

  • Tested in VR (note headset under Notes)
  • Tested in desktop / non-VR mode
  • Tested with phone controls (mobile touch input)
  • N/A — change does not touch player/XR/input code

Where applicable, confirm these flows still work after your changes:

  • Hot-switching (desktop ↔ VR mode swap at runtime)
  • Avatar swapping
  • Server swapping (joining / leaving / changing servers)
  • N/A — change does not touch any of the above

Notes

What "Tested" covers (Windows): In the editor — direct stream playback (RTSPT / fMP4 / TS / HLS) unchanged; a split avc1+mp4a VOD source playing in sync and paced (no fast-forward); auto-detect classifying a seekable HTTP body as on-demand and a live HLS stream as live; the StreamUrl field resolving real YouTube and Twitch links (and direct URLs bypassing the resolver); and the clear "resolver package needed" message when no resolver is installed. In a built player — live Twitch HLS playing smoothly at 1080p60 under the per-AU pacing, and the synced page URL resolving independently across two networked clients.

Most checklist rows are N/A for this change — it's load-path / native decode / integration code, with no per-frame gameplay work:

  • No transform access, camera access, Addressables loads, or scene-wide discovery anywhere in the change.
  • No new per-frame work, so nothing is added to BasisEventDriver and there are no hot paths — the "hot path" allocation/logging/collection rows don't apply. (The one cold-path delimiter array in IsDirectlyPlayable is hoisted to a static readonly field regardless.)
  • No new GetComponent; BasisMediaPlayerStreaming uses the existing TryGetComponent.
  • Jobification N/A — the work is socket I/O plus OS hardware decode on native threads, not parallelisable compute.
  • Public fields (Delivery, BasisMediaUrlRouter.Resolver) are plain fields per house style; the resolver delegate is freely reassignable.

Prerequisite: the yt-dlp resolver only activates once the com.yewnyx.ytdlp plugin is present (separate PR). Until then this change is inert on that path and direct-URL playback is unaffected.

Draft status is about the dlp-native embedding decision above, not the player work: split-stream, VOD pacing, live-HLS pacing, page-URL resolution and the multiplayer page-URL sync are all validated in the editor and in a built player (incl. live Twitch and two-client sync). The integration stays inert until the plugin lands.

towneh added 9 commits June 14, 2026 13:14
… streams

Play a video-only stream and a separate audio-only stream in sync on one
decoder and clock, for sources that deliver A/V as distinct streams (e.g.
adaptive YouTube above ~360p: H.264 video-only + AAC audio-only).

Native: basis_media_open_dual(video_url, audio_url) runs a second demux thread
for the audio-only URL into the shared decoder. The protocol path is
parameterised per pipeline so the second leg isn't hardcoded as "audio", and
state/errors route through the sink so a subordinate leg suppresses them. A
null/empty audio URL is exactly basis_media_open, so the single-stream path is
unchanged.

C#: BasisMediaSource gains AudioUri (+ AudioHeaders). The audio URL passes the
same trust gate as the video URL and threads through to a new OpenWithAudio
P/Invoke, which falls back to basis_media_open when no audio stream is set.
…tion

Add a paced mode for on-demand sources that arrive faster than real time (e.g.
CDN-delivered split streams), threaded as BasisMediaSource.Paced ->
basis_media_open_dual(video, audio, paced). When set, the engine throttles
delivery to ~1x and presents on a smooth clock held a fixed buffer behind the
decode edge, rather than the live-edge clock that fast-forwards on bursty or
faster-than-real-time delivery; a paced source also plays once and stops at
end-of-file instead of looping. The live path (RTSP/RTMP/RIST/live HLS) is
unchanged.

Also select the HTTP demuxer by sniffing the leading bytes (ISO-BMFF box vs
MPEG-TS sync) instead of the URL extension, so extensionless CDN URLs that
deliver fragmented MP4 with no .mp4 in the path demux correctly rather than
falling through to the MPEG-TS demuxer.
…d playback

SelectSource picks the best H.264 video-only (<=1080p) + AAC audio-only formats
and returns a split BasisMediaSource (Uri + AudioUri) with Paced = true, since
adaptive YouTube above ~360p is delivered as separate streams faster than real
time. Falls back to a single muxed/HLS stream (Twitch, live YouTube, progressive
~360p) when no split pair is available.
Paced HTTP sources now drain the network into a compressed byte ring on a
dedicated reader thread, while the demuxer consumes from the ring at the paced
1x rate. This decouples delivery from decode: bursty CDN delivery (e.g.
googlevideo ships ~2.5s chunks with ~1s gaps) is banked in the ring instead of
starving the decoder, which previously paused-and-caught-up every few seconds.
Compressed bytes are cheap (a 16MB ring holds many seconds), unlike the
VRAM-bound decode ring. Read-ahead is paced-mode only; live sources read
directly with no added latency.
BasisMediaUrlRouter lets an optional package (e.g. a yt-dlp-based resolver)
install a URL resolver the player consults before loading, without the player
core depending on it. IsDirectlyPlayable is the single classifier for whether
the engine can open a URL directly (a transport scheme, or an http(s) URL whose
path ends in a media-container extension) versus a page URL that needs
resolving. It only steers; it never blocks. No consumer yet — wired next.
… URLs

BasisMediaSource.Delivery (Auto/Live/OnDemand) selects the playback clock, and
basis_media_open_dual's third argument carries it as a hint. Auto resolves at
open: the engine treats a finite, byte-range-seekable HTTP body (known
Content-Length + Accept-Ranges) or an HLS playlist with EXT-X-ENDLIST as
on-demand and paces it; an open-ended HTTP response or a non-HTTP transport is
live. A VOD source that arrives faster than real time is paced rather than
fast-forwarding, without the caller having to flag it.

BasisMediaPlayerStreaming routes its URL through BasisMediaUrlRouter: a page URL
(no media extension) goes to an installed resolver, a direct stream loads
straight through, and with no resolver installed a page URL reports that the
resolver package is needed instead of failing silently.

The yt-dlp integration registers its resolver into the seam and requests
on-demand for the split adaptive pairs it selects; NeedsResolution defers to the
player's IsDirectlyPlayable so the classification has one home.

Includes the rebuilt Windows plugin.
…ution

Bring the media-player README up to date: live vs on-demand and the Delivery
field, split-stream playback via AudioUri, and the optional page-URL resolver
seam (with the extensionless-HTTP steering gap called out). Add a README to the
yt-dlp integration package covering what it resolves, requirements, and that
removing it drops common-site resolution while direct streams keep working.
@towneh towneh added the enhancement New feature or request label Jun 14, 2026
towneh added 2 commits June 14, 2026 22:00
LoadUrl now consults BasisMediaUrlRouter before loading, so every entry point —
the in-game UI, BasisMediaPlayerNetworking, and the streaming example — steers a
page URL (YouTube/Twitch/…) through an installed resolver, and loads a directly
playable URL straight through. With no resolver installed, a page URL reports
that rather than silently failing to demux an HTML page. LoadSource stays
unrouted (it receives already-resolved or direct sources, including the
resolver's own output). Centralising here lets BasisMediaPlayerStreaming drop
its own routing call.
…orked playback

Networked playback now broadcasts the URL that was set — the page URL for
resolved sources, or the direct stream URL — instead of the resolved CDN URL.
Resolved URLs (e.g. googlevideo) are per-client and expiring, so they can't be
shared: each client resolves the page URL itself. Remote clients apply a page
URL via LoadUrl (routing through the resolver) and a directly-playable URL via
LoadSource with the synced seek position as before. currentSyncedUrl is only
back-filled from the active source when nothing was set explicitly. Seek/pause
fidelity for resolved sources is best-effort.
Adds pace_delivery, decoupling "throttle AU delivery" from "present on the paced
VOD clock". Live HLS now sets pace_delivery — so the engine's PTS pace_gate meters
delivery, tracking VBR exactly and recovering from stalls via its wall-clock anchor
— while keeping paced=0, so it still presents at and converges to the live edge.
The HLS source's byte-rate token bucket is disabled; pace_gate replaces it. VOD
keeps both (paced delivery + paced clock); RTSP/RIST and open-ended live HTTP are
unaffected.

Verified on a live Twitch stream (VBR HLS via yt-dlp) at 1080p60. Includes the
rebuilt Windows plugin.
dooly123 added a commit that referenced this pull request Jun 15, 2026
… playback (#872)

## Summary

**Fixes stuttering on HLS streams** (Twitch and similar — the
single-stream, non-split path). On variable-bitrate video you'd get
occasional audio dropouts and uneven playback.

The HLS source controls how fast it feeds the decoder, which drops
frames if fed faster than real time. It was pacing to the stream's
**average** bitrate — but live video is variable-bitrate, so during
busier scenes (which run above average) it fed too slowly, the buffers
ran dry, and playback hitched.

The fix paces to the **current segment's** bitrate instead of a
stream-wide average, so each part of the stream is fed at the rate it
actually needs and busy scenes no longer starve playback. Self-contained
to the HLS source (`protocol/basis_hls.c`) — no engine or clock changes.

### Follow-up

This paces by byte count at segment granularity — a big improvement, but
not yet frame-exact. The frame-timestamp-exact version (sub-segment) is
built on #871, which carries the engine's pacing machinery, and will
supersede this once that lands. This stands on its own until then.

## Required checks
All boxes below must be ticked before this PR can merge. If a check is
genuinely N/A, tick it anyway and explain under **Notes**.

<!-- required-checks-start -->
<!-- Tick the boxes in place — do not edit the line text. The
pr-checklist workflow parses this block; per-PR context goes under
Notes. -->
- [x] **Tested** — I built and ran this locally. The change works in the
editor and (where relevant) in a built player.
- [x] **Transform access is combined and limited** — In hot paths,
transform reads/writes go through `TransformAccessArray` or are
otherwise batched. I have not added per-frame `transform.position` /
`transform.rotation` / `transform.localPosition` calls inside loops.
Whenever I need both position and rotation, I use the combined APIs —
`SetPositionAndRotation` / `SetLocalPositionAndRotation` for writes,
`GetPositionAndRotation` / `GetLocalPositionAndRotation` for reads —
instead of two separate property accesses; the combined call does one
local-to-world matrix traversal instead of two.
- [x] **Addressables used for asset/memory loading** — Any new asset
loads go through Addressables. No new `Resources.Load`, no direct asset
references that pull large content into memory on scene load.
- [x] **No new `GetComponent` / `AddComponent` where avoidable** — Where
unavoidable, the result is cached on a field, and any `GetComponent<T>`
is replaced with `TryGetComponent<T>(out var x)` — bare `GetComponent`
will be denied. `TryGetComponent` is the modern API (Unity 2019.2+) and
skips the Editor-only GC allocation `GetComponent` causes when a
component is missing: Unity wraps the `null` return in a managed "fake
null" object so its overloaded `==` operator can still detect destroyed
C++ objects, and constructing that wrapper allocates; `TryGetComponent`
returns a `bool` plus `out` parameter and never builds the wrapper. None
of these calls run inside `Update`, `LateUpdate`, `FixedUpdate`, jobs,
or other per-frame code paths.
- [x] **Per-frame work is scheduled through `BasisEventDriver`** — Any
new per-frame work hooks into `BasisEventDriver` rather than adding
standalone `Update` / `LateUpdate` / `FixedUpdate` callbacks on a
MonoBehaviour.
- [x] **Anything added to `BasisEventDriver` is bulletproof, or guarded
by `try`/`catch`** — `BasisEventDriver` runs the single per-frame tick
that drives the whole framework (network apply, local player sim,
blendshapes, JigglePhysics, nameplates, and more) as one sequential
chain. An unhandled exception anywhere in that chain aborts the rest of
the tick, so every step after the throwing one is silently skipped for
that frame. New work added to the driver must either be guaranteed not
to throw, or be wrapped in a `try`/`catch` that contains the failure and
surfaces it through `BasisDebug` — logged once / rate-limited, never
every frame (see the existing `HVRBasisBuiltInAddresses.Simulate()`
guard for the pattern). Expect this to be scrutinized closely in review.
- [x] **Considered jobification** — I asked whether this work can be
moved to a Unity Job (Burst-compiled where possible). If it can, it is.
If it cannot, the reason is in **Notes**.
- [x] **No needless `{ get; set; }` properties or access lockdowns** —
Public fields are fine; Basis is meant to be read and modified freely,
so don't wall things off `private`/`internal` without a real reason.
Don't wrap a field in `{ get; set; }` when the accessors do nothing —
property accessors have a real performance cost vs direct field access,
and the lead maintainer prefers plain fields (or a method / setter-only
property when only the setter needs logic) over a noop-getter pair. For
`.Instance` singletons, callers reassigning `Type.Instance` is allowed;
if that would break your code, log a warning or throw — don't block the
assignment. Locking down access is not your call.
- [x] **Camera access goes through `BasisLocalCameraDriver`** — Code
that needs the local camera (transform, projection, rig data, etc.)
pulls it from `BasisLocalCameraDriver` rather than looking one up
itself. Don't roll a separate camera discovery path.
- [x] **Logging uses `BasisDebug`** — All new logging calls go through
`BasisDebug.Log` / `BasisDebug.LogWarning` / `BasisDebug.LogError` (with
an appropriate `LogTag`) instead of `UnityEngine.Debug.Log` /
`Debug.LogWarning` / `Debug.LogError`. `BasisDebug` routes through
Basis's tagged, color-coded logger and respects the project-wide
`LoggingDisabled` toggle so logging can be killed at runtime; bare
`Debug.Log` calls bypass that and will be denied.
- [x] **No scene-wide discovery for dependencies** — New code is
architected so it does not need `FindObjectOfType` / `FindObjectsOfType`
/ `GameObject.Find` / `FindGameObjectsWithTag` to locate what it depends
on. References are wired in — registered through an existing
manager/driver, injected at init, or passed in by the caller — rather
than discovered by scanning the scene at runtime. If a scene scan is
genuinely unavoidable, justify it under **Notes**.
- [x] **No allocations in hot paths** — Per-frame code (Update /
LateUpdate / FixedUpdate, simulation loops, jobs, anything called once
per frame or more) does not allocate. No `new` on reference types, no
LINQ, no `string` concatenation/interpolation, no boxing, no `foreach`
over interface-typed collections. Allocate once at init and reuse the
buffer.
- [x] **No debugging in hot paths** — No log calls of any kind on
per-frame paths, including `BasisDebug`. Hot-path logging floods the
console and incurs cost on every frame regardless of whether the message
is filtered out downstream. If a hot-path log is needed while iterating,
gate it behind `#if UNITY_EDITOR` and remove (or leave gated) before
merge.
- [x] **Hot-path collection access is optimized** — Cache `.Count`
(lists) / `.Length` (arrays) into a local `int` before the loop instead
of re-reading the property each iteration. Prefer `T[]` (with a separate
length int when the array is over-sized) over `List<T>` where the data
is hot — Unity's mono BCL doesn't expose
`CollectionsMarshal.AsSpan(List<T>)`, so a list can't be fed into
`Span<T>` / unsafe paths cleanly. Where the perf justifies it, drop into
`Span<T>` / `ref` locals / `Unsafe.As` / `unsafe` pointer code to skip
bounds checks and copies, and call out the invariants you're relying on
under **Notes** so reviewers can sanity-check them.
<!-- required-checks-end -->

## Testing details
Tick the platforms you actually tested on. Leave the rest unticked —
these are informational and do not block merge.

- [x] Windows
- [ ] Linux
- [ ] Android
- [ ] iOS
- [ ] macOS

Input / control mode coverage:

- [ ] Tested in VR (note headset under **Notes**)
- [x] Tested in desktop / non-VR mode
- [ ] Tested with phone controls (mobile touch input)
- [ ] N/A — change does not touch player/XR/input code

Where applicable, confirm these flows still work after your changes:

- [ ] Hot-switching (desktop ↔ VR mode swap at runtime)
- [ ] Avatar swapping
- [ ] Server swapping (joining / leaving / changing servers)
- [x] N/A — change does not touch any of the above

## Notes

**Tested (Windows, desktop):** a live VBR HLS stream that previously
stuttered (occasional audio dropouts + uneven video) now plays smoothly
with the per-segment metering.

**The checklist rows are N/A** — this is a single native-C change in
`protocol/basis_hls.c` (the HLS byte source's pacing), with no
managed/Unity code, no per-frame work, no
transform/camera/Addressables/scene-discovery, and no new allocations on
any per-frame path. Jobification N/A — it's socket I/O pacing on the
existing HLS producer/consumer threads.
@towneh towneh requested review from dooly123 and yewnyx June 15, 2026 00:14
The byte-rate token bucket was disabled when HLS moved to engine AU-pacing; this
removes everything it left behind — the struct fields (target_bps, acc_bytes/
acc_dur_ms, tb_tokens/tb_last_us, the per-segment counters), the unused hls_now_us
helper, and basis_hls_read's vestigial budget variable, always-true guard and stale
"rate-limited" comment. No behaviour change.
@towneh towneh force-pushed the feat/mediaplayer-ytdlp-resolution branch from 0d9853b to f7d2528 Compare June 15, 2026 00:21
towneh added 4 commits June 15, 2026 01:28
Resolved basis_hls.c + the plugin to ours: this branch's engine-level AU pacing
supersedes the byte-rate per-segment metering that landed on developer via BasisVR#872,
so the HLS source carries no byte-level metering here.
…esolved stream

The playback panel's refresh mirrored the player's resolved ActiveMediaSource.Uri
back into the editable URL field. After a page URL (Twitch/YouTube) resolved, the
field showed the per-client, expiring CDN URL — so pressing Load URL again called
SetUrl with that resolved URL and broadcast it to every peer, defeating the
page-URL sync (peers must resolve their own stream).

Mirror the networked SyncedUrl (the input/page URL) when a networking component is
present, falling back to ActiveMediaSource.Uri only for solo playback. Re-loading
now re-shares the page URL.
… sync

Spell out that an extensionless direct HTTP stream sent to the resolver fails
with an error (no extractable stream) rather than loading. Add a Networked sync
section: the input/page URL is synced (each client resolves its own stream),
along with play/pause/stop and drift-corrected position, and note that a
remote-applied resolved source auto-plays from the live edge rather than
applying the owner's exact position or initial pause/stop state.
Two related improvements so peers watching a resolved (YouTube/Twitch) source stay
aligned with the owner:

- Broadcast a page URL up front (in SetUrl) instead of only after the owner's OnReady,
  so peers resolve it in parallel rather than starting their seconds-long resolution
  only once the owner is already playing. Scoped to page URLs; directly-playable URLs
  keep the OnReady-gated path (no resolution latency to hide, and it avoids announcing a
  load before it is confirmed openable).

- Add an owner-side position heartbeat: a small Sequenced (unreliable, latest-wins)
  position ping every few seconds feeds the existing drift correction, pulling any client
  that fell behind back onto the owner's clock and converging late joiners. Drift-only —
  it never changes play/pause/stop, so a stale or out-of-order ping cannot resurrect a
  stopped state. VOD-only (live reports no position and converges to the edge); the 9-byte
  payload keeps fan-out cheap in large instances.

Update the README networked-sync section to match.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant