Skip to content

Commit 2c38dde

Browse files
committed
fix ios
1 parent 8b64c2d commit 2c38dde

12 files changed

Lines changed: 190 additions & 70 deletions

File tree

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,19 @@ const { enter, toggle } = useFullscreen();
109109
enter('landscape');
110110
```
111111

112-
Opt in to YouTube-style auto fullscreen with `autoFullscreenOnRotate` (off by
113-
default): physically rotating the device to landscape enters fullscreen and
112+
Fullscreen **locks** orientation — it never follows the device sensor.
113+
Tapping the fullscreen button rotates to landscape (default) and it stays put
114+
however you hold the phone; tapping exit returns to portrait. To also stop the
115+
*inline* video from sensor-rotating with the rest of the app, set
116+
`lockPortrait` on the provider — the app stays portrait and only fullscreen
117+
rotates to landscape:
118+
119+
```tsx
120+
<VideoProvider config={{ lockPortrait: true }}>
121+
```
122+
123+
Opt in to YouTube-style sensor auto fullscreen with `autoFullscreenOnRotate`
124+
(off by default): physically rotating to landscape enters fullscreen and
114125
rotating back exits. Requires the app to allow landscape at the OS level:
115126

116127
```tsx

docs/API.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ built-in fullscreen + floating hosts above the app.
1717
| `fullscreenHost` | `true` | Render the built-in fullscreen host (in-window overlay) |
1818
| `floatingHost` | `true` | Render the built-in draggable floating host |
1919
| `pauseOnDetach` | `false` | Pause when the active surface unmounts (default keeps audio running) |
20+
| `lockPortrait` | `false` | Lock the app portrait so the video never sensor-rotates inline — only fullscreen rotates to landscape (on tap). iOS needs the AppDelegate forwarding |
2021

2122
### `<VideoSurface surfaceId autoAttach? …ViewProps>`
2223
A dumb mount point. Registers its native view under `surfaceId`; the engine
@@ -99,14 +100,16 @@ floating is a draggable 16:9 window with close/expand buttons.
99100
Docked bar (thumbnail surface + title + play/pause + close). Attach with
100101
`attach(surfaceId)` (default `"__au_mini__"`).
101102

102-
### `<VideoControls doubleTapSeek? hideAfter? showFullscreenButton? live? liveIcon? onClose? />`
103+
### `<VideoControls doubleTapSeek? hideAfter? showFullscreenButton? onClose? />`
103104
Minimal chrome: play/pause, seek bar, times, mute, fullscreen toggle,
104105
tap-to-show, double-tap seek. Build your own from the hooks if you need
105106
custom design.
106107

107-
Pass `live` for a live stream: the seek bar and times are hidden and mute
108-
moves to the bottom-left, leaving mute + fullscreen. `liveIcon: () => ReactNode`
109-
renders a live indicator in the bar while `live` (e.g. a Lottie badge).
108+
Live state comes from the store (set via `VideoPlayer`'s `live` / `liveIcon`,
109+
or `useVideo().setLive(live, liveIcon)`), so the same controls show it inline
110+
and in the fullscreen host. When live: the seek bar/times are hidden (mute +
111+
fullscreen remain) and the `liveIcon` badge sticks to the **top-right, always
112+
visible** — it does not auto-hide with the rest of the controls.
110113

111114
### `<GestureOverlay onSingleTap? onDoubleTapLeft? onDoubleTapRight? onLongPress?>`
112115
Tap-gesture layer used by VideoControls, exported as a building block.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-native-video-provider",
3-
"version": "0.2.8",
3+
"version": "0.2.9",
44
"description": "Singleton-engine video library for React Native (one native player, many surfaces)",
55
"main": "./lib/module/index.js",
66
"types": "./lib/typescript/src/index.d.ts",

src/__tests__/VideoManager.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,12 @@ describe('VideoManager', () => {
176176
);
177177
});
178178

