diff --git a/.github/workflows/release-react.yml b/.github/workflows/release-react.yml new file mode 100644 index 0000000..b80e8d6 --- /dev/null +++ b/.github/workflows/release-react.yml @@ -0,0 +1,71 @@ +name: Publish React Player + +on: + pull_request: + types: + - closed + branches: + - main + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version: 20 + - run: npm ci + working-directory: ./packages/player-react + + publish-gpr: + if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && github.event.pull_request.head.label == 'video-db:release') + needs: build + runs-on: ubuntu-latest + permissions: + packages: write + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version: 20 + registry-url: https://registry.npmjs.org/ + - name: get-npm-version + id: package-version + uses: martinbeentjes/npm-get-version-action@main + with: + path: ./packages/player-react + - name: Check Version for Tag + id: npm-tag + run: | + VERSION=${{ steps.package-version.outputs.current-version }} + if [[ $VERSION == *beta* || $VERSION == *alpha* || $VERSION == *rc* ]]; then + echo "tag=next" >> $GITHUB_OUTPUT + else + echo "tag=latest" >> $GITHUB_OUTPUT + fi + ############# TAG RELEASE ############## + - name: "Push tag v${{ steps.package-version.outputs.current-version }}" + if: steps.npm-tag.outputs.tag == 'latest' + uses: rickstaa/action-create-tag@v1 + id: tag_version + with: + tag: "v${{ steps.package-version.outputs.current-version }}" + ############# GITHUB RELEASE ############## + - name: Extract release notes + id: extract-release-notes + uses: ffurrer2/extract-release-notes@v1 + - name: "Create a GitHub release v${{ steps.package-version.outputs.current-version }}" + if: steps.npm-tag.outputs.tag == 'latest' + uses: ncipollo/release-action@v1 + with: + tag: "v${{ steps.package-version.outputs.current-version }}" + name: "Release v${{ steps.package-version.outputs.current-version }}" + body: | + ${{ steps.extract-release-notes.outputs.release_notes }} + - run: npm publish --access public --tag ${{ steps.npm-tag.outputs.tag }} + working-directory: ./packages/player-react + env: + NODE_AUTH_TOKEN: ${{secrets.NODE_AUTH_TOKEN}} diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-vue.yml similarity index 74% rename from .github/workflows/release-package.yml rename to .github/workflows/release-vue.yml index aca8805..124caa0 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-vue.yml @@ -1,4 +1,4 @@ -name: Publish +name: Publish Vue Player on: pull_request: @@ -17,6 +17,7 @@ jobs: with: node-version: 20 - run: npm ci + working-directory: ./packages/player-vue publish-gpr: if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && github.event.pull_request.head.label == 'video-db:release') @@ -34,6 +35,8 @@ jobs: - name: get-npm-version id: package-version uses: martinbeentjes/npm-get-version-action@main + with: + path: ./packages/player-vue - name: Check Version for Tag id: npm-tag run: | @@ -44,25 +47,25 @@ jobs: echo "tag=latest" >> $GITHUB_OUTPUT fi ############# TAG RELEASE ############## - - name: 'Push tag v${{ steps.package-version.outputs.current-version }}' + - name: "Push tag v${{ steps.package-version.outputs.current-version }}" if: steps.npm-tag.outputs.tag == 'latest' uses: rickstaa/action-create-tag@v1 id: tag_version with: - tag: 'v${{ steps.package-version.outputs.current-version }}' + tag: "v${{ steps.package-version.outputs.current-version }}" ############# GITHUB RELEASE ############## - name: Extract release notes id: extract-release-notes uses: ffurrer2/extract-release-notes@v1 - - name: 'Create a GitHub release v${{ steps.package-version.outputs.current-version }}' + - name: "Create a GitHub release v${{ steps.package-version.outputs.current-version }}" if: steps.npm-tag.outputs.tag == 'latest' uses: ncipollo/release-action@v1 with: - tag: 'v${{ steps.package-version.outputs.current-version }}' - name: 'Release v${{ steps.package-version.outputs.current-version }}' + tag: "v${{ steps.package-version.outputs.current-version }}" + name: "Release v${{ steps.package-version.outputs.current-version }}" body: | ${{ steps.extract-release-notes.outputs.release_notes }} - - run: npm ci - run: npm publish --access public --tag ${{ steps.npm-tag.outputs.tag }} + working-directory: ./packages/player-vue env: - NODE_AUTH_TOKEN: ${{secrets.NODE_AUTH_TOKEN}} \ No newline at end of file + NODE_AUTH_TOKEN: ${{secrets.NODE_AUTH_TOKEN}} diff --git a/README.md b/README.md index 002d054..40b70e0 100644 --- a/README.md +++ b/README.md @@ -106,14 +106,21 @@ VideoDB Player offers a well crafted UI, composed of modular components that ens Clone this repo or use the following command in your project's directory: +**For VueJS applications** ```bash npm install @videodb/player-vue ``` +**For ReactJS applications** +```bash +npm install @videodb/player-react +``` + ### Usage Import the necessary components and styles. ( *Currently supports Vue.js only* ) +**For VueJS** ```html -``` - -> โ„น๏ธ Checkout [VideoDBPlayer interface](#videodbplayer) for more details - -
- -### ๐Ÿง‘โ€๐Ÿ’ป Accessing Player Instance - -Internal player instance are exposed through a `ref`. -Useful for accessing player methods and state from outside the component. +**For ReactJS** +```jsx +import { VideoDBPlayer } from "@videodb/player-vue"; -```html - +export default function Player() { + return ( + + ) +} - ``` -> โ„น๏ธ Checkout [VideoDBPlayer interface](#videodbplayer) for more details - -
- -### ๐Ÿ”” Events - -Emits several events that you can listen to, in the parent component: - -1. `play`: Emitted when the video starts playing. -2. `pause`: Emitted when the video is paused. - > โ„น๏ธ Checkout [VideoDBPlayer interface](#videodbplayer) for full list of events - -Example usage: - -```html - - - -``` - -
- -# ๐Ÿ“ก Interface - -## VideoDBPlayer - -### Props - -- `streamUrl`: (String, required): URL of the video stream. -- `thumbnailUrl`: (String, optional): URL of the video thumbnail. -- `aspectRatio`: (String, default: "16:9"): Aspect ratio of the video. (ratio, seperated by ":") -- `subtitlesConfig`: (Object, optional): Configuration for subtitles. - - `src` (String, default: ""): URL of the subtitles file. - - `kind` (String, default: "captions"): text track type. - - `lang` (String, default: "en"): Language of the subtitles. - - `label` (String, default: "English"): Label for the subtitles. -- `startAt` (Number, default: 0): Time in seconds to start the video. -- `autoPlay` (Boolean, default: false): Toggle to start playing automatically. -- `autoHideDuration` (Number, default: 5000): Duration in milliseconds before controls are hidden. -- `defaultControls` (Boolean, default: true): Toggle to use default controls. -- `defaultOverlay` (Boolean, default: true): Toggle to use the default overlay. -- `defaultPlayBackRate` (Number, default: 1): Default playback rate. -- `debug` (Boolean, default: false): Enable debug mode. - -### Exposed and Injected Variables - -Following variables are both exposed (via `defineExpose`) and injected (via `provide`) under the key "videodb-player": - -### State Variables - -- `showElements`: Boolean indicating whether control elements are visible. -- `playing`: Boolean indicating if the video is currently playing. -- `volume`: Number representing the current volume level. -- `videoMuted`: Boolean indicating if the video is muted. -- `duration`: Number representing the total duration of the video. -- `time`: Number representing the current playback time. -- `percentagePlayed`: Number representing the percentage of the video that has been played. -- `playBackRate`: Number representing the current playback speed. -- `showSubtitles`: Boolean indicating if subtitles are currently displayed. -- `subtitlesConfig`: Object containing subtitle configuration (src, kind, lang, label). -- `isFullScreen`: Boolean indicating if the player is in fullscreen mode. - -### Methods - -- `play()`: Start playing the video. -- `pause()`: Pause the video. -- `togglePlay()`: Toggle between play and pause. -- `toggleMute()`: Toggle audio mute. -- `seekTo(time)`: Seek to a specific time in the video. -- `seekToPercentage(percentage)`: Seek to a specific percentage of the video. -- `setPlayBackRate(rate)`: Set the playback speed. -- `setVolume(level)`: Set the volume level. -- `toggleFullScreen(value)`: Toggle fullscreen mode. -- `toggleSubtitles(value)`: Toggle subtitle display. - -### Events - -- `play`: Emitted when the video starts playing. -- `pause`: Emitted when the video is paused. -- `ended`: Emitted when the video playback ends. -- `loadeddata`: Emitted when video data has loaded. -- `waiting`: Emitted when the video is waiting for data to continue playback. -- `playing`: Emitted when the video starts playing after being paused or stopped for buffering. -- `timeupdate`: Emitted continuously as the video plays, providing the current playback time. -- `canplay`: Emitted when the browser can start playing the video. -- `canplaythrough`: Emitted when the browser estimates it can play through the video without stopping for buffering. -- `videoerrror`: Emitted when an error occurs during video playback. -- `toggleSubtitles`: Emitted when subtitles are toggled on or off. -- `fullScreenChange`: Emitted when entering or exiting fullscreen mode. - -## SearchInsideMedia - -### Props - -- `searchInputPlaceholder` (String, default: "Search or ask a question"): Placeholder text for the search input. -- `searchContent` (String, required): The search query. -- `searchSuggestions` (Array, optional): List of search suggestions. Each suggestion is an object with the format `{ "text": "search suggestion" }`. -- `searchResultsLoading` (Boolean, required): Whether search results are loading. -- `showSearchResults` (Boolean, required): Whether to show search results. -- `searchResults` (Object, optional): Search results object containing: - - `hits` (Array, required): Array of match objects, each with the following fields: - - `id` (String, required): Unique identifier for the match. - - `start` (Number, required): Start time of the match in seconds relative to video start. - - `end` (Number, required): End time of the match in seconds relative to video start. - - `text` (String, required): Relevant text matching the query. - - `type` (String, required): Type of match, either "relevant" or "exact". - -### Events - -- `toggle-results`: Emitted when the search results are required to be toggled on or off. -- `search-change`: Emitted when the search input changes. -- `search-submit`: Emitted when the search is submitted (enter key pressed ) - ## ๐Ÿ›ฃ๏ธ Roadmap - Integration with upcoming VideoDB chat and agents projects. diff --git a/packages/player-react/.gitattributes b/packages/player-react/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/packages/player-react/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/packages/player-react/.gitignore b/packages/player-react/.gitignore new file mode 100644 index 0000000..b02a1ff --- /dev/null +++ b/packages/player-react/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +package-lock.json + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/player-react/.prettierrc b/packages/player-react/.prettierrc new file mode 100644 index 0000000..7325c5b --- /dev/null +++ b/packages/player-react/.prettierrc @@ -0,0 +1,5 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"], + "singleQuote": false, + "semi": true +} diff --git a/packages/player-react/README.md b/packages/player-react/README.md new file mode 100644 index 0000000..69270e5 --- /dev/null +++ b/packages/player-react/README.md @@ -0,0 +1,422 @@ + + + +[![NPM version][npm-shield]][npm-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] +[![Website][website-shield]][website-url] +[![Discord][discord-shield]][discord-url] + + +
+

