mirror of
https://github.com/LuanRT/googlevideo.git
synced 2026-06-13 00:32:11 +00:00
chore(examples): Add a minimal sabr player
Based on Kira's code.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
## SABR/UMP Player Example
|
||||
https://github.com/LuanRT/yt-sabr-shaka-demo
|
||||
## SABR/UMP Player
|
||||
See [sabr-shaka-example/README.md](./sabr-shaka-example/README.md).
|
||||
|
||||
## Downloader Example
|
||||
|
||||
|
||||
32
examples/sabr-shaka-example/README.md
Normal file
32
examples/sabr-shaka-example/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# SABR + Shaka Player Example
|
||||
|
||||
This project provides a minimal, self-contained example of how to use [Shaka Player](https://shaka-player-demo.appspot.com/) with the `SabrStreamingAdapter` from the [googlevideo](https://github.com/LuanRT/googlevideo) library to play YouTube videos.
|
||||
|
||||
## Note
|
||||
|
||||
For an implementation that includes a proper user interface, advanced features, and best practices, please see the main **[Kira](https://github.com/LuanRT/yt-sabr-shaka-demo)** project this example is derived from.
|
||||
|
||||
Things **not included** in this minimal example but available in the main project:
|
||||
* A proper watch page and user interface.
|
||||
* Support for DRM-protected content.
|
||||
* A recommendation system and persistent user sessions.
|
||||
* Saving and resuming playback position.
|
||||
* Advanced error handling and UI feedback.
|
||||
* A video/audio downloader.
|
||||
|
||||
## How to Run
|
||||
|
||||
1. **Install dependencies:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Start the development server:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. Open your browser and navigate to the local URL provided by Vite (e.g., `http://localhost:5173`).
|
||||
|
||||
## License
|
||||
Distributed under the [MIT](./LICENSE) License.
|
||||
29
examples/sabr-shaka-example/index.html
Normal file
29
examples/sabr-shaka-example/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SABR Shaka Player Example</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; background-color: #181818; color: #fff; margin: 0; padding: 2rem; }
|
||||
#video-container { max-width: 800px; margin: 2rem auto; }
|
||||
video { width: 100%; height: auto; background-color: #000; aspect-ratio: 16 / 9; }
|
||||
.controls { max-width: 800px; margin: 0 auto 1rem; display: flex; gap: 0.5rem; }
|
||||
input { flex-grow: 1; padding: 0.5rem; }
|
||||
button { padding: 0.5rem 1rem; }
|
||||
#status { text-align: center; margin-top: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="controls">
|
||||
<input type="text" id="videoIdInput" value="7j86ktJHjfI" placeholder="Enter YouTube Video ID" />
|
||||
<button id="loadButton">Load Video</button>
|
||||
</div>
|
||||
<div id="video-container">
|
||||
<video id="video" autoplay></video>
|
||||
</div>
|
||||
<div id="status">Ready. Enter a video ID and click "Load Video".</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1025
examples/sabr-shaka-example/package-lock.json
generated
Normal file
1025
examples/sabr-shaka-example/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
examples/sabr-shaka-example/package.json
Normal file
21
examples/sabr-shaka-example/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "sabr-shaka-example",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"patch:shaka": "node ./scripts/patchShaka.mjs",
|
||||
"postinstall": "npm run patch:shaka"
|
||||
},
|
||||
"dependencies": {
|
||||
"bgutils-js": "^3.2.0",
|
||||
"googlevideo": "^4.0.4",
|
||||
"shaka-player": "^4.16.2",
|
||||
"youtubei.js": "^15.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11"
|
||||
}
|
||||
}
|
||||
30
examples/sabr-shaka-example/scripts/patchShaka.mjs
Normal file
30
examples/sabr-shaka-example/scripts/patchShaka.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import { readFile, appendFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import * as url from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
|
||||
async function main() {
|
||||
const fileNames = [ 'shaka-player.ui.d.ts', 'shaka-player.ui.debug.d.ts' ];
|
||||
|
||||
for (const filename of fileNames) {
|
||||
await fixTypes(filename);
|
||||
}
|
||||
}
|
||||
|
||||
async function fixTypes(filename) {
|
||||
const filePath = path.join(__dirname, '..', 'node_modules', 'shaka-player', 'dist', filename);
|
||||
|
||||
const shakaTs = await readFile(filePath, 'utf-8');
|
||||
|
||||
if (!shakaTs.includes('export default shaka')) {
|
||||
await appendFile(filePath, 'export default shaka;');
|
||||
console.log(`[PatchShaka] Fixed types in ${filename}`);
|
||||
} else {
|
||||
console.log(`[PatchShaka] No changes needed in ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(() => {
|
||||
console.error('[PatchShaka]', 'Failed to patch shaka-player');
|
||||
});
|
||||
130
examples/sabr-shaka-example/src/BotguardService.ts
Normal file
130
examples/sabr-shaka-example/src/BotguardService.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { fetchFunction } from './helpers.js';
|
||||
import type { DescrambledChallenge, WebPoSignalOutput } from 'bgutils-js';
|
||||
import { BG, buildURL, GOOG_API_KEY } from 'bgutils-js';
|
||||
|
||||
export class BotguardService {
|
||||
private readonly waaRequestKey = 'O43z0dpjhgX20SCx4KAo';
|
||||
|
||||
public botguardClient?: BG.BotGuardClient;
|
||||
public initializationPromise?: Promise<BG.BotGuardClient | undefined> | null = null;
|
||||
public integrityTokenBasedMinter?: BG.WebPoMinter;
|
||||
public bgChallenge?: DescrambledChallenge & { challenge?: string, interpreterUrl?: string };
|
||||
|
||||
async init() {
|
||||
if (this.initializationPromise) {
|
||||
return await this.initializationPromise;
|
||||
}
|
||||
|
||||
return this.setup();
|
||||
}
|
||||
|
||||
private async setup() {
|
||||
if (this.initializationPromise)
|
||||
return await this.initializationPromise;
|
||||
|
||||
this.initializationPromise = this._initBotguard();
|
||||
|
||||
try {
|
||||
this.botguardClient = await this.initializationPromise;
|
||||
return this.botguardClient;
|
||||
} finally {
|
||||
this.initializationPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _initBotguard() {
|
||||
const challengeResponse = await fetch(buildURL('Create', true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json+protobuf',
|
||||
'x-goog-api-key': GOOG_API_KEY,
|
||||
'x-user-agent': 'grpc-web-javascript/0.1'
|
||||
},
|
||||
body: JSON.stringify([ this.waaRequestKey ])
|
||||
});
|
||||
|
||||
const challengeResponseData = await challengeResponse.json();
|
||||
this.bgChallenge = BG.Challenge.parseChallengeData(challengeResponseData);
|
||||
|
||||
if (!this.bgChallenge)
|
||||
return;
|
||||
|
||||
const interpreterJavascript = this.bgChallenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
|
||||
|
||||
if (!interpreterJavascript) {
|
||||
console.error('[BotguardService]', 'Could not get interpreter javascript. Interpreter Hash:', this.bgChallenge.interpreterHash);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.getElementById(this.bgChallenge.interpreterHash)) {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.id = this.bgChallenge.interpreterHash;
|
||||
script.textContent = interpreterJavascript;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
this.botguardClient = await BG.BotGuardClient.create({
|
||||
globalObj: globalThis,
|
||||
globalName: this.bgChallenge.globalName,
|
||||
program: this.bgChallenge.program
|
||||
});
|
||||
|
||||
if (this.bgChallenge) {
|
||||
const webPoSignalOutput: WebPoSignalOutput = [];
|
||||
const botguardResponse = await this.botguardClient.snapshot({ webPoSignalOutput });
|
||||
|
||||
const integrityTokenResponse = await fetchFunction(buildURL('GenerateIT', true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json+protobuf',
|
||||
'x-goog-api-key': GOOG_API_KEY,
|
||||
'x-user-agent': 'grpc-web-javacript/0.1'
|
||||
},
|
||||
body: JSON.stringify([ this.waaRequestKey, botguardResponse ])
|
||||
});
|
||||
|
||||
const integrityTokenResponseData = await integrityTokenResponse.json();
|
||||
const integrityToken = integrityTokenResponseData[0] as string | undefined;
|
||||
|
||||
if (!integrityToken) {
|
||||
console.error('[BotguardService]', 'Could not get integrity token. Interpreter Hash:', this.bgChallenge.interpreterHash);
|
||||
return;
|
||||
}
|
||||
|
||||
this.integrityTokenBasedMinter = await BG.WebPoMinter.create({ integrityToken }, webPoSignalOutput);
|
||||
}
|
||||
|
||||
return this.botguardClient;
|
||||
}
|
||||
|
||||
public mintColdStartToken(contentBinding: string) {
|
||||
return BG.PoToken.generateColdStartToken(contentBinding);
|
||||
}
|
||||
|
||||
public isInitialized() {
|
||||
return !!this.botguardClient && !!this.integrityTokenBasedMinter;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
if (this.botguardClient && this.bgChallenge) {
|
||||
this.botguardClient.shutdown();
|
||||
this.botguardClient = undefined;
|
||||
this.integrityTokenBasedMinter = undefined;
|
||||
|
||||
const script = document.getElementById(this.bgChallenge.interpreterHash);
|
||||
if (script) {
|
||||
script.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async reinit() {
|
||||
if (this.initializationPromise)
|
||||
return this.initializationPromise;
|
||||
this.dispose();
|
||||
return this.setup();
|
||||
}
|
||||
}
|
||||
|
||||
export const botguardService = new BotguardService();
|
||||
498
examples/sabr-shaka-example/src/ShakaPlayerAdapter.ts
Normal file
498
examples/sabr-shaka-example/src/ShakaPlayerAdapter.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
|
||||
import { FormatKeyUtils, type CacheManager, type RequestMetadataManager, isGoogleVideoURL } from 'googlevideo/utils';
|
||||
|
||||
import type { SabrFormat } from 'googlevideo/shared-types';
|
||||
|
||||
import {
|
||||
SabrUmpProcessor,
|
||||
type RequestFilter,
|
||||
type ResponseFilter,
|
||||
type SabrPlayerAdapter,
|
||||
type SabrRequestMetadata,
|
||||
type UmpProcessingResult
|
||||
} from 'googlevideo/sabr-streaming-adapter';
|
||||
|
||||
import {
|
||||
asMap, checkExtension,
|
||||
createRecoverableError,
|
||||
getInjectedProxyFunction,
|
||||
headersToGenericObject,
|
||||
makeResponse
|
||||
} from './helpers.js';
|
||||
|
||||
interface ShakaResponseArgs {
|
||||
uri: string;
|
||||
request: shaka.extern.Request;
|
||||
requestType: shaka.net.NetworkingEngine.RequestType;
|
||||
response: Response;
|
||||
arrayBuffer?: Uint8Array | ArrayBuffer;
|
||||
}
|
||||
|
||||
export class ShakaPlayerAdapter implements SabrPlayerAdapter {
|
||||
protected player: shaka.Player | null = null;
|
||||
private requestMetadataManager?: RequestMetadataManager;
|
||||
private cacheManager?: CacheManager;
|
||||
private abortController?: AbortController;
|
||||
|
||||
private requestFilter?: (type: shaka.net.NetworkingEngine.RequestType, request: shaka.extern.Request, context?: shaka.extern.RequestContext) => Promise<void>;
|
||||
private responseFilter?: (type: shaka.net.NetworkingEngine.RequestType, response: shaka.extern.Response, context?: shaka.extern.RequestContext) => Promise<void>;
|
||||
|
||||
public initialize(
|
||||
player: shaka.Player,
|
||||
requestMetadataManager: RequestMetadataManager,
|
||||
cacheManager: CacheManager
|
||||
): void {
|
||||
this.player = player;
|
||||
this.requestMetadataManager = requestMetadataManager;
|
||||
this.cacheManager = cacheManager;
|
||||
|
||||
const networkingEngine = shaka.net.NetworkingEngine;
|
||||
const schemes = [ 'http', 'https' ];
|
||||
|
||||
if (!shaka.net.HttpFetchPlugin.isSupported())
|
||||
throw new Error('The Fetch API is not supported in this browser.');
|
||||
|
||||
schemes.forEach((scheme) => {
|
||||
networkingEngine.registerScheme(
|
||||
scheme, this.parseRequest.bind(this),
|
||||
networkingEngine.PluginPriority.PREFERRED
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private parseRequest(
|
||||
uri: string,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType,
|
||||
progressUpdated: shaka.extern.ProgressUpdated,
|
||||
headersReceived: shaka.extern.HeadersReceived,
|
||||
config: shaka.extern.SchemePluginConfig
|
||||
): shaka.extern.IAbortableOperation<shaka.extern.Response> {
|
||||
const headers = new Headers();
|
||||
asMap(request.headers).forEach((value, key) => {
|
||||
headers.append(key as string, value);
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
this.abortController = controller;
|
||||
|
||||
const init: RequestInit = {
|
||||
body: request.body as any || undefined,
|
||||
headers,
|
||||
method: request.method,
|
||||
signal: this.abortController.signal,
|
||||
credentials: request.allowCrossSiteCredentials ? 'include' : undefined
|
||||
};
|
||||
|
||||
const abortStatus = { canceled: false, timedOut: false };
|
||||
|
||||
const minBytes = config.minBytesForProgressEvents || 0;
|
||||
|
||||
const pendingRequest = this.request(uri, request, requestType, init, controller, abortStatus, progressUpdated, headersReceived, minBytes);
|
||||
|
||||
const operation = new shaka.util.AbortableOperation(
|
||||
pendingRequest,
|
||||
() => {
|
||||
abortStatus.canceled = true;
|
||||
controller.abort();
|
||||
return Promise.resolve();
|
||||
}
|
||||
);
|
||||
|
||||
const timeoutMs = request.retryParameters.timeout;
|
||||
if (timeoutMs) {
|
||||
const timer = new shaka.util.Timer(() => {
|
||||
abortStatus.timedOut = true;
|
||||
controller.abort();
|
||||
console.warn('[ShakaPlayerAdapter]', 'Request aborted due to timeout:', uri, requestType);
|
||||
});
|
||||
timer.tickAfter(timeoutMs / 1000);
|
||||
operation.finally(() => timer.stop());
|
||||
}
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async handleCachedRequest(
|
||||
requestMetadata: SabrRequestMetadata,
|
||||
uri: string,
|
||||
request: shaka.extern.Request,
|
||||
progressUpdated: shaka.extern.ProgressUpdated,
|
||||
headersReceived: shaka.extern.HeadersReceived,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType
|
||||
): Promise<shaka.extern.Response | null> {
|
||||
if (!requestMetadata.byteRange || !this.cacheManager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segmentKey = FormatKeyUtils.createSegmentCacheKeyFromMetadata(requestMetadata);
|
||||
|
||||
let arrayBuffer = (
|
||||
requestMetadata.isInit ?
|
||||
this.cacheManager.getInitSegment(segmentKey) :
|
||||
this.cacheManager.getSegment(segmentKey)
|
||||
)?.buffer as ArrayBuffer;
|
||||
|
||||
if (!arrayBuffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (requestMetadata.isInit) {
|
||||
arrayBuffer = arrayBuffer.slice(
|
||||
requestMetadata.byteRange.start,
|
||||
requestMetadata.byteRange.end + 1
|
||||
);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'content-type': requestMetadata.format?.mimeType?.split(';')[0] || '',
|
||||
'content-length': arrayBuffer.byteLength.toString(),
|
||||
'x-shaka-from-cache': 'true'
|
||||
};
|
||||
|
||||
headersReceived(headers);
|
||||
progressUpdated(0, arrayBuffer.byteLength, 0);
|
||||
|
||||
return makeResponse(headers, arrayBuffer, 200, uri, uri, request, requestType);
|
||||
}
|
||||
|
||||
private async handleUmpResponse(
|
||||
response: Response,
|
||||
requestMetadata: SabrRequestMetadata,
|
||||
uri: string,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType,
|
||||
progressUpdated: shaka.extern.ProgressUpdated,
|
||||
abortController: AbortController,
|
||||
minBytes: number
|
||||
): Promise<shaka.extern.Response> {
|
||||
let lastTime = Date.now();
|
||||
|
||||
const sabrUmpReader = new SabrUmpProcessor(requestMetadata, this.cacheManager);
|
||||
|
||||
const checkResultIntegrity = (result: UmpProcessingResult) => {
|
||||
if (!result.data && ((!!requestMetadata.error || requestMetadata.streamInfo?.streamProtectionStatus?.status === 3) && !requestMetadata.streamInfo?.sabrContextUpdate)) {
|
||||
throw createRecoverableError('Server streaming error', requestMetadata);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldReturnEmptyResponse = () => {
|
||||
return requestMetadata.isSABR && (requestMetadata.streamInfo?.redirect || requestMetadata.streamInfo?.sabrContextUpdate);
|
||||
};
|
||||
|
||||
// Fetch returning a ReadableStream response body is not currently
|
||||
// supported by all browsers.
|
||||
// Browser compatibility:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
|
||||
// If it is not supported, returning the whole segment when
|
||||
// it's ready (as xhr)
|
||||
if (!response.body) {
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const currentTime = Date.now();
|
||||
|
||||
progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
|
||||
|
||||
const result = await sabrUmpReader.processChunk(new Uint8Array(arrayBuffer));
|
||||
|
||||
if (result) {
|
||||
checkResultIntegrity(result);
|
||||
return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: result.data });
|
||||
}
|
||||
|
||||
if (shouldReturnEmptyResponse()) {
|
||||
return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: undefined });
|
||||
}
|
||||
|
||||
throw createRecoverableError('Empty response with no redirect information', requestMetadata);
|
||||
} else {
|
||||
const reader = response.body.getReader();
|
||||
|
||||
let loaded = 0;
|
||||
let lastLoaded = 0;
|
||||
let contentLength;
|
||||
|
||||
while (!abortController.signal.aborted) {
|
||||
let readObj;
|
||||
try {
|
||||
readObj = await reader.read();
|
||||
} catch {
|
||||
// If we abort the request while reading, we'll get an error here. Just ignore it.
|
||||
break;
|
||||
}
|
||||
|
||||
const { value, done } = readObj;
|
||||
|
||||
if (done) {
|
||||
// If we got here, we read the whole response but there was no segment data; it means we must follow a
|
||||
// redirect, or handle protocol updates.
|
||||
if (shouldReturnEmptyResponse()) {
|
||||
return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: undefined });
|
||||
}
|
||||
throw createRecoverableError('Empty response with no redirect information', requestMetadata);
|
||||
}
|
||||
|
||||
const result = await sabrUmpReader.processChunk(value);
|
||||
|
||||
const segmentInfo = sabrUmpReader.getSegmentInfo();
|
||||
|
||||
if (segmentInfo) {
|
||||
if (!contentLength) {
|
||||
contentLength = segmentInfo.mediaHeader.contentLength;
|
||||
}
|
||||
|
||||
loaded += segmentInfo.lastChunkSize || 0;
|
||||
segmentInfo.lastChunkSize = 0;
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
const chunkSize = loaded - lastLoaded;
|
||||
|
||||
// If the time between last time and this time we got
|
||||
// progress event is long enough, or if a whole segment
|
||||
// is downloaded, call progressUpdated().
|
||||
if ((currentTime - lastTime > 100 && chunkSize >= minBytes) || result) {
|
||||
// If we have a result, check its integrity before attempting anything.
|
||||
if (result) checkResultIntegrity(result);
|
||||
if (contentLength) {
|
||||
const numBytesRemaining = result ? 0 : parseInt(contentLength) - loaded;
|
||||
try {
|
||||
progressUpdated(currentTime - lastTime, chunkSize, numBytesRemaining);
|
||||
} catch { /** no-op */
|
||||
} finally {
|
||||
lastLoaded = loaded;
|
||||
lastTime = currentTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result) {
|
||||
abortController.abort();
|
||||
return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: result.data });
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable if the loop is aborted correctly.
|
||||
throw createRecoverableError('UMP stream processing was aborted but did not produce a result.', requestMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
private async request(
|
||||
uri: string,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType,
|
||||
init: RequestInit,
|
||||
abortController: AbortController,
|
||||
abortStatus: { canceled: boolean; timedOut: boolean },
|
||||
progressUpdated: shaka.extern.ProgressUpdated,
|
||||
headersReceived: shaka.extern.HeadersReceived,
|
||||
minBytes: number
|
||||
): Promise<shaka.extern.Response> {
|
||||
try {
|
||||
const requestMetadata = this.requestMetadataManager?.getRequestMetadata(uri);
|
||||
|
||||
// Check the cache first.
|
||||
if (requestMetadata) {
|
||||
const cachedResponse = await this.handleCachedRequest(requestMetadata, uri, request, progressUpdated, headersReceived, requestType);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// We only make one InnerTube request through the player, and it needs to be proxied properly.
|
||||
const fetchFn = uri.includes('get_drm_license') && checkExtension() ? getInjectedProxyFunction() : fetch;
|
||||
|
||||
const response = await fetchFn(uri, init);
|
||||
headersReceived(headersToGenericObject(response.headers));
|
||||
|
||||
if (requestMetadata && init.method !== 'HEAD' && response.headers.get('content-type') === 'application/vnd.yt-ump') {
|
||||
return this.handleUmpResponse(response, requestMetadata, uri, request, requestType, progressUpdated, abortController, minBytes);
|
||||
}
|
||||
|
||||
// Handle other requests normally.
|
||||
const lastTime = Date.now();
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const currentTime = Date.now();
|
||||
|
||||
progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
|
||||
|
||||
return this.createShakaResponse({
|
||||
uri,
|
||||
request,
|
||||
requestType,
|
||||
response,
|
||||
arrayBuffer
|
||||
});
|
||||
} catch (error) {
|
||||
if (abortStatus.canceled) {
|
||||
throw new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.OPERATION_ABORTED,
|
||||
uri, requestType
|
||||
);
|
||||
} else if (abortStatus.timedOut) {
|
||||
throw new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.TIMEOUT,
|
||||
uri, requestType
|
||||
);
|
||||
}
|
||||
throw new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.HTTP_ERROR,
|
||||
uri, error, requestType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public checkPlayerStatus(): asserts this is ({ player: shaka.Player } & this) {
|
||||
if (!this.player) {
|
||||
throw new Error('Player not initialized');
|
||||
}
|
||||
}
|
||||
|
||||
public getPlayerTime() {
|
||||
this.checkPlayerStatus();
|
||||
return this.player.getMediaElement()?.currentTime || 0;
|
||||
}
|
||||
|
||||
public getPlaybackRate() {
|
||||
this.checkPlayerStatus();
|
||||
return this.player.getPlaybackRate();
|
||||
}
|
||||
|
||||
public getBandwidthEstimate() {
|
||||
this.checkPlayerStatus();
|
||||
return this.player.getStats().estimatedBandwidth;
|
||||
}
|
||||
|
||||
public getActiveTrackFormats(activeFormat: SabrFormat, sabrFormats: SabrFormat[]): {
|
||||
videoFormat?: SabrFormat;
|
||||
audioFormat?: SabrFormat
|
||||
} {
|
||||
this.checkPlayerStatus();
|
||||
|
||||
const activeVariant = this.player.getVariantTracks().find((track) =>
|
||||
FormatKeyUtils.getUniqueFormatId(activeFormat) === (activeFormat.width ? track.originalVideoId : track.originalAudioId)
|
||||
);
|
||||
|
||||
if (!activeVariant) {
|
||||
return { videoFormat: undefined, audioFormat: undefined };
|
||||
}
|
||||
|
||||
const formatMap = new Map(sabrFormats.map((format) => [ FormatKeyUtils.getUniqueFormatId(format), format ]));
|
||||
|
||||
return {
|
||||
videoFormat: activeVariant.originalVideoId ? formatMap.get(activeVariant.originalVideoId) : undefined,
|
||||
audioFormat: activeVariant.originalAudioId ? formatMap.get(activeVariant.originalAudioId) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
public registerRequestInterceptor(interceptor: RequestFilter): void {
|
||||
this.checkPlayerStatus();
|
||||
|
||||
const networkingEngine = this.player.getNetworkingEngine();
|
||||
if (!networkingEngine)
|
||||
return;
|
||||
|
||||
this.requestFilter = async (type, request, context) => {
|
||||
if (type !== shaka.net.NetworkingEngine.RequestType.SEGMENT || !isGoogleVideoURL(request.uris[0])) return;
|
||||
|
||||
const modifiedRequest = await interceptor({
|
||||
headers: request.headers,
|
||||
url: request.uris[0],
|
||||
method: request.method,
|
||||
segment: {
|
||||
getStartTime: () => context?.segment?.getStartTime() ?? null,
|
||||
isInit: () => !context?.segment
|
||||
},
|
||||
body: request.body
|
||||
});
|
||||
|
||||
if (modifiedRequest) {
|
||||
request.uris = modifiedRequest.url ? [ modifiedRequest.url ] : request.uris;
|
||||
request.method = modifiedRequest.method || request.method;
|
||||
request.headers = modifiedRequest.headers || request.headers;
|
||||
request.body = modifiedRequest.body || request.body;
|
||||
}
|
||||
};
|
||||
|
||||
networkingEngine.registerRequestFilter(this.requestFilter);
|
||||
}
|
||||
|
||||
public registerResponseInterceptor(interceptor: ResponseFilter): void {
|
||||
this.checkPlayerStatus();
|
||||
const networkingEngine = this.player.getNetworkingEngine();
|
||||
if (!networkingEngine) return;
|
||||
|
||||
this.responseFilter = async (type, response, context) => {
|
||||
if (type !== shaka.net.NetworkingEngine.RequestType.SEGMENT || !isGoogleVideoURL(response.uri)) return;
|
||||
|
||||
const modifiedResponse = await interceptor({
|
||||
url: response.originalRequest.uris[0],
|
||||
method: response.originalRequest.method,
|
||||
headers: response.headers,
|
||||
data: response.data,
|
||||
makeRequest: async (url: string, headers: Record<string, string>) => {
|
||||
const retryParameters = this.player!.getConfiguration().streaming.retryParameters;
|
||||
const redirectRequest = shaka.net.NetworkingEngine.makeRequest([ url ], retryParameters);
|
||||
Object.assign(redirectRequest.headers, headers);
|
||||
|
||||
const requestOperation = networkingEngine.request(type, redirectRequest, context);
|
||||
const redirectResponse = await requestOperation.promise;
|
||||
|
||||
return {
|
||||
url: redirectResponse.uri,
|
||||
method: redirectResponse.originalRequest.method,
|
||||
headers: redirectResponse.headers,
|
||||
data: redirectResponse.data
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (modifiedResponse) {
|
||||
response.data = modifiedResponse.data ?? response.data;
|
||||
Object.assign(response.headers, modifiedResponse.headers);
|
||||
}
|
||||
};
|
||||
|
||||
networkingEngine.registerResponseFilter(this.responseFilter);
|
||||
}
|
||||
|
||||
public createShakaResponse(args: ShakaResponseArgs): shaka.extern.Response {
|
||||
return makeResponse(
|
||||
headersToGenericObject(args.response.headers),
|
||||
args.arrayBuffer as any || new ArrayBuffer(0),
|
||||
args.response.status,
|
||||
args.uri,
|
||||
args.response.url,
|
||||
args.request,
|
||||
args.requestType
|
||||
);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = undefined;
|
||||
}
|
||||
|
||||
if (this.player) {
|
||||
const networkingEngine = this.player.getNetworkingEngine();
|
||||
|
||||
if (networkingEngine && this.requestFilter && this.responseFilter) {
|
||||
networkingEngine.unregisterRequestFilter(this.requestFilter);
|
||||
networkingEngine.unregisterResponseFilter(this.responseFilter);
|
||||
}
|
||||
|
||||
shaka.net.NetworkingEngine.unregisterScheme('http');
|
||||
shaka.net.NetworkingEngine.unregisterScheme('https');
|
||||
|
||||
this.player = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
101
examples/sabr-shaka-example/src/helpers.ts
Normal file
101
examples/sabr-shaka-example/src/helpers.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
|
||||
export function checkExtension(): boolean {
|
||||
return 'ytcBridge' in window && (window as any).ytcBridge.installed;
|
||||
}
|
||||
|
||||
export function getInjectedProxyFunction() {
|
||||
return (window as any).proxyFetch;
|
||||
}
|
||||
|
||||
export async function fetchFunction(input: string | Request | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = input instanceof URL ? input : new URL(typeof input === 'string' ? input : input.url);
|
||||
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
||||
const requestInit = { ...init, headers };
|
||||
|
||||
if (url.pathname.includes('v1/player')) {
|
||||
url.searchParams.set('$fields', 'playerConfig,storyboards,captions,playabilityStatus,streamingData,responseContext.mainAppWebResponseContext.datasyncId,videoDetails.isLive,videoDetails.isLiveContent,videoDetails.title,videoDetails.author,videoDetails.thumbnail');
|
||||
}
|
||||
|
||||
const proxyFetch = getInjectedProxyFunction();
|
||||
|
||||
if (proxyFetch) {
|
||||
if (url.pathname.includes('initplayback')) {
|
||||
return fetch(url, requestInit);
|
||||
}
|
||||
return proxyFetch(url.toString(), requestInit);
|
||||
}
|
||||
|
||||
throw new Error('Proxy fetch function not found.');
|
||||
}
|
||||
|
||||
export function asMap<K, V>(object: Record<string, V>): Map<K, V> {
|
||||
const map = new Map<K, V>();
|
||||
for (const key of Object.keys(object)) {
|
||||
map.set(key as K, object[key]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function createRecoverableError(message: string, info?: Record<string, any>) {
|
||||
return new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.HTTP_ERROR,
|
||||
message,
|
||||
{ info }
|
||||
);
|
||||
}
|
||||
|
||||
export function headersToGenericObject(headers: Headers): Record<string, string> {
|
||||
const headersObj: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
// Since Edge incorrectly returns the header with a leading new line
|
||||
// character ('\n'), we trim the header here.
|
||||
headersObj[key.trim()] = value;
|
||||
});
|
||||
return headersObj;
|
||||
}
|
||||
|
||||
export function makeResponse(
|
||||
headers: Record<string, string>,
|
||||
data: BufferSource,
|
||||
status: number,
|
||||
uri: string,
|
||||
responseURL: string,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType
|
||||
): shaka.extern.Response & { originalRequest: shaka.extern.Request } {
|
||||
if (status >= 200 && status <= 299 && status !== 202) {
|
||||
return {
|
||||
uri: responseURL || uri,
|
||||
originalUri: uri,
|
||||
data,
|
||||
status,
|
||||
headers,
|
||||
originalRequest: request,
|
||||
fromCache: !!headers['x-shaka-from-cache']
|
||||
};
|
||||
}
|
||||
|
||||
let responseText: string | null = null;
|
||||
try {
|
||||
responseText = shaka.util.StringUtils.fromBytesAutoDetect(data);
|
||||
} catch { /* no-op */ }
|
||||
|
||||
const severity = status === 401 || status === 403
|
||||
? shaka.util.Error.Severity.CRITICAL
|
||||
: shaka.util.Error.Severity.RECOVERABLE;
|
||||
|
||||
throw new shaka.util.Error(
|
||||
severity,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.BAD_HTTP_STATUS,
|
||||
uri,
|
||||
status,
|
||||
responseText,
|
||||
headers,
|
||||
requestType,
|
||||
responseURL || uri
|
||||
);
|
||||
}
|
||||
248
examples/sabr-shaka-example/src/main.ts
Normal file
248
examples/sabr-shaka-example/src/main.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui.js';
|
||||
import { Innertube, UniversalCache, YT, Utils, Constants } from 'youtubei.js';
|
||||
import { SabrStreamingAdapter } from 'googlevideo/sabr-streaming-adapter';
|
||||
import { buildSabrFormat } from 'googlevideo/utils';
|
||||
import { ShakaPlayerAdapter } from './ShakaPlayerAdapter.js';
|
||||
import { checkExtension, fetchFunction } from './helpers.js';
|
||||
import { botguardService } from './BotguardService.js';
|
||||
import 'shaka-player/dist/controls.css';
|
||||
|
||||
const videoElement = document.getElementById('video') as HTMLVideoElement;
|
||||
const videoContainer = document.getElementById('video-container') as HTMLDivElement;
|
||||
const videoIdInput = document.getElementById('videoIdInput') as HTMLInputElement;
|
||||
const loadButton = document.getElementById('loadButton') as HTMLButtonElement;
|
||||
const statusElement = document.getElementById('status') as HTMLDivElement;
|
||||
|
||||
let player: shaka.Player;
|
||||
let sabrAdapter: SabrStreamingAdapter;
|
||||
let innertube: Innertube;
|
||||
let sessionPoTokenContentBinding: string | undefined;
|
||||
let sessionPoTokenCreationLock = false;
|
||||
let sessionPoToken: string | undefined;
|
||||
let coldStartToken: string | undefined;
|
||||
|
||||
async function main() {
|
||||
shaka.polyfill.installAll();
|
||||
|
||||
if (!shaka.Player.isBrowserSupported())
|
||||
throw new Error('Shaka Player is not supported on this browser.');
|
||||
|
||||
if (!checkExtension()) {
|
||||
throw new Error('This application requires the "ytc-bridge" browser extension to function. This extension is needed to communicate with YouTube\'s internal APIs by bypassing browser security restrictions (like CORS) that would otherwise block requests. Please install the extension from https://github.com/LuanRT/ytc-bridge and then reload the page.');
|
||||
}
|
||||
|
||||
innertube = await Innertube.create({
|
||||
cache: new UniversalCache(true),
|
||||
fetch: fetchFunction
|
||||
});
|
||||
|
||||
botguardService.init().then(() => console.info('[App]', 'BotGuard client initialized'));
|
||||
|
||||
sessionPoTokenContentBinding = innertube.session.context.client.visitorData;
|
||||
|
||||
console.log('[Main] Innertube initialized');
|
||||
|
||||
// Now init the player.
|
||||
player = new shaka.Player(videoElement);
|
||||
player.configure({
|
||||
abr: { enabled: true },
|
||||
streaming: {
|
||||
bufferingGoal: 120,
|
||||
rebufferingGoal: 2
|
||||
}
|
||||
});
|
||||
|
||||
const ui = new shaka.ui.Overlay(player, videoContainer, videoElement);
|
||||
|
||||
ui.configure({
|
||||
addBigPlayButton: false,
|
||||
overflowMenuButtons: [
|
||||
'captions',
|
||||
'quality',
|
||||
'language',
|
||||
'chapter',
|
||||
'picture_in_picture',
|
||||
'playback_rate',
|
||||
'loop',
|
||||
'recenter_vr',
|
||||
'toggle_stereoscopic',
|
||||
'save_video_frame'
|
||||
],
|
||||
customContextMenu: true
|
||||
});
|
||||
|
||||
const volumeContainer = videoContainer.getElementsByClassName('shaka-volume-bar-container');
|
||||
volumeContainer[0].addEventListener('mousewheel', (event) => {
|
||||
event.preventDefault();
|
||||
const delta = Math.sign((event as any).deltaY);
|
||||
const newVolume = Math.max(0, Math.min(1, videoElement.volume - delta * 0.05));
|
||||
videoElement.volume = newVolume;
|
||||
});
|
||||
|
||||
console.log('[Main] Shaka Player initialized');
|
||||
|
||||
// Set up UI listeners.
|
||||
loadButton.addEventListener('click', () => loadVideo(videoIdInput.value));
|
||||
loadButton.disabled = false;
|
||||
statusElement.textContent = 'Ready. Enter a video ID and click "Load Video".';
|
||||
}
|
||||
|
||||
async function loadVideo(videoId: string) {
|
||||
if (!videoId) {
|
||||
alert('Please enter a video ID.');
|
||||
return;
|
||||
}
|
||||
|
||||
statusElement.textContent = `Loading video: ${videoId}...`;
|
||||
console.log('[Player]', `Loading video: ${videoId}`);
|
||||
|
||||
try {
|
||||
// Unload previous video.
|
||||
await player.unload();
|
||||
|
||||
if (sabrAdapter) {
|
||||
sabrAdapter.dispose();
|
||||
}
|
||||
|
||||
// Now fetch video info from YouTube.
|
||||
const playerResponse = await innertube.actions.execute('/player', {
|
||||
videoId,
|
||||
contentCheckOk: true,
|
||||
racyCheckOk: true,
|
||||
playbackContext: {
|
||||
adPlaybackContext: {
|
||||
pyv: true
|
||||
},
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: innertube.session.player?.sts
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const cpn = Utils.generateRandomString(16);
|
||||
const videoInfo = new YT.VideoInfo([ playerResponse ], innertube.actions, cpn);
|
||||
|
||||
if (videoInfo.playability_status?.status !== 'OK') {
|
||||
throw new Error(`Cannot play video: ${videoInfo.playability_status?.reason}`);
|
||||
}
|
||||
|
||||
const isLive = videoInfo.basic_info.is_live;
|
||||
const isPostLiveDVR = !!videoInfo.basic_info.is_post_live_dvr ||
|
||||
(videoInfo.basic_info.is_live_content && !!(videoInfo.streaming_data?.dash_manifest_url || videoInfo.streaming_data?.hls_manifest_url));
|
||||
|
||||
// Initialize and attach SABR adapter.
|
||||
sabrAdapter = new SabrStreamingAdapter({
|
||||
playerAdapter: new ShakaPlayerAdapter(),
|
||||
clientInfo: {
|
||||
osName: innertube.session.context.client.osName,
|
||||
osVersion: innertube.session.context.client.osVersion,
|
||||
clientName: parseInt(Constants.CLIENT_NAME_IDS[innertube.session.context.client.clientName as keyof typeof Constants.CLIENT_NAME_IDS]),
|
||||
clientVersion: innertube.session.context.client.clientVersion
|
||||
}
|
||||
});
|
||||
|
||||
sabrAdapter.onMintPoToken(async () => {
|
||||
if (!sessionPoToken) {
|
||||
// For live streams, we must block and wait for the PO token as it's sometimes required for playback to start.
|
||||
// For VODs, we can mint the token in the background to avoid delaying playback, as it's not immediately required.
|
||||
// While BotGuard is pretty darn fast, it still makes a difference in user experience (from my own testing).
|
||||
if (isLive) {
|
||||
await mintSessionPoToken();
|
||||
} else {
|
||||
mintSessionPoToken().then();
|
||||
}
|
||||
}
|
||||
|
||||
return sessionPoToken || coldStartToken || '';
|
||||
});
|
||||
|
||||
sabrAdapter.onReloadPlayerResponse(async (reloadContext) => {
|
||||
console.log('[SABR]', 'Reloading player response...');
|
||||
|
||||
const reloadedInfo = await innertube.actions.execute('/player', {
|
||||
videoId,
|
||||
contentCheckOk: true,
|
||||
racyCheckOk: true,
|
||||
playbackContext: {
|
||||
adPlaybackContext: {
|
||||
pyv: true
|
||||
},
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: innertube.session.player?.sts
|
||||
},
|
||||
reloadPlaybackContext: reloadContext
|
||||
}
|
||||
});
|
||||
|
||||
const parsedInfo = new YT.VideoInfo([ reloadedInfo ], innertube.actions, cpn);
|
||||
sabrAdapter.setStreamingURL(innertube.session.player!.decipher(parsedInfo.streaming_data?.server_abr_streaming_url));
|
||||
sabrAdapter.setUstreamerConfig(videoInfo.player_config?.media_common_config.media_ustreamer_request_config?.video_playback_ustreamer_config);
|
||||
});
|
||||
|
||||
sabrAdapter.attach(player);
|
||||
|
||||
if (videoInfo.streaming_data && !isPostLiveDVR && !isLive) {
|
||||
sabrAdapter.setStreamingURL(innertube.session.player!.decipher(videoInfo.streaming_data?.server_abr_streaming_url));
|
||||
sabrAdapter.setUstreamerConfig(videoInfo.player_config?.media_common_config.media_ustreamer_request_config?.video_playback_ustreamer_config);
|
||||
sabrAdapter.setServerAbrFormats(videoInfo.streaming_data.adaptive_formats.map(buildSabrFormat));
|
||||
}
|
||||
|
||||
let manifestUri: string | undefined;
|
||||
if (videoInfo.streaming_data) {
|
||||
if (isLive) {
|
||||
manifestUri = videoInfo.streaming_data.dash_manifest_url ? `${videoInfo.streaming_data.dash_manifest_url}/mpd_version/7` : videoInfo.streaming_data.hls_manifest_url;
|
||||
} else if (isPostLiveDVR) {
|
||||
manifestUri = videoInfo.streaming_data.hls_manifest_url || `${videoInfo.streaming_data.dash_manifest_url}/mpd_version/7`;
|
||||
} else {
|
||||
manifestUri = `data:application/dash+xml;base64,${btoa(await videoInfo.toDash({
|
||||
manifest_options: {
|
||||
is_sabr: true,
|
||||
captions_format: 'vtt',
|
||||
include_thumbnails: false
|
||||
}
|
||||
}))}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!manifestUri)
|
||||
throw new Error('Could not find a valid manifest URI.');
|
||||
|
||||
await player.load(manifestUri);
|
||||
|
||||
statusElement.textContent = `Playing: ${videoInfo.basic_info.title}`;
|
||||
console.log('[Player]', `Now playing: ${videoInfo.basic_info.title}`);
|
||||
} catch (e: any) {
|
||||
console.error('[Player]', 'Error:', e);
|
||||
statusElement.textContent = `Error: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function mintSessionPoToken() {
|
||||
if (!sessionPoTokenContentBinding || sessionPoTokenCreationLock) return;
|
||||
|
||||
sessionPoTokenCreationLock = true;
|
||||
try {
|
||||
coldStartToken = botguardService.mintColdStartToken(sessionPoTokenContentBinding);
|
||||
console.info('[Player]', `Cold start token created (Content binding: ${decodeURIComponent(sessionPoTokenContentBinding)})`);
|
||||
|
||||
if (!botguardService.isInitialized()) await botguardService.reinit();
|
||||
|
||||
if (botguardService.integrityTokenBasedMinter) {
|
||||
sessionPoToken = await botguardService.integrityTokenBasedMinter.mintAsWebsafeString(decodeURIComponent(sessionPoTokenContentBinding));
|
||||
console.info('[Player]', `Session PO token created (Content binding: ${decodeURIComponent(sessionPoTokenContentBinding)})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Player]', 'Error minting session PO token', err);
|
||||
} finally {
|
||||
sessionPoTokenCreationLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
statusElement.textContent = 'Initializing...';
|
||||
loadButton.disabled = true;
|
||||
main().catch((err) => {
|
||||
console.error('Initialization failed:', err);
|
||||
statusElement.textContent = `Initialization failed: ${err.message}`;
|
||||
});
|
||||
});
|
||||
17
examples/sabr-shaka-example/tsconfig.json
Normal file
17
examples/sabr-shaka-example/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user