179-
it('carries a standing lock through an unscoped fullscreen unchanged', () => {
179+
it('defaults to landscape even with a standing portrait lock, and restores it on exit', () => {
180+
// The `lockPortrait` use case: app is portrait inline, fullscreen still
181+
// rotates to landscape; exiting returns to the portrait lock.
180182
manager.setOrientation('portrait');
181183
manager.enterFullscreen(); // no scoped override
182-
expect(native.enterFullscreen).toHaveBeenLastCalledWith('portrait');
184+
expect(native.enterFullscreen).toHaveBeenLastCalledWith('landscape');
183185

184186
manager.exitFullscreen();
185187
expect(native.exitFullscreen).toHaveBeenLastCalledWith('portrait');
@@ -206,6 +208,19 @@ describe('VideoManager', () => {
206208
manager.enterFullscreen('auto');
207209
expect(native.enterFullscreen).toHaveBeenLastCalledWith('auto');
208210
});
211+
212+
it('lockPortrait config locks the app portrait on init', () => {
213+
manager.destroy();
214+
jest.clearAllMocks();
215+
manager.init({ lockPortrait: true });
216+
expect(native.setOrientation).toHaveBeenLastCalledWith('portrait');
217+
expect(manager.store.getState().orientationLock).toBe('portrait');
218+
219+
// Reset so the leaked config doesn't affect later tests (init merges
220+
// config and destroy() keeps it).
221+
manager.destroy();
222+
manager.init({ lockPortrait: false });
223+
});
209224
});
210225

211226
describe('floating', () => {

src/components/FullscreenPlayer.tsx

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,68 @@
11
import { useEffect } from 'react';
2-
import { BackHandler, StatusBar, StyleSheet, View } from 'react-native';
2+
import {
3+
BackHandler,
4+
Modal,
5+
Platform,
6+
StatusBar,
7+
StyleSheet,
8+
View,
9+
} from 'react-native';
310
import { FULLSCREEN_SURFACE_ID } from '../core/VideoManager';
411
import { usePlayback } from '../hooks/usePlayback';
512
import { useVideoManager } from '../provider/VideoContext';
13+
import type { OrientationLock } from '../types/video';
614
import { VideoControls } from './VideoControls';
715
import { VideoSurface } from './VideoSurface';
816

17+
type ModalOrientation =
18+
| 'portrait'
19+
| 'portrait-upside-down'
20+
| 'landscape'
21+
| 'landscape-left'
22+
| 'landscape-right';
23+
24+
const ALL_ORIENTATIONS: ModalOrientation[] = [
25+
'portrait',
26+
'portrait-upside-down',
27+
'landscape',
28+
'landscape-left',
29+
'landscape-right',
30+
];
31+
32+
/** Which orientations the iOS Modal may present — this is what locks it. */
33+
function modalOrientations(lock: OrientationLock): ModalOrientation[] {
34+
switch (lock) {
35+
case 'portrait':
36+
case 'inverted-portrait':
37+
return ['portrait', 'portrait-upside-down'];
38+
case 'landscape':
39+
case 'inverted-landscape':
40+
return ['landscape', 'landscape-left', 'landscape-right'];
41+
default:
42+
return ALL_ORIENTATIONS; // 'auto' → follow the sensor
43+
}
44+
}
45+
946
/**
10-
* Built-in fullscreen host, rendered by VideoProvider. While visible,
11-
* rotation is unlocked natively (all orientations allowed); on exit the
12-
* previous orientation lock is restored and the player re-attaches to the
13-
* surface it came from.
14-
*
15-
* Implemented as an in-window absolute overlay (not a Modal). A Modal is a
16-
* separate Android window, and re-parenting the player's TextureView into it
17-
* — then rotating — drops the video surface (black screen, audio only) for
18-
* live streams. The floating host already proves in-window re-parenting is
19-
* reliable, so fullscreen uses the same approach.
47+
* Built-in fullscreen host, rendered by VideoProvider.
2048
*
21-
* Rendered automatically — you normally never mount this yourself. Apps
22-
* that want fullscreen "inside" a navigation screen can instead render a
23-
* plain <VideoSurface> there and call attach().
49+
* Platform split:
50+
* - iOS uses a `Modal` whose `supportedOrientations` are derived from the
51+
* locked fullscreen orientation. That's how iOS rotates a fullscreen video
52+
* (and locks out the portrait sensor) WITHOUT app-wide landscape config.
53+
* - Android uses an in-window absolute overlay (a Modal is a separate window,
54+
* and re-parenting the player's TextureView into it drops the video surface
55+
* — black screen, audio only — for live streams). Rotation is driven by the
56+
* native `requestedOrientation` lock.
2457
*/
2558
export function FullscreenPlayer() {
2659
const manager = useVideoManager();
2760
const fullscreen = usePlayback((s) => s.fullscreen);
61+
const fullscreenLock = usePlayback((s) => s.fullscreenLock);
2862

29-
// Android hardware back exits fullscreen (Modal used to do this for us).
63+
// Android hardware back exits fullscreen (the iOS Modal handles its own).
3064
useEffect(() => {
31-
if (!fullscreen) {
65+
if (!fullscreen || Platform.OS !== 'android') {
3266
return;
3367
}
3468
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
@@ -42,20 +76,42 @@ export function FullscreenPlayer() {
4276
return null;
4377
}
4478

45-
return (
46-
<View style={styles.overlay}>
79+
const content = (
80+
<>
4781
<StatusBar hidden />
4882
<VideoSurface
4983
surfaceId={FULLSCREEN_SURFACE_ID}
5084
autoAttach
5185
style={styles.surface}
5286
/>
5387
<VideoControls onClose={() => manager.exitFullscreen()} />
54-
</View>
88+
</>
5589
);
90+
91+
if (Platform.OS === 'ios') {
92+
return (
93+
<Modal
94+
visible
95+
transparent={false}
96+
animationType="fade"
97+
presentationStyle="fullScreen"
98+
statusBarTranslucent
99+
supportedOrientations={modalOrientations(fullscreenLock)}
100+
onRequestClose={() => manager.exitFullscreen()}
101+
>
102+
<View style={styles.container}>{content}</View>
103+
</Modal>
104+
);
105+
}
106+
107+
return <View style={styles.overlay}>{content}</View>;
56108
}
57109

58110
const styles = StyleSheet.create({
111+
container: {
112+
flex: 1,
113+
backgroundColor: '#000',
114+
},
59115
overlay: {
60116
position: 'absolute',
61117
top: 0,

src/components/VideoControls.tsx

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import {
2-
useCallback,
3-
useEffect,
4-
useRef,
5-
useState,
6-
type ReactNode,
7-
} from 'react';
1+
import { useCallback, useEffect, useRef, useState } from 'react';
82
import {
93
Pressable,
104
StyleSheet,
@@ -27,31 +21,21 @@ export interface VideoControlsProps {
2721
hideAfter?: number;
2822
/** Show the fullscreen toggle button. Default true. */
2923
showFullscreenButton?: boolean;
30-
/**
31-
* Mark this as a live stream: hides the seek bar/times and moves mute to
32-
* the bottom-left. Default false.
33-
*/
34-
live?: boolean;
35-
/**
36-
* Render a live indicator (e.g. a Lottie animation or a "LIVE" badge),
37-
* shown in the control bar only while `live`.
38-
*/
39-
liveIcon?: () => ReactNode;
4024
/** Called by the close (✕) button; button hidden when omitted. */
4125
onClose?: () => void;
4226
}
4327

4428
/**
4529
* Minimal built-in chrome: play/pause, seek bar, time, mute and fullscreen
46-
* toggles, with tap-to-show / double-tap-to-seek gestures. Apps wanting a
47-
* custom design can ignore this and build on usePlayback()/useVideo().
30+
* toggles, with tap-to-show / double-tap-to-seek gestures. `live` and the
31+
* live badge come from the store (set via VideoPlayer's `live` / `liveIcon`),
32+
* so they show inline and in the fullscreen host alike. Apps wanting a custom
33+
* design can ignore this and build on usePlayback()/useVideo().
4834
*/
4935
export function VideoControls({
5036
doubleTapSeek = 10,
5137
hideAfter = 3000,
5238
showFullscreenButton = true,
53-
live = false,
54-
liveIcon,
5539
onClose,
5640
}: VideoControlsProps) {
5741
const manager = useVideoManager();
@@ -61,6 +45,8 @@ export function VideoControls({
6145
const duration = usePlayback((s) => s.duration);
6246
const muted = usePlayback((s) => s.muted);
6347
const fullscreen = usePlayback((s) => s.fullscreen);
48+
const live = usePlayback((s) => s.live);
49+
const liveIcon = usePlayback((s) => s.liveIcon);
6450

6551
const [visible, setVisible] = useState(true);
6652
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -138,6 +124,13 @@ export function VideoControls({
138124
onDoubleTapLeft={() => manager.seekBy(-doubleTapSeek)}
139125
onDoubleTapRight={() => manager.seekBy(doubleTapSeek)}
140126
/>
127+
{/* Live badge: stuck to the top-right, always visible while live —
128+
it does NOT hide with the auto-hiding controls. */}
129+
{live && liveIcon ? (
130+
<View style={styles.liveBadge} pointerEvents="none">
131+
{liveIcon()}
132+
</View>
133+
) : null}
141134
{visible ? (
142135
<View style={styles.chrome} pointerEvents="box-none">
143136
<View style={styles.topRow}>
@@ -171,7 +164,6 @@ export function VideoControls({
171164
<View style={styles.bottomRow}>
172165
{live ? (
173166
<>
174-
{liveIcon ? liveIcon() : null}
175167
{muteButton}
176168
<View style={styles.spacer} />
177169
{fullscreenButton}
@@ -210,6 +202,11 @@ const styles = StyleSheet.create({
210202
backgroundColor: 'rgba(0,0,0,0.35)',
211203
justifyContent: 'space-between',
212204
},
205+
liveBadge: {
206+
position: 'absolute',
207+
top: 12,
208+
right: 12,
209+
},
213210
topRow: {
214211
flexDirection: 'row',
215212
justifyContent: 'space-between',

src/components/VideoPlayer.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,13 @@ export const VideoPlayer = forwardRef<VideoManager, VideoPlayerProps>(
195195
}
196196
}, [manager, muted]);
197197

198+
useEffect(() => {
199+
// Publish live state + badge to the store so the built-in fullscreen
200+
// host (which renders its own controls) shows them too.
201+
manager.setLive(live, liveIcon ?? null);
202+
return () => manager.setLive(false);
203+
}, [manager, live, liveIcon]);
204+
198205
useEffect(() => {
199206
if (!orientation || orientation === 'auto') {
200207
return;
@@ -296,7 +303,7 @@ export const VideoPlayer = forwardRef<VideoManager, VideoPlayerProps>(
296303
{thumbnail()}
297304
</View>
298305
) : null}
299-
{controls ? <VideoControls live={live} liveIcon={liveIcon} /> : null}
306+
{controls ? <VideoControls /> : null}
300307
</View>
301308
);
302309
}

0 commit comments

Comments
 (0)