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 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': {}, }, }; ``` 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 5f5c7ee..d8e6218 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -8,14 +8,13 @@ "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", "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.13.0", "react-native-web": "^0.19.13", "react-native-web-image-loader": "^0.1.1" }, @@ -35,7 +34,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", @@ -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", @@ -11517,9 +11511,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.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" }, @@ -13037,9 +13031,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..6918a5a 100644 --- a/example/package.json +++ b/example/package.json @@ -6,19 +6,19 @@ "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" }, "dependencies": { - "@react-native-community/slider": "^4.5.4", "react": "18.3.1", "react-dom": "18.3.1", "react-native": "^0.75.4", "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.13.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", 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 diff --git a/package-lock.json b/package-lock.json index 18ebe90..fac4162 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,12 @@ "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" }, "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", @@ -37,7 +37,6 @@ "typescript-eslint": "^8.11.0" }, "peerDependencies": { - "@react-native-community/slider": "*", "react": "*", "react-native": "*", "react-native-google-cast": "*", @@ -2186,6 +2185,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", @@ -3482,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==", - "dev": 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 41a1e3c..4d02f70 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", @@ -71,7 +70,6 @@ "typescript-eslint": "^8.11.0" }, "peerDependencies": { - "@react-native-community/slider": "*", "react": "*", "react-native": "*", "react-native-google-cast": "*", @@ -97,6 +95,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/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 }); }; diff --git a/src/ui/components/hooks/useCurrentTime.ts b/src/ui/components/hooks/useCurrentTime.ts new file mode 100644 index 0000000..591e35f --- /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); + 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..76a64c1 --- /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); + 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..7d5bbcf --- /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); + 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/hooks/useThumbnailTrack.ts b/src/ui/components/hooks/useThumbnailTrack.ts new file mode 100644 index 0000000..4a65e35 --- /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); + 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)); +}; diff --git a/src/ui/components/seekbar/SeekBar.tsx b/src/ui/components/seekbar/SeekBar.tsx index d209ea2..557eb0b 100644 --- a/src/ui/components/seekbar/SeekBar.tsx +++ b/src/ui/components/seekbar/SeekBar.tsx @@ -1,182 +1,98 @@ -import React, { PureComponent } from 'react'; -import { LayoutChangeEvent, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; -import { DurationChangeEvent, LoadedMetadataEvent, PlayerEventType, ProgressEvent, TimeRange, TimeUpdateEvent } from 'react-native-theoplayer'; +import React, { useContext, useState } from 'react'; +import { type LayoutChangeEvent, StyleProp, View, ViewStyle } from 'react-native'; import { PlayerContext, UiContext } from '../util/PlayerContext'; -import Slider from '@react-native-community/slider'; +import { Slider } from '@miblanchard/react-native-slider'; +import { useDuration } from '../hooks/useDuration'; +import { useSeekable } from '../hooks/useSeekable'; +import { useDebounce } from '../hooks/useDebounce'; import { SingleThumbnailView } from './thumbnail/SingleThumbnailView'; +import { useSliderTime } from './useSliderTime'; export interface SeekBarProps { /** * Optional style applied to the SeekBar. */ style?: StyleProp; -} -interface SeekBarState { - ignoreTimeupdate: boolean; - isSeeking: boolean; - pausedDueToScrubbing: boolean; - seekable: TimeRange[]; - duration: number; - sliderTime: number; - width: number; + sliderContainerStyle?: ViewStyle; + + sliderMaximumTrackStyle?: ViewStyle; } /** - * 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 }); - } +const DEBOUNCE_SEEK_DELAY = 250; + +export const SeekBar = (props: SeekBarProps) => { + const player = useContext(PlayerContext).player; + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubberTime, setScrubberTime] = useState(undefined); + const [width, setWidth] = useState(0); + const duration = useDuration(); + const seekable = useSeekable(); + const sliderTime = useSliderTime(); + + // Do not continuously seek while dragging the slider + const debounceSeek = useDebounce((value: number) => { + player.currentTime = value; + }, DEBOUNCE_SEEK_DELAY); + + const onSlidingStart = (value: number[]) => { + setIsScrubbing(true); + debounceSeek(value[0]); }; - 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; - if (!player.paused) { - this.debounceSeek(value); - player.pause(); - this.setState({ pausedDueToScrubbing: true }); + const onSlidingValueChange = (value: number[]) => { + if (isScrubbing) { + setScrubberTime(value[0]); + debounceSeek(value[0]); } }; - private _onValueChange = (value: number) => { - this.setState({ sliderTime: value }); - if (this.state.ignoreTimeupdate) { - this.debounceSeek(value); - } + const onSlidingComplete = (value: number[]) => { + setScrubberTime(undefined); + setIsScrubbing(false); + debounceSeek(value[0], true); }; - private _onSlidingComplete = (value: number) => { - this.setState({ sliderTime: value }); - this.debounceSeek(value, true); - const player = (this.context as UiContext).player; - const isEnded = player.currentTime === player.duration; - if (this.state.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; - } - }; - - 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 : Math.max(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); + }}> + 0) && seekable.length > 0) || context.adInProgress} + minimumValue={seekableStart} + maximumValue={seekableEnd} + containerStyle={props.sliderContainerStyle ?? { marginHorizontal: 8 }} + maximumTrackStyle={props.sliderMaximumTrackStyle ?? {}} + step={1000} + renderAboveThumbComponent={(_index: number, value: number) => { + return ( + isScrubbing && + scrubberTime !== undefined && ( + + ) + ); + }} + onSlidingStart={onSlidingStart} + onValueChange={onSlidingValueChange} + onSlidingComplete={onSlidingComplete} + value={isScrubbing && scrubberTime !== undefined ? scrubberTime : sliderTime} + 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..7d51386 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,25 +13,31 @@ 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); - const marginHorizontal = 10; + 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 + marginHorizontal < 0.5 * thumbnailSize) { + left = -offset - marginHorizontal; + } else if (offset - marginHorizontal > seekBarWidth - 0.5 * thumbnailSize) { + left = -offset + marginHorizontal + seekBarWidth - thumbnailSize; + } return ( - + ); 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)} + + ); +}; 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; +};