From a4c1b42bbdbf925cfc5c17a79e84159a005e489a Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Sun, 31 May 2026 23:08:20 +0100 Subject: [PATCH 1/2] feat(mediaplayer): HLS / Low-Latency HLS playback (Windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an HLS source layered on top of the existing OS-codec media player. It parses the M3U8, selects a single rendition, starts near the live edge, and stitches segments (and LL-HLS EXT-X-PART parts) into one continuous byte stream that the existing MPEG-TS / fragmented-MP4 demuxers consume. A background reader paces delivery to the stream's measured average bitrate so the wall-clock present path is fed at real time (not flooded), with enough burst to deliver segment-start keyframes promptly. Scope: Windows (WinHTTP fetch), clear streams, single rendition. Android/Quest support is planned. The existing RTSP and MPEG-TS implementations are NOT touched — basis_rtsp.c, basis_ts.c (and basis_rtmp.c, basis_mp4.c, basis_http.c, basis_url.c, the Windows decode/HTTP backends) have zero source edits. basis_media_core.c only gains an additive ".m3u8" branch ahead of the plain byte-source path; every other URL (rtsp/rtmp/.ts/.mp4) takes the identical existing route. No C# changes. - protocol/basis_hls.c/.h: new HLS source (M3U8 parse, segment/part scheduler, paced read-ahead buffer) - basis_media_core.c: route .m3u8 to the HLS source, feeding basis_ts_run / basis_mp4_run unchanged - CMakeLists.txt: add basis_hls.c to the portable core - README.md: document HLS support and the Windows / clear / single-rendition scope --- .../Native~/CMakeLists.txt | 1 + .../Native~/basis_media_core.c | 34 + .../Native~/protocol/basis_hls.c | 748 ++++++++++++++++++ .../Native~/protocol/basis_hls.h | 58 ++ .../Packages/com.basis.mediaplayer/README.md | 15 + 5 files changed, 856 insertions(+) create mode 100644 Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c create mode 100644 Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.h diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/CMakeLists.txt b/Basis/Packages/com.basis.mediaplayer/Native~/CMakeLists.txt index 19242e0d3..70ef4e6ac 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/CMakeLists.txt +++ b/Basis/Packages/com.basis.mediaplayer/Native~/CMakeLists.txt @@ -67,6 +67,7 @@ set(CORE_SOURCES protocol/basis_rtmp.c protocol/basis_ts.c protocol/basis_mp4.c + protocol/basis_hls.c ) # --------------------------------------------------------------------------- diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c b/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c index a2dcbe501..66594198b 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c @@ -21,6 +21,7 @@ #include "protocol/basis_ts.h" #include "protocol/basis_mp4.h" #include "protocol/basis_http.h" +#include "protocol/basis_hls.h" #include #include @@ -192,6 +193,32 @@ static int ends_with_ci(const char* s, const char* suffix) { return 1; } +/* HLS / LL-HLS: the URL is a playlist, not a continuous byte stream. The HLS + * source fetches+parses the M3U8, stitches segments (and LL-HLS parts) into one + * byte stream, and the existing TS/fMP4 demuxers consume it. Windows fetches via + * WinHTTP; Android/Quest support is planned. */ +static void run_hls(basis_media_engine_t* e) { +#if defined(_WIN32) + basis_http_provider_t provider = { + basis_win_http_open, basis_win_http_read, basis_win_http_close + }; + int is_fmp4 = 0; + void* hls = basis_hls_open(e->url, &provider, e->sink.is_running, e->sink.user, &is_fmp4); + if (!hls) { + basis_engine_set_error(e, "failed to open HLS playlist"); + return; + } + basis_engine_set_state(e, BASIS_MEDIA_STATE_BUFFERING); + if (is_fmp4) + basis_mp4_run(&e->sink, basis_hls_read, hls); + else + basis_ts_run(&e->sink, basis_hls_read, hls); + basis_hls_close(hls); +#else + basis_engine_set_error(e, "HLS playback currently requires the Windows backend."); +#endif +} + static void run_http_like(basis_media_engine_t* e) { /* Android: the OS extractor can demux the URL itself (TLS included). */ if (basis_decoder_try_open_url(e->decoder, e->url)) { @@ -200,6 +227,13 @@ static void run_http_like(basis_media_engine_t* e) { return; } + /* HLS playlists are not a single continuous stream — hand off to the HLS + * source before the plain TS/fMP4 byte-source path. (.m3u8 may carry a query.) */ + if (strstr(e->parts.path, ".m3u8")) { + run_hls(e); + return; + } + void* src = NULL; basis_read_fn rd = NULL; diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c new file mode 100644 index 000000000..c63e431e2 --- /dev/null +++ b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c @@ -0,0 +1,748 @@ +/* basis_hls.c — HLS / Low-Latency HLS source. See basis_hls.h for the contract. + * + * Model: parse the M3U8, select one rendition, and present the live media as a + * single continuous byte stream by stitching segments (and, for LL-HLS, parts) + * through a basis_read_fn that basis_ts_run / basis_mp4_run consume unchanged. + * + * This pass: clear streams, single rendition, Windows fetch. Android/Quest + * support is planned (it needs a non-Windows http provider). */ + +#include "basis_hls.h" + +#include +#include +#include + +#if defined(_WIN32) + #include +#else + #include + #include +#endif + +static void hls_sleep_ms(int ms) { +#if defined(_WIN32) + Sleep((DWORD)ms); +#else + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (long)(ms % 1000) * 1000000L; + nanosleep(&ts, NULL); +#endif +} + +/* ---- portable mutex / thread (read-ahead producer) ---------------------- */ +#if defined(_WIN32) +typedef CRITICAL_SECTION hls_mutex_t; +typedef HANDLE hls_thread_t; +static void hls_mutex_init(hls_mutex_t* m) { InitializeCriticalSection(m); } +static void hls_mutex_destroy(hls_mutex_t* m) { DeleteCriticalSection(m); } +static void hls_mutex_lock(hls_mutex_t* m) { EnterCriticalSection(m); } +static void hls_mutex_unlock(hls_mutex_t* m) { LeaveCriticalSection(m); } +#else +typedef pthread_mutex_t hls_mutex_t; +typedef pthread_t hls_thread_t; +static void hls_mutex_init(hls_mutex_t* m) { pthread_mutex_init(m, NULL); } +static void hls_mutex_destroy(hls_mutex_t* m) { pthread_mutex_destroy(m); } +static void hls_mutex_lock(hls_mutex_t* m) { pthread_mutex_lock(m); } +static void hls_mutex_unlock(hls_mutex_t* m) { pthread_mutex_unlock(m); } +#endif + +static int64_t hls_now_us(void) { +#if defined(_WIN32) + static LARGE_INTEGER freq; static int inited = 0; + if (!inited) { QueryPerformanceFrequency(&freq); inited = 1; } + LARGE_INTEGER c; QueryPerformanceCounter(&c); + int64_t f = freq.QuadPart ? freq.QuadPart : 1; + /* split to avoid int64 overflow of counter*1e6 at large uptime (~256h @10MHz) */ + return (c.QuadPart / f) * 1000000LL + (c.QuadPart % f) * 1000000LL / f; +#else + struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000000LL + ts.tv_nsec / 1000; +#endif +} + +#define HLS_MAX_URI 1024 +#define HLS_MAX_ITEMS 512 /* fetchable items collected per playlist parse */ +#define HLS_MAX_PLAYLIST (1 << 20) /* 1 MiB playlist cap */ +#define HLS_MAX_EMPTY_RELOADS 8 /* consecutive no-new-media reloads before giving up */ +#define HLS_LIVE_MARGIN_SEGMENTS 3 /* playout buffer kept behind the live edge for plain (non-LL) HLS */ +#define HLS_RING_CAP (4 * 1024 * 1024) /* read-ahead byte buffer (~5 s of 1080p HD) */ + +/* (msn, part) media position. part == -1 means a whole segment. */ +typedef struct { + char uri[HLS_MAX_URI]; + long msn; + int part; /* -1 = full segment, >=0 = partial segment index */ + long dur_ms; /* media duration of this segment/part (for real-time pacing) */ +} hls_item_t; + +typedef struct { + long target_duration_ms; + long media_seq_base; + int can_block_reload; /* EXT-X-SERVER-CONTROL: CAN-BLOCK-RELOAD=YES */ + long part_target_ms; /* EXT-X-PART-INF: PART-TARGET */ + int has_parts; /* any EXT-X-PART seen */ + int has_endlist; /* EXT-X-ENDLIST (VOD / stream finished) */ + int is_fmp4; /* EXT-X-MAP present or .m4s/.mp4 segment URIs */ + char map_uri[HLS_MAX_URI]; /* EXT-X-MAP init segment (fMP4), resolved */ + int nfull; /* count of full (#EXTINF) segments */ + hls_item_t items[HLS_MAX_ITEMS]; + int item_count; +} hls_playlist_t; + +typedef struct basis_hls { + basis_http_provider_t http; + int (*is_running)(void* user); + void* user; + + char media_url[HLS_MAX_URI]; /* resolved media-playlist URL (reload target) */ + int is_fmp4; + + /* media position cursor: the next thing we want to fetch */ + long want_msn; + int want_part; + + int can_block_reload; + long part_target_ms; + long target_duration_ms; + int endlist_seen; + + int map_served; /* fMP4: init segment already streamed once */ + char map_uri[HLS_MAX_URI]; + + /* pending fetch queue (resolved absolute URLs, in play order) */ + char pending[HLS_MAX_ITEMS][HLS_MAX_URI]; + long pending_dur[HLS_MAX_ITEMS]; /* media duration (ms) per queued item */ + int pending_head; + int pending_count; + + void* seg_ctx; /* current open segment byte source (producer only) */ + int empty_reloads; + + /* read-ahead: the producer thread fetches segments into `ring`; basis_hls_read + * drains it. Only `ring`/head/tail/count are shared (under `lock`); the queue, + * seg_ctx and playlist cursor are touched by the producer thread only. */ + uint8_t* ring; + int ring_cap; + int ring_head; /* write position */ + int ring_tail; /* read position */ + int ring_count; /* bytes currently buffered */ + hls_mutex_t lock; + hls_thread_t thread; + int thread_started; + volatile int stop; + volatile int producer_done; + + /* Real-time pacing: the decoder presents on a wall-clock and drops frames if + * fed faster than real-time (it never back-pressures). So basis_hls_read meters + * output to the stream's measured average bitrate via a token bucket. */ + volatile long target_bps; /* measured average bytes/sec; 0 until known */ + long long acc_bytes; /* producer: cumulative segment bytes ... */ + long long acc_dur_ms; /* ... and their cumulative media duration */ + long cur_seg_dur_ms; /* duration of the segment the producer is on */ + long long cur_seg_bytes; /* bytes delivered for it so far */ + double tb_tokens; /* consumer token bucket (bytes) */ + int64_t tb_last_us; /* last token refill timestamp (0 = uninit) */ +} basis_hls_t; + +/* ---- small string / URL helpers ----------------------------------------- */ + +static int ci_eq_n(const char* a, const char* b, size_t n) { + for (size_t i = 0; i < n; ++i) { + char x = a[i], y = b[i]; + if (x >= 'A' && x <= 'Z') x += 32; + if (y >= 'A' && y <= 'Z') y += 32; + if (x != y) return 0; + if (!x) return 1; + } + return 1; +} + +static int starts_with(const char* s, const char* p) { + return strncmp(s, p, strlen(p)) == 0; +} + +static int ends_with_ci(const char* s, const char* suffix) { + size_t ls = strlen(s), lf = strlen(suffix); + if (lf > ls) return 0; + return ci_eq_n(s + (ls - lf), suffix, lf); +} + +/* Extract a tag attribute value: KEY=VALUE or KEY="VALUE" from a tag line. */ +static int attr_str(const char* line, const char* key, char* out, int outsz) { + size_t klen = strlen(key); + const char* p = line; + while ((p = strstr(p, key)) != NULL) { + /* require the char before key to be ':' or ',' to avoid substring hits */ + if (p != line && p[-1] != ':' && p[-1] != ',') { p += klen; continue; } + if (p[klen] != '=') { p += klen; continue; } + const char* v = p + klen + 1; + int i = 0; + if (*v == '"') { + v++; + while (*v && *v != '"' && i < outsz - 1) out[i++] = *v++; + } else { + while (*v && *v != ',' && *v != '\r' && *v != '\n' && i < outsz - 1) out[i++] = *v++; + } + out[i] = 0; + return 1; + } + return 0; +} + +static long attr_long(const char* line, const char* key, long def) { + char buf[64]; + if (attr_str(line, key, buf, sizeof(buf))) return atol(buf); + return def; +} + +/* Seconds (possibly fractional, e.g. "0.33334") to milliseconds. */ +static long attr_ms(const char* line, const char* key, long def) { + char buf[64]; + if (!attr_str(line, key, buf, sizeof(buf))) return def; + double s = atof(buf); + return (long)(s * 1000.0 + 0.5); +} + +/* Resolve `ref` against the absolute base URL `base` into `out`. + * Handles absolute (http[s]://), root-relative (/path) and same-directory + * relative refs. "../" is not normalised (rare in HLS); acceptable first pass. */ +static void resolve_url(const char* base, const char* ref, char* out, int outsz) { + if (starts_with(ref, "http://") || starts_with(ref, "https://")) { + snprintf(out, outsz, "%s", ref); + return; + } + /* find scheme://host end (first '/' after "scheme://") */ + const char* host = strstr(base, "://"); + host = host ? host + 3 : base; + const char* host_end = strchr(host, '/'); + + if (ref[0] == '/') { + if (host_end) { + int hlen = (int)(host - base) + (int)(host_end - host); + snprintf(out, outsz, "%.*s%s", hlen, base, ref); + } else { + snprintf(out, outsz, "%s%s", base, ref); + } + return; + } + /* same-directory relative: base up to and including last '/' (before any '?') */ + const char* q = strchr(base, '?'); + const char* end = q ? q : base + strlen(base); + const char* slash = end; + while (slash > host && *slash != '/') slash--; + int dirlen = (slash >= host && *slash == '/') ? (int)(slash - base) + 1 : (int)(end - base); + snprintf(out, outsz, "%.*s%s", dirlen, base, ref); +} + +/* ---- playlist fetch ------------------------------------------------------ */ + +/* GET `url` fully into a NUL-terminated buffer (caller frees). Returns length, + * or <0 on error / stop. */ +static int fetch_text(basis_hls_t* h, const char* url, char** out) { + *out = NULL; + void* ctx = h->http.open(url); + if (!ctx) return -1; + + int cap = 16384, len = 0; + char* buf = (char*)malloc(cap); + if (!buf) { h->http.close(ctx); return -1; } + + for (;;) { + if (h->is_running && !h->is_running(h->user)) { free(buf); h->http.close(ctx); return -1; } + if (len + 4096 > cap) { + if (cap >= HLS_MAX_PLAYLIST) break; + cap *= 2; + char* nb = (char*)realloc(buf, cap); + if (!nb) { free(buf); h->http.close(ctx); return -1; } + buf = nb; + } + int n = h->http.read(ctx, (uint8_t*)buf + len, cap - len - 1); + if (n <= 0) break; + len += n; + } + h->http.close(ctx); + buf[len] = 0; + *out = buf; + return len; +} + +/* ---- M3U8 parsing -------------------------------------------------------- */ + +/* Returns 1 if the playlist is a master (has EXT-X-STREAM-INF). */ +static int playlist_is_master(const char* text) { + return strstr(text, "#EXT-X-STREAM-INF") != NULL; +} + +/* Pick a single rendition from a master playlist: highest BANDWIDTH variant. + * Writes its resolved absolute URL into out. Returns 1 on success. */ +static int master_pick_variant(basis_hls_t* h, const char* base, const char* text, + char* out, int outsz) { + const char* p = text; + long best_bw = -1; + char best_uri[HLS_MAX_URI] = {0}; + char line[2048]; + + while (*p) { + const char* nl = strchr(p, '\n'); + int llen = nl ? (int)(nl - p) : (int)strlen(p); + if (llen >= (int)sizeof(line)) llen = (int)sizeof(line) - 1; + memcpy(line, p, llen); line[llen] = 0; + if (llen && line[llen - 1] == '\r') line[llen - 1] = 0; + + if (starts_with(line, "#EXT-X-STREAM-INF")) { + long bw = attr_long(line, "BANDWIDTH", 0); + /* the variant URI is the next non-comment, non-empty line */ + const char* q = nl ? nl + 1 : p + llen; + while (*q) { + const char* qnl = strchr(q, '\n'); + int qlen = qnl ? (int)(qnl - q) : (int)strlen(q); + char uline[HLS_MAX_URI]; + if (qlen >= (int)sizeof(uline)) qlen = (int)sizeof(uline) - 1; + memcpy(uline, q, qlen); uline[qlen] = 0; + if (qlen && uline[qlen - 1] == '\r') uline[qlen - 1] = 0; + if (uline[0] && uline[0] != '#') { + if (bw >= best_bw) { best_bw = bw; resolve_url(base, uline, best_uri, sizeof(best_uri)); } + break; + } + if (!qnl) break; + q = qnl + 1; + } + } + if (!nl) break; + p = nl + 1; + } + + if (!best_uri[0]) return 0; + snprintf(out, outsz, "%s", best_uri); + return 1; +} + +/* Parse a media playlist into pl. base = media playlist URL (for resolving). */ +static void parse_media_playlist(const char* base, const char* text, hls_playlist_t* pl) { + memset(pl, 0, sizeof(*pl)); + pl->media_seq_base = 0; + + const char* p = text; + char line[2048]; + int seg_index = 0; /* full segments seen so far */ + int part_index = 0; /* parts of the in-progress segment */ + + while (*p) { + const char* nl = strchr(p, '\n'); + int llen = nl ? (int)(nl - p) : (int)strlen(p); + if (llen >= (int)sizeof(line)) llen = (int)sizeof(line) - 1; + memcpy(line, p, llen); line[llen] = 0; + if (llen && line[llen - 1] == '\r') line[llen - 1] = 0; + + if (starts_with(line, "#EXT-X-TARGETDURATION")) { + const char* c = strchr(line, ':'); + if (c) pl->target_duration_ms = atol(c + 1) * 1000; + } else if (starts_with(line, "#EXT-X-MEDIA-SEQUENCE")) { + const char* c = strchr(line, ':'); + if (c) pl->media_seq_base = atol(c + 1); + } else if (starts_with(line, "#EXT-X-SERVER-CONTROL")) { + char v[16]; + if (attr_str(line, "CAN-BLOCK-RELOAD", v, sizeof(v)) && ci_eq_n(v, "yes", 3)) + pl->can_block_reload = 1; + } else if (starts_with(line, "#EXT-X-PART-INF")) { + pl->part_target_ms = attr_ms(line, "PART-TARGET", pl->part_target_ms); + } else if (starts_with(line, "#EXT-X-MAP")) { + char uri[HLS_MAX_URI]; + if (attr_str(line, "URI", uri, sizeof(uri))) { + resolve_url(base, uri, pl->map_uri, sizeof(pl->map_uri)); + pl->is_fmp4 = 1; + } + } else if (starts_with(line, "#EXT-X-PART:")) { + char uri[HLS_MAX_URI]; + if (attr_str(line, "URI", uri, sizeof(uri)) && pl->item_count < HLS_MAX_ITEMS) { + hls_item_t* it = &pl->items[pl->item_count++]; + resolve_url(base, uri, it->uri, sizeof(it->uri)); + it->msn = pl->media_seq_base + seg_index; + it->part = part_index++; + it->dur_ms = attr_ms(line, "DURATION", 0); + pl->has_parts = 1; + if (ends_with_ci(it->uri, ".m4s") || ends_with_ci(it->uri, ".mp4")) pl->is_fmp4 = 1; + } + } else if (starts_with(line, "#EXTINF")) { + /* #EXTINF:, — capture the duration for real-time pacing */ + long extinf_ms = 0; + { const char* c = strchr(line, ':'); if (c) extinf_ms = (long)(atof(c + 1) * 1000.0 + 0.5); } + /* the segment URI is the next non-comment line */ + const char* q = nl ? nl + 1 : p + llen; + while (*q) { + const char* qnl = strchr(q, '\n'); + int qlen = qnl ? (int)(qnl - q) : (int)strlen(q); + char uline[HLS_MAX_URI]; + if (qlen >= (int)sizeof(uline)) qlen = (int)sizeof(uline) - 1; + memcpy(uline, q, qlen); uline[qlen] = 0; + if (qlen && uline[qlen - 1] == '\r') uline[qlen - 1] = 0; + if (uline[0] && uline[0] != '#') { + if (pl->item_count < HLS_MAX_ITEMS) { + hls_item_t* it = &pl->items[pl->item_count++]; + resolve_url(base, uline, it->uri, sizeof(it->uri)); + it->msn = pl->media_seq_base + seg_index; + it->part = -1; + it->dur_ms = extinf_ms; + if (ends_with_ci(it->uri, ".m4s") || ends_with_ci(it->uri, ".mp4")) pl->is_fmp4 = 1; + } + seg_index++; + part_index = 0; /* parts now belong to the next in-progress segment */ + p = qnl ? qnl : q + qlen; + goto next_line; + } + if (!qnl) { p = q + qlen; goto next_line; } + q = qnl + 1; + } + } else if (starts_with(line, "#EXT-X-ENDLIST")) { + pl->has_endlist = 1; + } + + if (!nl) break; + p = nl + 1; + continue; + next_line: + if (!*p) break; + if (*p == '\n') p++; + continue; + } + + pl->nfull = seg_index; +} + +/* ---- queue helpers ------------------------------------------------------- */ + +static void queue_push(basis_hls_t* h, const char* url, long dur_ms) { + if (h->pending_count >= HLS_MAX_ITEMS) return; + int idx = (h->pending_head + h->pending_count) % HLS_MAX_ITEMS; + snprintf(h->pending[idx], HLS_MAX_URI, "%s", url); + h->pending_dur[idx] = dur_ms; + h->pending_count++; +} + +static const char* queue_pop(basis_hls_t* h, long* out_dur_ms) { + if (h->pending_count == 0) return NULL; + int idx = h->pending_head; + const char* s = h->pending[idx]; + if (out_dur_ms) *out_dur_ms = h->pending_dur[idx]; + h->pending_head = (h->pending_head + 1) % HLS_MAX_ITEMS; + h->pending_count--; + return s; +} + +/* Enqueue everything in the freshly parsed playlist that is at or beyond our + * (want_msn, want_part) cursor, advancing the cursor past what we enqueue. A full + * segment for msn M supersedes its parts: if we already consumed parts of M we + * skip the full segment, otherwise we take it. */ +static void enqueue_new_media(basis_hls_t* h, const hls_playlist_t* pl) { + for (int i = 0; i < pl->item_count; ++i) { + const hls_item_t* it = &pl->items[i]; + if (it->part >= 0) { + /* part P of segment M */ + if (it->msn > h->want_msn || (it->msn == h->want_msn && it->part >= h->want_part)) { + queue_push(h, it->uri, it->dur_ms); + h->want_msn = it->msn; + h->want_part = it->part + 1; + } + } else { + /* full segment M completes that segment */ + if (h->want_msn < it->msn) { + queue_push(h, it->uri, it->dur_ms); + h->want_msn = it->msn + 1; + h->want_part = 0; + } else if (h->want_msn == it->msn && h->want_part == 0) { + queue_push(h, it->uri, it->dur_ms); + h->want_msn = it->msn + 1; + h->want_part = 0; + } else if (h->want_msn == it->msn && h->want_part > 0) { + /* already rode this segment's parts; skip the redundant full segment */ + h->want_msn = it->msn + 1; + h->want_part = 0; + } + } + } +} + +/* Build the (optionally blocking) reload URL and fetch+parse the media playlist, + * enqueuing any new media. Returns 1 if new media was enqueued, 0 if none, <0 on + * error/stop. */ +static int reload_and_enqueue(basis_hls_t* h) { + char url[HLS_MAX_URI + 96]; + if (h->can_block_reload && h->want_part >= 0) { + char sep = strchr(h->media_url, '?') ? '&' : '?'; + snprintf(url, sizeof(url), "%s%c_HLS_msn=%ld&_HLS_part=%d", + h->media_url, sep, h->want_msn, h->want_part); + } else { + snprintf(url, sizeof(url), "%s", h->media_url); + } + + char* text = NULL; + int n = fetch_text(h, url, &text); + if (n < 0) { free(text); return -1; } + + hls_playlist_t pl; + parse_media_playlist(h->media_url, text, &pl); + free(text); + + if (pl.target_duration_ms) h->target_duration_ms = pl.target_duration_ms; + if (pl.has_endlist) h->endlist_seen = 1; + + int before = h->pending_count; + enqueue_new_media(h, &pl); + return (h->pending_count > before) ? 1 : 0; +} + +/* ---- read-ahead producer ------------------------------------------------- */ + +static int hls_should_run(basis_hls_t* h) { + return !h->stop && (h->is_running == NULL || h->is_running(h->user)); +} + +/* Copy n bytes into the ring, sleeping while it's full. Stops early on shutdown. */ +static void ring_write(basis_hls_t* h, const uint8_t* data, int n) { + int written = 0; + while (written < n && hls_should_run(h)) { + hls_mutex_lock(&h->lock); + int space = h->ring_cap - h->ring_count; + if (space > 0) { + int chunk = n - written; + if (chunk > space) chunk = space; + int first = h->ring_cap - h->ring_head; + if (first > chunk) first = chunk; + memcpy(h->ring + h->ring_head, data + written, (size_t)first); + if (chunk > first) memcpy(h->ring, data + written + first, (size_t)(chunk - first)); + h->ring_head = (h->ring_head + chunk) % h->ring_cap; + h->ring_count += chunk; + hls_mutex_unlock(&h->lock); + written += chunk; + } else { + hls_mutex_unlock(&h->lock); + hls_sleep_ms(2); /* ring full — wait for the consumer to drain */ + } + } +} + +/* Producer loop: fetch segments/parts (and reload the live playlist) ahead of + * playout into the ring, so the decoder sees a continuous byte stream with no + * per-segment connection gaps. */ +static void hls_producer(basis_hls_t* h) { + uint8_t tmp[16384]; + while (hls_should_run(h)) { + if (!h->seg_ctx) { + if (h->is_fmp4 && !h->map_served && h->map_uri[0]) { + h->seg_ctx = h->http.open(h->map_uri); /* fMP4 init segment first */ + h->map_served = 1; + h->cur_seg_dur_ms = 0; h->cur_seg_bytes = 0; /* init segment: not counted */ + if (!h->seg_ctx) continue; + } else { + const char* next = queue_pop(h, &h->cur_seg_dur_ms); + if (next) { + h->cur_seg_bytes = 0; + h->seg_ctx = h->http.open(next); + if (!h->seg_ctx) continue; /* skip a transient open failure */ + h->empty_reloads = 0; + } else if (h->endlist_seen) { + break; /* VOD / stream finished */ + } else { + int r = reload_and_enqueue(h); + if (r > 0) { h->empty_reloads = 0; } + else if (r < 0) { + if (!hls_should_run(h)) break; + hls_sleep_ms(50); /* transient fetch error — back off and retry */ + } else { /* r == 0: nothing new yet */ + if (++h->empty_reloads >= HLS_MAX_EMPTY_RELOADS) break; + if (!h->can_block_reload) { + long wait = h->target_duration_ms > 0 ? h->target_duration_ms / 2 : 1000, w = 0; + while (w < wait && hls_should_run(h)) { hls_sleep_ms(50); w += 50; } + } + } + continue; + } + } + } + + int n = h->http.read(h->seg_ctx, tmp, (int)sizeof(tmp)); + if (n > 0) { + h->cur_seg_bytes += n; + ring_write(h, tmp, n); + } else { + h->http.close(h->seg_ctx); + h->seg_ctx = NULL; + /* fold this segment into the running average bitrate that paces output */ + if (h->cur_seg_dur_ms > 0) { + h->acc_bytes += h->cur_seg_bytes; + h->acc_dur_ms += h->cur_seg_dur_ms; + if (h->acc_dur_ms > 0) + h->target_bps = (long)(h->acc_bytes * 1000 / h->acc_dur_ms); + } + h->cur_seg_bytes = 0; h->cur_seg_dur_ms = 0; + /* top up the queue in the background so the next segment is ready */ + if (!h->endlist_seen && h->pending_count <= HLS_LIVE_MARGIN_SEGMENTS) + reload_and_enqueue(h); + } + } + h->producer_done = 1; +} + +#if defined(_WIN32) +static DWORD WINAPI hls_thread_entry(LPVOID arg) { hls_producer((basis_hls_t*)arg); return 0; } +#else +static void* hls_thread_entry(void* arg) { hls_producer((basis_hls_t*)arg); return NULL; } +#endif + +static int hls_thread_start(basis_hls_t* h) { +#if defined(_WIN32) + h->thread = CreateThread(NULL, 0, hls_thread_entry, h, 0, NULL); + return h->thread != NULL; +#else + return pthread_create(&h->thread, NULL, hls_thread_entry, h) == 0; +#endif +} + +static void hls_thread_join(basis_hls_t* h) { + if (!h->thread_started) return; +#if defined(_WIN32) + WaitForSingleObject(h->thread, INFINITE); + CloseHandle(h->thread); +#else + pthread_join(h->thread, NULL); +#endif + h->thread_started = 0; +} + +/* ---- public API ---------------------------------------------------------- */ + +void* basis_hls_open(const char* url, const basis_http_provider_t* http, + int (*is_running)(void* user), void* user, int* out_is_fmp4) { + if (!url || !http || !http->open || !http->read || !http->close) return NULL; + + basis_hls_t* h = (basis_hls_t*)calloc(1, sizeof(*h)); + if (!h) return NULL; + h->http = *http; + h->is_running = is_running; + h->user = user; + + /* Fetch the entry playlist; follow one master->media indirection. */ + char* text = NULL; + if (fetch_text(h, url, &text) < 0 || !text) { free(text); free(h); return NULL; } + + if (playlist_is_master(text)) { + char media[HLS_MAX_URI]; + if (!master_pick_variant(h, url, text, media, sizeof(media))) { free(text); free(h); return NULL; } + snprintf(h->media_url, sizeof(h->media_url), "%s", media); + free(text); + if (fetch_text(h, h->media_url, &text) < 0 || !text) { free(text); free(h); return NULL; } + } else { + snprintf(h->media_url, sizeof(h->media_url), "%s", url); + } + + hls_playlist_t pl; + parse_media_playlist(h->media_url, text, &pl); + free(text); + + if (pl.nfull == 0 && !pl.has_parts) { free(h); return NULL; } + + h->is_fmp4 = pl.is_fmp4; + h->can_block_reload = pl.can_block_reload && pl.has_parts; + h->part_target_ms = pl.part_target_ms; + h->target_duration_ms = pl.target_duration_ms ? pl.target_duration_ms : 6000; + h->endlist_seen = pl.has_endlist; + if (pl.map_uri[0]) snprintf(h->map_uri, sizeof(h->map_uri), "%s", pl.map_uri); + + /* Start at the live edge. LL: the in-progress segment's first part (lowest + * latency; first part of a segment is normally an independent keyframe). + * Non-LL: the last complete segment (guaranteed keyframe). */ + if (h->can_block_reload) { + h->want_msn = pl.media_seq_base + pl.nfull; + h->want_part = 0; + } else { + /* Start a few segments behind the live edge so playout always has a buffer and + * segment fetches never wait on the encoder (plain HLS has no parts to ride). Each + * segment starts on a keyframe, so any of these is a valid decode start. */ + long edge = pl.media_seq_base + (pl.nfull > 0 ? pl.nfull - 1 : 0); + long start = edge - HLS_LIVE_MARGIN_SEGMENTS; + if (start < pl.media_seq_base) start = pl.media_seq_base; + h->want_msn = start; + h->want_part = 0; + } + + enqueue_new_media(h, &pl); + + /* Start the read-ahead producer so segments buffer ahead of playout. */ + h->ring_cap = HLS_RING_CAP; + h->ring = (uint8_t*)malloc((size_t)h->ring_cap); + if (!h->ring) { free(h); return NULL; } + hls_mutex_init(&h->lock); + if (!hls_thread_start(h)) { + hls_mutex_destroy(&h->lock); + free(h->ring); + free(h); + return NULL; + } + h->thread_started = 1; + + if (out_is_fmp4) *out_is_fmp4 = h->is_fmp4; + return h; +} + +int basis_hls_read(void* ctx, uint8_t* buf, int len) { + basis_hls_t* h = (basis_hls_t*)ctx; + if (!h || len <= 0) return 0; + + for (;;) { + if (h->stop || (h->is_running && !h->is_running(h->user))) return 0; + + /* Real-time pacing: the decoder presents on a wall-clock and drops frames + * if fed faster than real-time, so meter output to the measured average + * bitrate via a token bucket (≈0.25 s burst). Before the rate is known + * (first segment), serve unthrottled. */ + int budget = len; + long rate = h->target_bps; + if (rate > 0) { + int64_t now = hls_now_us(); + if (h->tb_last_us == 0) h->tb_last_us = now; + h->tb_tokens += (double)rate * (double)(now - h->tb_last_us) / 1000000.0; + h->tb_last_us = now; + /* ~0.5 s burst: enough to deliver a segment-start keyframe (which is several + * frames' worth of bytes) promptly so `newest` doesn't stall and the present + * buffer doesn't dip — still ≤ the decoder's ~533 ms present ring, so it can't + * lap/flood. Steady rate stays at the average bitrate. */ + double burst = (double)rate * 0.5; + if (h->tb_tokens > burst) h->tb_tokens = burst; + if (h->tb_tokens < (double)budget) budget = (int)h->tb_tokens; + } + + if (budget > 0) { + hls_mutex_lock(&h->lock); + if (h->ring_count > 0) { + int take = h->ring_count < budget ? h->ring_count : budget; + int first = h->ring_cap - h->ring_tail; + if (first > take) first = take; + memcpy(buf, h->ring + h->ring_tail, (size_t)first); + if (take > first) memcpy(buf + first, h->ring, (size_t)(take - first)); + h->ring_tail = (h->ring_tail + take) % h->ring_cap; + h->ring_count -= take; + hls_mutex_unlock(&h->lock); + if (rate > 0) h->tb_tokens -= take; + return take; + } + int done = h->producer_done; + hls_mutex_unlock(&h->lock); + if (done) return 0; /* producer finished and ring drained -> end of stream */ + } + + hls_sleep_ms(2); /* rate-limited, or waiting for the producer to buffer */ + } +} + +void basis_hls_close(void* ctx) { + basis_hls_t* h = (basis_hls_t*)ctx; + if (!h) return; + h->stop = 1; + hls_thread_join(h); + if (h->seg_ctx) { h->http.close(h->seg_ctx); h->seg_ctx = NULL; } + hls_mutex_destroy(&h->lock); + free(h->ring); + free(h); +} diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.h b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.h new file mode 100644 index 000000000..4e2cc4299 --- /dev/null +++ b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.h @@ -0,0 +1,58 @@ +/* HLS / Low-Latency HLS source. + * + * Not a demuxer: this fetches and parses the M3U8 playlist, selects a single + * rendition, and exposes the live media as ONE continuous byte stream + * (basis_read_fn-compatible) by stitching segments — and, for LL-HLS, partial + * segments — back to back. The existing MPEG-TS (basis_ts_run) and fMP4 + * (basis_mp4_run) demuxers then consume it unchanged. + * + * Transport is injected as a basis_http_provider so the protocol code stays + * portable: on Windows the engine passes the basis_win_http_* trio (TLS, redirects + * and chunked handled there). Playlists and segments are fetched through it. + * + * Low latency: when the origin advertises EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD + * with EXT-X-PART parts, the client starts near the live edge, reloads the media + * playlist with blocking _HLS_msn/_HLS_part queries, and feeds parts as they are + * produced — targeting ~PART-HOLD-BACK latency. Against a plain (non-LL) origin it + * falls back to live-edge, segment-by-segment playback (segment-bound latency). + * + * Scope: clear streams, single rendition, Windows fetch. Android/Quest planned. + */ +#ifndef BASIS_HLS_H +#define BASIS_HLS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Pluggable HTTP(S) byte-source. Matches basis_win_http_open/read/close exactly: + * open(url) returns a context that streams the response body; read is + * basis_read_fn-compatible (bytes read, 0 on EOF, <0 on error); close frees it. */ +typedef struct basis_http_provider { + void* (*open)(const char* url); + int (*read)(void* ctx, uint8_t* buf, int len); + void (*close)(void* ctx); +} basis_http_provider_t; + +/* Open an HLS source. Fetches the (master and/or media) playlist via `http`, + * resolves a single rendition, and prepares the stitched live byte stream. + * `is_running(user)` is polled during blocking reloads/retries so a stop unwinds + * promptly. On success returns a context and sets *out_is_fmp4 to 1 when segments + * are fragmented-MP4 (feed basis_mp4_run) or 0 for MPEG-TS (feed basis_ts_run). + * Returns NULL on failure. */ +void* basis_hls_open(const char* url, const basis_http_provider_t* http, + int (*is_running)(void* user), void* user, int* out_is_fmp4); + +/* basis_read_fn-compatible. Serves the stitched segment/part bytes, advancing to + * the next segment and reloading the playlist (blocking for LL-HLS) as needed. + * Returns bytes read, 0 when the stream ends or the engine stops, <0 on error. */ +int basis_hls_read(void* ctx, uint8_t* buf, int len); + +void basis_hls_close(void* ctx); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/Basis/Packages/com.basis.mediaplayer/README.md b/Basis/Packages/com.basis.mediaplayer/README.md index bcb560280..c4ee1720a 100644 --- a/Basis/Packages/com.basis.mediaplayer/README.md +++ b/Basis/Packages/com.basis.mediaplayer/README.md @@ -18,10 +18,25 @@ presented **zero-copy** into a Unity texture. No transcode server, no VP9, no | `rtmp://` | RTMP pull | `rtmp://stream.vrcdn.live/live/vrcdn` | | `https://…​.mp4` | fragmented MP4 over HTTPS | `https://stream.vrcdn.live/live/vrcdn.live.mp4` | | `https://…​.ts` | MPEG-TS over HTTPS (Quest) | `https://stream.vrcdn.live/live/vrcdn.live.ts` | +| `https://…​.m3u8` | HLS / Low-Latency HLS (Windows) | `https://stream.example/live/index.m3u8` | The protocol/demux core (RTSP/RTP, RTMP/FLV, MPEG-TS, fMP4) is portable C; the OS backends only decode + present. +### HLS / Low-Latency HLS + +`.m3u8` URLs are handled by `protocol/basis_hls.c`, which is **not** a demuxer: it +parses the playlist, selects one rendition, starts at the live edge, and stitches +the segments — and, for LL-HLS, the partial segments (`EXT-X-PART`) — into one byte +stream that the existing MPEG-TS / fMP4 demuxers consume. When the origin advertises +`EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD` with parts, the client uses blocking +`_HLS_msn`/`_HLS_part` playlist reloads and rides parts to target roughly +`PART-HOLD-BACK` latency (~5 s). **The ~5 s target needs an LL-HLS origin** — against +a plain HLS origin you get its segment-bound latency, not 5 s. + +Runs on **Windows** (WinHTTP fetch), **clear streams**, **single rendition**. +Android/Quest support is planned. + ## Usage ```csharp From a44d65dbd5a8a99d98968b93accd21b78fef0d7a Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Sun, 31 May 2026 23:08:30 +0100 Subject: [PATCH 2/2] chore(mediaplayer): rebuild basis_media_native.dll (Windows x64) with HLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows x86_64 plugin rebuilt to include the new HLS source. RTSP and MPEG-TS (and RTMP / fMP4 / HTTP) are behaviourally unchanged — their source wasn't edited, only recompiled. --- .../Windows/x86_64/basis_media_native.dll | Bin 69120 -> 79360 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Basis/Packages/com.basis.mediaplayer/Plugins/Windows/x86_64/basis_media_native.dll b/Basis/Packages/com.basis.mediaplayer/Plugins/Windows/x86_64/basis_media_native.dll index b9554a6dabf41e147a1067dc73a5200ddfd311f5..a7883b9c9f73499c326e839bc6dd71fbd142cd15 100644 GIT binary patch delta 27175 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 Wa&Ob#i+h>z;E*)wu!R47;{O14o_