mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-05 13:46:59 +00:00
261 lines
8.6 KiB
Markdown
261 lines
8.6 KiB
Markdown
# react-native-omni
|
|
|
|
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)
|
|
|
|
## Features
|
|
|
|
- **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...
|
|
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
bun add react-native-omni react-native-nitro-modules
|
|
```
|
|
|
|
### Expo config plugin (Android)
|
|
|
|
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`:
|
|
|
|
```json
|
|
{
|
|
"expo": {
|
|
"plugins": ["react-native-omni"]
|
|
}
|
|
}
|
|
```
|
|
|
|
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
|
|
```
|