From d9b15295875395057aae5cd9c5b94a59bf552d70 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Tue, 17 Dec 2024 13:52:11 +0100 Subject: [PATCH 01/18] Update dependencies --- example/package-lock.json | 16 ++++++++-------- example/package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/example/package-lock.json b/example/package-lock.json index 5f5c7ee..d861301 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -15,7 +15,7 @@ "react-native-google-cast": "^4.8.3", "react-native-svg": "^15.8.0", "react-native-svg-web": "^1.0.9", - "react-native-theoplayer": "^8.6.0", + "react-native-theoplayer": "^8.10.0", "react-native-web": "^0.19.13", "react-native-web-image-loader": "^0.1.1" }, @@ -35,7 +35,7 @@ "copy-webpack-plugin": "^12.0.2", "eslint": "^8.57.1", "html-webpack-plugin": "^5.6.3", - "theoplayer": "^8.3.0", + "theoplayer": "^8.6.2", "typescript": "5.0.4", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", @@ -11517,9 +11517,9 @@ } }, "node_modules/react-native-theoplayer": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/react-native-theoplayer/-/react-native-theoplayer-8.6.0.tgz", - "integrity": "sha512-LfRlFWVlwd8MIEkOL2+yHqsnFQa5EvpiVXhqTYX5HG2ObfTwX9nQdLhdLlWsLz0xYOsF8wAG8p6nYArzz/Y67w==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/react-native-theoplayer/-/react-native-theoplayer-8.10.0.tgz", + "integrity": "sha512-GCMm9vEA2E0GX8avaftCwf8k7haA7PEhY9r1Ogv7VshLJvaZDLLBxTCKKD4vG+Y/UmZyHj1gRu/rnUpeUVbvsw==", "dependencies": { "buffer": "^6.0.3" }, @@ -13037,9 +13037,9 @@ "dev": true }, "node_modules/theoplayer": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/theoplayer/-/theoplayer-8.3.0.tgz", - "integrity": "sha512-KV9cpPQHVv8cvtt88lRCM+u+gXd64PlDmDq7Yqzcgkoy7RXisHqtiTdxnCO7U2zkMLNy4rM8WVnjWKZNrDbC0g==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/theoplayer/-/theoplayer-8.6.2.tgz", + "integrity": "sha512-+uWxIIeb/JoTiymRaDkE7l+i7Gn7uKaLEoqnFAT2BNvyf+6ByhEy4nQQhggDeh9OGvxwHcBkANqyARkRmlD96g==", "devOptional": true }, "node_modules/thingies": { diff --git a/example/package.json b/example/package.json index 30bd8af..14d8e69 100644 --- a/example/package.json +++ b/example/package.json @@ -18,7 +18,7 @@ "react-native-google-cast": "^4.8.3", "react-native-svg": "^15.8.0", "react-native-svg-web": "^1.0.9", - "react-native-theoplayer": "^8.6.0", + "react-native-theoplayer": "^8.10.0", "react-native-web": "^0.19.13", "react-native-web-image-loader": "^0.1.1" }, @@ -38,7 +38,7 @@ "copy-webpack-plugin": "^12.0.2", "eslint": "^8.57.1", "html-webpack-plugin": "^5.6.3", - "theoplayer": "^8.3.0", + "theoplayer": "^8.6.2", "typescript": "5.0.4", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", From a8bb68dbb9fbfd1d8f989aedaa4bdced54abc1f5 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Tue, 17 Dec 2024 13:52:23 +0100 Subject: [PATCH 02/18] Fix webpack --- example/web/webpack.config.js | 45 ++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/example/web/webpack.config.js b/example/web/webpack.config.js index ea8aec0..9c98312 100644 --- a/example/web/webpack.config.js +++ b/example/web/webpack.config.js @@ -2,15 +2,16 @@ const path = require('path'); const HTMLWebpackPlugin = require('html-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); -const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); -const projectDirectory = path.resolve(__dirname, '..'); +const projectDirectory = path.resolve(__dirname, '../..'); + +const appDirectory = path.resolve(__dirname, '..'); // A folder for any stub components we need in case there is no counterpart for it on react-native-web. -const stubDirectory = path.resolve(projectDirectory, './web/stub/'); +const stubDirectory = path.resolve(appDirectory, './web/stub/'); const HTMLWebpackPluginConfig = new HTMLWebpackPlugin({ - template: path.resolve(projectDirectory, './web/public/index.html'), + template: path.resolve(appDirectory, './web/public/index.html'), filename: 'index.html', inject: 'body', }); @@ -26,12 +27,18 @@ const CopyWebpackPluginConfig = new CopyWebpackPlugin({ { // Copy transmuxer worker files. // THEOplayer will find them by setting `libraryLocation` in the playerConfiguration. - from: path.resolve(projectDirectory, './node_modules/theoplayer/THEOplayer.transmux.*').replace(/\\/g, '/'), + from: path.resolve(appDirectory, './node_modules/theoplayer/THEOplayer.transmux.*').replace(/\\/g, '/'), + to: `${libraryLocation}/[name][ext]`, + }, + { + // Copy service worker + // THEOplayer will find them by setting `libraryLocation` in the playerConfiguration. + from: path.resolve(appDirectory, './node_modules/theoplayer/theoplayer.sw.js').replace(/\\/g, '/'), to: `${libraryLocation}/[name][ext]`, }, { // Copy CSS files - from: path.resolve(projectDirectory, './web/public/*.css').replace(/\\/g, '/'), + from: path.resolve(appDirectory, './web/public/*.css').replace(/\\/g, '/'), to: `[name][ext]`, }, ], @@ -42,15 +49,18 @@ const CopyWebpackPluginConfig = new CopyWebpackPlugin({ // published. If you depend on uncompiled packages they may cause webpack build // errors. To fix this webpack can be configured to compile to the necessary // `node_module`. +// +// /\.tsx?$/ : process all tsx files. +// /.*@theoplayer\/.*\.js$/ : process all js files from @theoplayer packages to apply the root import alias. This is only needed for this example. const babelLoaderConfiguration = { - test: /\.tsx?$/, + test: [/\.tsx?$/, /.*@theoplayer\/.*\.js$/], exclude: ['/**/*.d.ts', '/**/node_modules/'], use: { loader: 'babel-loader', options: { cacheDirectory: true, // The 'metro-react-native-babel-preset' preset is recommended to match React Native's packager - presets: ['module:metro-react-native-babel-preset'], + presets: ['module:@react-native/babel-preset'], // Re-write paths to import only the modules needed by the app plugins: ['react-native-web'], }, @@ -68,15 +78,15 @@ const imageLoaderConfiguration = { module.exports = { entry: [ // load any web API polyfills - // path.resolve(projectDirectory, 'polyfills-web.js'), + // path.resolve(appDirectory, 'polyfills-web.js'), // your web-specific entry file - path.resolve(projectDirectory, 'index.web.tsx'), + path.resolve(appDirectory, 'index.web.tsx'), ], // configures where the build ends up output: { filename: 'bundle.web.js', - path: path.resolve(projectDirectory, outputLocation), + path: path.resolve(appDirectory, outputLocation), }, module: { @@ -88,22 +98,23 @@ module.exports = { 'react-native$': 'react-native-web', 'react-native-url-polyfill': 'url-polyfill', 'react-native-google-cast': path.resolve(stubDirectory, 'CastButtonStub'), - 'react-native-web': path.resolve(projectDirectory, 'node_modules/react-native-web'), - 'react-native-svg': path.resolve(projectDirectory, 'node_modules/react-native-svg-web'), + 'react-native-web': path.resolve(appDirectory, 'node_modules/react-native-web'), + 'react-native-svg': path.resolve(appDirectory, 'node_modules/react-native-svg-web'), + theoplayer: path.resolve(appDirectory, 'node_modules/theoplayer'), // Avoid duplicate react env. - react: path.resolve(projectDirectory, 'node_modules/react'), - 'react-dom': path.resolve(projectDirectory, 'node_modules/react-dom'), + react: path.resolve(appDirectory, 'node_modules/react'), + 'react-dom': path.resolve(appDirectory, 'node_modules/react-dom'), }, }, - plugins: [HTMLWebpackPluginConfig, CopyWebpackPluginConfig, new NodePolyfillPlugin()], + plugins: [HTMLWebpackPluginConfig, CopyWebpackPluginConfig], devServer: { // Tells dev-server to open the browser after server had been started. open: true, historyApiFallback: true, static: [ { - directory: path.join(projectDirectory, 'web/public'), + directory: path.join(appDirectory, 'web/public'), }, ], // Hot reload on source changes From f9b81e76de303fe38b5c4a7314b0e677d7183132 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Fri, 20 Dec 2024 15:01:38 +0100 Subject: [PATCH 03/18] Use functional component --- src/ui/components/hooks/useCurrentTime.ts | 27 +++ src/ui/components/hooks/useDebounce.ts | 20 ++ src/ui/components/hooks/useDuration.ts | 25 +++ src/ui/components/hooks/useSeekable.ts | 26 +++ src/ui/components/seekbar/SeekBar.tsx | 232 +++++++--------------- 5 files changed, 175 insertions(+), 155 deletions(-) create mode 100644 src/ui/components/hooks/useCurrentTime.ts create mode 100644 src/ui/components/hooks/useDebounce.ts create mode 100644 src/ui/components/hooks/useDuration.ts create mode 100644 src/ui/components/hooks/useSeekable.ts diff --git a/src/ui/components/hooks/useCurrentTime.ts b/src/ui/components/hooks/useCurrentTime.ts new file mode 100644 index 0000000..a7f31b8 --- /dev/null +++ b/src/ui/components/hooks/useCurrentTime.ts @@ -0,0 +1,27 @@ +import { useCallback, useContext, useSyncExternalStore } from 'react'; +import { PlayerContext } from '@theoplayer/react-native-ui'; +import { type PlayerEventMap, PlayerEventType } from 'react-native-theoplayer'; + +const TIME_CHANGE_EVENTS = [PlayerEventType.TIME_UPDATE, PlayerEventType.SEEKING, PlayerEventType.SEEKED] satisfies ReadonlyArray< + keyof PlayerEventMap +>; + +/** + * Returns {@link react-native-theoplayer!THEOplayer.duration | the player's current time}, automatically updating whenever it changes. + * + * This hook must only be used in a component mounted inside a {@link THEOplayerDefaultUi} or {@link UiContainer}, + * or alternatively any other component that provides a {@link PlayerContext}. + * + * @group Hooks + */ +export const useCurrentTime = () => { + const player = useContext(PlayerContext).player; + const subscribe = useCallback( + (callback: () => void) => { + TIME_CHANGE_EVENTS.forEach((event) => player?.addEventListener(event, callback)); + return () => TIME_CHANGE_EVENTS.forEach((event) => player?.removeEventListener(event, callback)); + }, + [player], + ); + return useSyncExternalStore(subscribe, () => (player ? player.currentTime : 0)); +}; diff --git a/src/ui/components/hooks/useDebounce.ts b/src/ui/components/hooks/useDebounce.ts new file mode 100644 index 0000000..f41ffbc --- /dev/null +++ b/src/ui/components/hooks/useDebounce.ts @@ -0,0 +1,20 @@ +import { useCallback, useRef } from 'react'; + +export function useDebounce(func: (value: T) => void, delay: number) { + const timeoutRef = useRef(undefined); + return useCallback( + (value: T, force?: boolean) => { + clearTimeout(timeoutRef.current); + timeoutRef.current = undefined; + if (force) { + func(value); + } else { + timeoutRef.current = setTimeout(() => { + timeoutRef.current = undefined; + func(value); + }, delay); + } + }, + [func, delay], + ); +} diff --git a/src/ui/components/hooks/useDuration.ts b/src/ui/components/hooks/useDuration.ts new file mode 100644 index 0000000..0b63749 --- /dev/null +++ b/src/ui/components/hooks/useDuration.ts @@ -0,0 +1,25 @@ +import { useCallback, useContext, useSyncExternalStore } from 'react'; +import { PlayerContext } from '@theoplayer/react-native-ui'; +import { type PlayerEventMap, PlayerEventType } from 'react-native-theoplayer'; + +const DURATION_CHANGE_EVENTS = [PlayerEventType.LOADED_DATA, PlayerEventType.DURATION_CHANGE] satisfies ReadonlyArray; + +/** + * Returns {@link react-native-theoplayer!THEOplayer.duration | the player's duration}, automatically updating whenever it changes. + * + * This hook must only be used in a component mounted inside a {@link THEOplayerDefaultUi} or {@link UiContainer}, + * or alternatively any other component that provides a {@link PlayerContext}. + * + * @group Hooks + */ +export const useDuration = () => { + const player = useContext(PlayerContext).player; + const subscribe = useCallback( + (callback: () => void) => { + DURATION_CHANGE_EVENTS.forEach((event) => player?.addEventListener(event, callback)); + return () => DURATION_CHANGE_EVENTS.forEach((event) => player?.removeEventListener(event, callback)); + }, + [player], + ); + return useSyncExternalStore(subscribe, () => (player ? player.duration : NaN)); +}; diff --git a/src/ui/components/hooks/useSeekable.ts b/src/ui/components/hooks/useSeekable.ts new file mode 100644 index 0000000..06af3a0 --- /dev/null +++ b/src/ui/components/hooks/useSeekable.ts @@ -0,0 +1,26 @@ +import { useContext, useEffect, useState } from 'react'; +import { PlayerContext } from '@theoplayer/react-native-ui'; +import { PlayerEventType, type TimeRange, ProgressEvent } from 'react-native-theoplayer'; + +/** + * Returns {@link react-native-theoplayer!THEOplayer.seekable | the player's seekable range}, automatically updating whenever it changes. + * + * This hook must only be used in a component mounted inside a {@link THEOplayerDefaultUi} or {@link UiContainer}, + * or alternatively any other component that provides a {@link PlayerContext}. + * + * @group Hooks + */ +export const useSeekable = () => { + const player = useContext(PlayerContext).player; + const [seekable, setSeekable] = useState([]); + useEffect(() => { + const onUpdateSeekable = (event: ProgressEvent) => { + setSeekable(event.seekable ?? player?.seekable ?? []); + }; + player?.addEventListener(PlayerEventType.PROGRESS, onUpdateSeekable); + return () => { + player?.removeEventListener(PlayerEventType.PROGRESS, onUpdateSeekable); + }; + }, [player]); + return seekable; +}; diff --git a/src/ui/components/seekbar/SeekBar.tsx b/src/ui/components/seekbar/SeekBar.tsx index d209ea2..b3b7111 100644 --- a/src/ui/components/seekbar/SeekBar.tsx +++ b/src/ui/components/seekbar/SeekBar.tsx @@ -1,9 +1,12 @@ -import React, { PureComponent } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { LayoutChangeEvent, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; -import { DurationChangeEvent, LoadedMetadataEvent, PlayerEventType, ProgressEvent, TimeRange, TimeUpdateEvent } from 'react-native-theoplayer'; import { PlayerContext, UiContext } from '../util/PlayerContext'; import Slider from '@react-native-community/slider'; import { SingleThumbnailView } from './thumbnail/SingleThumbnailView'; +import { useDuration } from '../hooks/useDuration'; +import { useSeekable } from '../hooks/useSeekable'; +import { useCurrentTime } from '../hooks/useCurrentTime'; +import { useDebounce } from '../hooks/useDebounce'; export interface SeekBarProps { /** @@ -12,171 +15,90 @@ export interface SeekBarProps { style?: StyleProp; } -interface SeekBarState { - ignoreTimeupdate: boolean; - isSeeking: boolean; - pausedDueToScrubbing: boolean; - seekable: TimeRange[]; - duration: number; - sliderTime: number; - width: number; -} - /** - * The default seek bar component for the `react-native-theoplayer` UI. + * The delay in milliseconds before an actual seek is executed while scrubbing the SeekBar. */ -export class SeekBar extends PureComponent { - private static initialState: SeekBarState = { - ignoreTimeupdate: false, - isSeeking: false, - sliderTime: 0, - duration: 0, - seekable: [], - pausedDueToScrubbing: false, - width: 0, - }; - - private _seekBlockingTimeout: NodeJS.Timeout | undefined; - private _clearIsScrubbingTimout: NodeJS.Timeout | undefined; - - constructor(props: SeekBarProps) { - super(props); - this.state = SeekBar.initialState; - } - - componentDidMount() { - const player = (this.context as UiContext).player; - player.addEventListener(PlayerEventType.LOADED_METADATA, this._onLoadedMetadata); - player.addEventListener(PlayerEventType.DURATION_CHANGE, this._onDurationChange); - player.addEventListener(PlayerEventType.TIME_UPDATE, this._onTimeUpdate); - player.addEventListener(PlayerEventType.PROGRESS, this._onProgress); - this.setState({ - ...SeekBar.initialState, - sliderTime: player.currentTime, - duration: player.duration, - seekable: player.seekable, - }); - } - - componentWillUnmount() { - const player = (this.context as UiContext).player; - player.removeEventListener(PlayerEventType.LOADED_METADATA, this._onLoadedMetadata); - player.removeEventListener(PlayerEventType.DURATION_CHANGE, this._onDurationChange); - player.removeEventListener(PlayerEventType.TIME_UPDATE, this._onTimeUpdate); - player.removeEventListener(PlayerEventType.PROGRESS, this._onProgress); - clearTimeout(this._seekBlockingTimeout); - clearTimeout(this._clearIsScrubbingTimout); - } - - private _onTimeUpdate = (event: TimeUpdateEvent) => { - if (!this.state.ignoreTimeupdate) { - this.setState({ sliderTime: event.currentTime }); - } - }; - private _onLoadedMetadata = (event: LoadedMetadataEvent) => this.setState({ duration: event.duration }); - private _onDurationChange = (event: DurationChangeEvent) => { - const player = (this.context as UiContext).player; - this.setState({ duration: event.duration, seekable: player.seekable }); - }; - private _onProgress = (event: ProgressEvent) => this.setState({ seekable: event.seekable }); - - private _onSlidingStart = (value: number) => { - this.setState({ sliderTime: value }); - this._prepareSeekStart(); - const player = (this.context as UiContext).player; +const DEBOUNCE_SEEK_DELAY = 250; + +export const SeekBar = ({ style }: SeekBarProps) => { + const player = useContext(PlayerContext).player; + const [isSeeking, setIsSeeking] = useState(false); + const [sliderTime, setSliderTime] = useState(0); + const [pausedDueToScrubbing, setPausedDueToScrubbing] = useState(false); + const [width, setWidth] = useState(0); + + const duration = useDuration(); + const seekable = useSeekable(); + const currentTime = useCurrentTime(); + + useEffect(() => { + // Set sliderTime based on currentTime changes + setSliderTime(currentTime); + }, [currentTime]); + + // Do not continuously seek while dragging the slider + const debounceSeek = useDebounce((value: number) => { + player.currentTime = value; + }, DEBOUNCE_SEEK_DELAY); + + const onSlidingStart = (value: number) => { + setSliderTime(value); + setIsSeeking(true); if (!player.paused) { - this.debounceSeek(value); + debounceSeek(value); player.pause(); - this.setState({ pausedDueToScrubbing: true }); + setPausedDueToScrubbing(true); } }; - private _onValueChange = (value: number) => { - this.setState({ sliderTime: value }); - if (this.state.ignoreTimeupdate) { - this.debounceSeek(value); + const onValueChange = (value: number) => { + setSliderTime(value); + if (isSeeking) { + debounceSeek(value); } }; - private _onSlidingComplete = (value: number) => { - this.setState({ sliderTime: value }); - this.debounceSeek(value, true); - const player = (this.context as UiContext).player; + const onSlidingComplete = (value: number) => { + setSliderTime(value); + debounceSeek(value, true); const isEnded = player.currentTime === player.duration; - if (this.state.pausedDueToScrubbing && !isEnded) { + if (pausedDueToScrubbing && !isEnded) { player.play(); - this.setState({ pausedDueToScrubbing: false }); - } - this._finishSeek(); - }; - - private readonly _prepareSeekStart = () => { - this.setState({ isSeeking: true, ignoreTimeupdate: true }); - clearTimeout(this._clearIsScrubbingTimout); - }; - - private readonly _finishSeek = () => { - this.setState({ isSeeking: false }); - // Wait for timeupdate events to settle after seeking, so the Slider does not jump back and forth. - this._clearIsScrubbingTimout = setTimeout(() => { - this.setState({ ignoreTimeupdate: false }); - }, 1000); - }; - - private debounceSeek = (value: number, force = false) => { - // Don't bombard the player with seeks when seeking. Allow only one seek ever X milliseconds: - const MAX_SEEK_INTERVAL = 200; - if (force || this._seekBlockingTimeout === undefined) { - if (force) { - clearTimeout(this._seekBlockingTimeout); - this._seekBlockingTimeout = undefined; - } - this._seekBlockingTimeout = setTimeout(() => { - this._seekBlockingTimeout = undefined; - }, MAX_SEEK_INTERVAL); - // Do seek - const player = (this.context as UiContext).player; - player.currentTime = value; + setPausedDueToScrubbing(false); } + setIsSeeking(false); }; - render() { - const { seekable, sliderTime, duration, isSeeking, width } = this.state; - const { style } = this.props; - const normalizedDuration = isNaN(duration) ? 0 : duration; - const seekableStart = seekable.length > 0 ? seekable[0].start : 0; - const seekableEnd = seekable.length > 0 ? seekable[0].end : normalizedDuration; - return ( - - {(context: UiContext) => ( - { - this.setState({ width: event.nativeEvent.layout.width }); - }}> - {isSeeking && ( - - )} - 0) && seekable.length > 0) || context.adInProgress} - style={[StyleSheet.absoluteFill, style]} - minimumValue={seekableStart} - maximumValue={seekableEnd} - step={1000} - onSlidingStart={this._onSlidingStart} - onValueChange={this._onValueChange} - onSlidingComplete={this._onSlidingComplete} - value={sliderTime} - focusable={true} - minimumTrackTintColor={context.style.colors.seekBarMinimum} - maximumTrackTintColor={context.style.colors.seekBarMaximum} - thumbTintColor={context.style.colors.seekBarDot} - /> - - )} - - ); - } -} - -SeekBar.contextType = PlayerContext; + const normalizedDuration = isNaN(duration) || !isFinite(duration) ? 0 : duration; + const seekableStart = seekable.length > 0 ? seekable[0].start : 0; + const seekableEnd = seekable.length > 0 ? seekable[0].end : normalizedDuration; + + return ( + + {(context: UiContext) => ( + { + setWidth(event.nativeEvent.layout.width); + }}> + {isSeeking && } + 0) && seekable.length > 0) || context.adInProgress} + style={[StyleSheet.absoluteFill, style]} + minimumValue={seekableStart} + maximumValue={seekableEnd} + step={1000} + onSlidingStart={onSlidingStart} + onValueChange={onValueChange} + onSlidingComplete={onSlidingComplete} + value={sliderTime} + focusable={true} + minimumTrackTintColor={context.style.colors.seekBarMinimum} + maximumTrackTintColor={context.style.colors.seekBarMaximum} + thumbTintColor={context.style.colors.seekBarDot} + /> + + )} + + ); +}; From 90be2cda1026ee68c07bb930b7c42aa76a97879e Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Fri, 20 Dec 2024 15:11:18 +0100 Subject: [PATCH 04/18] Do not pause while scrubbing --- src/ui/components/seekbar/SeekBar.tsx | 34 +++++++++++---------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/src/ui/components/seekbar/SeekBar.tsx b/src/ui/components/seekbar/SeekBar.tsx index b3b7111..c0386a5 100644 --- a/src/ui/components/seekbar/SeekBar.tsx +++ b/src/ui/components/seekbar/SeekBar.tsx @@ -22,9 +22,8 @@ const DEBOUNCE_SEEK_DELAY = 250; export const SeekBar = ({ style }: SeekBarProps) => { const player = useContext(PlayerContext).player; - const [isSeeking, setIsSeeking] = useState(false); + const [isScrubbing, setIsScrubbing] = useState(false); const [sliderTime, setSliderTime] = useState(0); - const [pausedDueToScrubbing, setPausedDueToScrubbing] = useState(false); const [width, setWidth] = useState(0); const duration = useDuration(); @@ -33,7 +32,9 @@ export const SeekBar = ({ style }: SeekBarProps) => { useEffect(() => { // Set sliderTime based on currentTime changes - setSliderTime(currentTime); + if (!isScrubbing) { + setSliderTime(currentTime); + } }, [currentTime]); // Do not continuously seek while dragging the slider @@ -43,30 +44,21 @@ export const SeekBar = ({ style }: SeekBarProps) => { const onSlidingStart = (value: number) => { setSliderTime(value); - setIsSeeking(true); - if (!player.paused) { - debounceSeek(value); - player.pause(); - setPausedDueToScrubbing(true); - } + setIsScrubbing(true); + debounceSeek(value); }; - const onValueChange = (value: number) => { - setSliderTime(value); - if (isSeeking) { + const onSlidingValueChange = (value: number) => { + if (isScrubbing) { + setSliderTime(value); debounceSeek(value); } }; const onSlidingComplete = (value: number) => { setSliderTime(value); + setIsScrubbing(false); debounceSeek(value, true); - const isEnded = player.currentTime === player.duration; - if (pausedDueToScrubbing && !isEnded) { - player.play(); - setPausedDueToScrubbing(false); - } - setIsSeeking(false); }; const normalizedDuration = isNaN(duration) || !isFinite(duration) ? 0 : duration; @@ -81,7 +73,9 @@ export const SeekBar = ({ style }: SeekBarProps) => { onLayout={(event: LayoutChangeEvent) => { setWidth(event.nativeEvent.layout.width); }}> - {isSeeking && } + {isScrubbing && ( + + )} 0) && seekable.length > 0) || context.adInProgress} style={[StyleSheet.absoluteFill, style]} @@ -89,7 +83,7 @@ export const SeekBar = ({ style }: SeekBarProps) => { maximumValue={seekableEnd} step={1000} onSlidingStart={onSlidingStart} - onValueChange={onValueChange} + onValueChange={onSlidingValueChange} onSlidingComplete={onSlidingComplete} value={sliderTime} focusable={true} From b8348b67ea4bc552e171d61bed09290c1ca7f7b4 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Thu, 16 Jan 2025 12:09:24 +0100 Subject: [PATCH 05/18] Drop seeking condition --- src/ui/components/button/PlayButton.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/ui/components/button/PlayButton.tsx b/src/ui/components/button/PlayButton.tsx index 7fa4e1d..8178b51 100644 --- a/src/ui/components/button/PlayButton.tsx +++ b/src/ui/components/button/PlayButton.tsx @@ -61,18 +61,10 @@ export class PlayButton extends PureComponent } private onPlay = () => { - const player = (this.context as UiContext).player; - if (player.seeking) { - return; - } this.setState({ paused: false, ended: false }); }; private onPause = () => { - const player = (this.context as UiContext).player; - if (player.seeking) { - return; - } this.setState({ paused: true }); }; From d27e1869c4b1a182c307ec2d5cc788908d3be691 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Thu, 16 Jan 2025 12:19:40 +0100 Subject: [PATCH 06/18] Add thumbnail track hook --- src/ui/components/hooks/useThumbnailTrack.ts | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/ui/components/hooks/useThumbnailTrack.ts diff --git a/src/ui/components/hooks/useThumbnailTrack.ts b/src/ui/components/hooks/useThumbnailTrack.ts new file mode 100644 index 0000000..eb7adfb --- /dev/null +++ b/src/ui/components/hooks/useThumbnailTrack.ts @@ -0,0 +1,25 @@ +import { useCallback, useContext, useSyncExternalStore } from 'react'; +import { PlayerContext } from '@theoplayer/react-native-ui'; +import { PlayerEventType, filterThumbnailTracks, type PlayerEventMap } from 'react-native-theoplayer'; + +const TEXT_TRACK_CHANGE_EVENTS = [PlayerEventType.LOADED_DATA, PlayerEventType.TEXT_TRACK_LIST] satisfies ReadonlyArray; + +/** + * Returns a thumbnail track, if available. + * + * This hook must only be used in a component mounted inside a {@link THEOplayerDefaultUi} or {@link UiContainer}, + * or alternatively any other component that provides a {@link PlayerContext}. + * + * @group Hooks + */ +export const useThumbnailTrack = () => { + const player = useContext(PlayerContext).player; + const subscribe = useCallback( + (callback: () => void) => { + TEXT_TRACK_CHANGE_EVENTS.forEach((event) => player?.addEventListener(event, callback)); + return () => TEXT_TRACK_CHANGE_EVENTS.forEach((event) => player?.removeEventListener(event, callback)); + }, + [player], + ); + return useSyncExternalStore(subscribe, () => filterThumbnailTracks(player.textTracks)); +}; From 6d1dbad9816ad948240d890b630babb7c2c7b15b Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Thu, 16 Jan 2025 12:20:23 +0100 Subject: [PATCH 07/18] Replace slider component & simplify --- example/App.tsx | 17 +- example/package-lock.json | 8 +- example/package.json | 2 +- package-lock.json | 10 + package.json | 1 + src/ui/components/seekbar/SeekBar.tsx | 37 +-- .../seekbar/thumbnail/SingleThumbnailView.tsx | 13 +- .../seekbar/thumbnail/ThumbnailView.tsx | 283 +++++++++--------- 8 files changed, 196 insertions(+), 175 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index 3a81967..3f70027 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -60,9 +60,20 @@ export default function App() { player.addEventListener(PlayerEventType.SEEKED, console.log); player.addEventListener(PlayerEventType.ENDED, console.log); player.source = { - sources: { - src: 'https://cdn.theoplayer.com/video/adultswim/clip.m3u8', - type: 'application/x-mpegurl', + sources: [ + { + src: 'https://cdn.theoplayer.com/video/dash/bbb_30fps/bbb_with_multiple_tiled_thumbnails.mpd', + type: 'application/dash+xml', + }, + ], + poster: 'https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg', + metadata: { + title: 'Big Buck Bunny', + subtitle: 'DASH - Thumbnails in manifest', + album: 'React-Native THEOplayer demos', + mediaUri: 'https://theoplayer.com', + displayIconUri: 'https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg', + artist: 'THEOplayer', }, }; diff --git a/example/package-lock.json b/example/package-lock.json index d861301..5ade0d8 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -15,7 +15,7 @@ "react-native-google-cast": "^4.8.3", "react-native-svg": "^15.8.0", "react-native-svg-web": "^1.0.9", - "react-native-theoplayer": "^8.10.0", + "react-native-theoplayer": "^8.13.0", "react-native-web": "^0.19.13", "react-native-web-image-loader": "^0.1.1" }, @@ -11517,9 +11517,9 @@ } }, "node_modules/react-native-theoplayer": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/react-native-theoplayer/-/react-native-theoplayer-8.10.0.tgz", - "integrity": "sha512-GCMm9vEA2E0GX8avaftCwf8k7haA7PEhY9r1Ogv7VshLJvaZDLLBxTCKKD4vG+Y/UmZyHj1gRu/rnUpeUVbvsw==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/react-native-theoplayer/-/react-native-theoplayer-8.13.0.tgz", + "integrity": "sha512-G+YX5Kqd2z17Td3HusVxFVqIDTY6XvO5kj+kQ4yewJZ/anvB27uaJwSKYRB9Cg/HMWOA5Gs2JWy46kw62mCkWQ==", "dependencies": { "buffer": "^6.0.3" }, diff --git a/example/package.json b/example/package.json index 14d8e69..ae10e8e 100644 --- a/example/package.json +++ b/example/package.json @@ -18,7 +18,7 @@ "react-native-google-cast": "^4.8.3", "react-native-svg": "^15.8.0", "react-native-svg-web": "^1.0.9", - "react-native-theoplayer": "^8.10.0", + "react-native-theoplayer": "^8.13.0", "react-native-web": "^0.19.13", "react-native-web-image-loader": "^0.1.1" }, diff --git a/package-lock.json b/package-lock.json index 18ebe90..6aa9254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.9.0", "license": "SEE LICENSE AT https://www.theoplayer.com/terms", "dependencies": { + "@miblanchard/react-native-slider": "^2.6.0", "react-native-url-polyfill": "^1.3.0", "url-polyfill": "^1.1.12" }, @@ -2186,6 +2187,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@miblanchard/react-native-slider": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@miblanchard/react-native-slider/-/react-native-slider-2.6.0.tgz", + "integrity": "sha512-o7hk/f/8vkqh6QNR5L52m+ws846fQeD/qNCC9CCSRdBqjq66KiCgbxzlhRzKM/gbtxcvMYMIEEJ1yes5cr6I3A==", + "peerDependencies": { + "react": ">=16.8", + "react-native": ">=0.59" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", diff --git a/package.json b/package.json index 41a1e3c..e788c01 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ ] }, "dependencies": { + "@miblanchard/react-native-slider": "^2.6.0", "react-native-url-polyfill": "^1.3.0", "url-polyfill": "^1.1.12" } diff --git a/src/ui/components/seekbar/SeekBar.tsx b/src/ui/components/seekbar/SeekBar.tsx index c0386a5..fca8533 100644 --- a/src/ui/components/seekbar/SeekBar.tsx +++ b/src/ui/components/seekbar/SeekBar.tsx @@ -1,18 +1,22 @@ import React, { useContext, useEffect, useState } from 'react'; -import { LayoutChangeEvent, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +import { type LayoutChangeEvent, StyleProp, View, ViewStyle } from 'react-native'; import { PlayerContext, UiContext } from '../util/PlayerContext'; -import Slider from '@react-native-community/slider'; -import { SingleThumbnailView } from './thumbnail/SingleThumbnailView'; +import { Slider } from '@miblanchard/react-native-slider'; import { useDuration } from '../hooks/useDuration'; import { useSeekable } from '../hooks/useSeekable'; import { useCurrentTime } from '../hooks/useCurrentTime'; import { useDebounce } from '../hooks/useDebounce'; +import { SingleThumbnailView } from './thumbnail/SingleThumbnailView'; export interface SeekBarProps { /** * Optional style applied to the SeekBar. */ style?: StyleProp; + + sliderContainerStyle?: ViewStyle; + + sliderMaximumTrackStyle?: ViewStyle; } /** @@ -20,12 +24,11 @@ export interface SeekBarProps { */ const DEBOUNCE_SEEK_DELAY = 250; -export const SeekBar = ({ style }: SeekBarProps) => { +export const SeekBar = (props: SeekBarProps) => { const player = useContext(PlayerContext).player; const [isScrubbing, setIsScrubbing] = useState(false); const [sliderTime, setSliderTime] = useState(0); const [width, setWidth] = useState(0); - const duration = useDuration(); const seekable = useSeekable(); const currentTime = useCurrentTime(); @@ -42,23 +45,23 @@ export const SeekBar = ({ style }: SeekBarProps) => { player.currentTime = value; }, DEBOUNCE_SEEK_DELAY); - const onSlidingStart = (value: number) => { - setSliderTime(value); + const onSlidingStart = (value: number[]) => { + setSliderTime(value[0]); setIsScrubbing(true); - debounceSeek(value); + debounceSeek(value[0]); }; - const onSlidingValueChange = (value: number) => { + const onSlidingValueChange = (value: number[]) => { if (isScrubbing) { - setSliderTime(value); - debounceSeek(value); + setSliderTime(value[0]); + debounceSeek(value[0]); } }; - const onSlidingComplete = (value: number) => { - setSliderTime(value); + const onSlidingComplete = (value: number[]) => { + setSliderTime(value[0]); setIsScrubbing(false); - debounceSeek(value, true); + debounceSeek(value[0], true); }; const normalizedDuration = isNaN(duration) || !isFinite(duration) ? 0 : duration; @@ -69,7 +72,7 @@ export const SeekBar = ({ style }: SeekBarProps) => { {(context: UiContext) => ( { setWidth(event.nativeEvent.layout.width); }}> @@ -78,15 +81,15 @@ export const SeekBar = ({ style }: SeekBarProps) => { )} 0) && seekable.length > 0) || context.adInProgress} - style={[StyleSheet.absoluteFill, style]} minimumValue={seekableStart} maximumValue={seekableEnd} + containerStyle={props.sliderContainerStyle ?? {}} + maximumTrackStyle={props.sliderMaximumTrackStyle ?? {}} step={1000} onSlidingStart={onSlidingStart} onValueChange={onSlidingValueChange} onSlidingComplete={onSlidingComplete} value={sliderTime} - focusable={true} minimumTrackTintColor={context.style.colors.seekBarMinimum} maximumTrackTintColor={context.style.colors.seekBarMaximum} thumbTintColor={context.style.colors.seekBarDot} diff --git a/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx b/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx index ca1084b..a6d9a95 100644 --- a/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx +++ b/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx @@ -1,8 +1,8 @@ import { Dimensions, View } from 'react-native'; -import { filterThumbnailTracks } from 'react-native-theoplayer'; import { PlayerContext } from '../../util/PlayerContext'; import { ThumbnailView } from './ThumbnailView'; -import React, { useContext } from 'react'; +import React, { useContext, useMemo } from 'react'; +import { useThumbnailTrack } from '../../hooks/useThumbnailTrack'; export interface ThumbnailViewProps { seekableStart: number; @@ -13,12 +13,15 @@ export interface ThumbnailViewProps { export function SingleThumbnailView(props: ThumbnailViewProps) { const player = useContext(PlayerContext).player; - const thumbnailTrack = filterThumbnailTracks(player.textTracks); + const thumbnailTrack = useThumbnailTrack(); + if (!thumbnailTrack) { return <>; } - const window = Dimensions.get('window'); - const thumbnailSize = 0.35 * Math.min(window.height, window.width); + const thumbnailSize = useMemo(() => { + const window = Dimensions.get('window'); + return 0.35 * Math.min(window.height, window.width); + }, []); const { seekableStart, seekableEnd, currentTime, seekBarWidth } = props; const percentageOffset = (currentTime - seekableStart) / (seekableEnd - seekableStart); diff --git a/src/ui/components/seekbar/thumbnail/ThumbnailView.tsx b/src/ui/components/seekbar/thumbnail/ThumbnailView.tsx index f4f428f..e5bb20e 100644 --- a/src/ui/components/seekbar/thumbnail/ThumbnailView.tsx +++ b/src/ui/components/seekbar/thumbnail/ThumbnailView.tsx @@ -1,4 +1,4 @@ -import React, { PureComponent } from 'react'; +import React, { useEffect, useState } from 'react'; import type { ImageErrorEventData, NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native'; import { Image, View } from 'react-native'; import type { TextTrackCue } from 'react-native-theoplayer'; @@ -11,13 +11,6 @@ import { URL as URLPolyfill } from './Urlpolyfill'; const SPRITE_REGEX = /^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)\s*$/; const TAG = 'ThumbnailView'; -interface ThumbnailViewState { - imageWidth: number; - imageHeight: number; - renderWidth: number; - renderHeight: number; -} - export interface ThumbnailViewProps { /** * Thumbnail track. A valid thumbnail track should have properties: @@ -38,8 +31,10 @@ export interface ThumbnailViewProps { /** * Whether to show a time label. + * + * @default false. */ - showTimeLabel: boolean; + showTimeLabel?: boolean; /** * Used to set the width of the rendered thumbnail. The height will be calculated according to the image's aspect ratio. @@ -68,133 +63,126 @@ export interface ThumbnailStyle { thumbnail: ViewStyle; } -export class ThumbnailView extends PureComponent { - static defaultProps = { - showTimeLabel: true, - }; - private _ismounted = false; - - constructor(props: ThumbnailViewProps) { - super(props); - const { size } = props; - this.state = { imageWidth: size, imageHeight: size, renderWidth: size, renderHeight: 1 }; +function getCueIndexAtTime(thumbnailTrack: TextTrack, time: number): number | undefined { + // Ignore if it's an invalid track or not a thumbnail track. + if (!isThumbnailTrack(thumbnailTrack)) { + console.warn(TAG, 'Invalid thumbnail track'); + return undefined; } - componentDidMount() { - this._ismounted = true; + // Ignore if the track does not have cues + if (thumbnailTrack.cues == null || thumbnailTrack.cues.length == 0) { + return undefined; } - componentWillUnmount() { - this._ismounted = false; - } - - private getCueIndexAtTime(time: number): number | undefined { - const { thumbnailTrack } = this.props; - - // Ignore if it's an invalid track or not a thumbnail track. - if (!isThumbnailTrack(thumbnailTrack)) { - console.warn(TAG, 'Invalid thumbnail track'); - return undefined; - } - - // Ignore if the track does not have cues - if (thumbnailTrack.cues == null || thumbnailTrack.cues.length == 0) { - return undefined; - } - - const cues = thumbnailTrack.cues; - let cueIndex = 0; - for (const [index, cue] of cues.entries()) { - if (cue.startTime <= time) { - cueIndex = index; - } else if (time >= cue.endTime) { - return cueIndex; - } + const cues = thumbnailTrack.cues; + let cueIndex = 0; + for (const [index, cue] of cues.entries()) { + if (cue.startTime <= time) { + cueIndex = index; + } else if (time >= cue.endTime) { + return cueIndex; } - return cueIndex; } + return cueIndex; +} - private resolveThumbnailUrl(thumbnail: string): string { - const { thumbnailTrack } = this.props; - // NOTE: TextTrack.src is supported in Android SDK as of 3.5+ - if (thumbnailTrack && thumbnailTrack.src) { - return new URLPolyfill(thumbnail, thumbnailTrack.src).href; - } else { - return thumbnail; - } +function resolveThumbnailUrl(thumbnailTrack: TextTrack, thumbnail: string): string { + if (thumbnailTrack && thumbnailTrack.src) { + return new URLPolyfill(thumbnail, thumbnailTrack.src).href; + } else { + return thumbnail; } +} - private getThumbnailImageForCue(cue: TextTrackCue): Thumbnail | null { - const thumbnailContent = cue && cue.content; - if (!thumbnailContent) { - // Cue does not contain any thumbnail info. - return null; - } - const spriteMatch = thumbnailContent.match(SPRITE_REGEX); - if (spriteMatch) { - // The thumbnail is part of a tile. - const [, url, x, y, w, h] = spriteMatch; - return { - tileX: +x, - tileY: +y, - tileWidth: +w, - tileHeight: +h, - url: this.resolveThumbnailUrl(url), - }; - } else { - // The thumbnail is a separate image. - return { - url: this.resolveThumbnailUrl(thumbnailContent), - }; - } +function getThumbnailImageForCue(thumbnailTrack: TextTrack, cue: TextTrackCue): Thumbnail | null { + const thumbnailContent = cue && cue.content; + if (!thumbnailContent) { + // Cue does not contain any thumbnail info. + return null; + } + const spriteMatch = thumbnailContent.match(SPRITE_REGEX); + if (spriteMatch) { + // The thumbnail is part of a tile. + const [, url, x, y, w, h] = spriteMatch; + return { + tileX: +x, + tileY: +y, + tileWidth: +w, + tileHeight: +h, + url: resolveThumbnailUrl(thumbnailTrack, url), + }; + } else { + // The thumbnail is a separate image. + return { + url: resolveThumbnailUrl(thumbnailTrack, thumbnailContent), + }; } +} + +export const ThumbnailView = (props: ThumbnailViewProps) => { + const [mounted, setMounted] = useState(false); + const [imageWidth, setImageWidth] = useState(props.size); + const [imageHeight, setImageHeight] = useState(props.size); + const [renderWidth, setRenderWidth] = useState(1); + const [renderHeight, setRenderHeight] = useState(1); + + useEffect(() => { + setMounted(true); + return () => { + setMounted(false); + }; + }, []); - private onTileImageLoad = (thumbnail: Thumbnail) => () => { - if (!this._ismounted) { + const onTileImageLoad = (thumbnail: Thumbnail) => () => { + if (!mounted) { return; } - const { size } = this.props; + const { size } = props; const { tileWidth, tileHeight } = thumbnail; if (tileWidth && tileHeight) { Image.getSize(thumbnail.url, (width: number, height: number) => { - this.setState({ - imageWidth: width, - imageHeight: height, - renderWidth: size, - renderHeight: (tileHeight * size) / tileWidth, - }); + setImageWidth(width); + setImageHeight(height); + setRenderWidth(size); + setRenderHeight((tileHeight * size) / tileWidth); }); } }; - private onImageLoadError = (event: NativeSyntheticEvent) => { + const onImageLoadError = (event: NativeSyntheticEvent) => { console.error(TAG, 'Failed to load thumbnail url:', event.nativeEvent.error); }; - private onImageLoad = (thumbnail: Thumbnail) => () => { - if (!this._ismounted) { + const onImageLoad = (thumbnail: Thumbnail) => () => { + if (!mounted) { return; } - const { size } = this.props; + const { size } = props; Image.getSize(thumbnail.url, (width: number, height: number) => { - this.setState({ - imageWidth: width, - imageHeight: height, - renderWidth: size, - renderHeight: (height * size) / width, - }); + setImageWidth(width); + setImageHeight(height); + setRenderWidth(size); + setRenderHeight((height * size) / width); }); }; - private renderThumbnail = (thumbnail: Thumbnail, index: number) => { - const { imageWidth, imageHeight, renderWidth, renderHeight } = this.state; - const { size } = this.props; + const renderThumbnail = (thumbnail: Thumbnail, index: number) => { + const { size } = props; const scale = 1.0; if (isTileMapThumbnail(thumbnail)) { const ratio = thumbnail.tileWidth == 0 ? 0 : (scale * size) / thumbnail.tileWidth; return ( - + ); } else { return ( - + ); } }; - render() { - const { time, duration, thumbnailTrack, showTimeLabel, timeLabelStyle } = this.props; - if (!thumbnailTrack || !thumbnailTrack.cues || thumbnailTrack.cues.length === 0) { - // No thumbnails to render. - return <>; - } + const { time, duration, thumbnailTrack, showTimeLabel, timeLabelStyle } = props; + if (!thumbnailTrack || !thumbnailTrack.cues || thumbnailTrack.cues.length === 0) { + // No thumbnails to render. + return <>; + } - const nowCueIndex = this.getCueIndexAtTime(time); - if (nowCueIndex === undefined) { - // No thumbnail for current time - return <>; - } + const nowCueIndex = getCueIndexAtTime(thumbnailTrack, time); + if (nowCueIndex === undefined) { + // No thumbnail for current time + return <>; + } - const current = this.getThumbnailImageForCue(thumbnailTrack.cues[nowCueIndex]); - if (current === null) { - // No thumbnail for current time - return <>; - } - const { renderHeight } = this.state; - return ( - - {showTimeLabel && ( - - )} - {this.renderThumbnail(current, 0)} - - ); + const current = getThumbnailImageForCue(thumbnailTrack, thumbnailTrack.cues[nowCueIndex]); + if (current === null) { + // No thumbnail for current time + return <>; } -} + return ( + + {showTimeLabel && ( + + )} + {renderThumbnail(current, 0)} + + ); +}; From 3d7aab5bfcadec7c49dc4de8a68da36357d3f210 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Thu, 16 Jan 2025 12:22:08 +0100 Subject: [PATCH 08/18] Drop references to old slider in docs --- doc/getting-started.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/doc/getting-started.md b/doc/getting-started.md index e4447d9..dce84e0 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -39,8 +39,7 @@ The UI components also have a few non-transitive dependencies that are required npm install \ @theoplayer/react-native-ui \ react-native-theoplayer \ - react-native-svg \ - @react-native-community/slider + react-native-svg ``` The package contains a number of transitive dependencies that contain native iOS and Android platform code @@ -54,7 +53,6 @@ module.exports = { dependencies: { 'react-native-google-cast': {}, 'react-native-svg': {}, - '@react-native-community/slider': {}, }, }; ``` From 32ecbcc39600b4ce00eb31f26962077ebc986064 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Thu, 16 Jan 2025 14:58:20 +0100 Subject: [PATCH 09/18] Use slider thumbnail component --- src/ui/components/seekbar/SeekBar.tsx | 14 ++++++++----- .../seekbar/thumbnail/SingleThumbnailView.tsx | 20 ++++++++++--------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/ui/components/seekbar/SeekBar.tsx b/src/ui/components/seekbar/SeekBar.tsx index fca8533..00f447b 100644 --- a/src/ui/components/seekbar/SeekBar.tsx +++ b/src/ui/components/seekbar/SeekBar.tsx @@ -64,7 +64,7 @@ export const SeekBar = (props: SeekBarProps) => { debounceSeek(value[0], true); }; - const normalizedDuration = isNaN(duration) || !isFinite(duration) ? 0 : duration; + const normalizedDuration = isNaN(duration) || !isFinite(duration) ? 0 : Math.max(0, duration); const seekableStart = seekable.length > 0 ? seekable[0].start : 0; const seekableEnd = seekable.length > 0 ? seekable[0].end : normalizedDuration; @@ -76,16 +76,20 @@ export const SeekBar = (props: SeekBarProps) => { onLayout={(event: LayoutChangeEvent) => { setWidth(event.nativeEvent.layout.width); }}> - {isScrubbing && ( - - )} 0) && seekable.length > 0) || context.adInProgress} minimumValue={seekableStart} maximumValue={seekableEnd} - containerStyle={props.sliderContainerStyle ?? {}} + containerStyle={props.sliderContainerStyle ?? { marginHorizontal: 8 }} maximumTrackStyle={props.sliderMaximumTrackStyle ?? {}} step={1000} + renderAboveThumbComponent={(_index: number, value: number) => { + return ( + isScrubbing && ( + + ) + ); + }} onSlidingStart={onSlidingStart} onValueChange={onSlidingValueChange} onSlidingComplete={onSlidingComplete} diff --git a/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx b/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx index a6d9a95..ec45b91 100644 --- a/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx +++ b/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx @@ -24,17 +24,19 @@ export function SingleThumbnailView(props: ThumbnailViewProps) { }, []); const { seekableStart, seekableEnd, currentTime, seekBarWidth } = props; - const percentageOffset = (currentTime - seekableStart) / (seekableEnd - seekableStart); - const marginHorizontal = 10; + + // Do not let the thumbnail pass left & right borders. + const seekableRange = seekableEnd - seekableStart; + const offset = seekableRange ? (seekBarWidth * (currentTime - seekableStart)) / seekableRange : 0; + let left = -0.5 * thumbnailSize; + if (offset < 0.5 * thumbnailSize) { + left = -offset; + } else if (offset > seekBarWidth - 0.5 * thumbnailSize) { + left = -offset + seekBarWidth - thumbnailSize; + } return ( - + ); From edda6b7d23539b43dbfca1b617194cf39bdea165 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Thu, 16 Jan 2025 15:07:54 +0100 Subject: [PATCH 10/18] Simplify positioning --- .../seekbar/thumbnail/SingleThumbnailView.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx b/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx index ec45b91..7d51386 100644 --- a/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx +++ b/src/ui/components/seekbar/thumbnail/SingleThumbnailView.tsx @@ -24,19 +24,20 @@ export function SingleThumbnailView(props: ThumbnailViewProps) { }, []); const { seekableStart, seekableEnd, currentTime, seekBarWidth } = props; + const marginHorizontal = 8; // Do not let the thumbnail pass left & right borders. const seekableRange = seekableEnd - seekableStart; const offset = seekableRange ? (seekBarWidth * (currentTime - seekableStart)) / seekableRange : 0; let left = -0.5 * thumbnailSize; - if (offset < 0.5 * thumbnailSize) { - left = -offset; - } else if (offset > seekBarWidth - 0.5 * thumbnailSize) { - left = -offset + seekBarWidth - thumbnailSize; + if (offset + marginHorizontal < 0.5 * thumbnailSize) { + left = -offset - marginHorizontal; + } else if (offset - marginHorizontal > seekBarWidth - 0.5 * thumbnailSize) { + left = -offset + marginHorizontal + seekBarWidth - thumbnailSize; } return ( - + ); From 7e6faf9fd42a0b82c76730bf4c5af8555ebaf00a Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Mon, 20 Jan 2025 13:23:46 +0100 Subject: [PATCH 11/18] Optimize slider updates --- src/ui/components/seekbar/SeekBar.tsx | 4 ++-- src/ui/components/seekbar/useSliderTime.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 src/ui/components/seekbar/useSliderTime.ts diff --git a/src/ui/components/seekbar/SeekBar.tsx b/src/ui/components/seekbar/SeekBar.tsx index 00f447b..3de299c 100644 --- a/src/ui/components/seekbar/SeekBar.tsx +++ b/src/ui/components/seekbar/SeekBar.tsx @@ -4,9 +4,9 @@ import { PlayerContext, UiContext } from '../util/PlayerContext'; import { Slider } from '@miblanchard/react-native-slider'; import { useDuration } from '../hooks/useDuration'; import { useSeekable } from '../hooks/useSeekable'; -import { useCurrentTime } from '../hooks/useCurrentTime'; import { useDebounce } from '../hooks/useDebounce'; import { SingleThumbnailView } from './thumbnail/SingleThumbnailView'; +import { useSliderTime } from './useSliderTime'; export interface SeekBarProps { /** @@ -31,7 +31,7 @@ export const SeekBar = (props: SeekBarProps) => { const [width, setWidth] = useState(0); const duration = useDuration(); const seekable = useSeekable(); - const currentTime = useCurrentTime(); + const currentTime = useSliderTime(); useEffect(() => { // Set sliderTime based on currentTime changes diff --git a/src/ui/components/seekbar/useSliderTime.ts b/src/ui/components/seekbar/useSliderTime.ts new file mode 100644 index 0000000..422f91e --- /dev/null +++ b/src/ui/components/seekbar/useSliderTime.ts @@ -0,0 +1,6 @@ +import { useCurrentTime } from '../hooks/useCurrentTime'; + +export const useSliderTime = () => { + const currentTime = useCurrentTime(); + return Number.isFinite(currentTime) ? Math.round(currentTime * 1e-3) * 1e3 : 0; +}; From b0ff8998eb8345c2a750ef03b8cac38ffeee63db Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Mon, 20 Jan 2025 13:47:21 +0100 Subject: [PATCH 12/18] Use scrubbingTime --- src/ui/components/seekbar/SeekBar.tsx | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/ui/components/seekbar/SeekBar.tsx b/src/ui/components/seekbar/SeekBar.tsx index 3de299c..65264fd 100644 --- a/src/ui/components/seekbar/SeekBar.tsx +++ b/src/ui/components/seekbar/SeekBar.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useState } from 'react'; import { type LayoutChangeEvent, StyleProp, View, ViewStyle } from 'react-native'; import { PlayerContext, UiContext } from '../util/PlayerContext'; import { Slider } from '@miblanchard/react-native-slider'; @@ -27,18 +27,11 @@ const DEBOUNCE_SEEK_DELAY = 250; export const SeekBar = (props: SeekBarProps) => { const player = useContext(PlayerContext).player; const [isScrubbing, setIsScrubbing] = useState(false); - const [sliderTime, setSliderTime] = useState(0); + const [scrubberTime, setScrubberTime] = useState(undefined); const [width, setWidth] = useState(0); const duration = useDuration(); const seekable = useSeekable(); - const currentTime = useSliderTime(); - - useEffect(() => { - // Set sliderTime based on currentTime changes - if (!isScrubbing) { - setSliderTime(currentTime); - } - }, [currentTime]); + const sliderTime = useSliderTime(); // Do not continuously seek while dragging the slider const debounceSeek = useDebounce((value: number) => { @@ -46,20 +39,19 @@ export const SeekBar = (props: SeekBarProps) => { }, DEBOUNCE_SEEK_DELAY); const onSlidingStart = (value: number[]) => { - setSliderTime(value[0]); setIsScrubbing(true); debounceSeek(value[0]); }; const onSlidingValueChange = (value: number[]) => { if (isScrubbing) { - setSliderTime(value[0]); + setScrubberTime(value[0]); debounceSeek(value[0]); } }; const onSlidingComplete = (value: number[]) => { - setSliderTime(value[0]); + setScrubberTime(undefined); setIsScrubbing(false); debounceSeek(value[0], true); }; @@ -85,7 +77,8 @@ export const SeekBar = (props: SeekBarProps) => { step={1000} renderAboveThumbComponent={(_index: number, value: number) => { return ( - isScrubbing && ( + isScrubbing && + scrubberTime !== undefined && ( ) ); From f05e0531d9e4de3187e6ac5e51c7044969142c4f Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Mon, 20 Jan 2025 13:47:33 +0100 Subject: [PATCH 13/18] Add web release script --- example/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/example/package.json b/example/package.json index ae10e8e..87295a0 100644 --- a/example/package.json +++ b/example/package.json @@ -6,6 +6,7 @@ "android": "react-native run-android", "ios": "react-native run-ios", "web": "webpack-dev-server --config ./web/webpack.config.js --mode development", + "web-release": "webpack --config ./web/webpack.config.js --mode production", "lint": "eslint \"**/*.{ts,tsx}\"", "start": "react-native start", "test": "jest" From 1b6c04efbdf0b07b573cd1787f83da204dce205a Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Mon, 20 Jan 2025 13:56:28 +0100 Subject: [PATCH 14/18] Override sliderTime during scrubbing --- src/ui/components/seekbar/SeekBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/components/seekbar/SeekBar.tsx b/src/ui/components/seekbar/SeekBar.tsx index 65264fd..557eb0b 100644 --- a/src/ui/components/seekbar/SeekBar.tsx +++ b/src/ui/components/seekbar/SeekBar.tsx @@ -86,7 +86,7 @@ export const SeekBar = (props: SeekBarProps) => { onSlidingStart={onSlidingStart} onValueChange={onSlidingValueChange} onSlidingComplete={onSlidingComplete} - value={sliderTime} + value={isScrubbing && scrubberTime !== undefined ? scrubberTime : sliderTime} minimumTrackTintColor={context.style.colors.seekBarMinimum} maximumTrackTintColor={context.style.colors.seekBarMaximum} thumbTintColor={context.style.colors.seekBarDot} From d0faf2f6c348d57a1b2972c01819545dea897926 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Mon, 20 Jan 2025 15:36:53 +0100 Subject: [PATCH 15/18] Drop slider dependency --- package-lock.json | 3 +-- package.json | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6aa9254..82bf086 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ }, "devDependencies": { "@eslint/js": "^9.13.0", - "@react-native-community/slider": "^4.5.4", "@react-native/eslint-config": "^0.75.4", "@types/react": "^18.3.12", "@typescript-eslint/eslint-plugin": "^7.18.0", @@ -3496,7 +3495,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-4.5.4.tgz", "integrity": "sha512-TFhwnrp0LTFG90yat8mqEOBLLMJylpnchO5F0KusH8dI0VzViIVIXOzhnx0mUVqLvRwRPbqBB0tvhxd739UhmA==", - "dev": true + "peer": true }, "node_modules/@react-native/assets-registry": { "version": "0.75.4", diff --git a/package.json b/package.json index e788c01..ed5444b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ }, "devDependencies": { "@eslint/js": "^9.13.0", - "@react-native-community/slider": "^4.5.4", "@react-native/eslint-config": "^0.75.4", "@types/react": "^18.3.12", "@typescript-eslint/eslint-plugin": "^7.18.0", From 2dbe766da4c26286615f55c9515a916d2adf6b66 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Mon, 20 Jan 2025 15:40:12 +0100 Subject: [PATCH 16/18] Add changelog entry --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5a2c8..f044d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ 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.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Replaced slider component as a dependency of the seek bar. + +### Fixed + +- Fixed an issue where an app using the UI would crash when using the `SeekBar` component while streaming a live asset. + ## 0.9.0 (2024-10-25) ### Changed From 8751a1606ab8b82c8b7516a39d3d44bc95c152e9 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Mon, 20 Jan 2025 16:03:57 +0100 Subject: [PATCH 17/18] Drop old slider dependencies --- example/package-lock.json | 6 ------ example/package.json | 1 - package-lock.json | 7 ------- package.json | 1 - 4 files changed, 15 deletions(-) diff --git a/example/package-lock.json b/example/package-lock.json index 5ade0d8..d8e6218 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -8,7 +8,6 @@ "name": "example", "version": "0.0.1", "dependencies": { - "@react-native-community/slider": "^4.5.4", "react": "18.3.1", "react-dom": "18.3.1", "react-native": "^0.75.4", @@ -3147,11 +3146,6 @@ "node": ">=8" } }, - "node_modules/@react-native-community/slider": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-4.5.4.tgz", - "integrity": "sha512-TFhwnrp0LTFG90yat8mqEOBLLMJylpnchO5F0KusH8dI0VzViIVIXOzhnx0mUVqLvRwRPbqBB0tvhxd739UhmA==" - }, "node_modules/@react-native/assets-registry": { "version": "0.75.4", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.75.4.tgz", diff --git a/example/package.json b/example/package.json index 87295a0..6918a5a 100644 --- a/example/package.json +++ b/example/package.json @@ -12,7 +12,6 @@ "test": "jest" }, "dependencies": { - "@react-native-community/slider": "^4.5.4", "react": "18.3.1", "react-dom": "18.3.1", "react-native": "^0.75.4", diff --git a/package-lock.json b/package-lock.json index 82bf086..fac4162 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,6 @@ "typescript-eslint": "^8.11.0" }, "peerDependencies": { - "@react-native-community/slider": "*", "react": "*", "react-native": "*", "react-native-google-cast": "*", @@ -3491,12 +3490,6 @@ "node": ">=8" } }, - "node_modules/@react-native-community/slider": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-4.5.4.tgz", - "integrity": "sha512-TFhwnrp0LTFG90yat8mqEOBLLMJylpnchO5F0KusH8dI0VzViIVIXOzhnx0mUVqLvRwRPbqBB0tvhxd739UhmA==", - "peer": true - }, "node_modules/@react-native/assets-registry": { "version": "0.75.4", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.75.4.tgz", diff --git a/package.json b/package.json index ed5444b..4d02f70 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,6 @@ "typescript-eslint": "^8.11.0" }, "peerDependencies": { - "@react-native-community/slider": "*", "react": "*", "react-native": "*", "react-native-google-cast": "*", From cd0a80a94b9e74ce62993f2cf2fbca6a1b698546 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Tue, 21 Jan 2025 17:07:43 +0100 Subject: [PATCH 18/18] Use destructuring assignment --- src/ui/components/hooks/useCurrentTime.ts | 2 +- src/ui/components/hooks/useDuration.ts | 2 +- src/ui/components/hooks/useSeekable.ts | 2 +- src/ui/components/hooks/useThumbnailTrack.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ui/components/hooks/useCurrentTime.ts b/src/ui/components/hooks/useCurrentTime.ts index a7f31b8..591e35f 100644 --- a/src/ui/components/hooks/useCurrentTime.ts +++ b/src/ui/components/hooks/useCurrentTime.ts @@ -15,7 +15,7 @@ const TIME_CHANGE_EVENTS = [PlayerEventType.TIME_UPDATE, PlayerEventType.SEEKING * @group Hooks */ export const useCurrentTime = () => { - const player = useContext(PlayerContext).player; + const { player } = useContext(PlayerContext); const subscribe = useCallback( (callback: () => void) => { TIME_CHANGE_EVENTS.forEach((event) => player?.addEventListener(event, callback)); diff --git a/src/ui/components/hooks/useDuration.ts b/src/ui/components/hooks/useDuration.ts index 0b63749..76a64c1 100644 --- a/src/ui/components/hooks/useDuration.ts +++ b/src/ui/components/hooks/useDuration.ts @@ -13,7 +13,7 @@ const DURATION_CHANGE_EVENTS = [PlayerEventType.LOADED_DATA, PlayerEventType.DUR * @group Hooks */ export const useDuration = () => { - const player = useContext(PlayerContext).player; + const { player } = useContext(PlayerContext); const subscribe = useCallback( (callback: () => void) => { DURATION_CHANGE_EVENTS.forEach((event) => player?.addEventListener(event, callback)); diff --git a/src/ui/components/hooks/useSeekable.ts b/src/ui/components/hooks/useSeekable.ts index 06af3a0..7d5bbcf 100644 --- a/src/ui/components/hooks/useSeekable.ts +++ b/src/ui/components/hooks/useSeekable.ts @@ -11,7 +11,7 @@ import { PlayerEventType, type TimeRange, ProgressEvent } from 'react-native-the * @group Hooks */ export const useSeekable = () => { - const player = useContext(PlayerContext).player; + const { player } = useContext(PlayerContext); const [seekable, setSeekable] = useState([]); useEffect(() => { const onUpdateSeekable = (event: ProgressEvent) => { diff --git a/src/ui/components/hooks/useThumbnailTrack.ts b/src/ui/components/hooks/useThumbnailTrack.ts index eb7adfb..4a65e35 100644 --- a/src/ui/components/hooks/useThumbnailTrack.ts +++ b/src/ui/components/hooks/useThumbnailTrack.ts @@ -13,7 +13,7 @@ const TEXT_TRACK_CHANGE_EVENTS = [PlayerEventType.LOADED_DATA, PlayerEventType.T * @group Hooks */ export const useThumbnailTrack = () => { - const player = useContext(PlayerContext).player; + const { player } = useContext(PlayerContext); const subscribe = useCallback( (callback: () => void) => { TEXT_TRACK_CHANGE_EVENTS.forEach((event) => player?.addEventListener(event, callback));