mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-16 02:44:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d8b33aee3 | ||
|
|
645b226359 | ||
|
|
61ee6238d5 | ||
|
|
a7717dfcb9 | ||
|
|
337f338224 | ||
|
|
72328aacf6 | ||
|
|
b1966b0c57 | ||
|
|
77c1e36047 | ||
|
|
a4d125738a | ||
|
|
14dca7405e | ||
|
|
3c9e850f31 | ||
|
|
1c7f922996 | ||
|
|
453f86a448 | ||
|
|
c553c75433 | ||
|
|
0ac36e9890 | ||
|
|
466b792878 | ||
|
|
89799cd5cc | ||
|
|
d603f95720 | ||
|
|
0466e0fab4 | ||
|
|
9410846a0f | ||
|
|
22c41331f9 |
@@ -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
|
||||
```
|
||||
|
||||
@@ -24,7 +24,6 @@ import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem.RequestMetadata
|
||||
import androidx.media3.common.MediaItem.SubtitleConfiguration
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
@@ -64,43 +63,6 @@ class OmniPlayer : HybridOmniPlayerSpec() {
|
||||
runOnMainThread { player.release() }
|
||||
}
|
||||
|
||||
private var currentSource: Source? = null
|
||||
override var source: Source
|
||||
get() = currentSource
|
||||
?: throw IllegalStateException("source should be initialized before get")
|
||||
set(value) {
|
||||
Log.i("omni", "update source")
|
||||
currentSource = value
|
||||
val src = value.src.firstOrNull()
|
||||
if (src == null) {
|
||||
runOnMainThreadSync {
|
||||
player.setMediaItem(MediaItem.EMPTY)
|
||||
player.prepare()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val currentItem = buildMediaItem(src, value.metadata, value.subtitles)
|
||||
val mediaItems = mutableListOf<MediaItem>()
|
||||
|
||||
if (value.metadata?.hasPrev == true) {
|
||||
mediaItems.add(currentItem)
|
||||
}
|
||||
|
||||
mediaItems.add(currentItem)
|
||||
|
||||
if (value.metadata?.hasNext == true) {
|
||||
mediaItems.add(currentItem)
|
||||
}
|
||||
|
||||
runOnMainThreadSync {
|
||||
val startIndex = if (value.metadata?.hasPrev == true) 1 else 0
|
||||
val startPositionMs = (value.startTime?.coerceAtLeast(0.0) ?: 0.0) * 1000.0
|
||||
player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong())
|
||||
player.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildMediaItem(
|
||||
src: com.margelo.nitro.omni.VideoSrc,
|
||||
metadata: com.margelo.nitro.omni.Metadata?,
|
||||
@@ -153,8 +115,8 @@ class OmniPlayer : HybridOmniPlayerSpec() {
|
||||
}
|
||||
}
|
||||
|
||||
override val hasPrev: Boolean get() = currentSource?.metadata?.hasPrev == true
|
||||
override val hasNext: Boolean get() = currentSource?.metadata?.hasNext == true
|
||||
override val hasPrev: Boolean get() = player.hasPreviousMediaItem()
|
||||
override val hasNext: Boolean get() = player.hasNextMediaItem()
|
||||
override val status by mainThreadProperty {
|
||||
when (player.playbackState) {
|
||||
Player.STATE_IDLE,
|
||||
@@ -207,6 +169,38 @@ class OmniPlayer : HybridOmniPlayerSpec() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun setSource(src: Source) {
|
||||
Log.i("omni", "update source")
|
||||
val source = src.src.firstOrNull()
|
||||
if (source == null) {
|
||||
runOnMainThreadSync {
|
||||
player.setMediaItem(MediaItem.EMPTY)
|
||||
player.prepare()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val currentItem = buildMediaItem(source, src.metadata, src.subtitles)
|
||||
val mediaItems = mutableListOf<MediaItem>()
|
||||
|
||||
if (src.metadata?.hasPrev == true) {
|
||||
mediaItems.add(currentItem)
|
||||
}
|
||||
|
||||
mediaItems.add(currentItem)
|
||||
|
||||
if (src.metadata?.hasNext == true) {
|
||||
mediaItems.add(currentItem)
|
||||
}
|
||||
|
||||
runOnMainThreadSync {
|
||||
val startIndex = if (src.metadata?.hasPrev == true) 1 else 0
|
||||
val startPositionMs = (src.startTime?.coerceAtLeast(0.0) ?: 0.0) * 1000.0
|
||||
player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong())
|
||||
player.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
runOnMainThreadSync { player.play() }
|
||||
}
|
||||
|
||||
@@ -73,3 +73,4 @@ yarn-error.log
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
dist/
|
||||
|
||||
+42
-15
@@ -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,
|
||||
@@ -382,14 +392,17 @@ function PlayerExample({
|
||||
]}
|
||||
>
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @format
|
||||
*/
|
||||
|
||||
import { AppRegistry } from "react-native";
|
||||
import App from "./App";
|
||||
import { name as appName } from "./app.json";
|
||||
|
||||
AppRegistry.registerComponent(appName, () => App);
|
||||
|
||||
AppRegistry.runApplication(appName, {
|
||||
rootTag: document.getElementById("root"),
|
||||
});
|
||||
+18
-1
@@ -2,6 +2,8 @@ const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
|
||||
const path = require("node:path");
|
||||
const root = path.resolve(__dirname, "..");
|
||||
|
||||
const useWeb = process.env.USE_WEB === "true" || process.env.PLATFORM === "web";
|
||||
|
||||
/**
|
||||
* Metro configuration
|
||||
* https://facebook.github.io/metro/docs/configuration
|
||||
@@ -9,7 +11,22 @@ const root = path.resolve(__dirname, "..");
|
||||
* @type {import('metro-config').MetroConfig}
|
||||
*/
|
||||
const config = {
|
||||
watchFolders: [root],
|
||||
watchFolders: [root],
|
||||
...(useWeb && {
|
||||
resolver: {
|
||||
extraNodeModules: {
|
||||
"react-native": path.resolve(__dirname, "node_modules", "react-native-web"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
transformer: {
|
||||
getTransformOptions: async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: false,
|
||||
inlineRequires: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
|
||||
|
||||
+15
-4
@@ -7,15 +7,18 @@
|
||||
"ios": "react-native run-ios --simulator='iPhone 16'",
|
||||
"lint": "eslint .",
|
||||
"start": "react-native start --reset-cache --port 8082",
|
||||
"web": "webpack serve --mode development --open",
|
||||
"test": "jest",
|
||||
"pod": "bundle install && bundle exec pod install --project-directory=ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.84.1",
|
||||
"@react-native/new-app-screen": "0.84.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.84.1",
|
||||
"react-native-nitro-modules": "0.35.3",
|
||||
"react-native-safe-area-context": "^5.5.2",
|
||||
"react-native-nitro-modules": "0.35.3"
|
||||
"react-native-web": "^0.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
@@ -29,7 +32,15 @@
|
||||
"@react-native/metro-config": "0.84.1",
|
||||
"@react-native/typescript-config": "0.84.1",
|
||||
"@types/jest": "^29.5.13",
|
||||
"babel-plugin-module-resolver": "^5.0.2"
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"css-loader": "^7.1.4",
|
||||
"html-webpack-plugin": "^5.6.7",
|
||||
"style-loader": "^4.0.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-cli": "^7.0.3",
|
||||
"webpack-dev-server": "^5.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22.11.0"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>React Native Omni Example</title>
|
||||
<style>
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -4,7 +4,6 @@
|
||||
"exclude": ["**/node_modules", "**/Pods"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"react-native-omni": ["../src"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
const path = require("path");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
|
||||
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,
|
||||
// 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") }],
|
||||
},
|
||||
};
|
||||
+19
-24
@@ -9,6 +9,12 @@
|
||||
|
||||
// Forward declaration of `HybridOmniEventMapSpec` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { class HybridOmniEventMapSpec; }
|
||||
// Forward declaration of `Track` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Track; }
|
||||
// Forward declaration of `Rendition` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Rendition; }
|
||||
// Forward declaration of `PlayerStatus` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { enum class PlayerStatus; }
|
||||
// Forward declaration of `Source` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Source; }
|
||||
// Forward declaration of `VideoSrc` to properly resolve imports.
|
||||
@@ -19,23 +25,23 @@ namespace margelo::nitro::omni { struct Subtitle; }
|
||||
namespace margelo::nitro::omni { struct Metadata; }
|
||||
// Forward declaration of `MixAudioMode` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { enum class MixAudioMode; }
|
||||
// Forward declaration of `Track` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Track; }
|
||||
// Forward declaration of `Rendition` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Rendition; }
|
||||
// Forward declaration of `PlayerStatus` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { enum class PlayerStatus; }
|
||||
|
||||
#include <memory>
|
||||
#include "HybridOmniEventMapSpec.hpp"
|
||||
#include "JHybridOmniEventMapSpec.hpp"
|
||||
#include <optional>
|
||||
#include "Track.hpp"
|
||||
#include <vector>
|
||||
#include "JTrack.hpp"
|
||||
#include <string>
|
||||
#include "Rendition.hpp"
|
||||
#include "JRendition.hpp"
|
||||
#include "PlayerStatus.hpp"
|
||||
#include "JPlayerStatus.hpp"
|
||||
#include "Source.hpp"
|
||||
#include "JSource.hpp"
|
||||
#include "VideoSrc.hpp"
|
||||
#include <vector>
|
||||
#include "JVideoSrc.hpp"
|
||||
#include <string>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
#include "Subtitle.hpp"
|
||||
#include "JSubtitle.hpp"
|
||||
@@ -43,12 +49,6 @@ namespace margelo::nitro::omni { enum class PlayerStatus; }
|
||||
#include "JMetadata.hpp"
|
||||
#include "MixAudioMode.hpp"
|
||||
#include "JMixAudioMode.hpp"
|
||||
#include "Track.hpp"
|
||||
#include "JTrack.hpp"
|
||||
#include "Rendition.hpp"
|
||||
#include "JRendition.hpp"
|
||||
#include "PlayerStatus.hpp"
|
||||
#include "JPlayerStatus.hpp"
|
||||
|
||||
namespace margelo::nitro::omni {
|
||||
|
||||
@@ -85,15 +85,6 @@ namespace margelo::nitro::omni {
|
||||
auto __result = method(_javaPart);
|
||||
return __result->getJHybridOmniEventMapSpec();
|
||||
}
|
||||
Source JHybridOmniPlayerSpec::getSource() {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<JSource>()>("getSource");
|
||||
auto __result = method(_javaPart);
|
||||
return __result->toCpp();
|
||||
}
|
||||
void JHybridOmniPlayerSpec::setSource(const Source& source) {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<JSource> /* source */)>("setSource");
|
||||
method(_javaPart, JSource::fromCpp(source));
|
||||
}
|
||||
std::optional<bool> JHybridOmniPlayerSpec::getShowNotification() {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<jni::JBoolean>()>("getShowNotification");
|
||||
auto __result = method(_javaPart);
|
||||
@@ -232,6 +223,10 @@ namespace margelo::nitro::omni {
|
||||
}
|
||||
|
||||
// Methods
|
||||
void JHybridOmniPlayerSpec::setSource(const Source& src) {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<JSource> /* src */)>("setSource");
|
||||
method(_javaPart, JSource::fromCpp(src));
|
||||
}
|
||||
void JHybridOmniPlayerSpec::play() {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("play");
|
||||
method(_javaPart);
|
||||
|
||||
+1
-2
@@ -51,8 +51,6 @@ namespace margelo::nitro::omni {
|
||||
public:
|
||||
// Properties
|
||||
std::shared_ptr<HybridOmniEventMapSpec> getEventMap() override;
|
||||
Source getSource() override;
|
||||
void setSource(const Source& source) override;
|
||||
std::optional<bool> getShowNotification() override;
|
||||
void setShowNotification(std::optional<bool> showNotification) override;
|
||||
bool getHasPrev() override;
|
||||
@@ -77,6 +75,7 @@ namespace margelo::nitro::omni {
|
||||
|
||||
public:
|
||||
// Methods
|
||||
void setSource(const Source& src) override;
|
||||
void play() override;
|
||||
void pause() override;
|
||||
void seekBy(double offset) override;
|
||||
|
||||
+4
-6
@@ -29,12 +29,6 @@ abstract class HybridOmniPlayerSpec: HybridObject() {
|
||||
@get:Keep
|
||||
abstract val eventMap: HybridOmniEventMapSpec
|
||||
|
||||
@get:DoNotStrip
|
||||
@get:Keep
|
||||
@set:DoNotStrip
|
||||
@set:Keep
|
||||
abstract var source: Source
|
||||
|
||||
@get:DoNotStrip
|
||||
@get:Keep
|
||||
@set:DoNotStrip
|
||||
@@ -110,6 +104,10 @@ abstract class HybridOmniPlayerSpec: HybridObject() {
|
||||
abstract val isAutoQuality: Boolean
|
||||
|
||||
// Methods
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
abstract fun setSource(src: Source): Unit
|
||||
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
abstract fun play(): Unit
|
||||
|
||||
+1
-2
@@ -15,8 +15,6 @@ namespace margelo::nitro::omni {
|
||||
// load custom methods/properties
|
||||
registerHybrids(this, [](Prototype& prototype) {
|
||||
prototype.registerHybridGetter("eventMap", &HybridOmniPlayerSpec::getEventMap);
|
||||
prototype.registerHybridGetter("source", &HybridOmniPlayerSpec::getSource);
|
||||
prototype.registerHybridSetter("source", &HybridOmniPlayerSpec::setSource);
|
||||
prototype.registerHybridGetter("showNotification", &HybridOmniPlayerSpec::getShowNotification);
|
||||
prototype.registerHybridSetter("showNotification", &HybridOmniPlayerSpec::setShowNotification);
|
||||
prototype.registerHybridGetter("hasPrev", &HybridOmniPlayerSpec::getHasPrev);
|
||||
@@ -38,6 +36,7 @@ namespace margelo::nitro::omni {
|
||||
prototype.registerHybridGetter("muted", &HybridOmniPlayerSpec::getMuted);
|
||||
prototype.registerHybridSetter("muted", &HybridOmniPlayerSpec::setMuted);
|
||||
prototype.registerHybridGetter("isAutoQuality", &HybridOmniPlayerSpec::getIsAutoQuality);
|
||||
prototype.registerHybridMethod("setSource", &HybridOmniPlayerSpec::setSource);
|
||||
prototype.registerHybridMethod("play", &HybridOmniPlayerSpec::play);
|
||||
prototype.registerHybridMethod("pause", &HybridOmniPlayerSpec::pause);
|
||||
prototype.registerHybridMethod("seekBy", &HybridOmniPlayerSpec::seekBy);
|
||||
|
||||
+4
-5
@@ -15,23 +15,23 @@
|
||||
|
||||
// Forward declaration of `HybridOmniEventMapSpec` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { class HybridOmniEventMapSpec; }
|
||||
// Forward declaration of `Source` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Source; }
|
||||
// Forward declaration of `Track` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Track; }
|
||||
// Forward declaration of `Rendition` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Rendition; }
|
||||
// Forward declaration of `PlayerStatus` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { enum class PlayerStatus; }
|
||||
// Forward declaration of `Source` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Source; }
|
||||
|
||||
#include <memory>
|
||||
#include "HybridOmniEventMapSpec.hpp"
|
||||
#include "Source.hpp"
|
||||
#include <optional>
|
||||
#include "Track.hpp"
|
||||
#include <vector>
|
||||
#include "Rendition.hpp"
|
||||
#include "PlayerStatus.hpp"
|
||||
#include "Source.hpp"
|
||||
|
||||
namespace margelo::nitro::omni {
|
||||
|
||||
@@ -61,8 +61,6 @@ namespace margelo::nitro::omni {
|
||||
public:
|
||||
// Properties
|
||||
virtual std::shared_ptr<HybridOmniEventMapSpec> getEventMap() = 0;
|
||||
virtual Source getSource() = 0;
|
||||
virtual void setSource(const Source& source) = 0;
|
||||
virtual std::optional<bool> getShowNotification() = 0;
|
||||
virtual void setShowNotification(std::optional<bool> showNotification) = 0;
|
||||
virtual bool getHasPrev() = 0;
|
||||
@@ -87,6 +85,7 @@ namespace margelo::nitro::omni {
|
||||
|
||||
public:
|
||||
// Methods
|
||||
virtual void setSource(const Source& src) = 0;
|
||||
virtual void play() = 0;
|
||||
virtual void pause() = 0;
|
||||
virtual void seekBy(double offset) = 0;
|
||||
|
||||
+31
-18
@@ -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",
|
||||
@@ -10,10 +10,13 @@
|
||||
"scripts": {
|
||||
"dev": "cd example && bun start",
|
||||
"android": " cd example && bunx react-native run-android --no-packager --port 8082",
|
||||
"web": "cd example && bun web",
|
||||
"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",
|
||||
@@ -46,33 +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"
|
||||
"@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",
|
||||
@@ -87,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",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
type Selector,
|
||||
selectAudioTrack,
|
||||
selectBuffer,
|
||||
selectError,
|
||||
selectPlayback,
|
||||
selectPlaybackRate,
|
||||
selectQuality,
|
||||
selectTextTrack,
|
||||
selectTime,
|
||||
selectVolume,
|
||||
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";
|
||||
|
||||
type EventMapperConfig = {
|
||||
[Key in keyof OmniEvents]?: {
|
||||
selector: Selector<any, any>;
|
||||
handler: (cb: OmniEvents[Key], value: any, prev: any) => void;
|
||||
};
|
||||
};
|
||||
|
||||
function createEventMapper<Key extends keyof OmniEvents, Result>(
|
||||
key: Key,
|
||||
selector: Selector<any, Result>,
|
||||
handler: (
|
||||
cb: OmniEvents[Key],
|
||||
value: Result,
|
||||
prev: Result | undefined,
|
||||
) => void,
|
||||
) {
|
||||
return { [key]: { selector, handler } };
|
||||
}
|
||||
|
||||
const eventMapper: EventMapperConfig = {
|
||||
...createEventMapper("end", selectPlayback, (cb, value, prev) => {
|
||||
if (value?.ended && prev && !prev.ended) cb();
|
||||
}),
|
||||
...createEventMapper("error", selectError, (cb, value, prev) => {
|
||||
if (value?.error && value.error !== prev?.error) {
|
||||
cb(
|
||||
value.error.message ?? "Unknown error",
|
||||
value.error.message ?? "Unknown error",
|
||||
);
|
||||
}
|
||||
}),
|
||||
...createEventMapper("subtitleChange", selectTextTrack, (cb, value, prev) => {
|
||||
if (value === prev) return;
|
||||
const tracks = value?.textTrackList;
|
||||
if (tracks?.length) {
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const track = tracks[i];
|
||||
if (track?.mode === "showing") {
|
||||
cb({
|
||||
id: `text-${i}`,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
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>(
|
||||
event: Event,
|
||||
callback: OmniEvents[Event],
|
||||
) => {
|
||||
const config = eventMapper[event];
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
const prevRef = useRef<any>(undefined);
|
||||
const value = useStoreSelector(config?.selector ?? (() => undefined));
|
||||
const player = usePlayer() as WebOmniPlayer;
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
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>(
|
||||
key: Key,
|
||||
selector: Selector<State, Result>,
|
||||
mapper: (ret: Result) => OmniPlayerState[Key],
|
||||
): Pick<
|
||||
{
|
||||
[K in keyof OmniPlayerState]: {
|
||||
selector: Selector<State, Result>;
|
||||
mapper: (ret: Result) => OmniPlayerState[K];
|
||||
};
|
||||
},
|
||||
Key
|
||||
> {
|
||||
return { [key]: { selector, mapper } } as any;
|
||||
}
|
||||
|
||||
export const stateMapper = {
|
||||
...createMapper("status", selectPlayback, (s) => {
|
||||
if (s?.waiting) return "loading";
|
||||
if (s?.ended) return "idle";
|
||||
return "readyToPlay";
|
||||
}),
|
||||
...createMapper("isPlaying", selectPlayback, (s) => {
|
||||
if (!s) return false;
|
||||
return !s.paused;
|
||||
}),
|
||||
...createMapper("currentTime", selectTime, (s) => {
|
||||
return s?.currentTime ?? 0;
|
||||
}),
|
||||
...createMapper("buffered", selectBuffer, (s) => {
|
||||
if (!s?.buffered.length) return 0;
|
||||
const last = s.buffered[s.buffered.length - 1];
|
||||
return last?.[1] ?? 0;
|
||||
}),
|
||||
...createMapper("duration", selectTime, (s) => {
|
||||
return s?.duration ?? 0;
|
||||
}),
|
||||
...createMapper("playbackRate", selectPlaybackRate, (s) => {
|
||||
return s?.playbackRate ?? 1;
|
||||
}),
|
||||
...createMapper("volume", selectVolume, (s) => {
|
||||
return s?.volume ?? 1;
|
||||
}),
|
||||
...createMapper("muted", selectVolume, (s) => {
|
||||
return s?.muted ?? false;
|
||||
}),
|
||||
...createMapper("isAutoQuality", selectQuality, (q) => {
|
||||
if (!q) return true;
|
||||
return !q.videoRenditionList.some((r) => r.selected);
|
||||
}),
|
||||
};
|
||||
|
||||
export function usePlayerState<Key extends keyof OmniPlayerState>(
|
||||
key: Key,
|
||||
): OmniPlayerState[Key];
|
||||
export function usePlayerState(
|
||||
key: "currentTime",
|
||||
refresh?: number,
|
||||
): OmniPlayerState["currentTime"];
|
||||
export function usePlayerState<Key extends keyof OmniPlayerState>(
|
||||
key: Key,
|
||||
_refresh?: number,
|
||||
): OmniPlayerState[Key] {
|
||||
const config = stateMapper[key];
|
||||
if (!config) throw new Error(`No mapper for ${key}`);
|
||||
const ret = useStoreSelector(config.selector as Selector<any, any>);
|
||||
return config.mapper(ret) as OmniPlayerState[Key];
|
||||
}
|
||||
@@ -1,3 +1,19 @@
|
||||
export { useEvent, usePlayerState } from "./events";
|
||||
export { OmniProvider, usePlayer } from "./provider";
|
||||
export type { OmniEvents } from "./types/events";
|
||||
export type {
|
||||
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";
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import type { MediaVideoRendition } from "@videojs/core";
|
||||
import type { VideoPlayerStore } from "@videojs/core/dom";
|
||||
import {
|
||||
selectAudioTrack,
|
||||
selectQuality,
|
||||
selectTextTrack,
|
||||
} from "@videojs/react";
|
||||
import { stateMapper } from "./events.web";
|
||||
import type {
|
||||
OmniPlayer,
|
||||
PlayerStatus,
|
||||
Rendition,
|
||||
Track,
|
||||
} from "./types/player";
|
||||
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 = new Set<() => void>();
|
||||
onNext = new Set<() => void>();
|
||||
|
||||
constructor(store: VideoPlayerStore) {
|
||||
this._store = store;
|
||||
}
|
||||
|
||||
_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 {
|
||||
return this._showNotification;
|
||||
}
|
||||
|
||||
set showNotification(value: boolean) {
|
||||
this._showNotification = value;
|
||||
this.updateMediaSession();
|
||||
}
|
||||
|
||||
get status(): PlayerStatus {
|
||||
const state = this._store.state;
|
||||
return stateMapper.status.mapper(state);
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return !this._store.state.paused && !this._store.state.ended;
|
||||
}
|
||||
|
||||
get currentTime(): number {
|
||||
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;
|
||||
return buffered[buffered.length - 1]?.[1] ?? 0;
|
||||
}
|
||||
|
||||
get duration(): number {
|
||||
return this._store.state.duration;
|
||||
}
|
||||
|
||||
get playbackRate(): number {
|
||||
return this._store.state.playbackRate;
|
||||
}
|
||||
|
||||
set playbackRate(rate: number) {
|
||||
this._store.setPlaybackRate(rate);
|
||||
}
|
||||
|
||||
get volume(): number {
|
||||
return this._store.state.volume;
|
||||
}
|
||||
|
||||
set volume(vol: number) {
|
||||
this._store.setVolume(vol);
|
||||
}
|
||||
|
||||
get muted(): boolean {
|
||||
return this._store.state.muted;
|
||||
}
|
||||
|
||||
set muted(value: boolean) {
|
||||
if (value !== this.muted) {
|
||||
this._store.toggleMuted();
|
||||
}
|
||||
}
|
||||
|
||||
get isAutoQuality(): boolean {
|
||||
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 {
|
||||
this._store.seek(this.currentTime + offset).catch(() => {});
|
||||
}
|
||||
|
||||
playPrev(): void {
|
||||
for (const cb of this.onPrev) cb();
|
||||
}
|
||||
|
||||
playNext(): void {
|
||||
for (const cb of this.onNext) cb();
|
||||
}
|
||||
|
||||
get hasPrev(): boolean {
|
||||
return this._source?.metadata?.hasPrev ?? false;
|
||||
}
|
||||
|
||||
get hasNext(): boolean {
|
||||
return this._source?.metadata?.hasNext ?? false;
|
||||
}
|
||||
|
||||
get videos(): Track[] {
|
||||
// hls.js does not support alternative video tracks (e.g. camera angles)
|
||||
return [];
|
||||
}
|
||||
|
||||
selectVideo(_video: Track): void {
|
||||
// hls.js does not support alternative video tracks
|
||||
}
|
||||
|
||||
get audios(): Track[] {
|
||||
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 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[] {
|
||||
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 {
|
||||
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[] {
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@ export const OmniProvider = ({
|
||||
const player = useLazyRef(() => ProviderFactory.createPlayer(source));
|
||||
|
||||
useEffect(() => {
|
||||
player.source = source;
|
||||
player.setSource(source);
|
||||
}, [source]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createPlayer } from "@videojs/react";
|
||||
import { videoFeatures } from "@videojs/react/video";
|
||||
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";
|
||||
import { useLazyRef } from "./utils/lazy-ref";
|
||||
|
||||
export const VideoPlayer = createPlayer({ features: videoFeatures });
|
||||
|
||||
const PlayerCtx = createContext<OmniPlayer>(null!);
|
||||
|
||||
export const OmniProvider = ({
|
||||
children,
|
||||
source,
|
||||
showNotification = false,
|
||||
}: {
|
||||
source: Source;
|
||||
children: ReactNode;
|
||||
showNotification?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<VideoPlayer.Provider>
|
||||
<PlayerInitializer source={source} showNotification={showNotification}>
|
||||
{children}
|
||||
</PlayerInitializer>
|
||||
</VideoPlayer.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const PlayerInitializer = ({
|
||||
children,
|
||||
source,
|
||||
showNotification,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
source: Source;
|
||||
showNotification: boolean;
|
||||
}) => {
|
||||
const store = VideoPlayer.usePlayer();
|
||||
const player = useLazyRef(() => new WebOmniPlayer(store));
|
||||
const seekedForSrc = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
player.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;
|
||||
}, [showNotification]);
|
||||
|
||||
return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>;
|
||||
};
|
||||
|
||||
export const usePlayer = () => useContext(PlayerCtx);
|
||||
@@ -56,6 +56,8 @@ export interface OmniPlayer
|
||||
extends HybridObject<{ android: "kotlin" }>,
|
||||
OmniPlayerT {
|
||||
readonly eventMap: OmniEventMap;
|
||||
|
||||
setSource(src: Source): void;
|
||||
}
|
||||
|
||||
export interface OmniPlayerFactory extends HybridObject<{ android: "kotlin" }> {
|
||||
|
||||
+1
-4
@@ -1,7 +1,4 @@
|
||||
import type { Source } from "./source";
|
||||
|
||||
export interface OmniPlayer extends OmniPlayerState {
|
||||
source: Source;
|
||||
showNotification?: boolean;
|
||||
|
||||
play(): void;
|
||||
@@ -34,7 +31,7 @@ export interface OmniPlayerState {
|
||||
// between 0 and 1
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
readonly isAutoQuality: boolean
|
||||
readonly isAutoQuality: boolean;
|
||||
}
|
||||
|
||||
export type PlayerStatus = "idle" | "loading" | "readyToPlay" | "error";
|
||||
|
||||
+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} />;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
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,
|
||||
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 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={{ position: "relative", ...style }}
|
||||
>
|
||||
{src && (
|
||||
<Tech
|
||||
ref={ref}
|
||||
src={src.uri}
|
||||
config={isHls ? config : undefined}
|
||||
autoPlay={autoplay}
|
||||
playsInline
|
||||
crossOrigin="anonymous"
|
||||
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>
|
||||
);
|
||||
};
|
||||
+2
-4
@@ -6,14 +6,12 @@
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["esnext"],
|
||||
"lib": ["esnext", "dom"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitUseStrict": false,
|
||||
"noStrictGenericChecks": false,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user