+ + Logo + + +

VideoDB Player (React)

+ +

+ AI First Video Player +
+ View Demo ยป +
+
+ Report Bug + ยท + Request Feature +

+

+ + + +# ๐Ÿ“บ VideoDB Player + +VideoDB Player is an AI-first video player that enhances how we interact with videos. + +### ๐Ÿค– AI-Powered Interactive Features + +- Intelligent search bar and navigation in the video using search results. +- Automated chapter UI for easy consumpation and navigation. +- Seamless integration with chat interfaces for interactive experiences. + +### ๐ŸŒ Versatile and User-Friendly Experience + +- Cross platform support (Web, Android, iOS), features perfectly adjust on mobile devices. +- Out-of-the-box speed controls allow users to customize their viewing experience. +- [Designed](https://www.linkedin.com/posts/ashutoshtrivedi_spext-activity-7231616055834505216-UNsw) with both viewers and developers in mind, offering a sleek interface and easy integration. + +### ๐Ÿš€ Optimized for [VideoDB](https://videodb.io) Infra + +While it can work with any video source, VideoDB Player is specially optimized to leverage the full potential of VideoDB's advanced video infrastructure. + +- Enhanced compatibility with VideoDB's advanced video search feature. +- _(upcoming)_ [videodb-chat](https://github.com/video-db/videodb-chat) provides necessary Chat UI components specially designed for "Chat with Video" interfaces. + +
+ +# โœจ See it in Action + +
+ +https://github.com/user-attachments/assets/5d674179-16cd-4ec3-b3f5-c8c613562fb8 + +
+ +## ๐ŸŽจ Well-Designed UI Components + +
+ +![player-components](https://github.com/user-attachments/assets/c57447d3-8c01-4e3c-ac90-d51053488178) + +VideoDB Player offers a well crafted UI, composed of modular components that ensure both functionality and aesthetic appeal. Let's break down the key elements: + +1. Main Components: + +- `VideoPlayer` : Main Video Player Component + +2. Overlays: + +- `SearchInsideMedia`: Allows users to search within the video content. Includes a UI interface specifically designed to show Video Results โœจ. +- `Chapters`: Chapters that overlays on a Video. +- `BigCenterButton`: Prominent play/pause control for easy interaction + +3. Controls: + +- `ProgressBar`: Visual indicator of playback progress. With integration of Video Chapter functionality. +- `PlayPauseButton`: Toggle between play and pause states +- `VolumeControlButton`: Adjust audio levels +- `SpeedControlButton`: Modify playback speed +- `CaptionToggleButton`: Toggle closed captions +- `FullscreenToggleButton`: Expand to full-screen mode + +
+
+ +# ๐Ÿš€ Quickstart + +### Installation + +Clone this repo or use the following command in your project's directory: + +```bash +npm install @videodb/player-react +``` + +### Usage + +Import the player component. + +```jsx +import React from "react"; +import { VideoPlayer } from "@videodb/player-react"; + +function App() { + return ( + + ); +} + +export default App; +``` + +
+ +# ๐Ÿง‘โ€๐Ÿ’ป Advanced Usage + +### ๐Ÿงฉ Custom Overlays and Controls + +`` accepts two props to add Custom UI components on top of the player: + +- `overlayContent` (for overlays) +- `controlsContent` (for controls) + +Child Components can access the player state by using the `useVideoPlayerContext` hook. +Checkout [Accessing State Inside Child Components of `VideoPlayer`](#-accessing-player-state-inside-child-components-of-videoplayer) Section for more details + +![slots](https://github.com/user-attachments/assets/90276518-0a72-4be1-b293-2c6b1309c66a) + +
+ +### ๐Ÿ”ง Custom Controls + +To create custom controls for the VideoPlayer component, you can utilize the `controlsContent` prop. First, you'll need to disable the default controls by setting the `defaultControls` prop to `false`. Here's how you can do it: + +```jsx +} +/> +``` + +
+ +### ๐Ÿ”ง Custom Overlay + +Similar to disabling the default controls, you can disable the default overlay by setting the `defaultOverlay` prop to `false` within the VideoPlayer component: + +```jsx +} +/> +``` + +
+ +### ๐Ÿ”Ž Using `` Component + +The `` component enables in-video search functionality. To use it: + +1. Disable the default overlay in ``. +2. Add the `` component in the `overlayContent` prop. +3. To get the search results from Video you can use [VideoDB](https://videodb.io). + +```jsx +import React, { useState } from "react"; +import { VideoPlayer, SearchInsideMedia } from "@videodb/player-react"; + +function App() { + const [searchContent, setSearchContent] = useState(""); + const [searchResults, setSearchResults] = useState({ hits: [] }); + const [searchResultsLoading, setSearchResultsLoading] = useState(false); + const [showSearchResults, setShowSearchResults] = useState(false); + + const handleSearchChange = (val) => setSearchContent(val); + const handleSearchSubmit = (val) => { + // Fetch search results and update state + }; + + return ( + + + + } + /> + ); +} +``` + +> โ„น๏ธ Checkout [SearchInsideMedia interface](#searchinsidemedia) for more details on props and events + +
+ +### ๐Ÿง‘โ€๐Ÿ’ป Accessing Player State Inside Child Components of `VideoPlayer` + +The player state and methods are provided through React Context. You can access them using the `useVideoPlayerContext` hook in child components of `VideoPlayer`: + +```jsx +import { useVideoPlayerContext } from "@videodb/player-react/dist/context.js"; + +function MyCustomControl() { + const { + playing, + volume, + duration, + time, + togglePlay, + setVolume, + // ... other properties and methods + // checkout reference for more details + } = useVideoPlayerContext(); + // Use these in your component +} +``` + +> โ„น๏ธ Checkout [VideoPlayer interface](#videoplayer) for more details + +
+ +### ๐Ÿง‘โ€๐Ÿ’ป Accessing Player Instance + +Internal player instance methods and state are exposed through the context. If you need to access the player instance directly, you can use a `ref` to the `VideoPlayer` component, but most control is via context. + +```jsx +import React, { useRef, useEffect } from "react"; +import { VideoPlayer } from "@videodb/player-react"; + +function App() { + const playerRef = useRef(null); + + return ( + + ); +} +``` + +> โ„น๏ธ Checkout [VideoPlayer interface](#videoplayer) for more details + +
+ +### ๐Ÿ”” Events + +The player emits several events that you can listen to via props: + +1. `onPlay`: Called when the video starts playing. +2. `onPause`: Called when the video is paused. + > โ„น๏ธ Checkout [VideoPlayer interface](#videoplayer) for full list of events + +Example usage: + +```jsx +import React from "react"; +import { VideoPlayer } from "@videodb/player-react"; + +function App() { + const handlePlay = () => { + console.log("Video started playing"); + }; + + const handlePause = () => { + console.log("Video paused"); + }; + + return ( + + ); +} +``` + +
+ +# ๐Ÿ“ก Interface + +## VideoPlayer + +### Props + +- `streamUrl`: (String, required): URL of the video stream. +- `thumbnailUrl`: (String, optional): URL of the video thumbnail. +- `aspectRatio`: (String, default: "16:9"): Aspect ratio of the video. (ratio, separated by ":") +- `subtitlesConfig`: (Object, optional): Configuration for subtitles. + - `src` (String, default: ""): URL of the subtitles file. + - `kind` (String, default: "captions"): text track type. + - `lang` (String, default: "en"): Language of the subtitles. + - `label` (String, default: "English"): Label for the subtitles. +- `startAt` (Number, default: 0): Time in seconds to start the video. +- `autoPlay` (Boolean, default: false): Toggle to start playing automatically. +- `autoHideDuration` (Number, default: 5000): Duration in milliseconds before controls are hidden. +- `defaultControls` (Boolean, default: true): Toggle to use default controls. +- `defaultOverlay` (Boolean, default: true): Toggle to use the default overlay. +- `defaultPlayBackRate` (Number, default: 1): Default playback rate. +- `debug` (Boolean, default: false): Enable debug mode. + +### State Variables (via Context) + +- `showElements`: Boolean indicating whether control elements are visible. +- `playing`: Boolean indicating if the video is currently playing. +- `volume`: Number representing the current volume level. +- `videoMuted`: Boolean indicating if the video is muted. +- `duration`: Number representing the total duration of the video. +- `time`: Number representing the current playback time. +- `percentagePlayed`: Number representing the percentage of the video that has been played. +- `playBackRate`: Number representing the current playback speed. +- `showSubtitles`: Boolean indicating if subtitles are currently displayed. +- `subtitlesConfig`: Object containing subtitle configuration (src, kind, lang, label). +- `isFullScreen`: Boolean indicating if the player is in fullscreen mode. + +### Methods (via Context) + +- `play()`: Start playing the video. +- `pause()`: Pause the video. +- `togglePlay()`: Toggle between play and pause. +- `toggleMute()`: Toggle audio mute. +- `seekTo(time)`: Seek to a specific time in the video. +- `seekToPercentage(percentage)`: Seek to a specific percentage of the video. +- `setPlayBackRate(rate)`: Set the playback speed. +- `setVolume(level)`: Set the volume level. +- `toggleFullScreen(value)`: Toggle fullscreen mode. +- `toggleSubtitles(value)`: Toggle subtitle display. + +### Events (as Props) + +- `onPlay`: Called when the video starts playing. +- `onPause`: Called when the video is paused. +- `onEnded`: Called when the video playback ends. +- `onLoadedData`: Called when video data has loaded. +- `onWaiting`: Called when the video is waiting for data to continue playback. +- `onPlaying`: Called when the video starts playing after being paused or stopped for buffering. +- `onTimeUpdate`: Called continuously as the video plays, providing the current playback time. +- `onCanPlay`: Called when the browser can start playing the video. +- `onCanPlayThrough`: Called when the browser estimates it can play through the video without stopping for buffering. +- `onVideoError`: Called when an error occurs during video playback. +- `onToggleSubtitles`: Called when subtitles are toggled on or off. +- `onFullScreenChange`: Called when entering or exiting fullscreen mode. + +## SearchInsideMedia + +### Props + +- `searchInputPlaceholder` (String, default: "Search or ask a question"): Placeholder text for the search input. +- `searchContent` (String, required): The search query. +- `searchSuggestions` (Array, optional): List of search suggestions. Each suggestion is an object with the format `{ "text": "search suggestion" }`. +- `searchResultsLoading` (Boolean, required): Whether search results are loading. +- `showSearchResults` (Boolean, required): Whether to show search results. +- `searchResults` (Object, optional): Search results object containing: + - `hits` (Array, required): Array of match objects, each with the following fields: + - `id` (String, required): Unique identifier for the match. + - `start` (Number, required): Start time of the match in seconds relative to video start. + - `end` (Number, required): End time of the match in seconds relative to video start. + - `text` (String, required): Relevant text matching the query. + - `type` (String, required): Type of match, either "relevant" or "exact". + +### Events (as Props) + +- `toggleResults`: Called when the search results are required to be toggled on or off. +- `onSearchChange`: Called when the search input changes. +- `onSearchSubmit`: Called when the search is submitted (enter key pressed ) + +## ๐Ÿ›ฃ๏ธ Roadmap + +- Integration with upcoming VideoDB chat and agents projects. +- โšก Optimization for ProgressBar +- ๐Ÿ” Search Component Interface improvements +- ๐ŸŽจ Improve tailwind.config.js; use default values for spacing +- โš›๏ธ React Conversion + + + +## Contribute + +Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. + +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + + + + +[npm-shield]: https://img.shields.io/npm/v/@videodb/player-react?style=for-the-badge +[npm-url]: https://www.npmjs.com/package/@videodb/player-react +[discord-shield]: https://img.shields.io/badge/dynamic/json?style=for-the-badge&url=https://discord.com/api/invites/py9P639jGz?with_counts=true&query=$.approximate_member_count&logo=discord&logoColor=blue&color=green&label=discord +[discord-url]: https://discord.com/invite/py9P639jGz +[stars-shield]: https://img.shields.io/github/stars/video-db/videodb-player.svg?style=for-the-badge +[stars-url]: https://github.com/video-db/videodb-player/stargazers +[issues-shield]: https://img.shields.io/github/issues/video-db/videodb-player.svg?style=for-the-badge +[issues-url]: https://github.com/video-db/videodb-player/issues +[website-shield]: https://img.shields.io/website?url=https%3A%2F%2Fvideodb.io%2F&style=for-the-badge&label=videodb.io +[website-url]: https://videodb.io/ diff --git a/packages/player-react/eslint.config.js b/packages/player-react/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/packages/player-react/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/packages/player-react/package.json b/packages/player-react/package.json new file mode 100644 index 0000000..61b4525 --- /dev/null +++ b/packages/player-react/package.json @@ -0,0 +1,91 @@ +{ + "name": "@videodb/player-react", + "version": "0.1.0", + "description": "VideoDB Player is an AI-first video player that enhances how we interact with videos.", + "author": "VideoDB", + "license": "Apache-2.0", + "homepage": "https://videodb.io", + "repository": { + "type": "git", + "url": "https://github.com/video-db/videodb-player.git" + }, + "private": false, + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "style": "dist/tailwind.css", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./*": "./*" + }, + "scripts": { + "dev-types": "tsc --noEmit --watch", + "build-dev": "npm run build-lib -- --watch && npm run build-types -- --watch", + "build-lib": "vite build", + "build": "npm run build-lib && npm run build-types", + "build-types": "tsc --emitDeclarationOnly --project tsconfig.app.json", + "lint": "eslint 'src/**/*.{ts,tsx}'", + "format": "prettier --write 'src/**/*.{ts,tsx}'", + "test": "vitest", + "test-ci": "vitest run", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@floating-ui/react": "^0.27.8", + "lodash.debounce": "^4.0.8", + "rollup-plugin-postcss": "^4.0.2", + "swiper": "^11.2.6", + "video.js": "^8.22.0" + }, + "devDependencies": { + "@tsconfig/node20": "^20.1.2", + "@types/lodash.debounce": "^4.0.9", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.4.1", + "autoprefixer": "^10.4.21", + "eslint": "^9.22.0", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.34.1", + "eslint-plugin-react-hooks": "^5.2.0", + "jsdom": "^26.1.0", + "lint-staged": "^15.0.2", + "postcss": "^8.5.3", + "prettier": "^3.5.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "run-p": "^0.0.0", + "tailwindcss": "^3.4.17", + "typescript": "~5.7.2", + "vite": "^6.3.4", + "vite-plugin-css-injected-by-js": "^3.5.2", + "vitest": "^3.1.2" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "keywords": [ + "video", + "player", + "videodb", + "video-db", + "video_db", + "videodb-player", + "video-player", + "video-player-component", + "ai-video-player", + "react" + ] +} diff --git a/packages/player-react/postcss.config.cjs b/packages/player-react/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/packages/player-react/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/player-react/src/components/atoms/ellipse-loading/index.tsx b/packages/player-react/src/components/atoms/ellipse-loading/index.tsx new file mode 100644 index 0000000..2a7b664 --- /dev/null +++ b/packages/player-react/src/components/atoms/ellipse-loading/index.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import "./styles.css"; + +export default function Ellipses({ children }: React.PropsWithChildren) { + return ( + + {children} + + . + . + . + + + ); +} diff --git a/packages/player-react/src/components/atoms/ellipse-loading/styles.css b/packages/player-react/src/components/atoms/ellipse-loading/styles.css new file mode 100644 index 0000000..647e3d9 --- /dev/null +++ b/packages/player-react/src/components/atoms/ellipse-loading/styles.css @@ -0,0 +1,55 @@ +.ellipses_container { + letter-spacing: -0.1em; +} + +.one { + opacity: 0; + -webkit-animation: dot 1.3s infinite; + -webkit-animation-delay: 0s; + animation: dot 1.3s infinite; + animation-delay: 0s; +} + +.two { + opacity: 0; + -webkit-animation: dot 1.3s infinite; + -webkit-animation-delay: 0.2s; + animation: dot 1.3s infinite; + animation-delay: 0.2s; +} + +.three { + opacity: 0; + -webkit-animation: dot 1.3s infinite; + -webkit-animation-delay: 0.3s; + animation: dot 1.3s infinite; + animation-delay: 0.3s; +} + +@-webkit-keyframes dot { + 0% { + opacity: 0; + } + + 50% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes dot { + 0% { + opacity: 0; + } + + 50% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} \ No newline at end of file diff --git a/packages/player-react/src/components/atoms/loading/index.tsx b/packages/player-react/src/components/atoms/loading/index.tsx new file mode 100644 index 0000000..4e95aab --- /dev/null +++ b/packages/player-react/src/components/atoms/loading/index.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import "./styles.css"; + +export default function Loading() { + return ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+ ); +} diff --git a/packages/player-react/src/components/atoms/loading/styles.css b/packages/player-react/src/components/atoms/loading/styles.css new file mode 100644 index 0000000..5c8ca94 --- /dev/null +++ b/packages/player-react/src/components/atoms/loading/styles.css @@ -0,0 +1,94 @@ +.lds-spinner { + color: currentColor; + display: inline-block; + position: relative; + width: 32px; + height: 32px; +} + +.lds-spinner div { + transform-origin: 16px 16px; + animation: lds-spinner 1.2s linear infinite; +} + +.lds-spinner div::after { + content: " "; + display: block; + position: absolute; + top: 0; + left: 14px; + width: 3px; + height: 9px; + border-radius: 20%; + background: currentColor; +} + +.lds-spinner div:nth-child(1) { + transform: rotate(0deg); + animation-delay: -1.1s; +} + +.lds-spinner div:nth-child(2) { + transform: rotate(30deg); + animation-delay: -1s; +} + +.lds-spinner div:nth-child(3) { + transform: rotate(60deg); + animation-delay: -0.9s; +} + +.lds-spinner div:nth-child(4) { + transform: rotate(90deg); + animation-delay: -0.8s; +} + +.lds-spinner div:nth-child(5) { + transform: rotate(120deg); + animation-delay: -0.7s; +} + +.lds-spinner div:nth-child(6) { + transform: rotate(150deg); + animation-delay: -0.6s; +} + +.lds-spinner div:nth-child(7) { + transform: rotate(180deg); + animation-delay: -0.5s; +} + +.lds-spinner div:nth-child(8) { + transform: rotate(210deg); + animation-delay: -0.4s; +} + +.lds-spinner div:nth-child(9) { + transform: rotate(240deg); + animation-delay: -0.3s; +} + +.lds-spinner div:nth-child(10) { + transform: rotate(270deg); + animation-delay: -0.2s; +} + +.lds-spinner div:nth-child(11) { + transform: rotate(300deg); + animation-delay: -0.1s; +} + +.lds-spinner div:nth-child(12) { + transform: rotate(330deg); + animation-delay: 0s; +} + +@keyframes lds-spinner { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} \ No newline at end of file diff --git a/packages/player-react/src/components/atoms/time-code/index.tsx b/packages/player-react/src/components/atoms/time-code/index.tsx new file mode 100644 index 0000000..1cf2ecf --- /dev/null +++ b/packages/player-react/src/components/atoms/time-code/index.tsx @@ -0,0 +1,43 @@ +import React, { useMemo } from "react"; +import { useVideoPlayerContext } from "../../../context"; + +interface TimeCodeProps { + autoHide?: boolean; +} + +export default function TimeCode({ autoHide = true }: TimeCodeProps) { + const { time, duration, showElements } = useVideoPlayerContext(); + + const showHours = (duration: number) => Math.floor(duration / 3600) > 0; + + const formatPadded = (num: number) => String(num).padStart(2, "0"); + + const formatDuration = (duration: number) => { + const hrs = formatPadded(Math.floor(duration / 3600)) + ":"; + const mins = formatPadded(Math.floor((duration % 3600) / 60)) + ":"; + const secs = formatPadded(Math.floor(duration % 60)); + return `${showHours(duration) ? hrs : ""}${mins}${secs}`; + }; + + const currentTime = useMemo(() => formatDuration(time), [time]); + const totalTime = useMemo(() => formatDuration(duration), [duration]); + + const paragraphClasses = [ + "vdb-p-ml-12", + "vdb-p-inline", + "vdb-p-hidden", + "vdb-p-font-medium", + "vdb-p-text-white", + "vdb-p-transition", + "sm:vdb-p-block", + autoHide && !showElements ? "vdb-p-opacity-0" : "vdb-p-opacity-1", + ].join(" "); + + return ( +

+ {currentTime} + / + {totalTime} +

+ ); +} diff --git a/packages/player-react/src/components/atoms/transparent-btn/index.tsx b/packages/player-react/src/components/atoms/transparent-btn/index.tsx new file mode 100644 index 0000000..78cadf3 --- /dev/null +++ b/packages/player-react/src/components/atoms/transparent-btn/index.tsx @@ -0,0 +1,49 @@ +import React, { CSSProperties } from "react"; +import "./styles.css"; + +type ButtonState = "default" | "active" | "disabled" | "hidden"; + +interface CustomButtonProps { + buttonState?: ButtonState; + defaultStateCss?: string; + activeStateCss?: string; + disabledStateCss?: string; + onClickAction?: () => void; + children: React.ReactNode; + className?: string; + style?: CSSProperties; +} + +export default function CustomButton({ + buttonState = "default", + defaultStateCss = "vdb-p-bg-black-45 vdb-p-border vdb-p-border-yellow vdb-p-backdrop-blur hover:vdb-p-bg-random-313131 hover:vdb-p-border-random-8e6200", + activeStateCss = "chapter-button-active-lg vdb-p-border vdb-p-border-yellow pale-yellow", + disabledStateCss = "vdb-p-bg-black-45 vdb-p-opacity-20 vdb-p-border vdb-p-border-yellow vdb-p-backdrop-blur vdb-p-cursor-not-allowed vdb-p-pointer-events-none", + onClickAction, + children, + className = "", + style, +}: CustomButtonProps) { + const getCustomCss = () => { + switch (buttonState) { + case "active": + return activeStateCss; + case "disabled": + return disabledStateCss; + case "hidden": + return "vdb-p-hidden"; + default: + return defaultStateCss; + } + }; + + return ( + + ); +} diff --git a/packages/player-react/src/components/atoms/transparent-btn/styles.css b/packages/player-react/src/components/atoms/transparent-btn/styles.css new file mode 100644 index 0000000..b5e8944 --- /dev/null +++ b/packages/player-react/src/components/atoms/transparent-btn/styles.css @@ -0,0 +1,19 @@ +.chapter-button-active-lg { + background: linear-gradient(0deg, #fdedcb, #fdedcb), + radial-gradient(37.36% 50% at 49.15% 50%, + #f8c450 17.14%, + rgba(248, 196, 80, 0.29) 45.79%, + rgba(13, 9, 0, 0.35) 100%); +} + +.pale-yellow-bg { + background: #8e6200; +} + +.pale-yellow { + color: #8e6200; +} + +.vdb-p-backdrop-blur { + backdrop-filter: blur(8px); +} \ No newline at end of file diff --git a/packages/player-react/src/components/atoms/with-popper/index.tsx b/packages/player-react/src/components/atoms/with-popper/index.tsx new file mode 100644 index 0000000..bfe33a4 --- /dev/null +++ b/packages/player-react/src/components/atoms/with-popper/index.tsx @@ -0,0 +1,62 @@ +import { + arrow, + offset, + Placement, + shift, + useFloating, +} from "@floating-ui/react"; +import React, { useRef, useState } from "react"; + +interface CustomPopperProps { + popperText?: string; + isPopperActive?: boolean; + className?: string; + children: React.ReactNode; +} + +export default function CustomPopper({ + popperText = "", + className = "", + isPopperActive = false, + children, +}: CustomPopperProps) { + const [isOpen, setIsOpen] = useState(false); + const arrowRef = useRef(null); + + const { refs, floatingStyles, middlewareData } = useFloating({ + placement: "top" as Placement, + middleware: [offset(10), shift(), arrow({ element: arrowRef })], + }); + + return ( +
setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + > + {/* Trigger / reference element */} +
{children}
+ + {/* Floating popper */} + {isOpen && ( +
+ {popperText} +
+
+ )} +
+ ); +} diff --git a/packages/player-react/src/components/buttons/big-center-button/index.tsx b/packages/player-react/src/components/buttons/big-center-button/index.tsx new file mode 100644 index 0000000..e0c3222 --- /dev/null +++ b/packages/player-react/src/components/buttons/big-center-button/index.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { useVideoPlayerContext } from "../../../context"; +import TransparentButton from "../../atoms/transparent-btn"; +import PauseButton from "../../icons/pause"; +import PlayButton from "../../icons/play"; + +interface BigCenterButtonProps { + className?: string; + isActive?: boolean; + autoHide?: boolean; +} + +function BigCenterButton({ + className = "", + isActive = true, + autoHide = true, +}: BigCenterButtonProps) { + const { playing, togglePlay, showElements } = useVideoPlayerContext(); + + const buttonClassName = + `vdb-p-h-72 vdb-p-w-72 vdb-p-rounded-full ${className} ` + + (autoHide && !showElements ? "vdb-p-opacity-0" : "vdb-p-opacity-1"); + return ( + + {playing ? ( + + ) : ( + + )} + + ); +} + +export default BigCenterButton; diff --git a/packages/player-react/src/components/buttons/caption-button/index.tsx b/packages/player-react/src/components/buttons/caption-button/index.tsx new file mode 100644 index 0000000..970ce03 --- /dev/null +++ b/packages/player-react/src/components/buttons/caption-button/index.tsx @@ -0,0 +1,50 @@ +import React, { JSX, useMemo } from "react"; +import { useVideoPlayerContext } from "../../../context"; +import TransparentButton from "../../atoms/transparent-btn"; +import WithPopper from "../../atoms/with-popper"; + +interface CaptionToggleButtonProps { + isActive?: boolean; + autoHide?: boolean; +} + +export default function CaptionToggleButton({ + isActive = true, + autoHide = true, +}: CaptionToggleButtonProps): JSX.Element { + const { showElements, showSubtitles, toggleSubtitles, subtitlesConfig } = + useVideoPlayerContext(); + + const captionButtonState = useMemo(() => { + if (!subtitlesConfig?.src) return "disabled"; + return showSubtitles ? "active" : "default"; + }, [showSubtitles, subtitlesConfig]); + + const opacityClass = + autoHide && !showElements ? "vdb-p-opacity-0" : "vdb-p-opacity-1"; + + return ( + + toggleSubtitles(!showSubtitles)} + > +
+ CC +
+
+
+ ); +} diff --git a/packages/player-react/src/components/buttons/full-screen-button/index.tsx b/packages/player-react/src/components/buttons/full-screen-button/index.tsx new file mode 100644 index 0000000..dbd6993 --- /dev/null +++ b/packages/player-react/src/components/buttons/full-screen-button/index.tsx @@ -0,0 +1,46 @@ +import React, { JSX } from "react"; +import { useVideoPlayerContext } from "../../../context"; + +import TransparentButton from "../../atoms/transparent-btn"; +import WithPopper from "../../atoms/with-popper"; +import FullScreen from "../../icons/full-screen"; +import FullScreenExit from "../../icons/full-screen-exit"; + +interface FullscreenToggleButtonProps { + isActive?: boolean; + autoHide?: boolean; +} + +export default function FullscreenToggleButton({ + isActive = true, + autoHide = true, +}: FullscreenToggleButtonProps): JSX.Element { + const { showElements, isFullScreen, toggleFullScreen } = + useVideoPlayerContext(); + + const opacityClass = + autoHide && !showElements ? "vdb-p-opacity-0" : "vdb-p-opacity-1"; + + return ( + + toggleFullScreen(!isFullScreen)} + > +
+ {isFullScreen ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/packages/player-react/src/components/buttons/play-pause-button/index.tsx b/packages/player-react/src/components/buttons/play-pause-button/index.tsx new file mode 100644 index 0000000..e93b09e --- /dev/null +++ b/packages/player-react/src/components/buttons/play-pause-button/index.tsx @@ -0,0 +1,45 @@ +import React, { JSX } from "react"; +import { useVideoPlayerContext } from "../../../context"; +import TransparentButton from "../../atoms/transparent-btn"; +import WithPopper from "../../atoms/with-popper"; +import PauseButton from "../../icons/pause"; +import PlayButton from "../../icons/play"; + +interface PlayPauseButtonProps { + isActive?: boolean; + autoHide?: boolean; +} + +export default function PlayPauseButton({ + isActive = true, + autoHide = true, +}: PlayPauseButtonProps): JSX.Element { + const { playing, togglePlay, showElements } = useVideoPlayerContext(); + + const isPopperActive = true; + + const opacityClass = + autoHide && !showElements ? "vdb-p-opacity-0" : "vdb-p-opacity-1"; + + return ( + + + {playing ? ( + + ) : ( + + )} + + + ); +} diff --git a/packages/player-react/src/components/buttons/speed-control-button/index.tsx b/packages/player-react/src/components/buttons/speed-control-button/index.tsx new file mode 100644 index 0000000..bf93f88 --- /dev/null +++ b/packages/player-react/src/components/buttons/speed-control-button/index.tsx @@ -0,0 +1,47 @@ +import React, { JSX } from "react"; +import { useVideoPlayerContext } from "../../../context"; +import TransparentButton from "../../atoms/transparent-btn"; +import WithPopper from "../../atoms/with-popper"; + +interface PlaybackRateButtonProps { + isActive?: boolean; + autoHide?: boolean; + speedOptions?: number[]; +} + +export default function SpeedControlButton({ + isActive = true, + autoHide = true, + speedOptions = [1, 1.2, 1.5, 1.8, 2], +}: PlaybackRateButtonProps): JSX.Element { + const { playBackRate, setPlayBackRate, showElements } = + useVideoPlayerContext(); + const isPopperActive = true; + + const opacityClass = + autoHide && !showElements ? "vdb-p-opacity-0" : "vdb-p-opacity-1"; + + const onClickPlaybackRate = () => { + const currentIndex = speedOptions.indexOf(playBackRate); + const nextIndex = (currentIndex + 1) % speedOptions.length; + setPlayBackRate(speedOptions[nextIndex]); + }; + + return ( + + +
{playBackRate}x
+
+
+ ); +} diff --git a/packages/player-react/src/components/buttons/volume-control-button/index.tsx b/packages/player-react/src/components/buttons/volume-control-button/index.tsx new file mode 100644 index 0000000..74a4264 --- /dev/null +++ b/packages/player-react/src/components/buttons/volume-control-button/index.tsx @@ -0,0 +1,114 @@ +import React, { JSX, useEffect, useRef, useState } from "react"; +import { useVideoPlayerContext } from "../../../context"; +import TransparentButton from "../../atoms/transparent-btn"; +import WithPopper from "../../atoms/with-popper"; +import VolumeIcon from "../../icons/volume"; +import VolumeMuteIcon from "../../icons/volume-mute"; + +interface VolumeControlButtonProps { + isActive?: boolean; + autoHide?: boolean; +} + +export default function VolumeControlButton({ + isActive = true, + autoHide = true, +}: VolumeControlButtonProps): JSX.Element { + const { volume, setVolume, showElements } = useVideoPlayerContext(); + + const [volumeDrag, setVolumeDrag] = useState(false); + const volumeRef = useRef(null); + const isPopperActive = true; + + const opacityClass = + autoHide && !showElements ? "vdb-p-opacity-0" : "vdb-p-opacity-1"; + + const updateVolume = (x: number, vol?: number) => { + const volumeEl = volumeRef.current; + if (!volumeEl) return; + + let percentAsDecimal = 0; + if (vol !== undefined) { + percentAsDecimal = vol; + } else { + const rect = volumeEl.getBoundingClientRect(); + const position = x - rect.left; + percentAsDecimal = position / rect.width; + } + percentAsDecimal = Math.max(0, Math.min(1, percentAsDecimal)); + + volumeEl.style.clip = `rect(0px, ${percentAsDecimal * 32}px, 10px, 0px)`; + setVolume(percentAsDecimal); + }; + + const onMouseMove = (e: MouseEvent) => { + if (volumeDrag) { + updateVolume(e.pageX); + } + }; + + const onMouseDown = (e: React.MouseEvent) => { + setVolumeDrag(true); + updateVolume(e.pageX); + if (typeof window !== "undefined") { + window.addEventListener("mouseup", onMouseUp); + } + }; + + const onMouseUp = (e: MouseEvent) => { + if (typeof window === "undefined") return; + if (volumeDrag) { + setVolumeDrag(false); + updateVolume(e.pageX); + } + }; + + useEffect(() => { + window.addEventListener("mousemove", onMouseMove); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, []); + + return ( + +
+ +
updateVolume(0, volume === 0 ? 1 : 0)} + > + {volume === 0 ? ( + + ) : ( + + )} +
+
+
+ +
+
+
+
+ + ); +} diff --git a/packages/player-react/src/components/icons/arrow-left.tsx b/packages/player-react/src/components/icons/arrow-left.tsx new file mode 100644 index 0000000..a4b7426 --- /dev/null +++ b/packages/player-react/src/components/icons/arrow-left.tsx @@ -0,0 +1,23 @@ +import React, { JSX } from "react"; + +interface Props { + className?: string; +} + +export default function LeftArrowIcon(props: Props): JSX.Element { + return ( + + + + ); +} diff --git a/packages/player-react/src/components/icons/arrow-right.tsx b/packages/player-react/src/components/icons/arrow-right.tsx new file mode 100644 index 0000000..46b36be --- /dev/null +++ b/packages/player-react/src/components/icons/arrow-right.tsx @@ -0,0 +1,23 @@ +import React, { JSX } from "react"; + +interface Props { + className?: string; +} + +export default function RightArrowIcon(props: Props): JSX.Element { + return ( + + + + ); +} diff --git a/packages/player-react/src/components/icons/close.tsx b/packages/player-react/src/components/icons/close.tsx new file mode 100644 index 0000000..04246ff --- /dev/null +++ b/packages/player-react/src/components/icons/close.tsx @@ -0,0 +1,38 @@ +import React, { JSX } from "react"; + +interface Props { + className?: string; + color?: string; + onClick: () => void; +} + +export default function CloseCircleIcon({ + className = "", + color = "currentColor", + onClick, +}: Props): JSX.Element { + return ( + + + + + ); +} diff --git a/packages/player-react/src/components/icons/full-screen-exit.tsx b/packages/player-react/src/components/icons/full-screen-exit.tsx new file mode 100644 index 0000000..b5952a2 --- /dev/null +++ b/packages/player-react/src/components/icons/full-screen-exit.tsx @@ -0,0 +1,33 @@ +import React, { JSX } from "react"; + +type Props = { + className?: string; +}; + +export default function CloseCircleIcon({ + className = "", +}: Props): JSX.Element { + return ( + + + + + ); +} diff --git a/packages/player-react/src/components/icons/full-screen.tsx b/packages/player-react/src/components/icons/full-screen.tsx new file mode 100644 index 0000000..f2285fa --- /dev/null +++ b/packages/player-react/src/components/icons/full-screen.tsx @@ -0,0 +1,35 @@ +import React, { JSX } from "react"; + +type Props = { + className?: string; +}; + +export default function FullScreenIcon({ className = "" }: Props): JSX.Element { + return ( + + + + + + + ); +} diff --git a/packages/player-react/src/components/icons/pause.tsx b/packages/player-react/src/components/icons/pause.tsx new file mode 100644 index 0000000..9a7c9c8 --- /dev/null +++ b/packages/player-react/src/components/icons/pause.tsx @@ -0,0 +1,27 @@ +import React, { JSX } from "react"; + +interface Props { + className?: string; +} + +export default function PauseIcon({ className = "" }: Props): JSX.Element { + return ( + + + + + ); +} diff --git a/packages/player-react/src/components/icons/play.tsx b/packages/player-react/src/components/icons/play.tsx new file mode 100644 index 0000000..56cb964 --- /dev/null +++ b/packages/player-react/src/components/icons/play.tsx @@ -0,0 +1,23 @@ +import React, { JSX } from "react"; + +interface Props { + className?: string; +} + +export default function PlayIcon({ className = "" }: Props): JSX.Element { + return ( + + + + ); +} diff --git a/packages/player-react/src/components/icons/search.tsx b/packages/player-react/src/components/icons/search.tsx new file mode 100644 index 0000000..8ce0d21 --- /dev/null +++ b/packages/player-react/src/components/icons/search.tsx @@ -0,0 +1,35 @@ +import React, { JSX } from "react"; + +interface Props { + className?: string; + color?: string; +} + +export default function SearchIcon({ + className = "", + color = "currentColor", +}: Props): JSX.Element { + return ( + + + + + ); +} diff --git a/packages/player-react/src/components/icons/volume-mute.tsx b/packages/player-react/src/components/icons/volume-mute.tsx new file mode 100644 index 0000000..f281279 --- /dev/null +++ b/packages/player-react/src/components/icons/volume-mute.tsx @@ -0,0 +1,29 @@ +import React, { JSX } from "react"; + +interface Props { + className?: string; +} + +export default function VolumeMuteIcon({ className = "" }: Props): JSX.Element { + return ( + + + + + ); +} diff --git a/packages/player-react/src/components/icons/volume.tsx b/packages/player-react/src/components/icons/volume.tsx new file mode 100644 index 0000000..db66e24 --- /dev/null +++ b/packages/player-react/src/components/icons/volume.tsx @@ -0,0 +1,33 @@ +import React, { JSX } from "react"; + +interface Props { + className?: string; +} + +export default function VolumeIcon({ className = "" }: Props): JSX.Element { + return ( + + + + + + ); +} diff --git a/packages/player-react/src/components/search/search-inside-media/index.tsx b/packages/player-react/src/components/search/search-inside-media/index.tsx new file mode 100644 index 0000000..c9673f9 --- /dev/null +++ b/packages/player-react/src/components/search/search-inside-media/index.tsx @@ -0,0 +1,364 @@ +import React, { JSX, useEffect, useRef, useState } from "react"; +import EllipsesLoading from "../../atoms/ellipse-loading"; +import Loading from "../../atoms/loading"; +import CloseIcon from "../../icons/close"; +import SearchIcon from "../../icons/search"; +import SearchResults from "../search-results"; +import "./styles.css"; +import { useVideoPlayerContext } from "../../../context"; +interface Suggestion { + text: string; +} + +interface SearchResultHit { + id: string | number; + start: number; + end: number; + type: string; + text: string; +} + +interface SearchResult { + hits: SearchResultHit[]; +} + +interface Highlight { + time: number; +} + +interface Props { + autoHide?: boolean; + isActive?: boolean; + isRaw?: boolean; + isAutoPilotLoading?: boolean; + searchContent: string; + searchResultsLoading?: boolean; + searchInputPlaceholder?: string; + wordsLoading?: boolean; + showSearchResults?: boolean; + searchSuggestions?: Suggestion[]; + searchResults?: SearchResult; + updateParentIsFocused?: (focused: boolean) => void; + highlights?: Highlight[]; + isLight?: boolean; + onSearchChange: (val: string) => void; + onSearchSubmit: (val: string) => void; + className?: string; + toggleResults?: (show: boolean) => void; + onInputFocused?: () => void; +} + +export default function SearchInsideMedia({ + autoHide = true, + isActive = true, + isRaw = false, + isAutoPilotLoading = false, + searchContent = "", + searchResultsLoading = false, + searchInputPlaceholder = "Search or ask a question", + wordsLoading = false, + showSearchResults = false, + searchSuggestions = [], + searchResults = { hits: [] }, + updateParentIsFocused = () => {}, + highlights = [], + isLight = false, + onSearchChange, + onSearchSubmit, + className = "", + toggleResults, + onInputFocused, +}: Props): JSX.Element { + const { playing, duration, seekTo, showElements, togglePlay } = + useVideoPlayerContext(); + const [isFocused, setIsFocused] = useState(false); + const searchInputRef = useRef(null); + const searchTopContainer = useRef(null); + const isUploadingScreen = !isActive && !isAutoPilotLoading && !isRaw; + const isAutoPilotLoadingScreen = !isActive && isAutoPilotLoading && !isRaw; + const isRawScreen = !isActive && !isAutoPilotLoading && isRaw; + const isFinalScreen = isActive && !isAutoPilotLoading && !isRaw; + + const handleInputFocus = () => { + setIsFocused(true); + onInputFocused?.(); + window.parent.postMessage("Search Focus", "*"); + }; + + const handleInputBlur = () => { + setIsFocused(false); + updateParentIsFocused(false); + }; + + const closeInput = () => { + onSearchChange(""); + searchInputRef.current?.blur(); + setIsFocused(true); + }; + + const handleSuggestionClick = (text: string) => { + onSearchChange(text); + searchInputRef.current?.focus(); + setIsFocused(true); + }; + + const showSearchSuggestions = + searchContent === "" && searchSuggestions.length > 0; + + useEffect(() => { + if (toggleResults) { + if (searchContent === "") { + toggleResults(false); + } else { + toggleResults(true); + } + } + }, [searchContent]); + + const onSlideClick = (value: string) => { + seekTo(Number(value)); + if (!playing) { + togglePlay(); + } + searchTopContainer.current?.blur(); + toggleResults?.(false); + return; + }; + + useEffect(() => { + if (searchContent === "") { + updateParentIsFocused(isFocused); + } else if (isFocused) { + toggleResults?.(true); + } + }, [isFocused, searchContent, updateParentIsFocused, toggleResults]); + + useEffect(() => { + const checkForIdInParentNodes = ( + element: EventTarget | null, + id: string, + ): boolean => { + let el = element as HTMLElement | null; + while (el?.parentNode) { + if (el.id === id) return true; + el = el.parentNode as HTMLElement | null; + } + return false; + }; + + const clickAwayListener = (event: MouseEvent) => { + const ids = [ + "videoPlay", + "videoPause", + "searchResultsContainer", + "searchInputWrapper", + ]; + const isAnyComp = ids.some((id) => + checkForIdInParentNodes(event.target, id), + ); + if (!isAnyComp) { + toggleResults?.(false); + } + }; + + document.addEventListener("mousedown", clickAwayListener); + return () => { + document.removeEventListener("mousedown", clickAwayListener); + }; + }, [toggleResults]); + + const prevShowSearchResultsRef = useRef(null); + useEffect(() => { + if ( + prevShowSearchResultsRef.current === false && + showSearchResults === true + ) { + toggleResults?.(true); + } + prevShowSearchResultsRef.current = showSearchResults; + }, [showSearchResults, toggleResults]); + return ( +
+
+
+
+ {!isAutoPilotLoading && !isRaw && !wordsLoading ? ( + onSearchChange(e.target.value)} + onKeyUp={(e) => + e.key === "Enter" && onSearchSubmit(searchContent) + } + disabled={!isActive || wordsLoading} + /> + ) : ( +
+ {wordsLoading ? ( + + + {searchInputPlaceholder} + + + ) : ( + + {searchInputPlaceholder} + + )} +
+ )} + +
+ {!isUploadingScreen && ( + + )} + {searchContent !== "" && ( + + )} +
+ + {/* Mobile Icons */} +
+ {!isUploadingScreen && searchContent === "" && ( + + )} + {!isUploadingScreen && searchContent !== "" && ( + + )} +
+
+ + {showSearchSuggestions && ( +
+
+ Popular Topics in this file +
+
+ {searchSuggestions.map((s, i) => ( +
handleSuggestionClick(s.text)} + > + {s.text} +
+ ))} +
+
+ )} + + {showSearchResults && ( +
+ {searchResultsLoading ? ( +
+ +
+ ) : searchResults.hits.length > 0 ? ( + + ) : ( +

+ No results found +

+ )} +
+ )} +
+
+
+ ); +} diff --git a/packages/player-react/src/components/search/search-inside-media/styles.css b/packages/player-react/src/components/search/search-inside-media/styles.css new file mode 100644 index 0000000..2581b4c --- /dev/null +++ b/packages/player-react/src/components/search/search-inside-media/styles.css @@ -0,0 +1,137 @@ +.iframe-video-header:not(.inactive) { + background: linear-gradient(180deg, + rgba(0, 0, 0, 0.4) 0%, + rgba(0, 0, 0, 0) 100%); +} + +.search-suggestions-wrapper { + max-height: 160px; + overflow-y: auto; +} + +.search-suggestion:last-child { + border-bottom: none; + padding-bottom: 0.25rem; +} + +::-webkit-scrollbar { + width: 4px !important; + height: 7px !important; +} + +::-webkit-scrollbar-thumb { + background: #353536; +} + +/* Truncate search results */ +.truncate-overflow { + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + overflow: hidden; + -webkit-box-orient: vertical; +} + +.search-suggestions { + max-width: 344px; + width: calc(100% - 24px); + z-index: 28; + transition: all 0.4s ease-in-out; +} + +/* Search input */ +.search-input-wrapper { + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.08); + transition: all 0.4s ease-in-out; + width: calc(100% - 56px); + max-width: 344px; + z-index: 28; +} + +.search-input-wrapper.is-focused { + border: 1px solid rgba(255, 255, 255, 0.32); + background: rgba(0, 0, 0, 0.32); +} + +.search-input-wrapper input::placeholder { + transition: all 0.4s ease-in-out; + text-align: left; +} + +.search-input-wrapper.is-focused input::placeholder { + color: rgba(255, 255, 255, 0.4); +} + +.search-input-uploading::placeholder { + font-weight: 400; + color: white; +} + +.search-input-autopiloting::placeholder { + color: #8ca8bd; + font-weight: 500; +} + +.search-input-autopiloting { + width: 220px; + max-width: 220px; +} + +.search-input-raw::placeholder { + color: white; + font-weight: 500; +} + +.search-input-done::placeholder { + color: white; +} + +/* Swiper highlight */ +.spext-player-search /deep/.swiper-slide .highlight { + display: inline-block; + position: relative; +} + +.spext-player-search /deep/.swiper-slide .highlight::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + border-radius: 4px; + z-index: -1; + background: #e2462c; +} + +.searchTopContainer { + width: 100%; + height: 100%; +} + +.searchTopContainer:focus-within .search-suggestions { + display: block; +} + +@media (max-width: 639px) { + .search-input-wrapper-done { + max-width: unset; + width: calc(100% - 80px); + } + + .search-input-wrapper-done-full { + max-width: unset; + width: calc(100% - 40px); + } + + .search-suggestions { + max-width: unset; + width: calc(100% - 40px); + } + + .search-input-wrapper input::placeholder { + transition: all 0.4s ease-in-out; + text-align: left; + } +} \ No newline at end of file diff --git a/packages/player-react/src/components/search/search-result-slide/index.tsx b/packages/player-react/src/components/search/search-result-slide/index.tsx new file mode 100644 index 0000000..8f2e2e0 --- /dev/null +++ b/packages/player-react/src/components/search/search-result-slide/index.tsx @@ -0,0 +1,133 @@ +import React, { FC, useMemo } from "react"; +import PlayIcon from "../../icons/play"; +import "./styles.css"; + +interface SearchResultItem { + start: number; + id: string | number; + text: string; + type: string; + end?: number; +} + +interface SearchResultSlideProps { + searchContent?: string; + searchResultItem: SearchResultItem; + searchResultItemIndex?: number; + isLight?: boolean; +} + +function SearchResultSlide({ + searchContent = "", + searchResultItem, + searchResultItemIndex = 0, + isLight = true, +}: SearchResultSlideProps) { + const i = searchResultItemIndex; + + const startIsoString = useMemo( + () => + searchResultItem.type === "relevant" && + typeof searchResultItem.start === "number" + ? new Date(Math.floor(searchResultItem.start) * 1000).toISOString() + : null, + [searchResultItem.type, searchResultItem.start], + ); + + const endIsoString = useMemo( + () => + searchResultItem.type === "relevant" && + typeof searchResultItem.end === "number" + ? new Date(Math.floor(searchResultItem.end) * 1000).toISOString() + : null, + [searchResultItem.type, searchResultItem.end], + ); + + const wrapSpan = (strReplace: string): string => { + const trimmedSearchContent = searchContent.trim(); + if (!trimmedSearchContent) { + return strReplace; + } + const searchMask = trimmedSearchContent.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + const regEx = new RegExp(searchMask, "ig"); + const replaceMask = `${trimmedSearchContent}`; + + return strReplace.replace(regEx, replaceMask); + }; + + return ( +
+
+

+ +

+
+
+ +

+ {startIsoString && + (searchResultItem.start < 3600 + ? startIsoString.substring(14, 19) + : startIsoString.substring(11, 19))} + {endIsoString ? " - " : ""} + {endIsoString && + (searchResultItem.end! < 3600 + ? endIsoString.substring(14, 19) + : endIsoString.substring(11, 19))} +

+
+

+ {searchResultItem.type} +

+
+
+
+ ); +} + +export default SearchResultSlide; diff --git a/packages/player-react/src/components/search/search-result-slide/styles.css b/packages/player-react/src/components/search/search-result-slide/styles.css new file mode 100644 index 0000000..2e30b9f --- /dev/null +++ b/packages/player-react/src/components/search/search-result-slide/styles.css @@ -0,0 +1,147 @@ +.sr-swiper .swiper-container { + padding: 2rem; +} + +.sr-swiper.light .swiper-container { + padding: 0.5rem; +} + +.swiper-truncate-overflow { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + line-clamp: 3; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + word-break: break-word; +} + +.sr-timeline-bg { + background: rgba(0, 0, 0, 0.2); + border-radius: 30px; +} + +.sr-timeline-bg.light { + background: #f3f4f6; + border: 1px solid #e5e7eb; + border-radius: 30px; +} + +.swiper-slide.sr { + height: 128px; + padding: 10px; + background-color: transparent; + border-radius: 8px; +} + +.swiper-slide.sr:hover { + background: rgba(12, 12, 12, 0.4); +} + +.swiper-slide.sr.light:hover { + background: #eceff3; +} + +.swiper-slide.sr:hover .relevant-text-br { + color: #cde210; +} + +.swiper-slide.sr.light:hover .relevant-text-br { + color: #53b745; +} + +.swiper-slide.sr:hover .exact-text-br { + color: #f8c450; +} + +.swiper-slide.sr:hover .swiper-time.light { + background-color: #fff; +} + + +.swiper-time { + width: fit-content; + background-color: #323232; + border-radius: 4px; + padding: 6px; + display: flex; + align-items: center; +} + +.swiper-time.light { + background-color: #eceff3; +} + +.swiper-time>svg { + width: 14px; + height: auto; + margin-right: 4px; + flex-shrink: 0; +} + +.text-lime { + color: #cde210; + background-color: transparent; +} + +.text-lime.light { + color: initial; + background-color: #53b745; + padding: 0 2px; + border-radius: 2px; +} + +.text-yellow { + color: rgba(248, 196, 80, 1); + background-color: transparent; +} + +.text-yellow.light { + color: initial; + background-color: rgba(248, 196, 80, 0.3); + padding: 0 2px; + border-radius: 2px; +} + + +.bg-lime { + background-color: #cde210; +} + +.bg-lime.light { + background-color: #53b745; +} + +.sr-dot { + box-sizing: content-box; +} + +.sr-dot.active:not(.light) { + outline: 1px solid rgba(0, 0, 0, 0.35); +} + +.sr-dot.light { + outline: 1px solid rgba(255, 255, 255, 1); +} + +.sr-dot.relevant:not(.light):hover, +.sr-dot.forced-hover.relevant:not(.light) { + outline: 7px solid rgba(180, 194, 54, 0.35); + box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.53); +} + +.sr-dot.exact:not(.light):hover, +.sr-dot.forced-hover.exact:not(.light) { + outline: 7px solid rgba(248, 196, 80, 0.35); + box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.53); +} + +.sr-dot.light.relevant:hover, +.sr-dot.forced-hover.light.relevant { + outline: 7px solid rgba(83, 183, 69, 0.35); + box-shadow: 0px 1px 4px rgba(255, 255, 255, 0.53); +} + +.swiper-time-light-text { + color: #374151; +} \ No newline at end of file diff --git a/packages/player-react/src/components/search/search-results/index.tsx b/packages/player-react/src/components/search/search-results/index.tsx new file mode 100644 index 0000000..bcac6bd --- /dev/null +++ b/packages/player-react/src/components/search/search-results/index.tsx @@ -0,0 +1,376 @@ +import React, { FC, useCallback, useEffect, useRef, useState } from "react"; +import type { Swiper as SwiperCore } from "swiper"; +import { Manipulation, Mousewheel, Navigation, Virtual } from "swiper/modules"; +import { Swiper, SwiperSlide } from "swiper/react"; +import "./styles.css"; +import "swiper/css"; + +import ArrowLeftIcon from "../../icons/arrow-left"; +import ArrowRightIcon from "../../icons/arrow-right"; +import SearchResultSlide from "../search-result-slide"; + +interface SearchResultHit { + id: string | number; + start: number; + end: number; + type: string; + text: string; +} + +interface SearchResultsData { + hits?: SearchResultHit[]; +} + +interface Highlight { + time: number; +} + +interface Props { + bg?: string; + duration?: number; + searchResults?: SearchResultsData; + onSlideClick?: (value: string) => void; + highlights?: Highlight[]; + theme?: string; + searchContent?: string; + isLight?: boolean; +} + +const SearchResults: FC = ({ + bg = "255,255,255", + duration = 0.01, + searchResults = { hits: [] }, + onSlideClick = () => {}, + highlights = [], + theme = "yellow", + searchContent = "", + isLight = false, +}) => { + const [hoveredSlide, setHoveredSlide] = useState( + null, + ); + const [activeBullets, setActiveBullets] = useState([]); + const swiperContainerRef = useRef(null); + const [isBeginning, setIsBeginning] = useState(true); + const [isEnd, setIsEnd] = useState(false); + const swiperInstanceRef = useRef(null); + const swiperModules = [Navigation, Mousewheel, Virtual, Manipulation]; + + const getLeftValue = (startTime: number, totalDuration: number): number => { + const val = (startTime * 100) / totalDuration; + if (val < 0.5) return 0.5; + return val > 99.5 ? 99.5 : val; + }; + + const isRelevant = (searchResult: SearchResultHit): boolean => { + return searchResult.type === "relevant"; + }; + + const getSearchResultsItemWidth = (srItem: SearchResultHit): string => { + const itemDuration = srItem.end - srItem.start; + const width = `${(itemDuration / duration) * 100}%`; + return width; + }; + + const updateActiveBullets = useCallback( + (swiper: SwiperCore | null) => { + if (!swiper) return; + const newActiveBullets: number[] = []; + const currentBreakpoint = swiper.currentBreakpoint; + const breakpoints = swiper.params.breakpoints; + const slidesPerView = + breakpoints && + currentBreakpoint && + breakpoints[currentBreakpoint]?.slidesPerView + ? breakpoints[currentBreakpoint]!.slidesPerView! + : swiper.params.slidesPerView; + + const activeSlideCount = slidesPerView + ? Math.round(Number(slidesPerView)) + : 1; + + for ( + let i = swiper.activeIndex; + i < swiper.activeIndex + activeSlideCount; + i++ + ) { + if (i < (searchResults?.hits?.length ?? 0)) { + newActiveBullets.push(i); + } + } + setActiveBullets(newActiveBullets); + }, + [searchResults?.hits?.length], + ); + + const onSwiperSlideChangeTransitionStart = useCallback( + (swiper: SwiperCore) => { + setIsBeginning(swiper.isBeginning); + setIsEnd(swiper.isEnd); + updateActiveBullets(swiper); + }, + [updateActiveBullets], + ); + + const setUpSlideMouseEvents = useCallback(() => { + const swiperElement = swiperContainerRef.current; + if (!swiperElement) return; + + const slides = swiperElement.querySelectorAll(".swiper-slide"); + + if (slides && slides.length) { + slides.forEach((slide) => { + const handleMouseEnter = (e: MouseEvent) => { + const target = e.currentTarget as HTMLElement; + const id = target.getAttribute("data-id"); + setHoveredSlide(id || null); + }; + const handleMouseLeave = () => { + setHoveredSlide(null); + }; + + slide.removeEventListener("mouseenter", handleMouseEnter); + slide.removeEventListener("mouseleave", handleMouseLeave); + + slide.addEventListener("mouseenter", handleMouseEnter); + slide.addEventListener("mouseleave", handleMouseLeave); + }); + } + return () => { + slides.forEach((slide) => { + slide.removeEventListener("mouseenter", () => {}); + slide.removeEventListener("mouseleave", () => {}); + }); + }; + }, []); + + useEffect(() => { + const cleanup = setUpSlideMouseEvents(); + return cleanup; + }, [searchResults?.hits, setUpSlideMouseEvents]); + + const getSwiperRef = (swiper: SwiperCore) => { + swiperInstanceRef.current = swiper; + if (swiper) { + setIsBeginning(swiper.isBeginning); + setIsEnd(swiper.isEnd); + updateActiveBullets(swiper); + setUpSlideMouseEvents(); + } + }; + + const reachBeginning = (swiper: SwiperCore) => { + setIsBeginning(swiper.isBeginning); + }; + + const reachEnd = (swiper: SwiperCore) => { + setIsEnd(swiper.isEnd); + }; + + const goToSlide = (slideIndex: number) => { + if (swiperInstanceRef.current) { + swiperInstanceRef.current.slideTo(slideIndex); + } + }; + + const changeSlide = (type: "prev" | "next") => { + if (!swiperInstanceRef.current || !searchResults?.hits?.length) return; + + const swiper = swiperInstanceRef.current; + + if (type === "next") { + const lastVisibleIndex = + activeBullets.length > 0 + ? activeBullets[activeBullets.length - 1] + : swiper.activeIndex; + const slideToMove = lastVisibleIndex + 1; + goToSlide(Math.min(searchResults.hits.length - 1, slideToMove)); + } else if (type === "prev") { + const firstVisibleIndex = + activeBullets.length > 0 ? activeBullets[0] : swiper.activeIndex; + const currentBreakpoint = swiper.currentBreakpoint; + const breakpoints = swiper.params.breakpoints; + const slidesPerView = + breakpoints && + currentBreakpoint && + breakpoints[currentBreakpoint]?.slidesPerView + ? breakpoints[currentBreakpoint]!.slidesPerView! + : swiper.params.slidesPerView; + const slidesToMoveBack = slidesPerView + ? Math.floor(Number(slidesPerView)) + : 1; + + const slideToMove = firstVisibleIndex - slidesToMoveBack; + goToSlide(Math.max(0, slideToMove)); + } + }; + + const swiperClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + let value = target.getAttribute("data-seconds"); + + if (!value && target.parentElement) { + value = target.parentElement.getAttribute("data-seconds"); + } + + if (value && onSlideClick) { + onSlideClick(value); + } + }; + + return ( + <> +
+
+
+ {!isLight && + highlights?.map((item, index) => ( +
+ +
+ + {searchResults.hits?.map((slideData, index) => ( + + + + ))} + + + {!isBeginning && ( + <> +
+ + + )} + + {!isEnd && ( + <> +
+ + + )} +
+ + ); +}; + +export default SearchResults; diff --git a/packages/player-react/src/components/search/search-results/styles.css b/packages/player-react/src/components/search/search-results/styles.css new file mode 100644 index 0000000..d519624 --- /dev/null +++ b/packages/player-react/src/components/search/search-results/styles.css @@ -0,0 +1,124 @@ +.sr-swiper .swiper { + padding: 2rem; +} + +.sr-swiper.light .swiper { + padding: 0.5rem; +} + +.swiper-truncate-overflow { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + line-clamp: 3; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.sr-timeline-bg { + background: rgba(0, 0, 0, 0.2); + border-radius: 30px; +} + +.sr-timeline-bg.light { + background: #f3f4f6; + border: 1px solid #e5e7eb; + border-radius: 30px; +} + +.swiper-slide.sr { + height: 128px; + padding: 10px; +} + +.swiper-slide.sr:hover { + background: rgba(12, 12, 12, 0.4); +} + +.swiper-slide.sr.light:hover { + background: #eceff3; +} + +.swiper-slide.sr:hover .relevant-text-br { + color: #cde210; +} + +.swiper-slide.sr.light:hover .relevant-text-br { + color: #53b745; +} + +.swiper-slide.sr:hover .exact-text-br { + color: #f8c450; +} + +.swiper-slide.sr:hover .swiper-time.light { + background-color: #fff; +} + +.swiper-time { + width: fit-content; + background-color: #323232; + border-radius: 4px; + padding: 6px; + display: flex; +} + +.swiper-time.light { + background-color: #eceff3; +} + +.swiper-time>img { + width: 14px; + height: auto; + margin-right: 4px; +} + +.text-lime { + color: #cde210; +} + +.text-lime.light { + background-color: #53b745; +} + +.bg-lime { + background-color: #cde210; +} + +.bg-lime.light { + background-color: #53b745; +} + +.sr-dot { + box-sizing: content-box; +} + +.sr-dot.active:not(.light) { + outline: 1px solid rgba(0, 0, 0, 0.35); +} + +.sr-dot.light { + outline: 1px solid rgba(255, 255, 255, 1); +} + +.sr-dot.relevant:not(.light):hover, +.sr-dot.forced-hover.relevant:not(.light) { + outline: 7px solid rgba(180, 194, 54, 0.35); + box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.53); +} + +.sr-dot.exact:not(.light):hover, +.sr-dot.forced-hover.exact:not(.light) { + outline: 7px solid rgba(248, 196, 80, 0.35); + box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.53); +} + +.sr-dot.light.relevant:hover, +.sr-dot.forced-hover.light.relevant { + outline: 7px solid rgba(83, 183, 69, 0.35); + box-shadow: 0px 1px 4px rgba(255, 255, 255, 0.53); +} + +.swiper-time-light-text { + color: #374151; +} \ No newline at end of file diff --git a/packages/player-react/src/components/videos/progress-bar/index.css b/packages/player-react/src/components/videos/progress-bar/index.css new file mode 100644 index 0000000..503f267 --- /dev/null +++ b/packages/player-react/src/components/videos/progress-bar/index.css @@ -0,0 +1,108 @@ +.chapter { + position: relative; + background-color: rgba(255, 255, 255, 0.2); + height: 6px; + transition: all 0.2s ease-in-out; +} + +.chaptersCont { + min-height: 10px; +} + +.progress-bar .main-timeline .chapter.manualSlid { + background-color: rgb(251, 220, 150, 0.5); +} + +.progress-bar .main-timeline .chapter.manualSlid.active { + background-color: #f8c450; +} + +.progress-bar .main-timeline .chapter.active { + /* backdrop-filter: blur(16px); */ + /* background: rgba(0, 0, 0, 0.24); */ + height: 10px; +} + +.chapter:hover { + height: 10px; +} + +/* .progress-bar:hover .head { + opacity: 1; + } */ + +.head:hover { + border-width: 2px; + width: 14px; + height: 14px; +} + +/* + @media (any-hover: none) { + .head { + opacity: 1 !important; + } + } */ + +@media (max-width: 641px) { + .chapter { + height: 4px; + } + + .progress-bar .main-timeline .chapter.active { + height: 6px; + } + + .chaptersCont { + min-height: 6px; + } +} + +.summary-card { + /* height: 20vw; + max-height: 220px; + min-width: 280px; */ + + height: auto; + max-height: 220px; + width: auto; + max-width: 280px; +} + +.popover-width { + width: 130px; +} + +.three-line-ellipses { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + line-clamp: 3; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.title-blur { + background: rgba(20, 20, 20, 0.39); + backdrop-filter: blur(24px); +} + +.title-blur { + text-shadow: 0px 4px 4px rgba(0, 0, 0, 0.4); +} + +.chapter-title-cont { + height: 62px; +} + +.chapter-title-cont>p { + font-size: 11px; + line-height: 16px; +} + +@media (max-width: 640px) { + .chapter-title-cont { + width: 130%; + transform: translateX(-12.5%); + } +} \ No newline at end of file diff --git a/packages/player-react/src/components/videos/progress-bar/index.tsx b/packages/player-react/src/components/videos/progress-bar/index.tsx new file mode 100644 index 0000000..2366938 --- /dev/null +++ b/packages/player-react/src/components/videos/progress-bar/index.tsx @@ -0,0 +1,625 @@ +import debounce from "lodash.debounce"; +import React, { + JSX, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import videojs from "video.js"; +import Player from "video.js/dist/types/player"; +import { useVideoPlayerContext } from "../../../context"; +import "./index.css"; + +interface Chapter { + id?: string | number; + start: number; + length: number; + title?: string; + end?: number; +} + +interface Props { + isActive?: boolean; + autoHide?: boolean; + streamUrl?: string; + chaptersList?: Chapter[]; + persistentChapter?: boolean; + manualSlide?: number | null; + activeChapterProp?: string | null; + showChapters?: boolean; + previewPlayer?: string; + isChapterExpanded?: boolean; + setActiveChapter?: (chapterIndex: number | null) => void; + hideChapterSummary?: (isVisible: boolean) => void; + setPreviewVisibility?: (isVisible: boolean) => void; +} + +export default function ProgressBar({ + isActive = true, + autoHide = true, + streamUrl = "", + chaptersList = [], + persistentChapter = false, + manualSlide = null, + activeChapterProp: activeChapterPropRaw = null, + showChapters = false, + isChapterExpanded = false, + setActiveChapter = () => {}, + hideChapterSummary = () => {}, + setPreviewVisibility = () => {}, +}: Props): JSX.Element { + const { time, duration, seekTo, showElements } = useVideoPlayerContext(); + + const feedbackPlayerRef = useRef(null); + const progressBarRef = useRef(null); + const videoPreviewRef = useRef(null); + const progressBarInteractRef = useRef(null); + const videoFeedbackPlayerRef = useRef(null); + + const [moveToRatio, setMoveToRatio] = useState(0); + const [videoDrag, setVideoDrag] = useState(false); + const [seekGoToPoint, setSeekGoToPoint] = useState({ + ratio: 0, + timePosition: 0, + }); + const [showSummaryInfo, setShowSummaryInfo] = useState(true); + const [activeChapterLocal, setActiveChapterLocal] = useState( + activeChapterPropRaw !== null ? Number(activeChapterPropRaw) : null, + ); + const [leftOffset, setLeftOffset] = useState(0); + + useEffect(() => { + setActiveChapterLocal( + activeChapterPropRaw !== null ? Number(activeChapterPropRaw) : null, + ); + }, [activeChapterPropRaw]); + + useEffect(() => { + setActiveChapter(activeChapterLocal); + }, [activeChapterLocal, setActiveChapter]); + + useEffect(() => { + if (typeof window !== "undefined") { + const isWide = window.innerWidth > 640; + const maxRatio = isWide ? 11 / 12 : 10.5 / 12; + const minRatio = isWide ? 1 / 12 : 1.5 / 12; + const clampedRatio = Math.max(Math.min(moveToRatio, maxRatio), minRatio); + setLeftOffset(clampedRatio * 100); + } + }, [moveToRatio]); + + const videoDateVal = useMemo(() => { + if (!duration || !moveToRatio) return 0; + return moveToRatio * duration * 1000; + }, [duration, moveToRatio]); + + const shouldNotShowVideoPopover = useMemo(() => { + if (typeof window === "undefined") return true; + if (showChapters) { + return ( + activeChapterLocal === null || + moveToRatio === 0 || + (window.innerWidth < 640 && persistentChapter) + ); + } + return moveToRatio === 0; + }, [showChapters, activeChapterLocal, moveToRatio, persistentChapter]); + + const summaryCardVisible = useMemo(() => { + const overlapThreshold = isChapterExpanded ? 0.22 : 0.52; + if (activeChapterPropRaw === null) return false; + if (persistentChapter) { + return moveToRatio > overlapThreshold; + } + return true; + }, [isChapterExpanded, activeChapterPropRaw, persistentChapter, moveToRatio]); + + const isSummaryCardOverlapping = useMemo(() => { + return summaryCardVisible && persistentChapter; + }, [summaryCardVisible, persistentChapter]); + + useEffect(() => { + hideChapterSummary(isSummaryCardOverlapping); + }, [isSummaryCardOverlapping, hideChapterSummary]); + + const summaryHiddenOnceRef = useRef(false); + useEffect(() => { + if (moveToRatio > 0 && showSummaryInfo && !summaryHiddenOnceRef.current) { + summaryHiddenOnceRef.current = true; + const timer = setTimeout(() => { + setShowSummaryInfo(false); + }, 2000); + return () => clearTimeout(timer); + } + }, [moveToRatio, showSummaryInfo]); + + useEffect(() => { + setPreviewVisibility(moveToRatio > 0); + }, [moveToRatio, setPreviewVisibility]); + + useEffect(() => { + const getSource = () => { + const src = streamUrl; + if (!src) return null; + let type = "video/mp4"; + if (src.endsWith("m3u8")) { + type = "application/x-mpegURL"; + } + return { src, type }; + }; + const source = getSource(); + + if (videoFeedbackPlayerRef.current && source) { + if (!feedbackPlayerRef.current) { + feedbackPlayerRef.current = videojs(videoFeedbackPlayerRef.current, { + autoplay: false, + controls: false, + currentTime: 0, + html5: { nativeTextTracks: false }, + preload: "auto", + sources: [source], + }); + } + } + return () => { + if ( + feedbackPlayerRef.current && + !feedbackPlayerRef.current.isDisposed() + ) { + feedbackPlayerRef.current.dispose(); + feedbackPlayerRef.current = null; + } + }; + }, [streamUrl]); + + const findCurrentChapter = useCallback( + (timeToCheck: number): number | null => { + if (duration === 0) return null; + const referenceTime = Math.min(Math.max(timeToCheck, 0), duration); + for (let i = 0; i < chaptersList.length; i++) { + const chapter = chaptersList[i]; + if ( + chapter.start <= referenceTime && + chapter.start + chapter.length > referenceTime + ) { + return i; + } + } + return null; + }, + [chaptersList, duration], + ); + + const updateFeedbackPlayer = useCallback( + debounce((t: number) => { + if ( + feedbackPlayerRef.current && + !feedbackPlayerRef.current.isDisposed() + ) { + feedbackPlayerRef.current.currentTime(t); + } + }, 300), + [], + ); + + const seekVideoTimeCallback = useCallback( + (x: number) => { + if (!progressBarRef.current || duration === 0) { + return; + } + const rect = progressBarRef.current.getBoundingClientRect(); + let position = x - rect.left; + const totalWidth = rect.width; + + position = Math.max(0, Math.min(position, totalWidth)); + + const newRatio = position / totalWidth; + const newTimePosition = Math.round(duration * newRatio); + + setSeekGoToPoint({ + ratio: newRatio, + timePosition: newTimePosition, + }); + }, + [duration, setSeekGoToPoint], + ); + + const handleDragStartMouseDown = useCallback( + (e: React.MouseEvent) => { + setVideoDrag(true); + seekVideoTimeCallback(e.pageX); + }, + [seekVideoTimeCallback, setVideoDrag], + ); + + const handleDragStartTouchStart = useCallback( + (e: React.TouchEvent) => { + if (e.touches.length > 0) { + setVideoDrag(true); + seekVideoTimeCallback(e.touches[0].clientX); + } + }, + [seekVideoTimeCallback, setVideoDrag], + ); + + // Effect for managing global event listeners during drag + useEffect(() => { + const handleWindowMouseMove = (e: MouseEvent) => { + seekVideoTimeCallback(e.pageX); + }; + + const handleWindowTouchMove = (e: TouchEvent) => { + if (e.touches.length > 0) { + seekVideoTimeCallback(Math.floor(e.touches[0].clientX)); + } + }; + + const handleWindowMouseUp = () => { + setVideoDrag(false); + seekTo(seekGoToPoint.timePosition); + if (persistentChapter) { + const chapter = findCurrentChapter(seekGoToPoint.timePosition); + setActiveChapterLocal(chapter); + } + }; + + const handleWindowTouchEnd = () => { + setVideoDrag(false); + seekTo(Math.floor(seekGoToPoint.timePosition)); + setMoveToRatio(0); // Reset hover ratio + if (!persistentChapter) { + setActiveChapterLocal(null); + } + }; + + if (videoDrag) { + window.addEventListener("mousemove", handleWindowMouseMove); + window.addEventListener("touchmove", handleWindowTouchMove, { + passive: true, + }); // passive: false if preventDefault is used + window.addEventListener("mouseup", handleWindowMouseUp); + window.addEventListener("touchend", handleWindowTouchEnd); + + return () => { + window.removeEventListener("mousemove", handleWindowMouseMove); + window.removeEventListener("touchmove", handleWindowTouchMove); + window.removeEventListener("mouseup", handleWindowMouseUp); + window.removeEventListener("touchend", handleWindowTouchEnd); + }; + } + }, [ + videoDrag, + seekVideoTimeCallback, + seekTo, + persistentChapter, + findCurrentChapter, + setActiveChapterLocal, + setMoveToRatio, + seekGoToPoint, // Ensure latest seekGoToPoint is used in up/end handlers + ]); + + // --- Hover and Click Logic on progressBarInteract --- + const handleMouseMoveForHover = useCallback( + (e: React.MouseEvent) => { + if (!progressBarRef.current || duration === 0) return; + const rect = progressBarRef.current.getBoundingClientRect(); + const tempRatio = (e.clientX - rect.x) / rect.width; + + const currentMoveToRatio = Math.min(Math.max(0, tempRatio), 1); // Clamp between 0 and 1 + setMoveToRatio(currentMoveToRatio); + + const newTime = duration * currentMoveToRatio; + updateFeedbackPlayer(newTime); + + if (chaptersList.length > 0) { + setActiveChapterLocal(findCurrentChapter(newTime)); + } else { + setActiveChapterLocal(null); // Vue had `true`, null seems more appropriate + } + }, + [ + duration, + updateFeedbackPlayer, + chaptersList, + findCurrentChapter, + setMoveToRatio, + setActiveChapterLocal /* progressBarRef is stable */, + ], + ); + + const handleMouseLeaveForHover = useCallback(() => { + // Reset active chapter and hover ratio if not dragging + if (!videoDrag) { + // Only reset if not in midst of a drag + setActiveChapterLocal(null); + setMoveToRatio(0); + // Potentially reset showSummaryInfo if it should reappear + // setShowSummaryInfo(true); + // summaryHiddenOnceRef.current = false; + } + }, [setActiveChapterLocal, setMoveToRatio, videoDrag]); + + const handleTouchMoveOnProgressBar = useCallback( + (e: React.TouchEvent) => { + if (e.touches.length > 0 && progressBarRef.current && duration > 0) { + e.preventDefault(); // Prevent scrolling while scrubbing preview + const clientX = e.touches[0].clientX; + const rect = progressBarRef.current.getBoundingClientRect(); + const tempRatio = (clientX - rect.left) / rect.width; + + const currentMoveToRatio = Math.min(Math.max(0, tempRatio), 1); + setMoveToRatio(currentMoveToRatio); + + const newTime = duration * currentMoveToRatio; + updateFeedbackPlayer(newTime); + + if (chaptersList.length > 0) { + setActiveChapterLocal(findCurrentChapter(newTime)); + } else { + setActiveChapterLocal(null); + } + } + }, + [ + duration, + updateFeedbackPlayer, + chaptersList, + findCurrentChapter, + setMoveToRatio, + setActiveChapterLocal /* progressBarRef stable */, + ], + ); + + const handleProgressClick = useCallback(() => { + if (duration > 0) { + const goTo = duration * moveToRatio; // moveToRatio is from hover state + seekTo(goTo); + } + }, [duration, moveToRatio, seekTo]); + + const getActiveChapterTitle = useCallback(() => { + if (activeChapterLocal === null || !chaptersList[activeChapterLocal]) + return ""; + return chaptersList[activeChapterLocal]?.title || ""; + }, [chaptersList, activeChapterLocal]); + + const isManuallySlid = (key: number) => { + return persistentChapter && manualSlide === key && moveToRatio === 0; + }; + + const isActiveChapter = (key: number) => { + // Vue: isActiveChapter(key) && (persistentChapter && manualSlide && !moveToRatio ? isManuallySlid(key) : true)) || isManuallySlid(key) + // This logic in React becomes: + const currentlyActive = activeChapterLocal === key; + const manuallySlid = isManuallySlid(key); + + if (manuallySlid) return true; + if (currentlyActive) { + if ( + persistentChapter && + manualSlide !== null && + manualSlide <= key && + moveToRatio === 0 + ) { + // Check this condition. Vue had manualSlide >= key for manualSlid class + return isManuallySlid(key); // Which means if manualSlide is not key, it's false. So it means only if it *is* manually slid. + } + return true; + } + return false; + }; + + const headLeftPercent = videoDrag + ? seekGoToPoint.ratio * 100 + : duration > 0 + ? (time / duration) * 100 + : 0; + + return ( +
+