feat: add drm interfaces (#4657)

This commit is contained in:
Krzysztof Moch
2025-08-26 13:50:36 +02:00
committed by GitHub
parent 8e40502172
commit 5012373b7c
192 changed files with 5671 additions and 1289 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
module.exports = {
root: true,
extends: ['@react-native', '../config/.eslintrc.js'],
extends: ["../config/.eslintrc.js"],
parserOptions: {
tsconfigRootDir: __dirname,
project: true,
+33 -4
View File
@@ -8,7 +8,7 @@ PODS:
- hermes-engine (0.77.2):
- hermes-engine/Pre-built (= 0.77.2)
- hermes-engine/Pre-built (0.77.2)
- NitroModules (0.27.2):
- NitroModules (0.28.0):
- DoubleConversion
- glog
- hermes-engine
@@ -1565,7 +1565,7 @@ PODS:
- React-logger (= 0.77.2)
- React-perflogger (= 0.77.2)
- React-utils (= 0.77.2)
- ReactNativeVideo (7.0.0-alpha.2):
- ReactNativeVideo (7.0.0-alpha.3):
- DoubleConversion
- glog
- hermes-engine
@@ -1587,6 +1587,31 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- ReactNativeVideoDrm (0.1.0):
- DoubleConversion
- glog
- hermes-engine
- NitroModules
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-callinvoker
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeVideo
- Yoga
- SocketRocket (0.7.1)
- Yoga (0.0.0)
@@ -1661,6 +1686,7 @@ DEPENDENCIES:
- ReactCodegen (from `build/generated/ios`)
- ReactCommon/turbomodule/core (from `../../node_modules/react-native/ReactCommon`)
- ReactNativeVideo (from `../../node_modules/react-native-video`)
- "ReactNativeVideoDrm (from `../../node_modules/@twg/react-native-video-drm`)"
- Yoga (from `../../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS:
@@ -1805,6 +1831,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/react-native/ReactCommon"
ReactNativeVideo:
:path: "../../node_modules/react-native-video"
ReactNativeVideoDrm:
:path: "../../node_modules/@twg/react-native-video-drm"
Yoga:
:path: "../../node_modules/react-native/ReactCommon/yoga"
@@ -1816,7 +1844,7 @@ SPEC CHECKSUMS:
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8
hermes-engine: 8eb265241fa1d7095d3a40d51fd90f7dce68217c
NitroModules: 7ed5fd8f6f1e814810b9df26830b78f3355690bf
NitroModules: 1e4150c3e3676e05209234a8a5e0e8886fc0311a
RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82
RCTDeprecation: 85b72250b63cfb54f29ca96ceb108cb9ef3c2079
RCTRequired: 567cb8f5d42b990331bfd93faad1d8999b1c1736
@@ -1876,7 +1904,8 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: f334cebc0beed0a72490492e978007082c03d533
ReactCodegen: 474fbb3e4bb0f1ee6c255d1955db76e13d509269
ReactCommon: 7763e59534d58e15f8f22121cdfe319040e08888
ReactNativeVideo: db800650d21aab71c457ea1408c4f46e853b5886
ReactNativeVideo: 213235288864ce876c68a64cc1481fe8a3eae5d5
ReactNativeVideoDrm: 62840ae0e184f711a2e6495c18e342a74cb598f8
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: 31a098f74c16780569aebd614a0f37a907de0189
+4 -5
View File
@@ -7,14 +7,15 @@
"ios": "react-native run-ios",
"lint": "eslint .",
"typecheck": "tsc",
"start": "react-native start"
"start": "react-native start --client-logs"
},
"dependencies": {
"@react-native-community/slider": "^4.5.6",
"react": "18.3.1",
"react-native": "^0.77.0",
"react-native-nitro-modules": "^0.27.0",
"react-native-video": "*"
"react-native-nitro-modules": "^0.28.0",
"react-native-video": "*",
"@twg/react-native-video-drm": "*"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@@ -28,10 +29,8 @@
"@react-native/metro-config": "^0.77.0",
"@react-native/typescript-config": "^0.77.0",
"@types/react": "^18.2.44",
"@types/react-test-renderer": "^18.0.0",
"eslint": "^8.51.0",
"prettier": "^3.0.3",
"react-test-renderer": "18.3.1",
"typescript": "^5.2.2"
},
"engines": {
+36 -627
View File
@@ -1,67 +1,28 @@
import React from 'react';
import { Alert, SafeAreaView, ScrollView, Text, View } from 'react-native';
import { styles } from './styles';
import {
ActionButton,
ControlButton,
SwitchControl,
ToggleButton,
} from './components/Controls';
import Slider from '@react-native-community/slider';
import * as React from 'react';
import {
Alert,
SafeAreaView,
ScrollView,
StyleSheet,
Switch,
Text,
TouchableOpacity,
View,
} from 'react-native';
import {
useEvent,
useVideoPlayer,
VideoPlayer,
VideoView,
type IgnoreSilentSwitchMode,
type MixAudioMode,
type VideoViewRef,
type onLoadData,
type onProgressData,
type onVolumeChangeData,
type ResizeMode,
type TextTrack,
type VideoConfig,
type VideoPlayerStatus,
type VideoViewRef,
type onVolumeChangeData,
useVideoPlayer,
useEvent,
VideoView,
type VideoConfig,
} from 'react-native-video';
const formatTime = (seconds: number) => {
if (isNaN(seconds)) return '--:--';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
};
// Consolidated state interface
interface VideoSettings {
show: boolean;
volume: number;
muted: boolean;
rate: number;
loop: boolean;
showNativeControls: boolean;
resizeMode: ResizeMode;
mixAudioMode: MixAudioMode;
ignoreSilentSwitchMode: IgnoreSilentSwitchMode;
playInBackground: boolean;
playWhenInactive: boolean;
}
const defaultSettings: VideoSettings = {
show: false,
volume: 1,
muted: false,
rate: 1,
loop: false,
showNativeControls: false,
resizeMode: 'contain',
mixAudioMode: 'auto',
ignoreSilentSwitchMode: 'auto',
playInBackground: true,
playWhenInactive: false,
};
import TextTrackManager from './components/TextTrackManager';
import { type VideoSettings, defaultSettings } from './types/videoSettings';
import { formatTime } from './utils/time';
import { getVideoSource } from './utils/videoSource';
const VideoDemo = () => {
const videoViewRef = React.useRef<VideoViewRef>(null);
@@ -72,7 +33,6 @@ const VideoDemo = () => {
Array<{ id: string; message: string; timestamp: string }>
>([]);
// Helper function to update settings
const updateSetting = <K extends keyof VideoSettings>(
key: K,
value: VideoSettings[K]
@@ -80,17 +40,15 @@ const VideoDemo = () => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
// Helper function to add events
const addEvent = React.useCallback((message: string) => {
const newEvent = {
id: `${Date.now()}-${Math.random()}`,
message,
timestamp: new Date().toLocaleTimeString(),
};
setEvents((prev) => [newEvent, ...prev.slice(0, 49)]); // Keep latest 50 events
setEvents((prev) => [newEvent, ...prev.slice(0, 49)]);
}, []);
// Event handlers
const handleFullscreenChange = React.useCallback(
(fullscreen: boolean) => {
addEvent(
@@ -161,20 +119,9 @@ const VideoDemo = () => {
[addEvent]
);
// Setup player
const player = useVideoPlayer(
{
uri: 'https://www.w3schools.com/html/movie.mp4',
externalSubtitles: [
{
uri: 'https://bitdash-a.akamaihd.net/content/sintel/subtitles/subtitles_en.vtt',
label: 'External',
},
],
},
(_player) => {
// Setup player
}
getVideoSource(defaultSettings.videoType),
(_player) => {}
);
useEvent(player, 'onEnd', handlePlayerEnd);
@@ -186,7 +133,6 @@ const VideoDemo = () => {
useEvent(player, 'onPlaybackStateChange', handlePlayerStateChange);
useEvent(player, 'onVolumeChange', handleVolumeChange);
// Sync settings with player
React.useEffect(() => {
if (!settings.show) return;
@@ -207,7 +153,6 @@ const VideoDemo = () => {
return (
<ScrollView style={styles.container}>
{/* Video Player */}
<View style={styles.videoContainer}>
{settings.show ? (
<VideoView
@@ -228,7 +173,6 @@ const VideoDemo = () => {
)}
</View>
{/* Progress Controls */}
<View style={styles.section}>
<View style={styles.progressRow}>
<Text style={styles.timeText}>{formatTime(progress)}</Text>
@@ -250,7 +194,6 @@ const VideoDemo = () => {
</View>
</View>
{/* Transport Controls */}
<View style={styles.section}>
<View style={styles.transportRow}>
<ControlButton icon="⏮" onPress={() => player.seekTo(0)} />
@@ -262,9 +205,22 @@ const VideoDemo = () => {
/>
<ControlButton icon="⏩" onPress={() => player.seekBy(10)} />
</View>
<Text style={styles.subSectionTitle}>Video Type</Text>
<View style={styles.buttonGroup}>
{(['hls', 'mp4', 'drm'] as const).map((mode) => (
<ToggleButton
key={mode}
label={mode}
active={settings.videoType === mode}
onPress={async () => {
updateSetting('videoType', mode);
await player.replaceSourceAsync(getVideoSource(mode));
}}
/>
))}
</View>
</View>
{/* Display Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Display Settings</Text>
@@ -296,7 +252,6 @@ const VideoDemo = () => {
</View>
</View>
{/* Audio Controls */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Audio Controls</Text>
<View style={styles.audioControls}>
@@ -349,7 +304,6 @@ const VideoDemo = () => {
</View>
</View>
{/* Background Playback Controls */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Background Playback</Text>
<View style={styles.switchColumn}>
@@ -366,7 +320,6 @@ const VideoDemo = () => {
</View>
</View>
{/* Audio Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Audio Settings</Text>
@@ -397,13 +350,11 @@ const VideoDemo = () => {
</View>
</View>
{/* Text Track Controls */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Text Tracks</Text>
<TextTrackManager player={player} />
</View>
{/* Advanced Controls */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Advanced Controls</Text>
@@ -471,7 +422,6 @@ const VideoDemo = () => {
</View>
</View>
{/* Event Log */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Event Log</Text>
<View style={styles.eventLog}>
@@ -496,237 +446,6 @@ const VideoDemo = () => {
);
};
// Reusable Components
const ControlButton = ({
icon,
onPress,
size = 'normal',
}: {
icon: string;
onPress: () => void;
size?: 'normal' | 'large';
}) => (
<TouchableOpacity
style={[
styles.controlButton,
size === 'large' && styles.controlButtonLarge,
]}
onPress={onPress}
>
<Text
style={[styles.controlIcon, size === 'large' && styles.controlIconLarge]}
>
{icon}
</Text>
</TouchableOpacity>
);
const SwitchControl = ({
label,
value,
onValueChange,
}: {
label: string;
value: boolean;
onValueChange: (value: boolean) => void;
}) => (
<View style={styles.switchControl}>
<Text style={styles.switchLabel}>{label}</Text>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: '#e1e1e1', true: '#007aff' }}
thumbColor={value ? '#ffffff' : '#f4f3f4'}
/>
</View>
);
const ToggleButton = ({
label,
active,
onPress,
}: {
label: string;
active: boolean;
onPress: () => void;
}) => (
<TouchableOpacity
style={[styles.toggleButton, active && styles.toggleButtonActive]}
onPress={onPress}
>
<Text
style={[styles.toggleButtonText, active && styles.toggleButtonTextActive]}
>
{label}
</Text>
</TouchableOpacity>
);
const ActionButton = ({
label,
onPress,
}: {
label: string;
onPress: () => void;
}) => (
<TouchableOpacity style={styles.actionButton} onPress={onPress}>
<Text style={styles.actionButtonText}>{label}</Text>
</TouchableOpacity>
);
const TextTrackManager = ({ player }: { player: VideoPlayer }) => {
const [textTracks, setTextTracks] = React.useState<TextTrack[]>([]);
const [selectedTrackId, setSelectedTrackId] = React.useState<string | null>(
null
);
const [currentSelectedTrack, setCurrentSelectedTrack] =
React.useState<TextTrack | null>(null);
const [trackChangeEvents, setTrackChangeEvents] = React.useState<string[]>(
[]
);
const loadTextTracks = React.useCallback(() => {
try {
const tracks = player.getAvailableTextTracks();
setTextTracks(tracks);
// Get currently selected track using the new property
const selectedTrack = player.selectedTrack;
setSelectedTrackId(selectedTrack?.id || null);
setCurrentSelectedTrack(selectedTrack || null);
console.log('Available text tracks:', tracks);
console.log('Currently selected track:', selectedTrack);
} catch (error) {
console.error('Error loading text tracks:', error);
}
}, [player]);
const selectTrack = React.useCallback(
(track: TextTrack) => {
try {
player.selectTextTrack(track);
console.log('Selected text track:', track);
// onTrackChange event will update the state automatically
} catch (error) {
console.error('Error selecting text track:', error);
}
},
[player]
);
const disableTextTracks = React.useCallback(() => {
try {
// Pass null to disable text tracks
player.selectTextTrack(null);
console.log('Disabled text tracks');
// onTrackChange event will update the state automatically
} catch (error) {
console.error('Error disabling text tracks:', error);
}
}, [player]);
useEvent(player, 'onReadyToDisplay', () => {
loadTextTracks();
});
useEvent(player, 'onTrackChange', (track) => {
// Update state when track changes through any means (API or native controls)
setCurrentSelectedTrack(track);
setSelectedTrackId(track?.id || null);
// Add to event log
const timestamp = new Date().toLocaleTimeString();
const eventMessage = track
? `${timestamp}: Track changed to "${track.label}" (${track.id})${track.id.startsWith('external-') ? ' [External]' : ''}`
: `${timestamp}: All tracks disabled`;
setTrackChangeEvents((prev) => [eventMessage, ...prev.slice(0, 4)]); // Keep last 5 events
});
return (
<View>
<View style={styles.buttonGroup}>
<ActionButton label="Refresh Tracks" onPress={loadTextTracks} />
<ActionButton label="Disable Tracks" onPress={disableTextTracks} />
<ActionButton
label="Sync Selection"
onPress={() => {
try {
const selectedTrack = player.selectedTrack;
setCurrentSelectedTrack(selectedTrack || null);
setSelectedTrackId(selectedTrack?.id || null);
} catch (error) {
console.error('Error syncing selection:', error);
}
}}
/>
</View>
{currentSelectedTrack && (
<View style={styles.selectedTrackInfo}>
<Text style={styles.selectedTrackLabel}>Currently Selected:</Text>
<Text style={styles.selectedTrackText}>
{currentSelectedTrack.label}
{currentSelectedTrack.language &&
` (${currentSelectedTrack.language})`}
{currentSelectedTrack.id.startsWith('external-') && ' [External]'}
</Text>
</View>
)}
{trackChangeEvents.length > 0 && (
<View style={styles.eventLogContainer}>
<Text style={styles.eventLogTitle}>Track Change Events:</Text>
{trackChangeEvents.map((event, index) => (
<Text key={index} style={styles.eventLogText}>
{event}
</Text>
))}
</View>
)}
{textTracks.length > 0 ? (
<View style={styles.trackList}>
<Text style={styles.subSectionTitle}>
Available Tracks ({textTracks.length})
</Text>
{textTracks.map((track) => (
<TouchableOpacity
key={track.id}
style={[
styles.trackButton,
selectedTrackId === track.id && styles.trackButtonSelected,
]}
onPress={() => selectTrack(track)}
>
<Text
style={[
styles.trackButtonText,
selectedTrackId === track.id &&
styles.trackButtonTextSelected,
]}
>
{track.label} {track.language && `(${track.language})`}
{track.selected && ' ✓'}
{track.id.startsWith('external-') && ' [External]'}
</Text>
</TouchableOpacity>
))}
</View>
) : (
<View>
<Text style={styles.noTracksText}>No text tracks available</Text>
<Text style={styles.noTracksSubText}>
Make sure the video is loaded. External subtitles are loaded but not
automatically enabled - you need to select them manually.
</Text>
</View>
)}
</View>
);
};
export default function App() {
const [mounted, setMounted] = React.useState(true);
@@ -742,313 +461,3 @@ export default function App() {
</SafeAreaView>
);
}
const styles = StyleSheet.create({
app: {
flex: 1,
backgroundColor: '#f8f9fa',
},
container: {
flex: 1,
padding: 16,
},
videoContainer: {
width: '100%',
aspectRatio: 16 / 9,
backgroundColor: '#000',
borderRadius: 12,
overflow: 'hidden',
marginBottom: 16,
elevation: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
video: {
width: '100%',
height: '100%',
},
hiddenVideo: {
width: '100%',
height: '100%',
backgroundColor: '#333',
alignItems: 'center',
justifyContent: 'center',
},
hiddenVideoText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
},
section: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
elevation: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1a1a1a',
marginBottom: 12,
},
subSectionTitle: {
fontSize: 14,
fontWeight: '600',
color: '#666',
marginTop: 12,
marginBottom: 8,
},
progressRow: {
flexDirection: 'row',
alignItems: 'center',
},
progressSlider: {
flex: 1,
marginHorizontal: 12,
},
timeText: {
fontSize: 14,
fontWeight: '600',
color: '#666',
width: 50,
textAlign: 'center',
fontVariant: ['tabular-nums'],
},
transportRow: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 12,
},
controlButton: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#f0f0f0',
alignItems: 'center',
justifyContent: 'center',
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
},
controlButtonLarge: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: '#007aff',
},
controlIcon: {
fontSize: 20,
color: '#333',
},
controlIconLarge: {
fontSize: 28,
color: '#fff',
},
audioControls: {
gap: 16,
},
sliderControl: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
controlLabel: {
fontSize: 14,
fontWeight: '600',
color: '#333',
width: 60,
},
slider: {
flex: 1,
},
valueText: {
fontSize: 14,
fontWeight: '600',
color: '#666',
width: 40,
textAlign: 'right',
},
switchRow: {
flexDirection: 'row',
gap: 20,
flexWrap: 'wrap',
},
switchColumn: {
flexDirection: 'column',
gap: 8,
},
switchControl: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flex: 1,
minWidth: 120,
},
switchLabel: {
fontSize: 14,
fontWeight: '500',
color: '#333',
},
buttonGroup: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
toggleButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: '#f0f0f0',
borderWidth: 1,
borderColor: '#e0e0e0',
},
toggleButtonActive: {
backgroundColor: '#007aff',
borderColor: '#007aff',
},
toggleButtonText: {
fontSize: 14,
fontWeight: '500',
color: '#666',
textTransform: 'capitalize',
},
toggleButtonTextActive: {
color: '#fff',
fontWeight: '600',
},
actionGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
actionButton: {
backgroundColor: '#007aff',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
},
actionButtonText: {
color: '#fff',
fontWeight: '600',
fontSize: 14,
},
eventLog: {
backgroundColor: '#f8f9fa',
borderRadius: 8,
borderLeftWidth: 3,
borderLeftColor: '#007aff',
height: 200,
},
eventLogScroll: {
flex: 1,
padding: 12,
},
eventItem: {
paddingVertical: 4,
borderBottomWidth: 1,
borderBottomColor: '#e1e1e1',
},
eventTime: {
fontSize: 10,
color: '#999',
fontFamily: 'monospace',
marginBottom: 2,
},
eventText: {
fontSize: 12,
color: '#666',
fontFamily: 'monospace',
},
mountControl: {
padding: 16,
alignItems: 'center',
},
trackList: {
marginTop: 12,
},
trackButton: {
padding: 12,
borderWidth: 1,
borderColor: '#e0e0e0',
borderRadius: 8,
marginBottom: 8,
},
trackButtonSelected: {
backgroundColor: '#007aff',
borderColor: '#007aff',
},
trackButtonText: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
trackButtonTextSelected: {
color: '#fff',
fontWeight: '600',
},
noTracksText: {
fontSize: 14,
color: '#666',
fontWeight: '600',
textAlign: 'center',
},
noTracksSubText: {
fontSize: 12,
color: '#999',
textAlign: 'center',
},
selectedTrackInfo: {
backgroundColor: '#e7f4ff',
padding: 12,
borderRadius: 8,
borderLeftWidth: 3,
borderLeftColor: '#007aff',
marginBottom: 12,
},
selectedTrackLabel: {
fontSize: 12,
fontWeight: '600',
color: '#666',
marginBottom: 4,
},
selectedTrackText: {
fontSize: 14,
fontWeight: '600',
color: '#007aff',
},
eventLogContainer: {
backgroundColor: '#f8f9fa',
padding: 12,
borderRadius: 8,
borderLeftWidth: 3,
borderLeftColor: '#28a745',
marginBottom: 12,
},
eventLogTitle: {
fontSize: 12,
fontWeight: '600',
color: '#666',
marginBottom: 8,
},
eventLogText: {
fontSize: 11,
color: '#333',
fontFamily: 'monospace',
marginBottom: 2,
},
});
+80
View File
@@ -0,0 +1,80 @@
import React from 'react';
import { Switch, Text, TouchableOpacity, View } from 'react-native';
import { styles } from '../styles';
export const ControlButton = ({
icon,
onPress,
size = 'normal',
}: {
icon: string;
onPress: () => void;
size?: 'normal' | 'large';
}) => (
<TouchableOpacity
style={[
styles.controlButton,
size === 'large' && styles.controlButtonLarge,
]}
onPress={onPress}
>
<Text
style={[styles.controlIcon, size === 'large' && styles.controlIconLarge]}
>
{icon}
</Text>
</TouchableOpacity>
);
export const SwitchControl = ({
label,
value,
onValueChange,
}: {
label: string;
value: boolean;
onValueChange: (value: boolean) => void;
}) => (
<View style={styles.switchControl}>
<Text style={styles.switchLabel}>{label}</Text>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: '#e1e1e1', true: '#007aff' }}
thumbColor={value ? '#ffffff' : '#f4f3f4'}
/>
</View>
);
export const ToggleButton = ({
label,
active,
onPress,
}: {
label: string;
active: boolean;
onPress: () => void;
}) => (
<TouchableOpacity
style={[styles.toggleButton, active && styles.toggleButtonActive]}
onPress={onPress}
>
<Text
style={[styles.toggleButtonText, active && styles.toggleButtonTextActive]}
>
{label}
</Text>
</TouchableOpacity>
);
export const ActionButton = ({
label,
onPress,
}: {
label: string;
onPress: () => void;
}) => (
<TouchableOpacity style={styles.actionButton} onPress={onPress}>
<Text style={styles.actionButtonText}>{label}</Text>
</TouchableOpacity>
);
+149
View File
@@ -0,0 +1,149 @@
import React from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { useEvent, type TextTrack, type VideoPlayer } from 'react-native-video';
import { styles } from '../styles';
import { ActionButton } from './Controls';
export const TextTrackManager = ({ player }: { player: VideoPlayer }) => {
const [textTracks, setTextTracks] = React.useState<TextTrack[]>([]);
const [selectedTrackId, setSelectedTrackId] = React.useState<string | null>(
null
);
const [currentSelectedTrack, setCurrentSelectedTrack] =
React.useState<TextTrack | null>(null);
const [trackChangeEvents, setTrackChangeEvents] = React.useState<string[]>(
[]
);
const loadTextTracks = React.useCallback(() => {
try {
const tracks = player.getAvailableTextTracks();
setTextTracks(tracks);
const selectedTrack = player.selectedTrack;
setSelectedTrackId(selectedTrack?.id || null);
setCurrentSelectedTrack(selectedTrack || null);
} catch (error) {
console.error('Error loading text tracks:', error);
}
}, [player]);
const selectTrack = React.useCallback(
(track: TextTrack) => {
try {
player.selectTextTrack(track);
} catch (error) {
console.error('Error selecting text track:', error);
}
},
[player]
);
const disableTextTracks = React.useCallback(() => {
try {
player.selectTextTrack(null);
} catch (error) {
console.error('Error disabling text tracks:', error);
}
}, [player]);
useEvent(player, 'onReadyToDisplay', () => {
loadTextTracks();
});
useEvent(player, 'onTrackChange', (track) => {
setCurrentSelectedTrack(track);
setSelectedTrackId(track?.id || null);
const timestamp = new Date().toLocaleTimeString();
const eventMessage = track
? `${timestamp}: Track changed to "${track.label}" (${track.id})${track.id.startsWith('external-') ? ' [External]' : ''}`
: `${timestamp}: All tracks disabled`;
setTrackChangeEvents((prev) => [eventMessage, ...prev.slice(0, 4)]);
});
return (
<View>
<View style={styles.buttonGroup}>
<ActionButton label="Refresh Tracks" onPress={loadTextTracks} />
<ActionButton label="Disable Tracks" onPress={disableTextTracks} />
<ActionButton
label="Sync Selection"
onPress={() => {
try {
const selectedTrack = player.selectedTrack;
setCurrentSelectedTrack(selectedTrack || null);
setSelectedTrackId(selectedTrack?.id || null);
} catch (error) {
console.error('Error syncing selection:', error);
}
}}
/>
</View>
{currentSelectedTrack && (
<View style={styles.selectedTrackInfo}>
<Text style={styles.selectedTrackLabel}>Currently Selected:</Text>
<Text style={styles.selectedTrackText}>
{currentSelectedTrack.label}
{currentSelectedTrack.language &&
` (${currentSelectedTrack.language})`}
{currentSelectedTrack.id.startsWith('external-') && ' [External]'}
</Text>
</View>
)}
{trackChangeEvents.length > 0 && (
<View style={styles.eventLogContainer}>
<Text style={styles.eventLogTitle}>Track Change Events:</Text>
{trackChangeEvents.map((event, index) => (
<Text key={index} style={styles.eventLogText}>
{event}
</Text>
))}
</View>
)}
{textTracks.length > 0 ? (
<View style={styles.trackList}>
<Text style={styles.subSectionTitle}>
Available Tracks ({textTracks.length})
</Text>
{textTracks.map((track) => (
<TouchableOpacity
key={track.id}
style={[
styles.trackButton,
selectedTrackId === track.id && styles.trackButtonSelected,
]}
onPress={() => selectTrack(track)}
>
<Text
style={[
styles.trackButtonText,
selectedTrackId === track.id &&
styles.trackButtonTextSelected,
]}
>
{track.label} {track.language && `(${track.language})`}
{track.selected && ' ✓'}
{track.id.startsWith('external-') && ' [External]'}
</Text>
</TouchableOpacity>
))}
</View>
) : (
<View>
<Text style={styles.noTracksText}>No text tracks available</Text>
<Text style={styles.noTracksSubText}>
Make sure the video is loaded. External subtitles are loaded but not
automatically enabled - you need to select them manually.
</Text>
</View>
)}
</View>
);
};
export default TextTrackManager;
+311
View File
@@ -0,0 +1,311 @@
import { StyleSheet } from 'react-native';
export const styles = StyleSheet.create({
app: {
flex: 1,
backgroundColor: '#f8f9fa',
},
container: {
flex: 1,
padding: 16,
},
videoContainer: {
width: '100%',
aspectRatio: 16 / 9,
backgroundColor: '#000',
borderRadius: 12,
overflow: 'hidden',
marginBottom: 16,
elevation: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
video: {
width: '100%',
height: '100%',
},
hiddenVideo: {
width: '100%',
height: '100%',
backgroundColor: '#333',
alignItems: 'center',
justifyContent: 'center',
},
hiddenVideoText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
},
section: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
elevation: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1a1a1a',
marginBottom: 12,
},
subSectionTitle: {
fontSize: 14,
fontWeight: '600',
color: '#666',
marginTop: 12,
marginBottom: 8,
},
progressRow: {
flexDirection: 'row',
alignItems: 'center',
},
progressSlider: {
flex: 1,
marginHorizontal: 12,
},
timeText: {
fontSize: 14,
fontWeight: '600',
color: '#666',
width: 50,
textAlign: 'center',
fontVariant: ['tabular-nums'],
},
transportRow: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 12,
},
controlButton: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#f0f0f0',
alignItems: 'center',
justifyContent: 'center',
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
},
controlButtonLarge: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: '#007aff',
},
controlIcon: {
fontSize: 20,
color: '#333',
},
controlIconLarge: {
fontSize: 28,
color: '#fff',
},
audioControls: {
gap: 16,
},
sliderControl: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
controlLabel: {
fontSize: 14,
fontWeight: '600',
color: '#333',
width: 60,
},
slider: {
flex: 1,
},
valueText: {
fontSize: 14,
fontWeight: '600',
color: '#666',
width: 40,
textAlign: 'right',
},
switchRow: {
flexDirection: 'row',
gap: 20,
flexWrap: 'wrap',
},
switchColumn: {
flexDirection: 'column',
gap: 8,
},
switchControl: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flex: 1,
minWidth: 120,
},
switchLabel: {
fontSize: 14,
fontWeight: '500',
color: '#333',
},
buttonGroup: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
toggleButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: '#f0f0f0',
borderWidth: 1,
borderColor: '#e0e0e0',
},
toggleButtonActive: {
backgroundColor: '#007aff',
borderColor: '#007aff',
},
toggleButtonText: {
fontSize: 14,
fontWeight: '500',
color: '#666',
textTransform: 'capitalize',
},
toggleButtonTextActive: {
color: '#fff',
fontWeight: '600',
},
actionGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
actionButton: {
backgroundColor: '#007aff',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
},
actionButtonText: {
color: '#fff',
fontWeight: '600',
fontSize: 14,
},
eventLog: {
backgroundColor: '#f8f9fa',
borderRadius: 8,
borderLeftWidth: 3,
borderLeftColor: '#007aff',
height: 200,
},
eventLogScroll: {
flex: 1,
padding: 12,
},
eventItem: {
paddingVertical: 4,
borderBottomWidth: 1,
borderBottomColor: '#e1e1e1',
},
eventTime: {
fontSize: 10,
color: '#999',
fontFamily: 'monospace',
marginBottom: 2,
},
eventText: {
fontSize: 12,
color: '#666',
fontFamily: 'monospace',
},
mountControl: {
padding: 16,
alignItems: 'center',
},
trackList: {
marginTop: 12,
},
trackButton: {
padding: 12,
borderWidth: 1,
borderColor: '#e0e0e0',
borderRadius: 8,
marginBottom: 8,
},
trackButtonSelected: {
backgroundColor: '#007aff',
borderColor: '#007aff',
},
trackButtonText: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
trackButtonTextSelected: {
color: '#fff',
fontWeight: '600',
},
noTracksText: {
fontSize: 14,
color: '#666',
fontWeight: '600',
textAlign: 'center',
},
noTracksSubText: {
fontSize: 12,
color: '#999',
textAlign: 'center',
},
selectedTrackInfo: {
backgroundColor: '#e7f4ff',
padding: 12,
borderRadius: 8,
borderLeftWidth: 3,
borderLeftColor: '#007aff',
marginBottom: 12,
},
selectedTrackLabel: {
fontSize: 12,
fontWeight: '600',
color: '#666',
marginBottom: 4,
},
selectedTrackText: {
fontSize: 14,
fontWeight: '600',
color: '#007aff',
},
eventLogContainer: {
backgroundColor: '#f8f9fa',
padding: 12,
borderRadius: 8,
borderLeftWidth: 3,
borderLeftColor: '#28a745',
marginBottom: 12,
},
eventLogTitle: {
fontSize: 12,
fontWeight: '600',
color: '#666',
marginBottom: 8,
},
eventLogText: {
fontSize: 11,
color: '#333',
fontFamily: 'monospace',
marginBottom: 2,
},
});
+35
View File
@@ -0,0 +1,35 @@
import type {
IgnoreSilentSwitchMode,
MixAudioMode,
ResizeMode,
} from 'react-native-video';
export interface VideoSettings {
show: boolean;
videoType: 'hls' | 'mp4' | 'drm';
volume: number;
muted: boolean;
rate: number;
loop: boolean;
showNativeControls: boolean;
resizeMode: ResizeMode;
mixAudioMode: MixAudioMode;
ignoreSilentSwitchMode: IgnoreSilentSwitchMode;
playInBackground: boolean;
playWhenInactive: boolean;
}
export const defaultSettings: VideoSettings = {
show: false,
videoType: 'hls',
volume: 1,
muted: false,
rate: 1,
loop: false,
showNativeControls: false,
resizeMode: 'contain',
mixAudioMode: 'auto',
ignoreSilentSwitchMode: 'auto',
playInBackground: true,
playWhenInactive: false,
};
+6
View File
@@ -0,0 +1,6 @@
export const formatTime = (seconds: number) => {
if (isNaN(seconds)) return '--:--';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
};
+107
View File
@@ -0,0 +1,107 @@
import { Platform } from 'react-native';
import type { VideoConfig } from 'react-native-video';
import {
enable as enableDRMPlugin,
disable as disableDRMPlugin,
isEnabled as isDRMPluginEnabled,
} from '@twg/react-native-video-drm';
const getDRMSource = (): VideoConfig => {
const HLS =
'https://d2e67eijd6imrw.cloudfront.net/559c7a7e-960d-4cd8-9dba-bc4e59890177/assets/47cfca69-91b5-4311-bf6c-b9b1f297ed9b/videokit-720p-dash-hls-drm/hls/index.m3u8';
const DASH =
'https://d2e67eijd6imrw.cloudfront.net/559c7a7e-960d-4cd8-9dba-bc4e59890177/assets/47cfca69-91b5-4311-bf6c-b9b1f297ed9b/videokit-720p-dash-hls-drm/dash/index.mpd';
const CERT =
'https://thewidlarzgroup.la.drm.cloud/certificate/fairplay?brandGuid=559c7a7e-960d-4cd8-9dba-bc4e59890177';
const FAIRPLAY_LICENSE =
'https://thewidlarzgroup.la.drm.cloud/acquire-license/fairplay?brandGuid=559c7a7e-960d-4cd8-9dba-bc4e59890177';
const WIDEVINE_LICENSE =
'https://thewidlarzgroup.la.drm.cloud/acquire-license/widevine?brandGuid=559c7a7e-960d-4cd8-9dba-bc4e59890177';
const USER_TOKEN =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTU3NzQzMTQsImtpZCI6WyIqIl0sImR1cmF0aW9uIjo4NjQwMCwicGVyc2lzdGVudCI6dHJ1ZSwid2lkZXZpbmUiOnsibGljZW5zZV9kdXJhdGlvbiI6OTk5OTk5OSwicGxheWJhY2tfZHVyYXRpb24iOjk5OTk5OTksInJlbnRpYWxfZHVyYXRpb24iOjk5OTk5OTl9LCJmYWlycGxheSI6eyJzdG9yYWdlX2R1cmF0aW9uIjo5OTk5OTk5LCJwbGF5YmFja19kdXJhdGlvbiI6OTk5OTk5OX19.Gm5caVyq_pSTJIy8mZ-vrCeATKueRATmubirh-ajqVg';
if (Platform.OS === 'ios') {
return {
uri: HLS,
drm: {
type: 'fairplay',
licenseUrl: FAIRPLAY_LICENSE,
certificateUrl: CERT,
getLicense: async ({ spc, keyUrl }) => {
const formData = new FormData();
formData.append('spc', spc);
const fixedLicenseUrl = keyUrl.replace('skd://', 'https://');
try {
const response = await fetch(
`${fixedLicenseUrl}&userToken=${USER_TOKEN}`,
{
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data',
'Accept': 'application/json',
},
body: formData,
}
);
const responseData = await response.json();
return responseData.ckc;
} catch (error) {
console.error('Error fetching license:', error);
throw error;
}
},
},
} as VideoConfig;
}
if (Platform.OS === 'android') {
return {
uri: DASH,
headers: {
'x-drm-userToken': USER_TOKEN,
},
drm: {
type: 'widevine',
licenseUrl: WIDEVINE_LICENSE,
},
} as VideoConfig;
}
throw new Error(
'DRM is not Supported or Configured on Platform not supported'
);
};
export type VideoType = 'hls' | 'mp4' | 'drm';
export const getVideoSource = (type: VideoType): VideoConfig => {
if (type === 'drm') {
if (!isDRMPluginEnabled) {
enableDRMPlugin();
}
return getDRMSource();
}
if (isDRMPluginEnabled) {
disableDRMPlugin();
}
const HLS = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
const MP4 =
'https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_30MB.mp4';
return {
uri: type === 'hls' ? HLS : MP4,
externalSubtitles: [
{
label: 'External',
uri: 'https://gist.githubusercontent.com/samdutton/ca37f3adaf4e23679957b8083e061177/raw/e19399fbccbc069a2af4266e5120ae6bad62699a/sample.vtt',
language: 'en',
type: 'vtt',
},
],
} as VideoConfig;
};