diff --git a/examples/README.md b/examples/README.md index 49f383e..b1528eb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,6 +36,7 @@ Note that some starter examples include the creation of a `brightsign-dumps` fol | [send-plugin-message](browser/send-plugin-message) | Plugin message communication between BrightScript and HTML/JS | 8.x, 9.x | | [syncmanager-js](browser/syncmanager-js) | Multi-player content synchronization using SyncManager JS API | 8.x, 9.x | | [large-file-download](browser/large-file-download) | Memory-bounded multi-GB download using Node streams in roHtmlWidget | 8.x, 9.x | +| [seamless-video-switching](browser/seamless-video-switching) | Gap-free HTML5 video playback using dual video elements with background preloading; reads asset list via Node `fs` in roHtmlWidget | 8.x, 9.x | ### Node.js 14 Examples (`node-14/`) diff --git a/examples/browser/seamless-video-switching/README.md b/examples/browser/seamless-video-switching/README.md new file mode 100644 index 0000000..04c127b --- /dev/null +++ b/examples/browser/seamless-video-switching/README.md @@ -0,0 +1,94 @@ +# BrightSign Dual Video Player HTML5 Application - Seamless Playback + +> A seamless HTML5 video player application for BrightSign that provides gap-free video transitions using dual video elements with background preloading. + +## 🎯 Why Use This Approach? + +**This dual video player provides truly seamless, gap-free video playback** - essential for advertising and professional digital signage where any visible gap between videos is unacceptable. + +### The Dual Player Advantage: +- ✅ **Zero visible gaps** - No freeze frames or black screens between videos +- ✅ **Instant transitions** - Next video is preloaded and ready to play +- ✅ **No fade effects** - Clean cuts between videos without mixing content + +### How It Works: +1. Two video elements are layered on top of each other +2. While video 1 plays, video 2 loads the next file in the background +3. When video 1 ends, video 2 instantly becomes visible and starts playing +4. Video 1 (now hidden) loads the next file in the background +5. The cycle repeats for seamless continuous playback + +## Configuration + +You can customize the following settings in `index.js` before deploying: + +- **`rootStoragePath`** - The root storage path on the BrightSign player (default: `/storage/sd`) +- **`assetsFolder`** - The folder name containing your video files (default: `assets`) +- **`MULTI_DECODER`** - Set to `true` on players with multiple hardware decode pipelines to enable near-zero-gap transitions (default: `false`). See [Multi-decoder mode](#multi-decoder-mode) below. +- **`EARLY_START_LEAD_SECONDS`** - Multi-decoder only. How many seconds before the visible video ends to start the hidden player (default: `0.5`). Raise if the swap reveals unrendered frames; lower for a tighter cut. + +Example: +```javascript +const rootStoragePath = '/storage/sd'; +const assetsFolder = 'assets'; // Change this to use a different folder name +const MULTI_DECODER = false; // Set to true on XT/4K series players +const EARLY_START_LEAD_SECONDS = 0.5; // Multi-decoder only +``` + +If you change `assetsFolder` to a different name (e.g., `videos`), make sure to create that folder on your SD card and place your video files there instead. + +## Multi-decoder mode + +On BrightSign players with multiple hardware decode pipelines (mid-to-high-end XT and 4K series), set `MULTI_DECODER = true` in `index.js` to enable two optimizations that further reduce the transition gap: + +1. **Decoder pre-warming** — after the hidden player buffers (`canplay`), it plays briefly then pauses at frame 0. This initializes the hardware decode pipeline so `play()` at switch time starts near-instantly. +2. **Early start** — the hidden player begins playing (muted, hidden) `EARLY_START_LEAD_SECONDS` before the visible video ends (default 0.5s). By the time `ended` fires, the hidden player is already producing frames and the swap is immediate. + +If the early start does not complete in time, the swap falls back to awaiting the `playing` event, preventing a swap onto an unrendered frame. + +**Do not enable `MULTI_DECODER = true` on single-decoder hardware** — pre-warming and early start will compete with the active player's decoder and may cause stuttering. Leave `MULTI_DECODER = false` for the default single-decoder behavior. Refer to the [BrightSign model and series reference](https://docs.brightsign.biz/hardware/model-and-series-reference) to check your player's capabilities. + +| | `MULTI_DECODER = false` (default) | `MULTI_DECODER = true` | +|---|---|---| +| Hardware | Any BrightSign player | Multi-decoder required (XT, 4K) | +| Gap at transition | Small gap (decoder handoff) | Near-zero | +| Simultaneous decoding | No | Yes | + +## Deployment to BrightSign Player + +### SD Card Structure + +Your BrightSign player's SD card should have the following structure: + +``` +SD/ +├── autorun.brs (launches index.html) +├── index.html (loads index.js) +├── index.js (application logic) +└── assets/ (your video files) + ├── video1.mp4 + ├── video2.mp4 + └── video3.ts +``` + +### Deployment Steps + +1. Copy the following files to the root of your SD card: + - `autorun.brs` + - `index.html` + - `index.js` +2. Create an `assets/` folder on the SD card +3. Copy your video files into the `assets/` folder +4. Insert the SD card into your BrightSign player and power it on + +The application will automatically: +- Load video files from `/storage/sd/assets/` +- Sort them alphabetically +- Play them in sequence with seamless transitions +- Loop back to the first video after the last one finishes + +## Notes + +- **Supported video formats**: See [BrightSign video formats and codecs](https://docs.brightsign.biz/advanced/video-formats-and-codecs) for the list of containers, codecs, and profiles supported by each player series. +- **Performance**: Playback smoothness and switching behavior may vary depending on player model, OS version, video mode (resolution/framerate), and the encoding of your source files. Adjust the code (e.g., preload behavior, number of preloaded players) to fit your specific use-case. +- **Early start threshold (multi-decoder mode)**: Adjust `EARLY_START_LEAD_SECONDS` (default `0.5`) to trade content budget against transition reliability on your specific hardware and content type. diff --git a/examples/browser/seamless-video-switching/architecture.md b/examples/browser/seamless-video-switching/architecture.md new file mode 100644 index 0000000..488f4b9 --- /dev/null +++ b/examples/browser/seamless-video-switching/architecture.md @@ -0,0 +1,61 @@ +# Architecture Diagram + +```mermaid +graph TD + Player["BrightSign Player"] + Autorun["autorun.brs
(BrightScript)"] + HTML["index.html
(Dual Video Elements)"] + Bundle["index.js
(Switching Logic)"] + Display["HDMI Display
(Video Output)"] + Assets[("assets/
(Video Files
.mp4, .ts)")] + Player1["Video Player 1
(Visible/Hidden)"] + Player2["Video Player 2
(Hidden/Visible)"] + + Player -->|"Boots & Launches"| Autorun + Autorun -->|"Creates roHtmlWidget
Loads HTML"| HTML + HTML -->|"Loads & Executes"| Bundle + Bundle -->|"Reads Video Files"| Assets + Bundle -->|"Controls Playback
& Visibility"| Player1 + Bundle -->|"Controls Playback
& Visibility"| Player2 + Player1 -->|"Renders Video"| Display + Player2 -->|"Renders Video"| Display + + style Player fill:#4a90e2,stroke:#333,stroke-width:2px,color:#fff + style Autorun fill:#e67e22,stroke:#333,stroke-width:2px,color:#fff + style HTML fill:#9b59b6,stroke:#333,stroke-width:2px,color:#fff + style Bundle fill:#7b68ee,stroke:#333,stroke-width:2px,color:#fff + style Display fill:#34495e,stroke:#333,stroke-width:2px,color:#fff + style Assets fill:#50c878,stroke:#333,stroke-width:2px,color:#fff + style Player1 fill:#e74c3c,stroke:#333,stroke-width:2px,color:#fff + style Player2 fill:#e74c3c,stroke:#333,stroke-width:2px,color:#fff +``` + +## Seamless Video Switching Flow + +1. **Initial Load**: Player 1 loads and plays the first video +2. **Preload**: While Player 1 plays, Player 2 preloads the next video (hidden) +3. **Switch Trigger**: When Player 1 ends, the switching sequence begins +4. **Start Hidden Player**: Player 2 starts playing (while still hidden) +5. **Wait for Playback**: Wait until Player 2 is actually playing +6. **Instant Transition**: Player 2 becomes visible, Player 1 becomes hidden +7. **Background Preload**: Player 1 (now hidden) preloads the next video +8. **Loop**: Repeat steps 3-7 indefinitely for continuous playback + +## Key Features + +- **Zero-Gap Transitions**: No black screens or freeze frames between videos +- **Dual Player Technique**: Two HTML5 video elements layered using absolute positioning +- **Background Preloading**: Next video is fully loaded before current video ends +- **Instant Visibility Toggle**: CSS class switching provides immediate visual transition +- **Alphabetical Playback**: Videos are sorted and played in alphabetical order +- **Infinite Loop**: Playlist automatically loops back to the first video + +## Legend + +- **Blue**: BrightSign Player +- **Orange**: BrightScript +- **Purple**: HTML/JS Application +- **Purple (Dark)**: JavaScript Logic +- **Dark Gray**: External Hardware +- **Green**: Video Files +- **Red**: Video Player Elements diff --git a/examples/browser/seamless-video-switching/autorun.brs b/examples/browser/seamless-video-switching/autorun.brs new file mode 100644 index 0000000..c8da76a --- /dev/null +++ b/examples/browser/seamless-video-switching/autorun.brs @@ -0,0 +1,40 @@ +function main() + mp = CreateObject("roMessagePort") + + ' Create HTML Widget + widget = CreateHTMLWidget(mp) + widget.Show() + + 'Event Loop + while true + msg = wait(0, mp) + print "msg received - type=";type(msg) + if type(msg) = "roHtmlWidgetEvent" then + print "msg: ";msg + end if + end while + +end function + +function CreateHTMLWidget(mp as object) as object + ' Get Screen Resolution + vidmode = CreateObject("roVideoMode") + width = vidmode.GetResX() + height = vidmode.GetResY() + + r = CreateObject("roRectangle", 0, 0, width, height) + + ' Create HTML Widget config + config = { + javascript_enabled: true, + brightsign_js_objects_enabled: true, + nodejs_enabled: true, + url: "file:///sd:/index.html", + port: mp + } + + ' Create HTML Widget + h = CreateObject("roHtmlWidget", r, config) + return h + +end function diff --git a/examples/browser/seamless-video-switching/index.html b/examples/browser/seamless-video-switching/index.html new file mode 100644 index 0000000..d91b86a --- /dev/null +++ b/examples/browser/seamless-video-switching/index.html @@ -0,0 +1,51 @@ + + + + + + Seamless Video App + + + +
+ + +
+ + + diff --git a/examples/browser/seamless-video-switching/index.js b/examples/browser/seamless-video-switching/index.js new file mode 100644 index 0000000..128e32e --- /dev/null +++ b/examples/browser/seamless-video-switching/index.js @@ -0,0 +1,143 @@ +const fs = require('fs'); + +// Configuration - edit these values as needed +const rootStoragePath = '/storage/sd'; +const assetsFolder = 'assets'; + +// Enable on players with multiple hardware decode pipelines (XT, 4K series) for +// near-zero-gap transitions via decoder pre-warming and early start. On single- +// decoder hardware, leave false — pre-warming would compete with the active +// player's decoder and may cause stuttering. See README for details. +const MULTI_DECODER = false; + +// Multi-decoder only: how many seconds before the visible video ends to start +// the hidden player. Trades content budget against transition reliability — +// raise if the swap is revealing unrendered frames, lower for a tighter cut. +const EARLY_START_LEAD_SECONDS = 0.5; + +// State tracking +let currentVideoIndex = 0; +let visibleVideoPlayer = 1; +let videoFiles = []; + +function getPlayer(playerNumber) { + return document.getElementById(`video-player-${playerNumber}`); +} + +function getPlayers() { + const visible = getPlayer(visibleVideoPlayer); + const hidden = getPlayer(visibleVideoPlayer === 1 ? 2 : 1); + return { visible, hidden }; +} + +function preloadNextVideo() { + const nextVideoIndex = (currentVideoIndex + 1) % videoFiles.length; + const { hidden } = getPlayers(); + hidden.pause(); + hidden.currentTime = 0; + hidden.muted = true; + hidden.src = `${assetsFolder}/${videoFiles[nextVideoIndex]}`; + hidden.load(); + + if (MULTI_DECODER) { + // Warm the decode pipeline so play() starts near-instantly at switch time + hidden.addEventListener('canplay', () => { + hidden.play().then(() => { hidden.pause(); hidden.currentTime = 0; }).catch(() => {}); + }, { once: true }); + } +} + +// Multi-decoder only: start hidden player EARLY_START_LEAD_SECONDS before end +// so it's already playing when the swap happens, eliminating decoder handoff gap. +function armEarlyStart() { + const { visible } = getPlayers(); + visible.addEventListener('timeupdate', function onNearEnd() { + if (visible.duration - visible.currentTime <= EARLY_START_LEAD_SECONDS) { + visible.removeEventListener('timeupdate', onNearEnd); + getPlayers().hidden.play().catch(() => {}); + } + }); +} + +function switchToNextVideo() { + currentVideoIndex = (currentVideoIndex + 1) % videoFiles.length; + const { visible: currentPlayer, hidden: nextPlayer } = getPlayers(); + + if (MULTI_DECODER) { + // Early start may already have the hidden player running — swap instantly. + // Otherwise wait for `playing` to avoid revealing an unrendered frame. + const doSwap = () => { + currentPlayer.pause(); + currentPlayer.muted = true; + currentPlayer.classList.add('hidden'); + nextPlayer.muted = false; + nextPlayer.classList.remove('hidden'); + visibleVideoPlayer = visibleVideoPlayer === 1 ? 2 : 1; + preloadNextVideo(); + armEarlyStart(); + }; + + if (!nextPlayer.paused) { + doSwap(); + } else { + nextPlayer.play().catch(e => console.error('Play error:', e)); + nextPlayer.addEventListener('playing', doSwap, { once: true }); + } + return; + } + + // Single-decoder: start the hidden (preloaded) player and swap immediately. + nextPlayer.currentTime = 0; + nextPlayer.play().catch(e => console.error('Play error:', e)); + + currentPlayer.pause(); + currentPlayer.muted = true; + currentPlayer.classList.add('hidden'); + + nextPlayer.muted = false; + nextPlayer.classList.remove('hidden'); + + visibleVideoPlayer = visibleVideoPlayer === 1 ? 2 : 1; + preloadNextVideo(); +} + +async function main() { + console.log('main() - App ready!'); + + try { + videoFiles = fs.readdirSync(`${rootStoragePath}/${assetsFolder}`); + videoFiles = videoFiles.filter(filename => !filename.startsWith('.')); + videoFiles.sort(); + + if (videoFiles.length === 0) { + console.error(`No video files found in ${rootStoragePath}/${assetsFolder}`); + return; + } + + const player1 = getPlayer(1); + const player2 = getPlayer(2); + + player1.src = `${assetsFolder}/${videoFiles[0]}`; + player1.currentTime = 0; + player1.muted = false; + player2.muted = true; + + player1.addEventListener('ended', () => switchToNextVideo()); + player2.addEventListener('ended', () => switchToNextVideo()); + + player1.addEventListener('playing', () => { + console.log('Player 1 playing'); + preloadNextVideo(); + if (MULTI_DECODER) armEarlyStart(); + }, { once: true }); + + } catch (e) { + console.error('Error in main():', e); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', main); +} else { + main(); +}