@scalemule/gallop 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ScaleMule Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # Gallop Web SDK
2
+
3
+ **ScaleMule's Video Player for Web Applications**
4
+
5
+ *TypeScript • MSE • Zero Dependencies*
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ The Gallop Web SDK is a high-performance video player built with TypeScript and native browser APIs. It provides ScaleMule customers with a premium streaming experience including social features, deep analytics, and optimized playback.
12
+
13
+ ### Key Features
14
+
15
+ - **Instant Start** - Pre-warming system for TikTok-like instant playback
16
+ - **50% Bandwidth Savings** - VP9/HEVC codec support
17
+ - **Timeline Comments** - Social video features via scalemule-chat
18
+ - **Deep Analytics** - Heatmaps, engagement tracking, TTFF metrics
19
+ - **Zero Dependencies** - Custom engine (Phase 3+), no HLS.js in production
20
+
21
+ ---
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ npm install @scalemule/gallop
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Quick Start
32
+
33
+ ### React
34
+
35
+ ```tsx
36
+ import { GallopPlayer } from '@scalemule/gallop/react';
37
+
38
+ function VideoPage() {
39
+ return (
40
+ <GallopPlayer
41
+ videoId="your-video-id"
42
+ apiKey="sm_live_xxx"
43
+ onPlay={() => console.log('Playing')}
44
+ />
45
+ );
46
+ }
47
+ ```
48
+
49
+ ### Playback Analytics + Debugging
50
+
51
+ ```tsx
52
+ import { GallopPlayer } from '@scalemule/gallop/react';
53
+
54
+ <GallopPlayer
55
+ videoId="your-video-id"
56
+ apiKey="sm_live_xxx"
57
+ analytics={{
58
+ enabled: true,
59
+ progressIntervalSeconds: 10,
60
+ includeNetworkInfo: true,
61
+ includeDeviceInfo: true,
62
+ debug: false,
63
+ }}
64
+ onEngineStats={({ stats }) => {
65
+ console.log('HLS stats', stats);
66
+ }}
67
+ />;
68
+ ```
69
+
70
+ When `analytics.enabled` is on, the player sends playback telemetry to `POST /v1/videos/{id}/track` with:
71
+ - Core events: `play`, `pause`, `seek`, `complete`, `buffer`, `error`
72
+ - Session ID and playback position
73
+ - QoE metadata (TTFF, rebuffer ratio, quality switches)
74
+ - Network/device context (when available)
75
+
76
+ ### Web Component
77
+
78
+ ```html
79
+ <script type="module">
80
+ import '@scalemule/gallop/element';
81
+ </script>
82
+
83
+ <gallop-player
84
+ video-id="your-video-id"
85
+ api-key="sm_live_xxx"
86
+ ></gallop-player>
87
+ ```
88
+
89
+ ### Pre-warming (Instant Start)
90
+
91
+ ```typescript
92
+ import { gallop } from '@scalemule/gallop';
93
+
94
+ // Prewarm on hover for instant playback
95
+ thumbnail.addEventListener('mouseenter', () => {
96
+ gallop.prewarm('video-id');
97
+ });
98
+
99
+ // Or use IntersectionObserver for feeds
100
+ const observer = new IntersectionObserver((entries) => {
101
+ entries.forEach(entry => {
102
+ if (entry.isIntersecting) {
103
+ gallop.prewarm(entry.target.dataset.videoId);
104
+ }
105
+ });
106
+ }, { rootMargin: '200px' });
107
+ ```
108
+
109
+ ---
110
+
111
+ ## Architecture
112
+
113
+ ```
114
+ ┌─────────────────────────────────────────────────────────────────┐
115
+ │ Gallop Web │
116
+ ├─────────────────────────────────────────────────────────────────┤
117
+ │ UI Layer │
118
+ │ ├── React Components / Web Components │
119
+ │ ├── Controls (play, seek, volume, quality) │
120
+ │ └── Social Features (timeline comments, share) │
121
+ ├─────────────────────────────────────────────────────────────────┤
122
+ │ Engine Abstraction │
123
+ │ ├── HLSJSEngine (Phase 1-2, temporary) │
124
+ │ ├── GallopEngine (Phase 3+, custom) │
125
+ │ └── NativeHLSEngine (iOS Safari fallback) │
126
+ ├─────────────────────────────────────────────────────────────────┤
127
+ │ Performance Layer │
128
+ │ ├── Pre-warming System │
129
+ │ ├── Low-Latency Startup Profile │
130
+ │ └── Hot-Swap Fallback │
131
+ ├─────────────────────────────────────────────────────────────────┤
132
+ │ Browser APIs │
133
+ │ ├── Media Source Extensions (MSE) │
134
+ │ ├── Encrypted Media Extensions (EME) │
135
+ │ └── Web Workers │
136
+ └─────────────────────────────────────────────────────────────────┘
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Browser Support
142
+
143
+ | Browser | Version | MSE | DRM |
144
+ |---------|---------|-----|-----|
145
+ | Chrome | 70+ | ✅ | Widevine |
146
+ | Firefox | 78+ | ✅ | Widevine |
147
+ | Safari | 14+ | ✅ | FairPlay |
148
+ | Edge | 79+ | ✅ | Widevine |
149
+ | iOS Safari | 14+ | Native HLS | FairPlay |
150
+ | Android Chrome | 70+ | ✅ | Widevine |
151
+
152
+ ---
153
+
154
+ ## Development
155
+
156
+ ### Technology Stack
157
+
158
+ | Tool | Purpose |
159
+ |------|---------|
160
+ | TypeScript | Primary language (strict mode) |
161
+ | tsup | Build pipeline (ESM + CJS + UMD) |
162
+ | Vitest | Unit testing |
163
+ | Playwright | E2E testing |
164
+
165
+ ### Scripts
166
+
167
+ ```bash
168
+ # Development
169
+ npm run dev # Start dev server with hot reload
170
+ npm run build # Production build
171
+ npm run test # Run unit tests
172
+ npm run test:e2e # Run Playwright tests
173
+
174
+ # Quality
175
+ npm run lint # ESLint
176
+ npm run typecheck # TypeScript type checking
177
+ ```
178
+
179
+ ---
180
+
181
+ ## Documentation
182
+
183
+ | Document | Description |
184
+ |----------|-------------|
185
+ | [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) | Development phases, technical details |
186
+ | [Core Architecture](../scalemule-gallop/docs/ARCHITECTURE.md) | Shared architecture decisions |
187
+ | [Cross-Platform](../scalemule-gallop/docs/CROSS_PLATFORM.md) | Platform comparison |
188
+
189
+ ---
190
+
191
+ ## Related Repos
192
+
193
+ - **[scalemule-gallop](../scalemule-gallop)** - Core documentation (shared architecture)
194
+ - **[scalemule-gallop-ios](../scalemule-gallop-ios)** - iOS SDK (Swift)
195
+ - **[scalemule-gallop-android](../scalemule-gallop-android)** - Android SDK (Kotlin)
196
+
197
+ ---
198
+
199
+ ## License
200
+
201
+ Proprietary - ScaleMule, Inc.
@@ -0,0 +1,12 @@
1
+ import { d as GallopEventMap, c as GallopEventCallback } from './types-D9Oqcpr1.cjs';
2
+
3
+ declare class EventEmitter {
4
+ private listeners;
5
+ on<K extends keyof GallopEventMap>(event: K, callback: GallopEventCallback<GallopEventMap[K]>): void;
6
+ off<K extends keyof GallopEventMap>(event: K, callback: GallopEventCallback<GallopEventMap[K]>): void;
7
+ once<K extends keyof GallopEventMap>(event: K, callback: GallopEventCallback<GallopEventMap[K]>): void;
8
+ protected emit<K extends keyof GallopEventMap>(event: K, ...args: GallopEventMap[K] extends void ? [] : [GallopEventMap[K]]): void;
9
+ removeAllListeners(): void;
10
+ }
11
+
12
+ export { EventEmitter as E };
@@ -0,0 +1,12 @@
1
+ import { d as GallopEventMap, c as GallopEventCallback } from './types-D9Oqcpr1.js';
2
+
3
+ declare class EventEmitter {
4
+ private listeners;
5
+ on<K extends keyof GallopEventMap>(event: K, callback: GallopEventCallback<GallopEventMap[K]>): void;
6
+ off<K extends keyof GallopEventMap>(event: K, callback: GallopEventCallback<GallopEventMap[K]>): void;
7
+ once<K extends keyof GallopEventMap>(event: K, callback: GallopEventCallback<GallopEventMap[K]>): void;
8
+ protected emit<K extends keyof GallopEventMap>(event: K, ...args: GallopEventMap[K] extends void ? [] : [GallopEventMap[K]]): void;
9
+ removeAllListeners(): void;
10
+ }
11
+
12
+ export { EventEmitter as E };
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ // src/core/EventEmitter.ts
4
+ var EventEmitter = class {
5
+ constructor() {
6
+ this.listeners = /* @__PURE__ */ new Map();
7
+ }
8
+ on(event, callback) {
9
+ if (!this.listeners.has(event)) {
10
+ this.listeners.set(event, /* @__PURE__ */ new Set());
11
+ }
12
+ this.listeners.get(event).add(callback);
13
+ }
14
+ off(event, callback) {
15
+ this.listeners.get(event)?.delete(callback);
16
+ }
17
+ once(event, callback) {
18
+ const wrapper = ((...args) => {
19
+ this.off(event, wrapper);
20
+ callback(...args);
21
+ });
22
+ this.on(event, wrapper);
23
+ }
24
+ emit(event, ...args) {
25
+ const set = this.listeners.get(event);
26
+ if (!set) return;
27
+ for (const listener of set) {
28
+ try {
29
+ listener(...args);
30
+ } catch (err) {
31
+ console.error(`[Gallop] Error in ${event} listener:`, err);
32
+ }
33
+ }
34
+ }
35
+ removeAllListeners() {
36
+ this.listeners.clear();
37
+ }
38
+ };
39
+
40
+ exports.EventEmitter = EventEmitter;
@@ -0,0 +1,265 @@
1
+ 'use strict';
2
+
3
+ var chunk2JQGJ7NX_cjs = require('./chunk-2JQGJ7NX.cjs');
4
+
5
+ // src/iframe/GallopIframeController.ts
6
+ var HASH_CONFIG_ALLOWLIST = [
7
+ "autoplay",
8
+ "muted",
9
+ "loop",
10
+ "controls",
11
+ "startTime",
12
+ "preferredQuality",
13
+ "aspectRatio",
14
+ "theme",
15
+ "debug",
16
+ "doNotTrack",
17
+ "analytics",
18
+ "pageUrl"
19
+ ];
20
+ var MAX_HASH_CONFIG_BYTES = 4096;
21
+ function sanitizeConfigForHash(config) {
22
+ const safe = {};
23
+ for (const key of HASH_CONFIG_ALLOWLIST) {
24
+ if (config[key] !== void 0) {
25
+ safe[key] = config[key];
26
+ }
27
+ }
28
+ return safe;
29
+ }
30
+ var GallopIframeController = class extends chunk2JQGJ7NX_cjs.EventEmitter {
31
+ constructor(container, config) {
32
+ super();
33
+ this.container = container;
34
+ this.config = config;
35
+ this.targetOrigin = null;
36
+ this.isConnected = false;
37
+ this.pendingCalls = /* @__PURE__ */ new Map();
38
+ this.stateCache = {};
39
+ this.cachedQualityLevels = [];
40
+ this.cachedCurrentQuality = -1;
41
+ this.cachedDiagnostics = {};
42
+ this.sessionId = Math.random().toString(36).substring(7);
43
+ this.handshakeInterval = null;
44
+ this.iframe = this.createIframe();
45
+ this.container.appendChild(this.iframe);
46
+ this.boundHandler = this.handleMessage.bind(this);
47
+ window.addEventListener("message", this.boundHandler);
48
+ this.startHandshake();
49
+ }
50
+ createIframe() {
51
+ const iframe = document.createElement("iframe");
52
+ const videoId = this.config.videoId || "unknown";
53
+ const baseUrl = this.config.apiBaseUrl ? `${this.config.apiBaseUrl.replace(/\/$/, "")}/v1/videos/embed` : "https://api.scalemule.com/v1/videos/embed";
54
+ const url = new URL(`${baseUrl}/${videoId}`);
55
+ if (this.config.embedToken) {
56
+ url.searchParams.set("token", this.config.embedToken);
57
+ }
58
+ const safeConfig = sanitizeConfigForHash(this.config);
59
+ if (!safeConfig.pageUrl && typeof window !== "undefined") {
60
+ safeConfig.pageUrl = window.location.href;
61
+ }
62
+ const json = JSON.stringify(safeConfig);
63
+ const encoded = btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
64
+ if (encoded.length > MAX_HASH_CONFIG_BYTES) {
65
+ console.warn("[Gallop] Config too large for hash, using defaults");
66
+ } else {
67
+ url.hash = `config=${encoded}`;
68
+ }
69
+ iframe.src = url.toString();
70
+ iframe.style.width = "100%";
71
+ iframe.style.height = "100%";
72
+ iframe.style.border = "0";
73
+ iframe.allow = "autoplay; fullscreen; picture-in-picture; encrypted-media";
74
+ iframe.title = "Gallop Video Player";
75
+ return iframe;
76
+ }
77
+ startHandshake() {
78
+ let attempts = 0;
79
+ this.handshakeInterval = setInterval(() => {
80
+ if (this.isConnected || attempts >= 3) {
81
+ if (this.handshakeInterval) clearInterval(this.handshakeInterval);
82
+ this.handshakeInterval = null;
83
+ if (!this.isConnected && attempts >= 3) {
84
+ this.emit("error", { code: "CONNECT_TIMEOUT", message: "iframe handshake timed out" });
85
+ }
86
+ return;
87
+ }
88
+ this.sendMessage("gallop:ping", { sessionId: this.sessionId }, "*");
89
+ attempts++;
90
+ }, 1e3);
91
+ }
92
+ handleMessage(event) {
93
+ const data = event.data;
94
+ if (!data || typeof data.type !== "string" || !data.type.startsWith("gallop:")) return;
95
+ if (event.source !== this.iframe.contentWindow) return;
96
+ if (!this.isConnected) {
97
+ if (data.type === "gallop:hello") {
98
+ this.sendMessage("gallop:ping", { sessionId: this.sessionId }, "*");
99
+ return;
100
+ }
101
+ if (data.type === "gallop:pong") {
102
+ this.targetOrigin = event.origin;
103
+ this.isConnected = true;
104
+ if (this.handshakeInterval) {
105
+ clearInterval(this.handshakeInterval);
106
+ this.handshakeInterval = null;
107
+ }
108
+ if (data.diagnostics) {
109
+ this.cachedDiagnostics = data.diagnostics;
110
+ }
111
+ return;
112
+ }
113
+ if (data.type === "gallop:error") {
114
+ this.emit("error", { code: data.code || "EMBED_LOAD_FAILED", message: data.message || "Embed error" });
115
+ return;
116
+ }
117
+ return;
118
+ }
119
+ if (event.origin !== this.targetOrigin) return;
120
+ switch (data.type) {
121
+ case "gallop:event":
122
+ if (data.event === "qualitylevels" && data.data?.levels) {
123
+ this.cachedQualityLevels = data.data.levels;
124
+ }
125
+ if (data.event === "qualitychange" && data.data?.level) {
126
+ this.cachedCurrentQuality = data.data.level.index ?? -1;
127
+ }
128
+ this.emit(data.event, data.data);
129
+ break;
130
+ case "gallop:state":
131
+ this.stateCache = data.state;
132
+ break;
133
+ case "gallop:response":
134
+ this.handleResponse(data);
135
+ break;
136
+ case "gallop:error":
137
+ this.emit("error", { code: data.code || "UNKNOWN", message: data.message || "Unknown error" });
138
+ break;
139
+ }
140
+ }
141
+ handleResponse(data) {
142
+ const call = this.pendingCalls.get(data.callId);
143
+ if (call) {
144
+ clearTimeout(call.timeout);
145
+ if (data.error) {
146
+ call.reject(data.error);
147
+ } else {
148
+ call.resolve(data.result);
149
+ }
150
+ this.pendingCalls.delete(data.callId);
151
+ }
152
+ }
153
+ sendMessage(type, payload = {}, overrideOrigin) {
154
+ const target = overrideOrigin || this.targetOrigin;
155
+ if (!target) return;
156
+ this.iframe.contentWindow?.postMessage({
157
+ type,
158
+ ...payload,
159
+ version: 1
160
+ }, target);
161
+ }
162
+ callMethod(method, ...args) {
163
+ return new Promise((resolve, reject) => {
164
+ if (!this.isConnected && method !== "destroy") {
165
+ reject(new Error("Player not connected"));
166
+ return;
167
+ }
168
+ if (this.pendingCalls.size >= 20) {
169
+ reject(new Error("Too many pending calls"));
170
+ return;
171
+ }
172
+ const callId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2);
173
+ const timeout = setTimeout(() => {
174
+ this.pendingCalls.delete(callId);
175
+ reject(new Error(`Method ${method} timed out`));
176
+ }, 5e3);
177
+ this.pendingCalls.set(callId, { resolve, reject, timeout });
178
+ this.sendMessage("gallop:method", { method, args, callId });
179
+ });
180
+ }
181
+ // --- GallopPlayer Implementation ---
182
+ get currentTime() {
183
+ return this.stateCache.currentTime ?? 0;
184
+ }
185
+ get duration() {
186
+ return this.stateCache.duration ?? 0;
187
+ }
188
+ get paused() {
189
+ return this.stateCache.paused ?? true;
190
+ }
191
+ get status() {
192
+ return this.stateCache.status ?? "loading";
193
+ }
194
+ get isFullscreen() {
195
+ return this.stateCache.isFullscreen ?? false;
196
+ }
197
+ get volume() {
198
+ return this.stateCache.volume ?? 1;
199
+ }
200
+ set volume(v) {
201
+ void this.callMethod("setVolume", v);
202
+ }
203
+ get muted() {
204
+ return this.stateCache.muted ?? false;
205
+ }
206
+ set muted(m) {
207
+ void this.callMethod("setMuted", m);
208
+ }
209
+ get playbackRate() {
210
+ return this.stateCache.playbackRate ?? 1;
211
+ }
212
+ set playbackRate(r) {
213
+ void this.callMethod("setPlaybackRate", r);
214
+ }
215
+ play() {
216
+ return this.callMethod("play");
217
+ }
218
+ pause() {
219
+ return this.callMethod("pause");
220
+ }
221
+ seek(time) {
222
+ return this.callMethod("seek", time);
223
+ }
224
+ setQualityLevel(index) {
225
+ return this.callMethod("setQualityLevel", index);
226
+ }
227
+ setAutoQuality() {
228
+ return this.callMethod("setAutoQuality");
229
+ }
230
+ toggleFullscreen() {
231
+ return this.callMethod("toggleFullscreen");
232
+ }
233
+ getQualityLevels() {
234
+ return this.cachedQualityLevels;
235
+ }
236
+ getCurrentQuality() {
237
+ return this.cachedCurrentQuality;
238
+ }
239
+ getDiagnostics() {
240
+ return this.cachedDiagnostics;
241
+ }
242
+ query(key) {
243
+ return this.callMethod("query", key);
244
+ }
245
+ get connected() {
246
+ return this.isConnected;
247
+ }
248
+ destroy() {
249
+ if (this.handshakeInterval) {
250
+ clearInterval(this.handshakeInterval);
251
+ this.handshakeInterval = null;
252
+ }
253
+ for (const [, call] of this.pendingCalls) {
254
+ clearTimeout(call.timeout);
255
+ call.reject(new Error("Player destroyed"));
256
+ }
257
+ this.pendingCalls.clear();
258
+ window.removeEventListener("message", this.boundHandler);
259
+ this.iframe.remove();
260
+ this.emit("destroy");
261
+ this.removeAllListeners();
262
+ }
263
+ };
264
+
265
+ exports.GallopIframeController = GallopIframeController;