mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-16 02:44:51 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31bd0adcef | ||
|
|
0d8b33aee3 | ||
|
|
645b226359 | ||
|
|
61ee6238d5 | ||
|
|
a7717dfcb9 | ||
|
|
337f338224 | ||
|
|
72328aacf6 | ||
|
|
b1966b0c57 | ||
|
|
77c1e36047 | ||
|
|
a4d125738a | ||
|
|
14dca7405e | ||
|
|
3c9e850f31 | ||
|
|
1c7f922996 |
@@ -6,7 +6,7 @@ permissions:
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- '.github/workflows/android-build.yml'
|
||||
- 'example/android/**'
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -28,26 +31,25 @@ jobs:
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
# OIDC trusted publishing requires npm CLI >= 11.5.1; ubuntu-latest ships an older one.
|
||||
- name: Upgrade npm CLI (for OIDC trusted publishing)
|
||||
run: npm install -g npm@latest
|
||||
- name: Cache bun dependencies
|
||||
id: bun-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install npm dependencies (bun)
|
||||
- name: Install/Typecheck/Build
|
||||
# We use a `prepare` script that run builds to support git installs.
|
||||
run: bun install
|
||||
|
||||
- name: Build lib
|
||||
run: bun run build
|
||||
|
||||
- name: Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
GIT_AUTHOR_NAME: ${{ github.actor }}
|
||||
GIT_AUTHOR_EMAIL: '${{ github.actor }}@users.noreply.github.com'
|
||||
GIT_COMMITTER_NAME: ${{ github.actor }}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"branches": ["master"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"@semantic-release/npm",
|
||||
"@semantic-release/github"
|
||||
]
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
# react-native-omni
|
||||
|
||||
video player using vlc and videojs
|
||||
A library to have real players on android and web. It uses VLC on android and
|
||||
videojs v10 on the web (ios not implemented yet, PR welcome)
|
||||
|
||||
[](https://www.npmjs.com/package/react-native-omni)
|
||||
[](https://www.npmjs.com/package/react-native-omni)
|
||||
[](https://github.com/patrickkabwe/react-native-omni/LICENSE)
|
||||
## Features
|
||||
|
||||
## Requirements
|
||||
- **vlc v4**: to support more codecs than exoplayer, hdr and so on
|
||||
- **Adaptive streaming**: HLS out of the box, with automatic quality
|
||||
(rendition) selection or manual override.
|
||||
- **Multi-track playback**: enumerate and switch video, audio, and subtitle
|
||||
tracks at runtime.
|
||||
- **Rich subtitle support**: vtt, srt, ass (via [jassub](https://github.com/ThaUnknown/jassub)) and pgs (via [libpgs](https://github.com/Arcus92/libpgs-js))
|
||||
- **Picture-in-Picture**: enter PiP automatically or on demand on Android.
|
||||
- **and basic player stuff**: media sessions, playlists, hook based api...
|
||||
|
||||
- React Native v0.76.0 or higher
|
||||
- Node 18.0.0 or higher
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To Support `Nitro Views` you need to install React Native version v0.78.0 or higher.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -20,10 +21,240 @@ video player using vlc and videojs
|
||||
bun add react-native-omni react-native-nitro-modules
|
||||
```
|
||||
|
||||
## Credits
|
||||
### Expo config plugin (Android)
|
||||
|
||||
Bootstrapped with [create-nitro-module](https://github.com/patrickkabwe/create-nitro-module).
|
||||
The library ships an Expo config plugin that wires up media notifications and
|
||||
picture-in-picture. Add it to your `app.json` / `app.config.js`:
|
||||
|
||||
## Contributing
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"plugins": ["react-native-omni"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
||||
The plugin will:
|
||||
|
||||
- Register the `OmniPlayerService` media session service and add the
|
||||
`FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permissions.
|
||||
- Enable Picture-in-Picture on your `MainActivity` (declares
|
||||
`supportsPictureInPicture`, adds the required `configChanges`, and hooks the
|
||||
pip lifecycle callbacks).
|
||||
|
||||
If you are not using Expo, replicate those manifest/activity changes manually.
|
||||
|
||||
### Web setup
|
||||
|
||||
Most features are available out of the box, you need custom steps for advanced
|
||||
subtitles rendering:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>ass rendering</summary>
|
||||
|
||||
#### Cross-origin isolation
|
||||
|
||||
jassub's wasm is multi-threaded and relies on `SharedArrayBuffer`, which
|
||||
browsers only expose on a **cross-origin-isolated** page. Serve these response
|
||||
headers on your HTML document:
|
||||
|
||||
```
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: credentialless
|
||||
```
|
||||
|
||||
`credentialless` keeps cross-origin video/subtitle/font requests working without
|
||||
requiring `Cross-Origin-Resource-Policy` headers on every remote asset. Without
|
||||
isolation, JASSUB initializes but silently never renders.
|
||||
|
||||
</details>
|
||||
|
||||
## Quick start
|
||||
|
||||
Wrap your player UI in an `OmniProvider` with a `source`, then render an
|
||||
`OmniView` and drive it with the hooks.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
OmniProvider,
|
||||
OmniView,
|
||||
usePlayer,
|
||||
usePlayerState,
|
||||
useEvent,
|
||||
} from "react-native-omni";
|
||||
|
||||
function Player() {
|
||||
const player = usePlayer();
|
||||
const isPlaying = usePlayerState("isPlaying");
|
||||
const currentTime = usePlayerState("currentTime");
|
||||
const duration = usePlayerState("duration");
|
||||
|
||||
useEvent("end", () => player.playNext());
|
||||
|
||||
return (
|
||||
<>
|
||||
<OmniView style={{ width: "100%", aspectRatio: 16 / 9 }} autoplay />
|
||||
<Button
|
||||
title={isPlaying ? "Pause" : "Play"}
|
||||
onPress={() => (isPlaying ? player.pause() : player.play())}
|
||||
/>
|
||||
<Text>
|
||||
{currentTime.toFixed(0)} / {duration.toFixed(0)}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const source = {
|
||||
src: [{ uri: "https://example.com/stream.m3u8", headers: {} }],
|
||||
subtitles: [],
|
||||
metadata: { title: "My video", hasPrev: false, hasNext: true },
|
||||
};
|
||||
|
||||
return (
|
||||
<OmniProvider source={source} showNotification>
|
||||
<Player />
|
||||
</OmniProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `<OmniProvider>`
|
||||
|
||||
Creates the underlying player and exposes it to children. Must wrap any
|
||||
component that uses `<OmniView>`, `usePlayer`, `usePlayerState`, or `useEvent`.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------------ | --------- | ------- | ---------------------------------------------------- |
|
||||
| `source` | `Source` | — | The media to play. |
|
||||
| `showNotification` | `boolean` | `false` | Create a media session (OS controls/notification). |
|
||||
| `children` | `ReactNode` | — | Your player UI. |
|
||||
|
||||
Updating `source` swaps the media in place (and seeks to `source.startTime` if
|
||||
set) without recreating the player.
|
||||
|
||||
### `<OmniView>`
|
||||
|
||||
Renders the video surface. Place it inside an `OmniProvider`. Accepts a `style`
|
||||
plus:
|
||||
|
||||
| Prop | Type | Platform | Description |
|
||||
| ---------------- | ---------------- | -------- | ----------------------------------------------------------------------- |
|
||||
| `autoplay` | `boolean` | all | Start playing as soon as the media is ready. |
|
||||
| `autoPip` | `boolean` | Android | Automatically enter Picture-in-Picture when the app is backgrounded. |
|
||||
| `subtitleAssets` | `SubtitleAssets` | Web | URLs for the ASS/PGS renderer worker/wasm/font assets (see below). |
|
||||
|
||||
### `usePlayer()`
|
||||
|
||||
Returns the `OmniPlayer` instance — an imperative handle for controlling
|
||||
playback and reading/writing state.
|
||||
|
||||
**Playback controls**
|
||||
|
||||
```ts
|
||||
player.play();
|
||||
player.pause();
|
||||
player.seekBy(offset); // relative seek in seconds
|
||||
|
||||
player.playPrev(); // fires the `prev` event (implement navigation yourself)
|
||||
player.playNext(); // fires the `next` event
|
||||
player.hasPrev; // readonly boolean
|
||||
player.hasNext; // readonly boolean
|
||||
```
|
||||
|
||||
**Writable state**
|
||||
|
||||
```ts
|
||||
player.currentTime = 42; // seek to absolute position (seconds)
|
||||
player.playbackRate = 1.5;
|
||||
player.volume = 0.8; // 0..1
|
||||
player.muted = true;
|
||||
```
|
||||
|
||||
**Readonly state**
|
||||
|
||||
```ts
|
||||
player.status; // "idle" | "loading" | "readyToPlay" | "error"
|
||||
player.isPlaying;
|
||||
player.buffered; // seconds buffered
|
||||
player.duration; // seconds
|
||||
player.isAutoQuality; // whether rendition selection is automatic
|
||||
```
|
||||
|
||||
**Tracks & renditions**
|
||||
|
||||
```ts
|
||||
player.videos; // Track[]
|
||||
player.selectVideo(track);
|
||||
|
||||
player.audios; // Track[]
|
||||
player.selectAudio(track);
|
||||
|
||||
player.subtitles; // Track[]
|
||||
player.selectSubtitle(track); // pass undefined to turn subtitles off
|
||||
|
||||
player.rendition; // Rendition[]
|
||||
player.selectRendition(rendition); // pass undefined for automatic quality
|
||||
```
|
||||
|
||||
> Prefer `usePlayerState`/`useEvent` for values that change over time — reading
|
||||
> them directly off `player` gives you a one-time snapshot and won't re-render.
|
||||
|
||||
### `usePlayerState(key, refresh?)`
|
||||
|
||||
Subscribes to a single reactive player property and re-renders when it changes.
|
||||
Only the component using it re-renders.
|
||||
|
||||
```ts
|
||||
const status = usePlayerState("status");
|
||||
const isPlaying = usePlayerState("isPlaying");
|
||||
const duration = usePlayerState("duration");
|
||||
const currentTime = usePlayerState("currentTime", 1); // poll every 1s
|
||||
```
|
||||
|
||||
The optional `refresh` argument (seconds) sets a polling interval.
|
||||
Useful for `currentTime`, which defaults to a 1s refresh (we don't refresh every
|
||||
ms for this because crossing js/native bridge on native consumes lot of battery)
|
||||
|
||||
### `useEvent(event, callback)`
|
||||
|
||||
Subscribes to a player event for the lifetime of the component.
|
||||
|
||||
```ts
|
||||
useEvent("end", () => player.playNext());
|
||||
useEvent("prev", handlePrev);
|
||||
useEvent("next", handleNext);
|
||||
useEvent("error", (type, message) => console.warn(type, message));
|
||||
useEvent("audioFocusChange", (status) => {/* … */});
|
||||
|
||||
useEvent("videoTrackChange", (track) => {/* … */});
|
||||
useEvent("audioTrackChange", (track) => {/* … */});
|
||||
useEvent("subtitleChange", (track) => {/* track may be undefined = off */});
|
||||
useEvent("renditionChange", (rendition) => {/* … */});
|
||||
```
|
||||
|
||||
| Event | Signature | When |
|
||||
| ------------------ | -------------------------------------- | --------------------------------------------- |
|
||||
| `end` | `() => void` | Playback reached the end. |
|
||||
| `prev` / `next` | `() => void` | Prev/next requested (from UI or OS controls). |
|
||||
| `error` | `(type, message) => void` | A playback error occurred. |
|
||||
| `audioFocusChange` | `(status) => void` | The OS audio focus changed. |
|
||||
| `videoTrackChange` | `(track) => void` | The active video track changed. |
|
||||
| `audioTrackChange` | `(track) => void` | The active audio track changed. |
|
||||
| `subtitleChange` | `(track?) => void` | The active subtitle changed (`undefined` = off). |
|
||||
| `renditionChange` | `(rendition) => void` | The active quality/rendition changed. |
|
||||
|
||||
## Example app
|
||||
|
||||
A full example (playlist navigation, track/rendition selectors, an event log,
|
||||
and web subtitle assets) lives in [`example/`](./example). To run it:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run android # Android
|
||||
bun run web # Web
|
||||
```
|
||||
|
||||
@@ -73,3 +73,4 @@ yarn-error.log
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
dist/
|
||||
|
||||
+43
-16
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import {
|
||||
OmniProvider,
|
||||
@@ -15,6 +15,7 @@ const PLAYLIST = [
|
||||
artist: "multi audio",
|
||||
album: "Adaptive",
|
||||
uri: "https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/index.m3u8",
|
||||
imageLink: undefined,
|
||||
},
|
||||
{
|
||||
title: "Big Buck Bunny (HLS)",
|
||||
@@ -23,6 +24,7 @@ const PLAYLIST = [
|
||||
artwork:
|
||||
"https://peach.blender.org/wp-content/uploads/title_anouncement.jpg",
|
||||
uri: "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
|
||||
imageLink: undefined,
|
||||
},
|
||||
{
|
||||
title: "Sintel Trailer (MP4)",
|
||||
@@ -31,10 +33,10 @@ const PLAYLIST = [
|
||||
artwork:
|
||||
"https://download.blender.org/durian/trailer/sintel_trailer-480p.jpg",
|
||||
uri: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4",
|
||||
imageLink: undefined,
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
return "00:00";
|
||||
@@ -217,7 +219,15 @@ function PlayerExample({
|
||||
<Text style={styles.heading}>react-native-omni</Text>
|
||||
<Text style={styles.subheading}>{trackLabel}</Text>
|
||||
|
||||
<OmniView style={styles.video} autoplay={true} showNotification={true} />
|
||||
<OmniView
|
||||
style={styles.video}
|
||||
autoplay={true}
|
||||
subtitleAssets={{
|
||||
jassub: {
|
||||
fontUrl: "/jassub/default.woff2",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Pressable style={styles.button} onPress={togglePlayback}>
|
||||
@@ -272,9 +282,9 @@ function PlayerExample({
|
||||
{tracks.videos.length === 0 ? (
|
||||
<Text style={styles.emptyTrackText}>No video tracks</Text>
|
||||
) : (
|
||||
tracks.videos.map((video, index) => (
|
||||
tracks.videos.map((video) => (
|
||||
<Pressable
|
||||
key={`video-${video.id}-${index}`}
|
||||
key={`video-${video.id}`}
|
||||
style={[
|
||||
styles.trackButton,
|
||||
video.selected && styles.selectedTrackButton,
|
||||
@@ -299,9 +309,9 @@ function PlayerExample({
|
||||
{tracks.audios.length === 0 ? (
|
||||
<Text style={styles.emptyTrackText}>No audio tracks</Text>
|
||||
) : (
|
||||
tracks.audios.map((audio, index) => (
|
||||
tracks.audios.map((audio) => (
|
||||
<Pressable
|
||||
key={`audio-${audio.id}-${index}`}
|
||||
key={`audio-${audio.id}`}
|
||||
style={[
|
||||
styles.trackButton,
|
||||
audio.selected && styles.selectedTrackButton,
|
||||
@@ -344,9 +354,9 @@ function PlayerExample({
|
||||
{tracks.subtitles.length === 0 ? (
|
||||
<Text style={styles.emptyTrackText}>No subtitles</Text>
|
||||
) : (
|
||||
tracks.subtitles.map((subtitle, index) => (
|
||||
tracks.subtitles.map((subtitle) => (
|
||||
<Pressable
|
||||
key={`subtitle-${subtitle.id}-${index}`}
|
||||
key={`subtitle-${subtitle.id}`}
|
||||
style={[
|
||||
styles.trackButton,
|
||||
subtitle.selected && styles.selectedTrackButton,
|
||||
@@ -381,15 +391,18 @@ function PlayerExample({
|
||||
isAutoQuality && styles.selectedTrackButtonText,
|
||||
]}
|
||||
>
|
||||
Auto{isAutoQuality && tracks.renditions.find(r => r.selected) ? ` (${tracks.renditions.find(r => r.selected)!.height}p)` : ""}
|
||||
Auto
|
||||
{isAutoQuality && tracks.renditions.find((r) => r.selected)
|
||||
? ` (${tracks.renditions.find((r) => r.selected)!.height}p)`
|
||||
: ""}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{tracks.renditions.length === 0 ? (
|
||||
<Text style={styles.emptyTrackText}>No renditions</Text>
|
||||
) : (
|
||||
tracks.renditions.map((rendition, index) => (
|
||||
tracks.renditions.map((rendition) => (
|
||||
<Pressable
|
||||
key={`rendition-${rendition.id}-${index}`}
|
||||
key={`rendition-${rendition.id}`}
|
||||
style={[
|
||||
styles.trackButton,
|
||||
rendition.selected && styles.selectedTrackButton,
|
||||
@@ -419,8 +432,8 @@ function PlayerExample({
|
||||
{logs.length === 0 ? (
|
||||
<Text style={styles.logText}>Event log will appear here.</Text>
|
||||
) : (
|
||||
logs.map((entry, index) => (
|
||||
<Text key={`${entry}-${index}`} style={styles.logText}>
|
||||
logs.map((entry) => (
|
||||
<Text key={`${entry}`} style={styles.logText}>
|
||||
{entry}
|
||||
</Text>
|
||||
))
|
||||
@@ -449,12 +462,26 @@ function App(): React.JSX.Element {
|
||||
headers: {},
|
||||
},
|
||||
],
|
||||
subtitles: [],
|
||||
subtitles: [
|
||||
{
|
||||
id: "kusu",
|
||||
link: "https://jassub.pages.dev/subtitles/Kusriya%20S2%20OP1v3.ass",
|
||||
},
|
||||
{
|
||||
id: "pgs",
|
||||
link: "https://raw.githubusercontent.com/Arcus92/libpgs-js/main/tests/files/test.sup",
|
||||
label: "PGS test",
|
||||
},
|
||||
],
|
||||
fonts: [
|
||||
"https://jassub.pages.dev/fonts/FOT-TsukuCOldMinPr6NR.OTF",
|
||||
"https://jassub.pages.dev/fonts/arial.ttf",
|
||||
],
|
||||
metadata: {
|
||||
title: PLAYLIST[currentIndex].title,
|
||||
artist: PLAYLIST[currentIndex].artist,
|
||||
album: PLAYLIST[currentIndex].album,
|
||||
imageLink: PLAYLIST[currentIndex].artwork,
|
||||
imageLink: PLAYLIST[currentIndex].imageLink,
|
||||
hasPrev: true,
|
||||
hasNext: true,
|
||||
},
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.3",
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@react-native-community/cli": "20.2.0",
|
||||
"@react-native-community/cli-platform-android": "20.2.0",
|
||||
"@react-native-community/cli-platform-ios": "20.2.0",
|
||||
"@react-native-community/cli": "20.1.0",
|
||||
"@react-native-community/cli-platform-android": "20.1.0",
|
||||
"@react-native-community/cli-platform-ios": "20.1.0",
|
||||
"@react-native/babel-preset": "0.84.1",
|
||||
"@react-native/eslint-config": "0.84.1",
|
||||
"@react-native/metro-config": "0.84.1",
|
||||
|
||||
Binary file not shown.
@@ -4,7 +4,6 @@
|
||||
"exclude": ["**/node_modules", "**/Pods"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"react-native-omni": ["../src"]
|
||||
}
|
||||
|
||||
+70
-61
@@ -5,65 +5,74 @@ const appDirectory = path.resolve(__dirname);
|
||||
const rootDirectory = path.resolve(__dirname, "..");
|
||||
|
||||
module.exports = {
|
||||
mode: "development",
|
||||
devtool: "eval-source-map",
|
||||
entry: path.resolve(__dirname, "index.web.js"),
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
filename: "bundle.web.js",
|
||||
publicPath: "/",
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".web.tsx", ".web.ts", ".tsx", ".ts", ".web.js", ".js"],
|
||||
alias: {
|
||||
"react-native$": "react-native-web",
|
||||
"react-native-omni": path.resolve(rootDirectory, "src"),
|
||||
react: path.resolve(appDirectory, "node_modules", "react"),
|
||||
"react-dom": path.resolve(appDirectory, "node_modules", "react-dom"),
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|ts|tsx)$/,
|
||||
exclude: /node_modules\/(?!(react-native|@react-native|react-native-web|react-native-omni)\/).*/,
|
||||
use: {
|
||||
loader: "babel-loader",
|
||||
options: {
|
||||
presets: [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
plugins: [
|
||||
["@babel/plugin-transform-react-jsx", { runtime: "automatic" }],
|
||||
[
|
||||
"module-resolver",
|
||||
{
|
||||
extensions: [".js", ".ts", ".json", ".jsx", ".tsx"],
|
||||
alias: {
|
||||
"react-native-omni": path.resolve(rootDirectory, "src"),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg)$/i,
|
||||
type: "asset/resource",
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: path.resolve(__dirname, "public/index.html"),
|
||||
}),
|
||||
],
|
||||
devServer: {
|
||||
port: 3000,
|
||||
hot: true,
|
||||
historyApiFallback: true,
|
||||
},
|
||||
mode: "development",
|
||||
devtool: "eval-source-map",
|
||||
entry: path.resolve(__dirname, "index.web.js"),
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
filename: "bundle.web.js",
|
||||
publicPath: "/",
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".web.tsx", ".web.ts", ".tsx", ".ts", ".web.js", ".js"],
|
||||
alias: {
|
||||
"react-native$": "react-native-web",
|
||||
"react-native-omni": path.resolve(rootDirectory, "src"),
|
||||
react: path.resolve(appDirectory, "node_modules", "react"),
|
||||
"react-dom": path.resolve(appDirectory, "node_modules", "react-dom"),
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|ts|tsx)$/,
|
||||
exclude:
|
||||
/node_modules\/(?!(react-native|@react-native|react-native-web|react-native-omni)\/).*/,
|
||||
use: {
|
||||
loader: "babel-loader",
|
||||
options: {
|
||||
presets: [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
plugins: [
|
||||
["@babel/plugin-transform-react-jsx", { runtime: "automatic" }],
|
||||
[
|
||||
"module-resolver",
|
||||
{
|
||||
extensions: [".js", ".ts", ".json", ".jsx", ".tsx"],
|
||||
alias: {
|
||||
"react-native-omni": path.resolve(rootDirectory, "src"),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg)$/i,
|
||||
type: "asset/resource",
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: path.resolve(__dirname, "public/index.html"),
|
||||
}),
|
||||
],
|
||||
devServer: {
|
||||
port: 3000,
|
||||
hot: true,
|
||||
historyApiFallback: true,
|
||||
// jassub's wasm is multithreaded (pthreads), which needs SharedArrayBuffer
|
||||
// and therefore a cross-origin-isolated page. `credentialless` keeps
|
||||
// cross-origin video/subtitle/font requests working without CORP headers.
|
||||
headers: {
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "credentialless",
|
||||
},
|
||||
static: [{ directory: path.resolve(appDirectory, "public") }],
|
||||
},
|
||||
};
|
||||
|
||||
+30
-19
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "react-native-omni",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0-development",
|
||||
"description": "video player using vlc and videojs",
|
||||
"main": "./lib/commonjs/index.js",
|
||||
"module": "./lib/module/index.js",
|
||||
@@ -14,7 +14,9 @@
|
||||
"typecheck": "tsc --noEmit && tsc -p plugin/tsconfig.json --noEmit",
|
||||
"build:plugin": "tsc -p plugin/tsconfig.json",
|
||||
"build": "bun run typecheck && bob build && bun run build:plugin",
|
||||
"codegen": "nitrogen --logLevel=\"debug\" && node post-script.js"
|
||||
"prepare": "bun run build",
|
||||
"codegen": "nitrogen --logLevel=\"debug\" && node post-script.js",
|
||||
"release": "semantic-release"
|
||||
},
|
||||
"keywords": [
|
||||
"react-native",
|
||||
@@ -47,34 +49,40 @@
|
||||
"workspaces": [
|
||||
"example"
|
||||
],
|
||||
"repository": "https://github.com/zoeroux/react-native-omni.git",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zoriya/react-native-omni.git"
|
||||
},
|
||||
"author": "Zoe Roux",
|
||||
"license": "MIT",
|
||||
"bugs": "https://github.com/zoeroux/react-native-omni/issues",
|
||||
"homepage": "https://github.com/zoeroux/react-native-omni#readme",
|
||||
"bugs": "https://github.com/zoriya/react-native-omni/issues",
|
||||
"homepage": "https://github.com/zoriya/react-native-omni#readme",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/config-plugins": "^10.0.2",
|
||||
"@videojs/react": "^10.0.0-beta.24"
|
||||
"@expo/config-plugins": "^57.0.3",
|
||||
"@videojs/react": "10.0.0-beta.25",
|
||||
"jassub": "^2.5.7",
|
||||
"libpgs": "^0.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.10",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "19.2.0",
|
||||
"nitrogen": "0.35.3",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.84.1",
|
||||
"react-native-builder-bob": "^0.40.18",
|
||||
"react-native-nitro-modules": "0.35.3",
|
||||
"typescript": "^5.8.3"
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "19.2.17",
|
||||
"nitrogen": "0.36.1",
|
||||
"react": "19.2.7",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-builder-bob": "^0.43.0",
|
||||
"react-native-nitro-modules": "0.36.1",
|
||||
"semantic-release": "^25.0.7",
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-nitro-modules": "*"
|
||||
"react": ">=19.0.0",
|
||||
"react-native": ">=0.86.0",
|
||||
"react-native-nitro-modules": ">=0.36.0"
|
||||
},
|
||||
"react-native-builder-bob": {
|
||||
"source": "src",
|
||||
@@ -89,5 +97,8 @@
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"jassub@2.5.7": "patches/jassub@2.5.7.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
diff --git a/dist/jassub.d.ts b/dist/jassub.d.ts
|
||||
index 8b09b107be84ebde02961eb63637128bfb62f1a9..01f3792bb0e6532d782659b240aeb34e73018534 100644
|
||||
--- a/dist/jassub.d.ts
|
||||
+++ b/dist/jassub.d.ts
|
||||
@@ -24,6 +24,14 @@ export type JASSUBOptions = {
|
||||
queryFonts?: 'local' | 'localandremote' | false;
|
||||
libassMemoryLimit?: number;
|
||||
libassGlyphLimit?: number;
|
||||
+ /**
|
||||
+ * The video's native/source resolution, used as libass' storage size. Set
|
||||
+ * this on adaptive (MSE/HLS) streams — where `video.videoWidth` follows the
|
||||
+ * currently-decoded rendition — so subtitles keep full quality regardless
|
||||
+ * of the active bitrate. Defaults to the decoded video size.
|
||||
+ */
|
||||
+ videoWidth?: number;
|
||||
+ videoHeight?: number;
|
||||
} & ({
|
||||
video: HTMLVideoElement;
|
||||
canvas?: HTMLCanvasElement;
|
||||
@@ -49,6 +57,8 @@ export default class JASSUB {
|
||||
_video: HTMLVideoElement | undefined;
|
||||
_videoWidth: number;
|
||||
_videoHeight: number;
|
||||
+ _storageWidth: number;
|
||||
+ _storageHeight: number;
|
||||
_videoColorSpace: string | null;
|
||||
_canvas: HTMLCanvasElement;
|
||||
_ro: ResizeObserver;
|
||||
diff --git a/dist/jassub.js b/dist/jassub.js
|
||||
index bbdaa8d4dcb5269617c53652c0702968093dc922..66cfdac4168e004ff9bb047da7e6ee6b53e02ce4 100644
|
||||
--- a/dist/jassub.js
|
||||
+++ b/dist/jassub.js
|
||||
@@ -21,6 +21,13 @@ export default class JASSUB {
|
||||
_video;
|
||||
_videoWidth = 0;
|
||||
_videoHeight = 0;
|
||||
+ // The video's native/source resolution, used as libass' storage size. On
|
||||
+ // adaptive (MSE/HLS) streams `video.videoWidth` reports the currently
|
||||
+ // decoded rendition, which drops with bandwidth and would blur subtitles;
|
||||
+ // set the `videoWidth`/`videoHeight` options to the stream's real
|
||||
+ // resolution to keep subtitle quality independent of the active rendition.
|
||||
+ _storageWidth = 0;
|
||||
+ _storageHeight = 0;
|
||||
_videoColorSpace = null;
|
||||
_canvas;
|
||||
_ro = new ResizeObserver(async () => {
|
||||
@@ -41,6 +48,8 @@ export default class JASSUB {
|
||||
JASSUB._test();
|
||||
this.timeOffset = opts.timeOffset ?? 0;
|
||||
this._video = opts.video;
|
||||
+ this._storageWidth = opts.videoWidth ?? 0;
|
||||
+ this._storageHeight = opts.videoHeight ?? 0;
|
||||
this._canvas = opts.canvas ?? document.createElement('canvas');
|
||||
if (this._video && !opts.canvas) {
|
||||
this._canvas.className = 'JASSUB';
|
||||
@@ -119,7 +128,7 @@ export default class JASSUB {
|
||||
this._canvas.style.top = videoSize.y + 'px';
|
||||
this._canvas.style.left = videoSize.x + 'px';
|
||||
}
|
||||
- await this.renderer._resizeCanvas(renderWidth, renderHeight, this._videoWidth || renderWidth, this._videoHeight || renderHeight);
|
||||
+ await this.renderer._resizeCanvas(renderWidth, renderHeight, this._storageWidth || this._videoWidth || renderWidth, this._storageHeight || this._videoHeight || renderHeight);
|
||||
if (this._lastDemandTime)
|
||||
await this._demandRender(forceRepaint);
|
||||
}
|
||||
diff --git a/src/jassub.ts b/src/jassub.ts
|
||||
index 65d290d6a5134aca26caf30c1720a2fbbe881430..c5c0596ec0db35b0607124cbb2045d4312c94259 100644
|
||||
--- a/src/jassub.ts
|
||||
+++ b/src/jassub.ts
|
||||
@@ -37,6 +37,14 @@ export type JASSUBOptions = {
|
||||
queryFonts?: 'local' | 'localandremote' | false
|
||||
libassMemoryLimit?: number
|
||||
libassGlyphLimit?: number
|
||||
+ /**
|
||||
+ * The video's native/source resolution, used as libass' storage size. Set
|
||||
+ * this on adaptive (MSE/HLS) streams — where `video.videoWidth` follows the
|
||||
+ * currently-decoded rendition — so subtitles keep full quality regardless of
|
||||
+ * the active bitrate. Defaults to the decoded video size.
|
||||
+ */
|
||||
+ videoWidth?: number
|
||||
+ videoHeight?: number
|
||||
} & ({
|
||||
video: HTMLVideoElement
|
||||
canvas?: HTMLCanvasElement
|
||||
@@ -63,6 +71,13 @@ export default class JASSUB {
|
||||
_video
|
||||
_videoWidth = 0
|
||||
_videoHeight = 0
|
||||
+ // The video's native/source resolution, used as libass' storage size. On
|
||||
+ // adaptive (MSE/HLS) streams `video.videoWidth` reports the currently
|
||||
+ // decoded rendition, which drops with bandwidth and would blur subtitles;
|
||||
+ // set the `videoWidth`/`videoHeight` options to the stream's real
|
||||
+ // resolution to keep subtitle quality independent of the active rendition.
|
||||
+ _storageWidth = 0
|
||||
+ _storageHeight = 0
|
||||
_videoColorSpace: string | null = null
|
||||
_canvas
|
||||
_ro = new ResizeObserver(async () => {
|
||||
@@ -83,6 +98,8 @@ export default class JASSUB {
|
||||
|
||||
this.timeOffset = opts.timeOffset ?? 0
|
||||
this._video = opts.video
|
||||
+ this._storageWidth = opts.videoWidth ?? 0
|
||||
+ this._storageHeight = opts.videoHeight ?? 0
|
||||
this._canvas = opts.canvas ?? document.createElement('canvas')
|
||||
if (this._video && !opts.canvas) {
|
||||
this._canvas.className = 'JASSUB'
|
||||
@@ -183,8 +200,8 @@ export default class JASSUB {
|
||||
await this.renderer._resizeCanvas(
|
||||
renderWidth,
|
||||
renderHeight,
|
||||
- this._videoWidth || renderWidth,
|
||||
- this._videoHeight || renderHeight
|
||||
+ this._storageWidth || this._videoWidth || renderWidth,
|
||||
+ this._storageHeight || this._videoHeight || renderHeight
|
||||
)
|
||||
|
||||
if (this._lastDemandTime) await this._demandRender(forceRepaint)
|
||||
@@ -2,7 +2,6 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2019",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"declaration": true,
|
||||
"outDir": "build",
|
||||
"rootDir": "src",
|
||||
|
||||
@@ -52,6 +52,9 @@ export function usePlayerState<Key extends keyof OmniPlayerState>(
|
||||
case "status":
|
||||
em.addPlayerStatusListener(setState);
|
||||
return () => em.removePlayerStatusListener(setState);
|
||||
case "castStatus":
|
||||
// TODO: implement this
|
||||
return;
|
||||
}
|
||||
}, [player, key]);
|
||||
|
||||
|
||||
+62
-14
@@ -1,15 +1,20 @@
|
||||
import {
|
||||
type Selector,
|
||||
selectAudioTrack,
|
||||
selectBuffer,
|
||||
selectError,
|
||||
selectPlayback,
|
||||
selectPlaybackRate,
|
||||
selectQuality,
|
||||
selectRemotePlayback,
|
||||
selectTextTrack,
|
||||
selectTime,
|
||||
selectVolume,
|
||||
usePlayer,
|
||||
usePlayer as useStoreSelector,
|
||||
} from "@videojs/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { WebOmniPlayer } from "./player.web";
|
||||
import { usePlayer } from "./provider.web";
|
||||
import type { OmniEvents } from "./types/events";
|
||||
import type { OmniPlayerState } from "./types/player";
|
||||
|
||||
@@ -63,6 +68,29 @@ const eventMapper: EventMapperConfig = {
|
||||
}
|
||||
cb(undefined);
|
||||
}),
|
||||
...createEventMapper("audioTrackChange", selectAudioTrack, (cb, value) => {
|
||||
if (!value) return;
|
||||
const track = value.audioTrackList.find((t) => t.enabled);
|
||||
if (!track) return;
|
||||
|
||||
cb({
|
||||
id: track.id!,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: true,
|
||||
});
|
||||
}),
|
||||
...createEventMapper("renditionChange", selectQuality, (cb, value) => {
|
||||
const active = value?.activeVideoRendition;
|
||||
if (!active) return;
|
||||
cb({
|
||||
id: active.id!,
|
||||
width: active.width ?? 0,
|
||||
height: active.height ?? 0,
|
||||
bitrate: active.bitrate ?? 0,
|
||||
selected: true,
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
export const useEvent = <Event extends keyof OmniEvents>(
|
||||
@@ -73,15 +101,31 @@ export const useEvent = <Event extends keyof OmniEvents>(
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
const prevRef = useRef<any>(undefined);
|
||||
|
||||
const value = usePlayer(config?.selector ?? (() => ({})));
|
||||
|
||||
const value = useStoreSelector(config?.selector ?? (() => undefined));
|
||||
const player = usePlayer() as WebOmniPlayer;
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
const prev = prevRef.current;
|
||||
config.handler(callbackRef.current as any, value, prev);
|
||||
config.handler(callbackRef.current as any, value, prevRef.current);
|
||||
prevRef.current = value;
|
||||
}, [value, config]);
|
||||
|
||||
useEffect(() => {
|
||||
// don't use callbackRef.current directly to keep future callback updates
|
||||
const cb = () => (callbackRef.current as () => void)();
|
||||
if (event === "prev") {
|
||||
player.onPrev.add(cb);
|
||||
return () => {
|
||||
player.onPrev.delete(cb);
|
||||
};
|
||||
}
|
||||
if (event === "next") {
|
||||
player.onNext.add(cb);
|
||||
return () => {
|
||||
player.onNext.delete(cb);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [event, player]);
|
||||
};
|
||||
|
||||
function createMapper<Key extends keyof OmniPlayerState, State, Result>(
|
||||
@@ -130,13 +174,17 @@ export const stateMapper = {
|
||||
...createMapper("muted", selectVolume, (s) => {
|
||||
return s?.muted ?? false;
|
||||
}),
|
||||
...createMapper(
|
||||
"isAutoQuality",
|
||||
() => {},
|
||||
() => {
|
||||
return true;
|
||||
},
|
||||
),
|
||||
...createMapper("isAutoQuality", selectQuality, (q) => {
|
||||
if (!q) return true;
|
||||
return !q.videoRenditionList.some((r) => r.selected);
|
||||
}),
|
||||
...createMapper("castStatus", selectRemotePlayback, (r) => {
|
||||
if (!r) return "unavailable";
|
||||
if (r.remotePlaybackState === "connecting") return "connecting";
|
||||
if (r.remotePlaybackState === "connected") return "connected";
|
||||
if (r.remotePlaybackAvailability === "available") return "available";
|
||||
return "unavailable";
|
||||
}),
|
||||
};
|
||||
|
||||
export function usePlayerState<Key extends keyof OmniPlayerState>(
|
||||
@@ -152,6 +200,6 @@ export function usePlayerState<Key extends keyof OmniPlayerState>(
|
||||
): OmniPlayerState[Key] {
|
||||
const config = stateMapper[key];
|
||||
if (!config) throw new Error(`No mapper for ${key}`);
|
||||
const ret = usePlayer(config.selector as Selector<any, any>);
|
||||
const ret = useStoreSelector(config.selector as Selector<any, any>);
|
||||
return config.mapper(ret) as OmniPlayerState[Key];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
export { useEvent, usePlayerState } from "./events";
|
||||
export { OmniProvider, usePlayer } from "./provider";
|
||||
export type { OmniEvents } from "./types/events";
|
||||
export type {
|
||||
CastStatus,
|
||||
OmniPlayer,
|
||||
OmniPlayerState,
|
||||
PlayerStatus,
|
||||
Rendition,
|
||||
Track,
|
||||
} from "./types/player";
|
||||
export type {
|
||||
Metadata,
|
||||
MixAudioMode,
|
||||
Source,
|
||||
Subtitle,
|
||||
VideoSrc,
|
||||
} from "./types/source";
|
||||
export type { SubtitleAssets } from "./types/subtitles";
|
||||
export { OmniView } from "./view";
|
||||
|
||||
+223
-59
@@ -1,37 +1,97 @@
|
||||
import type { MediaVideoRendition } from "@videojs/core";
|
||||
import type { VideoPlayerStore } from "@videojs/core/dom";
|
||||
import type Hls from "hls.js";
|
||||
import {
|
||||
selectAudioTrack,
|
||||
selectQuality,
|
||||
selectTextTrack,
|
||||
} from "@videojs/react";
|
||||
import { stateMapper } from "./events.web";
|
||||
import type {
|
||||
CastStatus,
|
||||
OmniPlayer,
|
||||
PlayerStatus,
|
||||
Rendition,
|
||||
Track,
|
||||
} from "./types/player";
|
||||
import type { Source } from "./types/source";
|
||||
import type { Source, Subtitle } from "./types/source";
|
||||
|
||||
export type SubtitleFormat = "vtt" | "ass" | "pgs" | "native";
|
||||
|
||||
export const getSubtitleFormat = (subtitle: {
|
||||
mimeType?: string;
|
||||
link: string;
|
||||
}): SubtitleFormat => {
|
||||
const mime = subtitle.mimeType?.toLowerCase() ?? "";
|
||||
const ext = subtitle.link.split(/[?#]/)[0]?.split(".").pop()?.toLowerCase();
|
||||
if (
|
||||
mime.includes("ass") ||
|
||||
mime.includes("ssa") ||
|
||||
ext === "ass" ||
|
||||
ext === "ssa"
|
||||
)
|
||||
return "ass";
|
||||
if (mime.includes("pgs") || ext === "sup") return "pgs";
|
||||
if (mime.includes("vtt") || ext === "vtt") return "vtt";
|
||||
return "native";
|
||||
};
|
||||
|
||||
export const isCustomSubtitle = (subtitle: {
|
||||
mimeType?: string;
|
||||
link: string;
|
||||
}): boolean => {
|
||||
const format = getSubtitleFormat(subtitle);
|
||||
return format === "ass" || format === "pgs";
|
||||
};
|
||||
|
||||
export class WebOmniPlayer implements OmniPlayer {
|
||||
_store: VideoPlayerStore;
|
||||
onPrev?: () => void;
|
||||
onNext?: () => void;
|
||||
onPrev = new Set<() => void>();
|
||||
onNext = new Set<() => void>();
|
||||
|
||||
constructor(store: VideoPlayerStore) {
|
||||
this._store = store;
|
||||
}
|
||||
|
||||
private getHls(): Hls | null {
|
||||
const media = this._store.target?.media;
|
||||
return (media?.engine as Hls | undefined) ?? null;
|
||||
get castStatus(): CastStatus {
|
||||
return stateMapper.castStatus.mapper(this._store.state);
|
||||
}
|
||||
|
||||
source: Source | null = null;
|
||||
toggleCastStatus(): void {
|
||||
this._store.state.toggleRemotePlayback();
|
||||
}
|
||||
|
||||
_source: Source | null = null;
|
||||
private _showNotification = false;
|
||||
|
||||
// Selected ASS/PGS subtitle (drawn by the overlay); `null` when the active
|
||||
// subtitle is native or off. Exposed as an external store so the view can
|
||||
// react to selection changes.
|
||||
private overlaySubtitle: Subtitle | null = null;
|
||||
private overlayListeners = new Set<() => void>();
|
||||
|
||||
get source(): Source | null {
|
||||
return this._source;
|
||||
}
|
||||
|
||||
set source(source: Source | null) {
|
||||
this._source = source;
|
||||
// Drop the overlay subtitle if it is not part of the new source.
|
||||
if (
|
||||
this.overlaySubtitle &&
|
||||
!source?.subtitles.some((s) => s.id === this.overlaySubtitle?.id)
|
||||
) {
|
||||
this.setOverlaySubtitle(null);
|
||||
}
|
||||
this.updateMediaSession();
|
||||
}
|
||||
|
||||
get showNotification(): boolean {
|
||||
// TODO
|
||||
return false;
|
||||
return this._showNotification;
|
||||
}
|
||||
|
||||
set showNotification(_: boolean) {
|
||||
// TODO
|
||||
set showNotification(value: boolean) {
|
||||
this._showNotification = value;
|
||||
this.updateMediaSession();
|
||||
}
|
||||
|
||||
get status(): PlayerStatus {
|
||||
@@ -47,6 +107,10 @@ export class WebOmniPlayer implements OmniPlayer {
|
||||
return this._store.state.currentTime;
|
||||
}
|
||||
|
||||
set currentTime(time: number) {
|
||||
this._store.seek(time).catch(() => {});
|
||||
}
|
||||
|
||||
get buffered(): number {
|
||||
const buffered = this._store.state.buffered;
|
||||
if (buffered.length === 0) return 0;
|
||||
@@ -84,17 +148,20 @@ export class WebOmniPlayer implements OmniPlayer {
|
||||
}
|
||||
|
||||
get isAutoQuality(): boolean {
|
||||
const hls = this.getHls();
|
||||
if (!hls) return false;
|
||||
return hls.autoLevelEnabled;
|
||||
const quality = selectQuality(this._store.state);
|
||||
if (!quality) return true;
|
||||
// ABR ("auto") is on whenever no rendition is explicitly pinned.
|
||||
return !quality.videoRenditionList.some((r) => r.selected);
|
||||
}
|
||||
|
||||
play(): void {
|
||||
this._store.play();
|
||||
this.setPlaybackState("playing");
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this._store.pause();
|
||||
this.setPlaybackState("paused");
|
||||
}
|
||||
|
||||
seekBy(offset: number): void {
|
||||
@@ -102,21 +169,19 @@ export class WebOmniPlayer implements OmniPlayer {
|
||||
}
|
||||
|
||||
playPrev(): void {
|
||||
this.onPrev?.();
|
||||
for (const cb of this.onPrev) cb();
|
||||
}
|
||||
|
||||
playNext(): void {
|
||||
this.onNext?.();
|
||||
for (const cb of this.onNext) cb();
|
||||
}
|
||||
|
||||
get hasPrev(): boolean {
|
||||
// TODO
|
||||
return false;
|
||||
return this._source?.metadata?.hasPrev ?? false;
|
||||
}
|
||||
|
||||
get hasNext(): boolean {
|
||||
// TODO
|
||||
return false;
|
||||
return this._source?.metadata?.hasNext ?? false;
|
||||
}
|
||||
|
||||
get videos(): Track[] {
|
||||
@@ -129,60 +194,159 @@ export class WebOmniPlayer implements OmniPlayer {
|
||||
}
|
||||
|
||||
get audios(): Track[] {
|
||||
const hls = this.getHls();
|
||||
if (!hls) return [];
|
||||
const currentTrackId = hls.audioTrack;
|
||||
return hls.audioTracks.map((track, index) => ({
|
||||
id: index.toString(),
|
||||
label: track.name,
|
||||
language: track.lang,
|
||||
selected: index === currentTrackId,
|
||||
const audio = selectAudioTrack(this._store.state);
|
||||
if (!audio) return [];
|
||||
return audio.audioTrackList.map((track, i) => ({
|
||||
id: track.id ?? i.toString(),
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: track.enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
selectAudio(audio: Track): void {
|
||||
const hls = this.getHls();
|
||||
if (!hls) return;
|
||||
hls.audioTrack = parseInt(audio.id, 10);
|
||||
const tracks = selectAudioTrack(this._store.state);
|
||||
if (!tracks) return;
|
||||
tracks.selectAudioTrack(audio.id);
|
||||
}
|
||||
|
||||
private get overlaySubtitles(): Subtitle[] {
|
||||
return (this._source?.subtitles ?? []).filter(isCustomSubtitle);
|
||||
}
|
||||
|
||||
get subtitles(): Track[] {
|
||||
return this._store.textTrackList
|
||||
.map((x, i) => ({
|
||||
id: i.toString(),
|
||||
kind: x.kind,
|
||||
label: x.label,
|
||||
language: x.language,
|
||||
selected: x.mode === "showing",
|
||||
}))
|
||||
.filter((x) => x.kind === "subtitles" || x.kind === "captions");
|
||||
const textTracks = selectTextTrack(this._store.state)?.textTrackList ?? [];
|
||||
const native = textTracks
|
||||
.filter((x) => x.kind === "subtitles" || x.kind === "captions")
|
||||
.map((track) => ({
|
||||
id: track.id!,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: track.mode === "showing",
|
||||
}));
|
||||
const overlay = this.overlaySubtitles.map((sub) => ({
|
||||
id: sub.id,
|
||||
label: sub.label,
|
||||
language: sub.language,
|
||||
selected: this.overlaySubtitle?.id === sub.id,
|
||||
}));
|
||||
return [...native, ...overlay];
|
||||
}
|
||||
|
||||
selectSubtitle(subtitle?: Track): void {
|
||||
for (let i = 0; i < this._store.textTrackList.length; i++) {
|
||||
const track = this._store.textTrackList[i]!;
|
||||
if (track.kind !== "subtitles" && track.kind !== "captions") continue;
|
||||
track.mode =
|
||||
subtitle && i.toString() === subtitle.id ? "showing" : "hidden";
|
||||
}
|
||||
const overlay = subtitle
|
||||
? this.overlaySubtitles.find((s) => s.id === subtitle.id)
|
||||
: undefined;
|
||||
|
||||
const tracks = selectTextTrack(this._store.state);
|
||||
tracks?.selectSubtitlesTrack(overlay || !subtitle ? "off" : subtitle.id);
|
||||
this.setOverlaySubtitle(overlay ?? null);
|
||||
}
|
||||
|
||||
private setOverlaySubtitle(sub: Subtitle | null): void {
|
||||
if (this.overlaySubtitle === sub) return;
|
||||
this.overlaySubtitle = sub;
|
||||
for (const listener of this.overlayListeners) listener();
|
||||
}
|
||||
|
||||
// External store used by the view to render the ASS/PGS overlay.
|
||||
subscribeOverlaySubtitle = (callback: () => void): (() => void) => {
|
||||
this.overlayListeners.add(callback);
|
||||
return () => this.overlayListeners.delete(callback);
|
||||
};
|
||||
|
||||
getOverlaySubtitle = (): Subtitle | null => this.overlaySubtitle;
|
||||
|
||||
get rendition(): Rendition[] {
|
||||
const hls = this.getHls();
|
||||
if (!hls) return [];
|
||||
const currentLevel = hls.currentLevel;
|
||||
return hls.levels.map((level, index) => ({
|
||||
id: index.toString(),
|
||||
width: level.width,
|
||||
height: level.height,
|
||||
bitrate: level.bitrate,
|
||||
selected: index === currentLevel,
|
||||
function isSameRendition(
|
||||
a: MediaVideoRendition,
|
||||
b: MediaVideoRendition | null,
|
||||
): boolean {
|
||||
return (
|
||||
b != null &&
|
||||
a.id === b.id &&
|
||||
a.width === b.width &&
|
||||
a.height === b.height &&
|
||||
a.bitrate === b.bitrate
|
||||
);
|
||||
}
|
||||
|
||||
const quality = selectQuality(this._store.state);
|
||||
if (!quality) return [];
|
||||
const active = quality.activeVideoRendition;
|
||||
return quality.videoRenditionList.map((rendition, i) => ({
|
||||
id: rendition.id ?? i.toString(),
|
||||
width: rendition.width ?? 0,
|
||||
height: rendition.height ?? 0,
|
||||
bitrate: rendition.bitrate ?? 0,
|
||||
selected: rendition.selected || isSameRendition(rendition, active),
|
||||
}));
|
||||
}
|
||||
|
||||
selectRendition(rendition?: Rendition): void {
|
||||
const hls = this.getHls();
|
||||
if (!hls) return;
|
||||
hls.nextLevel = rendition ? parseInt(rendition.id, 10) : -1;
|
||||
const tracks = selectQuality(this._store.state);
|
||||
if (!tracks) return;
|
||||
tracks.selectVideoRendition(rendition ? rendition.id : "auto");
|
||||
}
|
||||
|
||||
private setPlaybackState(state: MediaSessionPlaybackState): void {
|
||||
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) {
|
||||
return;
|
||||
}
|
||||
if (this._showNotification) navigator.mediaSession.playbackState = state;
|
||||
}
|
||||
|
||||
private updateMediaSession(): void {
|
||||
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) {
|
||||
return;
|
||||
}
|
||||
const session = navigator.mediaSession;
|
||||
const actions: MediaSessionAction[] = [
|
||||
"play",
|
||||
"pause",
|
||||
"seekbackward",
|
||||
"seekforward",
|
||||
"seekto",
|
||||
"previoustrack",
|
||||
"nexttrack",
|
||||
];
|
||||
|
||||
if (!this._showNotification) {
|
||||
session.metadata = null;
|
||||
for (const action of actions) {
|
||||
try {
|
||||
session.setActionHandler(action, null);
|
||||
} catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = this._source?.metadata;
|
||||
if (metadata && typeof MediaMetadata !== "undefined") {
|
||||
session.metadata = new MediaMetadata({
|
||||
title: metadata.title,
|
||||
artist: metadata.artist ?? "",
|
||||
album: metadata.album ?? "",
|
||||
artwork: metadata.imageLink ? [{ src: metadata.imageLink }] : [],
|
||||
});
|
||||
}
|
||||
|
||||
const set = (
|
||||
action: MediaSessionAction,
|
||||
handler: MediaSessionActionHandler | null,
|
||||
) => {
|
||||
try {
|
||||
session.setActionHandler(action, handler);
|
||||
} catch {}
|
||||
};
|
||||
set("play", () => this.play());
|
||||
set("pause", () => this.pause());
|
||||
set("seekbackward", (d) => this.seekBy(-(d.seekOffset ?? 10)));
|
||||
set("seekforward", (d) => this.seekBy(d.seekOffset ?? 10));
|
||||
set("seekto", (d) => {
|
||||
if (d.seekTime != null) this.currentTime = d.seekTime;
|
||||
});
|
||||
set("previoustrack", this.hasPrev ? () => this.playPrev() : null);
|
||||
set("nexttrack", this.hasNext ? () => this.playNext() : null);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -1,6 +1,12 @@
|
||||
import { createPlayer } from "@videojs/react";
|
||||
import { videoFeatures } from "@videojs/react/video";
|
||||
import { createContext, type ReactNode, useContext, useEffect } from "react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { WebOmniPlayer } from "./player.web";
|
||||
import type { OmniPlayer } from "./types/player";
|
||||
import type { Source } from "./types/source";
|
||||
@@ -39,10 +45,16 @@ const PlayerInitializer = ({
|
||||
}) => {
|
||||
const store = VideoPlayer.usePlayer();
|
||||
const player = useLazyRef(() => new WebOmniPlayer(store));
|
||||
const seekedForSrc = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
player.source = source;
|
||||
}, [source]);
|
||||
const uri = source.src[0]?.uri;
|
||||
if (uri !== seekedForSrc.current) {
|
||||
seekedForSrc.current = uri;
|
||||
if (source.startTime) store.seek(source.startTime)
|
||||
}
|
||||
}, [source, store]);
|
||||
|
||||
useEffect(() => {
|
||||
player.showNotification = showNotification;
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface OmniPlayer extends OmniPlayerState {
|
||||
pause(): void;
|
||||
seekBy(offset: number): void;
|
||||
|
||||
toggleCastStatus(): void;
|
||||
|
||||
// trigger the prev/next event manually, the user has to implement the event
|
||||
playPrev(): void;
|
||||
playNext(): void;
|
||||
@@ -32,10 +34,17 @@ export interface OmniPlayerState {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
readonly isAutoQuality: boolean;
|
||||
readonly castStatus: CastStatus;
|
||||
}
|
||||
|
||||
export type PlayerStatus = "idle" | "loading" | "readyToPlay" | "error";
|
||||
|
||||
export type CastStatus =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "available"
|
||||
| "unavailable";
|
||||
|
||||
export interface Track {
|
||||
readonly id: string;
|
||||
readonly label?: string;
|
||||
|
||||
+4
-1
@@ -2,6 +2,9 @@ export interface Source {
|
||||
src: VideoSrc[];
|
||||
startTime?: number;
|
||||
subtitles: Subtitle[];
|
||||
// fonts that can be used by ass subtitles on the web (native will use
|
||||
// embedded fonts)
|
||||
fonts?: string[];
|
||||
metadata?: Metadata;
|
||||
mixAudio?: MixAudioMode;
|
||||
}
|
||||
@@ -11,7 +14,7 @@ export interface VideoSrc {
|
||||
mimeType?: string;
|
||||
// header sends with xhr requests (especially useful for HLS)
|
||||
// might not be available depending on browser/platform.
|
||||
headers: Record<string, string>;
|
||||
headers: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface Subtitle {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface SubtitleAssets {
|
||||
jassub?: {
|
||||
/** URL of `jassub-worker.js`. */
|
||||
workerUrl?: string;
|
||||
/** URL of `jassub-worker.wasm`. */
|
||||
wasmUrl?: string;
|
||||
/** URL of `jassub-worker-modern.wasm`. */
|
||||
modernWasmUrl?: string;
|
||||
/** URL of a fallback font used when the ASS file references none. */
|
||||
fontUrl?: string;
|
||||
};
|
||||
pgs?: {
|
||||
/** URL of `libpgs.worker.js`. */
|
||||
workerUrl?: string;
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { SubtitleAssets } from "./subtitles";
|
||||
|
||||
export interface OmniViewProps {
|
||||
autoplay?: boolean;
|
||||
autoPip?: boolean;
|
||||
/** Web-only: URLs for the ASS/PGS subtitle renderer assets. */
|
||||
subtitleAssets?: SubtitleAssets;
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ import { useState } from "react";
|
||||
|
||||
export const useLazyRef = <T>(init: () => T): T => {
|
||||
const [ret] = useState<T>(init);
|
||||
return ret
|
||||
return ret;
|
||||
};
|
||||
|
||||
+12
-6
@@ -1,3 +1,5 @@
|
||||
import { memo } from "react";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import {
|
||||
getHostComponent,
|
||||
type HybridViewMethods,
|
||||
@@ -7,14 +9,18 @@ import { usePlayer } from "./provider";
|
||||
import type { OmniPlayer } from "./specs/omni-player.nitro";
|
||||
import type { Props } from "./specs/omni-view.nitro";
|
||||
import type { OmniViewProps } from "./types/view";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import { memo } from "react";
|
||||
|
||||
const NativeView = memo(
|
||||
getHostComponent<Props, HybridViewMethods>("OmniView", () => OmniConfig),
|
||||
);
|
||||
|
||||
export const OmniView = memo((props: OmniViewProps & { style: ViewStyle }) => {
|
||||
const player = usePlayer() as OmniPlayer;
|
||||
return <NativeView player={player} {...props} />;
|
||||
});
|
||||
export const OmniView = memo(
|
||||
({
|
||||
// Web-only; not forwarded to the native view.
|
||||
subtitleAssets: _subtitleAssets,
|
||||
...props
|
||||
}: OmniViewProps & { style: ViewStyle }) => {
|
||||
const player = usePlayer() as OmniPlayer;
|
||||
return <NativeView player={player} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
+150
-11
@@ -1,32 +1,171 @@
|
||||
import { HlsVideo } from "@videojs/react/media/hls-video";
|
||||
import type { HlsMediaConfig } from "@videojs/core/dom/media/hls-js";
|
||||
import { HlsJsVideo } from "@videojs/react/media/hlsjs-video";
|
||||
import { Video } from "@videojs/react/video";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useRef } from "react";
|
||||
import type { WebOmniPlayer } from "./player.web";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import {
|
||||
getSubtitleFormat,
|
||||
isCustomSubtitle,
|
||||
type WebOmniPlayer,
|
||||
} from "./player.web";
|
||||
import { usePlayer, VideoPlayer } from "./provider.web";
|
||||
import type { SubtitleAssets } from "./types/subtitles";
|
||||
import type { OmniViewProps } from "./types/view";
|
||||
|
||||
const SubtitleOverlay = ({
|
||||
video,
|
||||
assets,
|
||||
fonts,
|
||||
}: {
|
||||
video: RefObject<HTMLVideoElement>;
|
||||
assets?: SubtitleAssets;
|
||||
fonts?: string[];
|
||||
}) => {
|
||||
const player = usePlayer() as WebOmniPlayer;
|
||||
const subtitle = useSyncExternalStore(
|
||||
player.subscribeOverlaySubtitle,
|
||||
player.getOverlaySubtitle,
|
||||
() => null,
|
||||
);
|
||||
const assetsRef = useRef(assets);
|
||||
assetsRef.current = assets;
|
||||
const fontsRef = useRef(fonts);
|
||||
fontsRef.current = fonts;
|
||||
|
||||
useEffect(() => {
|
||||
const el = video.current;
|
||||
if (!el || !subtitle) return;
|
||||
let renderer: { destroy(): void } | null = null;
|
||||
let cancelled = false;
|
||||
const attach = (created: { destroy(): void }) => {
|
||||
if (cancelled) created.destroy();
|
||||
else renderer = created;
|
||||
};
|
||||
|
||||
if (getSubtitleFormat(subtitle) === "ass") {
|
||||
const jassub = assetsRef.current?.jassub;
|
||||
const fonts = fontsRef.current;
|
||||
// The video's source resolution (its highest available rendition).
|
||||
// jassub uses this as libass' storage size so subtitles stay sharp
|
||||
// even while a low-bitrate rendition is playing — otherwise the
|
||||
// raster tracks the currently-decoded (possibly tiny) frame size.
|
||||
const renditions = player.rendition;
|
||||
const videoWidth = Math.max(0, ...renditions.map((r) => r.width));
|
||||
const videoHeight = Math.max(0, ...renditions.map((r) => r.height));
|
||||
import("jassub").then(({ default: JASSUB }) => {
|
||||
const instance = new JASSUB({
|
||||
video: el,
|
||||
subUrl: subtitle.link,
|
||||
...(videoWidth && { videoWidth }),
|
||||
...(videoHeight && { videoHeight }),
|
||||
...(fonts?.length && { fonts }),
|
||||
...(jassub?.workerUrl && { workerUrl: jassub.workerUrl }),
|
||||
...(jassub?.wasmUrl && { wasmUrl: jassub.wasmUrl }),
|
||||
...(jassub?.modernWasmUrl && { modernWasmUrl: jassub.modernWasmUrl }),
|
||||
...(jassub?.fontUrl && {
|
||||
availableFonts: { "liberation sans": jassub.fontUrl },
|
||||
defaultFont: "liberation sans",
|
||||
}),
|
||||
});
|
||||
attach({ destroy: () => instance.destroy() });
|
||||
});
|
||||
} else {
|
||||
const pgs = assetsRef.current?.pgs;
|
||||
import("libpgs").then(({ PgsRenderer }) => {
|
||||
const instance = new PgsRenderer({
|
||||
video: el,
|
||||
subUrl: subtitle.link,
|
||||
workerUrl:
|
||||
pgs?.workerUrl ??
|
||||
new URL("libpgs/dist/libpgs.worker.js", import.meta.url).href,
|
||||
});
|
||||
attach({ destroy: () => instance.dispose() });
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
renderer?.destroy();
|
||||
};
|
||||
}, [video, subtitle, player]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const OmniView = ({
|
||||
style,
|
||||
autoplay,
|
||||
subtitleAssets,
|
||||
}: OmniViewProps & { style: CSSProperties }) => {
|
||||
const player = usePlayer() as WebOmniPlayer;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const ref = useRef<HTMLVideoElement>(undefined!);
|
||||
|
||||
const uri = player.source?.src[0]?.uri;
|
||||
const Tech = uri?.endsWith("m3u8") ? HlsVideo : Video;
|
||||
const src = player.source?.src[0];
|
||||
const isHls =
|
||||
src?.mimeType?.toLowerCase().includes("mpegurl") ||
|
||||
src?.uri.split(/[?#]/)[0]?.toLowerCase().endsWith(".m3u8") ||
|
||||
false;
|
||||
const Tech = isHls ? HlsJsVideo : Video;
|
||||
|
||||
const headersRef = useRef(src?.headers);
|
||||
headersRef.current = src?.headers;
|
||||
|
||||
const config = useMemo<HlsMediaConfig>(
|
||||
() => ({
|
||||
hlsJs: {
|
||||
xhrSetup: (xhr: XMLHttpRequest) => {
|
||||
const headers = headersRef.current;
|
||||
if (!headers) return;
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value) xhr.setRequestHeader(key, value);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<VideoPlayer.Container ref={containerRef} style={style}>
|
||||
{uri && (
|
||||
<VideoPlayer.Container
|
||||
ref={containerRef}
|
||||
style={{ position: "relative", ...style }}
|
||||
>
|
||||
{src && (
|
||||
<Tech
|
||||
src={uri}
|
||||
ref={ref}
|
||||
src={src.uri}
|
||||
config={isHls ? config : undefined}
|
||||
autoPlay={autoplay}
|
||||
playsInline
|
||||
crossOrigin="anonymous"
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
>
|
||||
{(player.source?.subtitles ?? [])
|
||||
.filter((subtitle) => !isCustomSubtitle(subtitle))
|
||||
.map((subtitle) => (
|
||||
<track
|
||||
key={subtitle.id}
|
||||
id={subtitle.id}
|
||||
kind="subtitles"
|
||||
src={subtitle.link}
|
||||
srcLang={subtitle.language}
|
||||
label={subtitle.label ?? subtitle.language ?? subtitle.id}
|
||||
/>
|
||||
))}
|
||||
</Tech>
|
||||
)}
|
||||
<SubtitleOverlay
|
||||
video={ref}
|
||||
assets={subtitleAssets}
|
||||
fonts={player.source?.fonts}
|
||||
/>
|
||||
</VideoPlayer.Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
"noEmit": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitUseStrict": false,
|
||||
"noStrictGenericChecks": false,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user