spalla-react-native
v2.1.1
Published
Spalla SDK for RN
Readme
spalla-react-native
Spalla SDK for RN
Installation
npm install spalla-react-native react-native-video @react-native-async-storage/async-storage react-native-uuid react-native-google-castThe player UI is implemented in JS on top of react-native-video (the THEOplayer maven repository is no longer needed). Telemetry and CDN failover ship as a precompiled native core (C++ TurboModule), so the package requires React Native 0.76+ with the New Architecture enabled and does not run in Expo Go (use a dev client / prebuild).
Required: apply the native patches
This SDK depends on fixes to react-native-video that are not upstream yet (DAI
stream initialisation on Android, IMA ad overlay swallowing taps and a layout
loop on iOS). They ship with the package and are applied by patch-package.
Add the postinstall hook to your app's package.json and reinstall:
{
"scripts": {
"postinstall": "spalla-apply-patches"
}
}Then rebuild the native app — patching node_modules alone does not change an
already-compiled binary.
Verify at any time:
npx spalla-doctorWithout the patches, ad breaks and DAI live streams fail in ways that look like
content or CDN problems. The SDK also detects it at runtime: it logs an error,
emits an integrationWarning player event and exposes checkIntegration() for
your own startup health check.
The patch is generated against react-native-video 6.19.2 exactly, so pin it
("react-native-video": "6.19.2") — a range like ^6.19.1 can resolve to a
version the patch no longer applies to.
Recent npm versions warn that
spalla-react-nativehas an unapproved install script (install-scripts not yet covered by allowScripts). It is safe to ignore: the patches are applied by thepostinstallof your app, which always runs.npx spalla-doctorconfirms it.
Expo users: this works with prebuild/CNG and EAS Build. If your build cache skips
postinstall, runnpx spalla-doctorin a build step to fail early.
iOS
Enable Google IMA ads support in your ios/Podfile before use_react_native!:
$RNVideoUseGoogleIMA = trueThen run pod install.
Android
Enable IMA and HLS support in android/build.gradle (buildscript.ext):
ext {
// ...
useExoplayerIMA = true
useExoplayerHls = true
}If you are migrating from a previous version, remove maven { url 'https://maven.theoplayer.com/releases' } from your repositories.
For Picture-in-Picture, keep android:supportsPictureInPicture="true" on your MainActivity in AndroidManifest.xml (the Expo plugin does this automatically).
Usage
import SpallaPlayer, { initialize } from 'spalla-react-native';
// make sure to call initialize as soon as possible on your app. Can be on top of index.js or App.js
initialize(
'your spalla token',
null //application id for chromecast.
);
// ...
const playerRef = React.useRef<SpallaPlayer | null>(null);
const [muted, setMuted] = React.useState(false);
const [playing, setPlaying] = React.useState(true);
const [subtitle, setSubtitle] = React.useState<String | null>('pt-br');
<SafeAreaView style={styles.container}>
<SpallaPlayer
ref={playerRef}
style={styles.videoPlayer}
contentId="Spalla contentId"
muted={muted}
hideUI={false}
subtitle={subtitle}
pipEnabled={false}
onPlayerEvent={({ nativeEvent }) => {
switch (nativeEvent.event) {
case 'timeUpdate':
console.log('timeupdate', nativeEvent.time);
break;
case 'durationUpdate':
console.log('durationUpdate', nativeEvent.duration);
break;
case 'play':
case 'playing':
setPlaying(true);
break;
case 'pause':
setPlaying(false);
break;
case 'muted':
setMuted(true);
break;
case 'unmuted':
setMuted(false);
break;
case 'subtitleSelected':
console.log('subtitleSelected', nativeEvent.subtitle);
setSubtitle(nativeEvent.subtitle);
break;
case 'subtitlesAvailable':
console.log('subtitlesAvailable', nativeEvent.subtitles);
break;
case 'playbackRateSelected':
setPlaybackRate(nativeEvent.rate);
break;
case 'metadataLoaded':
console.log(
'metadataLoaded',
nativeEvent.isLive,
nativeEvent.duration
);
break;
default:
console.log('event', nativeEvent.event);
}
}}
>
<View style={styles.uicontainer}>{/*Place your custom UI here*/}</View>
</SpallaPlayer>
<Button
onPress={() => {
if (playing) {
playerRef.current?.pause();
} else {
playerRef.current?.play();
}
}}
title={playing ? 'Pause' : 'Play'}
/>
<Button
onPress={() => setMuted(!muted)}
title={muted ? 'Unmute' : 'Mute'}
/>
</SafeAreaView>Props
| Property | Type | Description |
| :----------------- | :------: | :----------- |
| contentId | string | Spalla contentId that will be played
| hideUI | boolean | hide or show the default UI (its a prop, but it can only be set once)
| muted | boolean | mute/unmute video
| startTime | number | time to start the video in seconds (defaults to 0 = start of the video)
| onPlayerEvent| callback | Function that will be called with player events
| subtitle | string | subtitle to enable (language code from subtitlesAvailable). Null will hide subtitles
| audioTrack | string | audio track to enable (name/language from audioTracksAvailable). Null uses the default track
| playbackRate | number | Playback speed. Allowed values are 0.5, 1.0, 1.5 and 2.0
| aspectRatio | string | how video fits the screen. "fit" | "fill" | "aspectFill" (can only be set once)
| customImaParams | Map | Custom parameters for IMA ads. Key and value must be strings
| customAds | Array | Custom VAST/VMAP ads. Must be an array of objects like {["url": "vast url", "offset": "start"]}
| pipEnabled | boolean | enable PiP support. Remember to add android:supportsPictureInPicture="true" on MainActivity on Manifest.xml on Android, and add backgroundMode PiP on iOS
Imperative Methods
Control and inspect the player through its ref:
| Method | Description |
| :----------------- | :----------- |
| play() | Resume playback
| pause() | Pause playback
| seekTo(time) | Seek to a time (in seconds)
| seekToLive() | Jump back to the live edge (live streams with DVR)
| setSubtitle(lang \| null) | Select a subtitle by language/name; null disables subtitles
| getSubtitle() | Currently selected subtitle (null when disabled)
| getAvailableSubtitles() | Subtitle languages/names announced by subtitlesAvailable
| setAudioTrack(track \| null) | Select an audio track by name/language; null returns to the default
| getAudioTrack() | Currently selected audio track (falls back to the active track reported by the player)
| getAvailableAudioTracks() | Audio tracks announced by audioTracksAvailable
| setPlaybackRate(rate) | Set playback speed (one of getAvailablePlaybackRates())
| getPlaybackRate() | Current playback speed
| getAvailablePlaybackRates() | [0.25, 0.5, 1.0, 1.25, 1.5, 2.0]
| setBitrate(bitrate \| null) | Pin the quality to a variant bitrate (bps); null returns to automatic ABR
| getBitrate() | Bitrate currently playing (bps), when known
| getAvailableBitrates() | Variant bitrates from the HLS master and the player's video tracks
| enterFullscreen() / exitFullscreen() | Toggle the native fullscreen player
| isFullscreen() | Whether the native fullscreen player is presented
| enterPiP() / exitPiP() | Toggle Picture-in-Picture (requires pipEnabled)
| isInPiP() | Whether Picture-in-Picture is active
| checkIntegration() | { ok, issue?, message? } — whether the required react-native-video patches are active (see Installation)
Invalid arguments (NaN/negative seeks, unknown playback rates, non-positive
bitrates) are rejected and reported through telemetry instead of reaching the
native player.
Setters coexist with the equivalent props (subtitle, audioTrack, playbackRate): the most recent change wins — calling a setter overrides the prop until the prop value changes again.
playerRef.current?.play();
playerRef.current?.pause();
playerRef.current?.seekTo(12); //position in seconds, if higher than duration it will move to the end
playerRef.current?.seekToLive();
// subtitles / audio
const subtitles = playerRef.current?.getAvailableSubtitles(); // e.g. ['pt-br', 'es']
playerRef.current?.setSubtitle(subtitles?.[0] ?? null);
playerRef.current?.setSubtitle(null); // disable
const tracks = playerRef.current?.getAvailableAudioTracks(); // e.g. ['Português', 'Español']
playerRef.current?.setAudioTrack(tracks?.[1] ?? null);
// speed, quality, fullscreen, PiP
playerRef.current?.setPlaybackRate(1.5);
const bitrates = playerRef.current?.getAvailableBitrates(); // e.g. [560301, 1136555, 1928717]
playerRef.current?.setBitrate(bitrates?.[0] ?? null); // pin lowest quality
playerRef.current?.setBitrate(null); // back to automatic ABR
playerRef.current?.enterFullscreen();
playerRef.current?.enterPiP();On Android the pin selects the exact variant when the player has enumerated its video tracks; on iOS (and before tracks are known) it is applied as a preferredPeakBitRate cap, so the ABR picks the highest variant at or below the requested bitrate.
Player events
All events arrive through onPlayerEvent as { nativeEvent }:
| Event | Payload | Description |
| :----------------- | :------: | :----------- |
| play / pause / playing / buffering / ended | — | playback state changes
| muted / unmuted | — | mute state changes
| timeUpdate | time, seekableDuration | current position; seekableDuration is the end of the seekable range (DVR window on live)
| durationUpdate | duration | content duration in seconds
| metadataLoaded | isLive, duration, isVertical, dvrEnabled | stream metadata. With a pre-roll it fires as soon as the ad break starts (from the stream config) and again with the real dimensions once the content loads, so your app never waits on an ad to call play()
| subtitlesAvailable | subtitles: string[] | subtitle languages/names available for subtitle
| subtitleSelected | subtitle | echoes the subtitle prop
| audioTracksAvailable | audioTracks: string[] | audio track names available for audioTrack
| audioTrackSelected | audioTrack | echoes the audioTrack prop
| thumbnailsAvailable | thumbnails: ThumbnailCue[] | scrubbing preview cues (see Thumbnails)
| playbackRateSelected | rate | echoes the playback rate
| enterPiP / exitPiP | — | Picture-in-Picture transitions
| onEnterFullScreen / onExitFullScreen | — | fullscreen transitions
| adBreakBegin / adBreakEnd / adBegin / adEnd / adError | — | ad lifecycle
| integrationWarning | code, message | the required react-native-video patches are missing, outdated or not in the native build (see Installation)
| error | message, canRetry | playback or loading failure
Subtitles
Subtitle languages come from the stream config (sp_player_legendas_idiomas) and/or from tracks embedded in the HLS manifest. Both are merged and announced via subtitlesAvailable; select one with the subtitle prop.
- Embedded manifest tracks (recommended): rendered natively by AVPlayer/ExoPlayer, including native fullscreen and PiP.
- Sidecar
.srtfallback: for streams without embedded tracks the SDK downloads and parses the.srtand renders the cues as a JS overlay on both platforms (react-native-videocannot sideload subtitles into HLS on either OS). The overlay is not visible inside native fullscreen or PiP.
To migrate a VOD to embedded subtitles on the packaging side, use scripts/embed-subtitles.sh: it converts each <lang>.srt to WebVTT, generates one subtitle media playlist per language and rewrites the master playlist (EXT-X-MEDIA + SUBTITLES group). Run scripts/embed-subtitles.sh --help for options.
Live DVR
When a live stream has DVR enabled, metadataLoaded.dvrEnabled is true and every timeUpdate carries seekableDuration (the DVR window). Use seekTo(time) to scrub inside the window and seekToLive() to return to the live edge.
Thumbnails (scrubbing preview)
Thumbnails are resolved automatically for VoDs, in this order:
EXT-X-IMAGE-STREAM-INFdeclared in the HLS master (Roku Image Media Playlist extension): the SDK parses the image playlist (EXT-X-TILESsprite grids or plain image segments) into cues.thumbnail.vttconvention next to the manifest (<CDN>/vod/<id>/thumbnail.vtt).
Either source emits thumbnailsAvailable with the same cue shape:
type ThumbnailCue = {
start: number; // seconds
end: number;
uri: string; // sprite image URL (signed)
x?: number; // sprite crop rect, present for #xywh storyboards
y?: number;
width?: number;
height?: number;
};Render the preview by cropping the sprite (an Image inside an overflow: 'hidden' container offset by -x/-y). See example/src/CustomControls.tsx for a complete scrub bar with previews.
EXT-X-I-FRAME-STREAM-INF renditions require no SDK handling: AVPlayer consumes them natively for trick play while scrubbing with the iOS native controls, and both players ignore them otherwise.
Chromecast
Chromecast support is provided through react-native-google-cast. Install it as shown above, then:
// Add the application id for chromecast. In this example, A123456
initialize(
'your spalla token',
'A123456'
);Note: the application id passed to
initialize()does not configure the native Cast discovery. The effective receiver App ID is the one set natively below (AppDelegate/Info.plist on iOS, AndroidManifest on Android), and changing it requires a native rebuild.
On iOS, initialize the cast context in your AppDelegate (didFinishLaunchingWithOptions), using your App ID:
#import <GoogleCast/GoogleCast.h>
GCKDiscoveryCriteria *criteria = [[GCKDiscoveryCriteria alloc] initWithApplicationID:@"A123456"];
GCKCastOptions *options = [[GCKCastOptions alloc] initWithDiscoveryCriteria:criteria];
// Without this, "Stop casting" only disconnects and the receiver keeps playing.
options.stopReceiverApplicationWhenEndingSession = YES;
[GCKCastContext setSharedInstanceWithOptions:options];Also open info.plist as SourceCode and copy this inside the main dict. Make sure to change A123456 with your App ID (keep the underscore at the start). More details on this link if needed.
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_A123456._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>We need network access to search for Cast devices</string>On Android, open Manifest.xml and add these meta data tags inside the tag (same level as activities). As before, change A123456 with your App ID.
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.reactnative.googlecast.GoogleCastOptionsProvider"/>
<meta-data
android:name="com.reactnative.googlecast.RECEIVER_APPLICATION_ID"
android:value="A123456"/>Spalla provides a RN View that you can use to add the cast button to your interface. Check the example app if you need an example of usage
import { SpallaCastButton } from 'spalla-react-native';
[...]
return <SpallaCastButton tintColor="white" />Contributing
See the contributing guide to learn how to contribute to the repository and the development workflow.
License
MIT
Made with create-react-native-library
