Skip to content

Commit

Permalink
Merge pull request #257 from THEOplayer/feature/fullscreen_sdk
Browse files Browse the repository at this point in the history
Feature/fullscreen sdk
  • Loading branch information
tvanlaerhoven authored Feb 9, 2024
2 parents 50bedcc + c4ef77a commit c19394a
Show file tree
Hide file tree
Showing 25 changed files with 449 additions and 80 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.1.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Added fullscreen presentation mode support for iOS and Android. More info on the [documentation](./doc/fullscreen.md) page.

## [3.6.0] - 24-02-02

### Fixed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ and discussed in the next section. Finally, an overview of features, limitations
- [Casting with Chromecast and Airplay](./doc/cast.md)
- [Custom iOS framework](./doc/custom-ios-framework.md)
- [Digital Rights Management (DRM)](./doc/drm.md)
- [Fullscreen presentation](./doc/fullscreen.md)
- [Media Caching](./doc/media_caching.md)
- [Migrating to `react-native-theoplayer` v2.x](./doc/migrating_v2.md)
- [Picture-in-Picture (PiP)](./doc/pip.md)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.view.View
import android.view.View.OnLayoutChangeListener
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewParent
import androidx.activity.ComponentActivity
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.children
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.Lifecycle
import com.facebook.react.ReactRootView
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.view.ReactViewGroup
import com.theoplayer.PlayerEventEmitter
import com.theoplayer.ReactTHEOplayerContext
import com.theoplayer.android.api.error.ErrorCode
Expand All @@ -28,7 +37,8 @@ class PresentationManager(
private var supportsPip = false
private var onUserLeaveHintReceiver: BroadcastReceiver? = null
private var onPictureInPictureModeChanged: BroadcastReceiver? = null

private var playerGroupParentNode: ViewGroup? = null
private var playerGroupChildIndex: Int? = null
private val pipUtils: PipUtils = PipUtils(viewCtx, reactContext)

var currentPresentationMode: PresentationMode = PresentationMode.INLINE
Expand Down Expand Up @@ -189,16 +199,38 @@ class PresentationManager(
}
val activity = reactContext.currentActivity ?: return
val window = activity.window

// Get the player's ReactViewGroup parent, which contains THEOplayerView and its children (typically the UI).
val reactPlayerGroup: ReactViewGroup? = getClosestParentOfType(this.viewCtx.playerView)

// Get ReactNative's root node or the render hiearchy
val root: ReactRootView? = getClosestParentOfType(reactPlayerGroup)

if (fullscreen) {
WindowInsetsControllerCompat(window, window.decorView).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}.hide(WindowInsetsCompat.Type.systemBars())
updatePresentationMode(PresentationMode.FULLSCREEN)

playerGroupParentNode = (reactPlayerGroup?.parent as ReactViewGroup?)?.also { parent ->
playerGroupChildIndex = parent.indexOfChild(reactPlayerGroup)
// Re-parent the playerViewGroup to the root node
parent.removeView(reactPlayerGroup)
root?.addView(reactPlayerGroup)
}
} else {
WindowInsetsControllerCompat(window, window.decorView).show(
WindowInsetsCompat.Type.systemBars()
)
updatePresentationMode(PresentationMode.INLINE)

root?.run {
// Re-parent the playerViewGroup from the root node to its original parent
removeView(reactPlayerGroup)
playerGroupParentNode?.addView(reactPlayerGroup, playerGroupChildIndex ?: 0)
playerGroupParentNode = null
playerGroupChildIndex = null
}
}
}

Expand All @@ -224,3 +256,11 @@ class PresentationManager(
}
}
}

inline fun <reified T : View> getClosestParentOfType(view: View?): T? {
var parent: ViewParent? = view?.parent
while (parent != null && parent !is T) {
parent = parent.parent
}
return parent as? T
}
136 changes: 136 additions & 0 deletions doc/fullscreen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
## A Fullscreen Video Player Component

Presenting a fullscreen video player poses challenges due to the need to seamlessly
transition between the regular view and fullscreen mode, while maintaining playback continuity.
This involves managing UI layers, native view hierarchies, and minimizing
disruptions during the transition process.

On this page we will present two ways of presenting a fullscreen player component. One that opens a
new screen to present the player, and a second that transitions to fullscreen from an inline player.
We will also discuss the related concept of "React Portals", which, when paired with a video player component,
offer versatile applications beyond fullscreen display.

### Table of Contents

- [Presenting a fullscreen video player](#presenting-a-fullscreen-video-player)
- [A separate player screen](#1-a-separate-player-screen)
- [An inline video player](#2-an-inline-video-player)
- [Portals](#portals)
- [Using Portals to transition from an inline player to a floating](#using-portals-to-transition-to-an-in-app-mini-player)
- [Closing remarks](#closing-remarks)

### Presenting a fullscreen video player

A native iOS or Android video player that transitions into fullscreen typically creates another activity
or view that _overlays the existing view stack_, while activating an immersive mode to maximize
screen size.
This also introduces a new user interface layer that includes essential playback controls and a method for
exiting fullscreen mode.

React Native, as a UI framework, makes it possible to create a screen with UI elements such a buttons
and text elements, and provides a means to create stacked views with packages such as
[react-native-navigation](https://reactnavigation.org/). It manages the lifecycle of the native UI elements, and
may encounter issues when elements are generated outside its control.

When an app integrates a video player that needs the ability to present itself in fullscreen mode, there are
typically two possibilities to transition to the fullscreen player:

#### 1. A separate player screen

Navigate to a _separate player screen_ that contains only the video player presented in an immersive mode.

The first option has the advantage that it is simple and straightforward to implement. The main disadvantage is that
transitioning to another screen means recreating (remounting) the player, causing a visual interruption in playback.
Typically, this option is used when the first screen has an inline preview image that transitions to a fullscreen player
screen when tapped.

#### 2. An inline video player

Make the current _inline video player_ component stretch itself while covering all elements in the current screen.

The second option is able to let a video play inline with the other screen elements. The player is a part of the
view hierarchy and will need to cover the whole screen without remounting the player component.

| ![](./fullscreen_android.gif) | ![](./fullscreen_ios.gif) |
|---------------------------------------------------------------|-----------------------------------------------------------|
| Transitioning from an inline player to fullscreen on Android. | Transitioning from an inline player to fullscreen on iOS. |

The `react-native-theoplayer` SDK supports this option on iOS and Android by re-parenting the native view to the
top-most node of the view hierarchy when the player's presentation mode is set to `fullscreen`.

```ts
import { PresentationMode } from './PresentationMode';

player.presentationMode = PresentationMode.fullscreen;
```

When the player transitions back to inline mode, the view hierarchy will be restored.

### Portals

A [Portal](https://react.dev/reference/react-dom/createPortal#usage) is a well-known concept in React that
enables rendering a component in a different location in the DOM view hierarchy. Normally, when a component is
rendered, it is mounted into the DOM as a child of the nearest parent node. Sometimes, however, it is useful
to let the child mount at a different location in the DOM tree. Internally, the portal will also re-parent the
view to a different node in the view hierarchy, similar to the approach `react-native-theoplayer` takes when
going to fullscreen.

A typical use case is when the child component needs to "break out" of its container. Examples are dialogs,
tooltips, and floating or fullscreen video components. In the next section we will outline the creation of an
in-app mini player.

### Using Portals to transition to an in-app mini player

This section introduces a basic example illustrating how Portals facilitate the creation of an inline video component
capable of transitioning to a mini player at the bottom of the screen, overlaying the other components.

There are many packages available that bring Portal functionality to React Native. However, we will
use [a package](https://www.npmjs.com/package/@alexzunik/rn-native-portals-reborn) that not only renders the component to an alternate location in the DOM tree, but also
_relocates the native view to a different parent in the native view hierarchy_.
This approach aims to prevent the remounting of the complex video component.

```tsx
export default function App() {
const [isMiniPlayer, setMiniPlayer] = useState(false);

const onPlayerReady = (player: THEOplayer) => {
// set-up player
}

return (
<View style={styles.container}>
<PortalOrigin destination={isMiniPlayer ? 'miniplayer' : null}>
<View style={isMiniPlayer ? styles.videoContainerMini : styles.videoContainer}>
<THEOplayerView config={playerConfig} onPlayerReady={onPlayerReady}>
{player !== undefined && (<UiContainer>{/*left out for clarity*/}</UiContainer>)}
</THEOplayerView>
</View>
</PortalOrigin>

<View style={styles.contentContainer}>
<Text style={{ color: '#ffffff' }}>This text will remain on screen when play-out continues into the mini-player.</Text>
</View>

<View style={isMiniPlayer ? styles.miniContainer : styles.miniContainerInactive}>
<PortalDestination name="miniplayer" />
</View>
</View>);
}
```

The player component along with its UI container in the example above is wrapped in a `PortalOrigin`.
Its destination is left `null` as long as the player is presented inline.

When the `isMiniPlayer` state property is set to `true`, the screen is re-rendered with destination set to `'miniplayer'`.
The player component will become a child of the `PortalDestination` with the same name. On a native level the view
will also be re-parented to the miniPlayer container at the bottom of the screen.

| ![](./miniplayer_android.gif) | ![](./miniplayer_ios.gif)
|----------------------------------------|------------------------------------|
| A mini-player using Portals on Android | A mini-player using Portals on iOS |

### Closing remarks

Variants of the approach discussed above put the `PortalDestination` on a dedicated route in a
[`NavigationContainer`](https://reactnavigation.org/docs/navigation-container/). This is
especially useful in a more complex app that has different routes towards showing the player component.
Binary file added doc/fullscreen_android.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/fullscreen_ios.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 0 additions & 11 deletions doc/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,3 @@ This currently poses a limitation on the ability to include a SDK that is custom
A custom-built library (an .aar of Android, framework for iOS and JavaScript library for web)
including a specific set of features currently still needs to be configured inside
`react-native-theoplayer` package itself.

### Fullscreen

The behaviour of the fullscreen property currently depends on both the target platform being used, and whether
the native (chromefull) UI or a built-in React Native UI is used:

- If the native (chromefull) UI is used, the fullscreen functionality works the same as when using the native SDK without
React Native.
- Otherwise, when using the fullscreen property with a chromeless UI:
- On Android and Web, the current activity or container will go into immersive mode;
- On iOS & tvOS, fullscreen should be implemented in React Native to preserve interaction with the player.
Binary file added doc/miniplayer_android.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/miniplayer_ios.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions doc/texttracks.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ THEOplayer's `TextTrack` api gives developers the capability to manage and manip
Some of its key functionalities include text track selection, styling, and listening to track and cue events.

### Table of Contents

- [Types of text tracks](#types-of-text-tracks)
- [Side-loaded text tracks](#side-loaded-text-tracks)
- [Listening to text track events](#listening-to-text-track-events)
Expand Down
6 changes: 6 additions & 0 deletions example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "npm:react-native-tvos@^0.71.13-1",
"react-native-status-bar-height": "^2.6.0",
"react-native-svg": "^13.14.0",
"react-native-web": "^0.19.9",
"react-native-web-image-loader": "^0.1.1"
Expand Down
13 changes: 7 additions & 6 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
} from '@theoplayer/react-native-ui';
import { PlayerConfiguration, PlayerEventType, THEOplayer, THEOplayerView } from 'react-native-theoplayer';

import { Platform, StyleSheet, View, ViewStyle } from 'react-native';
import { Platform, SafeAreaView, StyleSheet, View, ViewStyle } from 'react-native';
import { getStatusBarHeight } from 'react-native-status-bar-height';
import { SourceMenuButton, SOURCES } from './custom/SourceMenuButton';
import { BackgroundAudioSubMenu } from './custom/BackgroundAudioSubMenu';
import { PiPSubMenu } from './custom/PipSubMenu';
Expand Down Expand Up @@ -83,17 +84,17 @@ export default function App() {
const needsBorder = Platform.OS === 'ios';
const PLAYER_CONTAINER_STYLE: ViewStyle = {
position: 'absolute',
top: needsBorder ? 20 : 0,
left: needsBorder ? 5 : 0,
top: needsBorder ? getStatusBarHeight() : 0,
left: needsBorder ? 2 : 0,
bottom: 0,
right: needsBorder ? 5 : 0,
right: needsBorder ? 2 : 0,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000000',
};

return (
<View style={[StyleSheet.absoluteFill, { backgroundColor: '#000000' }]}>
<SafeAreaView style={[StyleSheet.absoluteFill, { backgroundColor: '#000000' }]}>
<View style={PLAYER_CONTAINER_STYLE}>
<THEOplayerView config={playerConfig} onPlayerReady={onPlayerReady}>
{player !== undefined && chromeless && (
Expand Down Expand Up @@ -147,6 +148,6 @@ export default function App() {
)}
</THEOplayerView>
</View>
</View>
</SafeAreaView>
);
}
22 changes: 2 additions & 20 deletions ios/THEOplayerRCTMainEventHandler.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// THEOplayerRCTView.swift
// THEOplayerRCTMainEventHandler.swift

import Foundation
import THEOplayerSDK
Expand Down Expand Up @@ -29,7 +29,6 @@ public class THEOplayerRCTMainEventHandler {
var onNativeRateChange: RCTDirectEventBlock?
var onNativeWaiting: RCTDirectEventBlock?
var onNativeCanPlay: RCTDirectEventBlock?
var onNativePresentationModeChange: RCTDirectEventBlock?

// MARK: player Listeners
private var playListener: EventListener?
Expand Down Expand Up @@ -60,9 +59,8 @@ public class THEOplayerRCTMainEventHandler {
}

// MARK: - player setup / breakdown
func setPlayer(_ player: THEOplayer, presentationModeContext: THEOplayerRCTPresentationModeContext) {
func setPlayer(_ player: THEOplayer) {
self.player = player
self.presentationModeContext = presentationModeContext

// attach listeners
self.attachListeners()
Expand Down Expand Up @@ -306,16 +304,6 @@ public class THEOplayerRCTMainEventHandler {
}
}
if DEBUG_EVENTHANDLER { PrintUtils.printLog(logText: "[NATIVE] Waiting listener attached to THEOplayer") }

// PRESENTATION_MODE_CHANGE
self.presentationModeChangeListener = player.addEventListener(type: PlayerEventTypes.PRESENTATION_MODE_CHANGE) { [weak self] event in
if DEBUG_THEOPLAYER_EVENTS || true { PrintUtils.printLog(logText: "[NATIVE] Received PRESENTATION_MODE_CHANGE event from THEOplayer (to \(event.presentationMode._rawValue))") }
if let forwardedPresentationModeChangeEvent = self?.onNativePresentationModeChange,
let presentationModeContext = self?.presentationModeContext {
forwardedPresentationModeChangeEvent(presentationModeContext.eventContextForNewPresentationMode(event.presentationMode))
}
}
if DEBUG_EVENTHANDLER { PrintUtils.printLog(logText: "[NATIVE] PresentationModeChange listener attached to THEOplayer") }
}

private func dettachListeners() {
Expand Down Expand Up @@ -430,11 +418,5 @@ public class THEOplayerRCTMainEventHandler {
player.removeEventListener(type: PlayerEventTypes.CAN_PLAY, listener: canPlayListener)
if DEBUG_EVENTHANDLER { PrintUtils.printLog(logText: "[NATIVE] CanPlay listener dettached from THEOplayer") }
}

// PRESENTATION_MODE_CHANGE
if let presentationModeChangeListener = self.presentationModeChangeListener {
player.removeEventListener(type: PlayerEventTypes.PRESENTATION_MODE_CHANGE, listener: presentationModeChangeListener)
if DEBUG_EVENTHANDLER { PrintUtils.printLog(logText: "[NATIVE] PresentationModeChange listener dettached from THEOplayer") }
}
}
}
Loading

0 comments on commit c19394a

Please sign in to comment.