diff --git a/config/package.js b/config/package.js index 22351299..3ef852be 100644 --- a/config/package.js +++ b/config/package.js @@ -12,5 +12,6 @@ export const plugins = [ 'ima', 'volume-bar', 'sticky', - 'hotkeys' + 'hotkeys', + 'mobile' ] diff --git a/examples/html5/config.js b/examples/html5/config.js index 84525b40..b4b97492 100644 --- a/examples/html5/config.js +++ b/examples/html5/config.js @@ -8,6 +8,7 @@ import Vlitejs from 'vlitejs' import VlitejsAirplay from 'vlitejs/plugins/airplay.js' import VlitejsCast from 'vlitejs/plugins/cast.js' import VlitejsHotkeys from 'vlitejs/plugins/hotkeys.js' +import VlitejsMobile from 'vlitejs/plugins/mobile.js' import VlitejsPip from 'vlitejs/plugins/pip.js' import VlitejsSubtitle from 'vlitejs/plugins/subtitle.js' import VlitejsVolumeBar from 'vlitejs/plugins/volume-bar.js' @@ -30,6 +31,7 @@ Vlitejs.registerPlugin('hotkeys', VlitejsHotkeys, { seekStep: 3, volumeStep: 0.2 }) +Vlitejs.registerPlugin('mobile', VlitejsMobile) new Vlitejs('#player', { options: { @@ -47,7 +49,7 @@ new Vlitejs('#player', { muted: false, autoHide: true }, - plugins: ['subtitle', 'pip', 'cast', 'airplay', 'volume-bar', 'hotkeys'], + plugins: ['subtitle', 'pip', 'cast', 'airplay', 'volume-bar', 'hotkeys', 'mobile'], onReady: (player) => { console.log(player) diff --git a/src/core/vlite.ts b/src/core/vlite.ts index c8612030..06fa7710 100644 --- a/src/core/vlite.ts +++ b/src/core/vlite.ts @@ -198,7 +198,9 @@ class Vlitejs { if (this.type === 'video') { this.container.addEventListener('click', this.onClickOnPlayer) this.container.addEventListener('dblclick', this.onDoubleClickOnPlayer) - this.autoHideGranted && this.container.addEventListener('mousemove', this.onMousemove) + if (this.autoHideGranted && !this.player.isTouch) { + this.container.addEventListener('mousemove', this.onMousemove) + } document.addEventListener(this.supportFullScreen.changeEvent, this.onChangeFullScreen) } } @@ -269,7 +271,7 @@ class Vlitejs { */ stopAutoHideTimer() { if (this.type === 'video' && this.player.elements.controlBar) { - this.player.elements.controlBar.classList.remove('v-hidden') + this.player.elements.controlBar?.classList.remove('v-hidden') clearTimeout(this.timerAutoHide) } } @@ -292,7 +294,9 @@ class Vlitejs { if (this.type === 'video') { this.container.removeEventListener('click', this.onClickOnPlayer) this.container.removeEventListener('dblclick', this.onDoubleClickOnPlayer) - this.autoHideGranted && this.container.removeEventListener('mousemove', this.onMousemove) + if (this.autoHideGranted && !this.player.isTouch) { + this.container.removeEventListener('mousemove', this.onMousemove) + } window.removeEventListener(this.supportFullScreen.changeEvent, this.onChangeFullScreen) } } diff --git a/src/plugins/mobile/README.md b/src/plugins/mobile/README.md new file mode 100644 index 00000000..00dcb09c --- /dev/null +++ b/src/plugins/mobile/README.md @@ -0,0 +1,46 @@ +# Plugin: Mobile + +Adds mobile-specific behaviors for touch devices. + +## Overview + +| | | +| ---------------- | -------------------------------------------- | +| Name | `mobile` | +| Path | `vlitejs/plugins/mobile` | +| Entry point | `vlitejs/plugins/mobile/mobile.js` | +| Provider² | `'html5', 'youtube', 'vimeo', 'dailymotion'` | +| Media type³ | `'video'` | + +The plugin is automatically **no-op on non-touch devices**. +It relies on the internal `player.isTouch` flag, so desktop behavior is not affected. + +### Behavior on touch devices + +On touch devices only: + +- A **tap on the overlay** (`.v-overlay`) behaves as: + - **First tap** (control bar hidden): shows the control bar + - **Next taps** (control bar visible): toggles play/pause + +## Usage + +### HTML + +```html + +``` + +### JavaScript + +```js +import 'vlitejs/vlite.css'; +import Vlitejs from 'vlitejs'; +import VlitejsMobile from 'vlitejs/plugins/mobile/mobile.js'; + +Vlitejs.registerPlugin('mobile', VlitejsMobile); + +new Vlitejs('#player', { + plugins: ['mobile'] +}); +``` diff --git a/src/plugins/mobile/mobile.ts b/src/plugins/mobile/mobile.ts new file mode 100644 index 00000000..4d061e2c --- /dev/null +++ b/src/plugins/mobile/mobile.ts @@ -0,0 +1,83 @@ +import type Player from 'core/player.js' +import validateTarget from 'validate-target' + +type pluginParameter = { + player: Player + options?: Record +} + +/** + * Vlitejs Mobile plugin + * @module Vlitejs/plugins/mobile + */ +export default class Mobile { + player: Player + + providers = ['html5', 'youtube', 'dailymotion', 'vimeo'] + types = ['video'] + + /** + * @constructor + * @param options + * @param options.player Player instance + */ + constructor({ player }: pluginParameter) { + this.player = player + + this.onClickOnContainer = this.onClickOnContainer.bind(this) + } + + /** + * Initialize + */ + init() { + this.player.isTouch && this.addEvents() + // TODO: add hotkeys in this plugin in the next major version + } + + /** + * Add event listeners + */ + addEvents() { + this.player.elements.container.addEventListener('click', this.onClickOnContainer) + } + + /** + * Handle click/tap on the container (only on touch devices) + * @param e Event data + */ + onClickOnContainer(e: Event) { + const target = e.target as HTMLElement + + const validateTargetOverlay = validateTarget({ + target, + selectorString: '.v-overlay', + nodeName: ['div'] + }) + + if (validateTargetOverlay) { + this.onClickOnOverlay(e) + } + } + + onClickOnOverlay(e: Event) { + const hasControlBar = !!this.player.elements.controlBar + const controlBarVisible = !this.player.elements.controlBar?.classList.contains('v-hidden') + + if (hasControlBar && !controlBarVisible) { + this.player.Vlitejs.stopAutoHideTimer() + this.player.Vlitejs.autoHideGranted && this.player.Vlitejs.startAutoHideTimer() + return + } + + // Control bar is visible, toggle the playback + this.player.controlBar.togglePlayPause(e) + } + + /** + * Destroy the plugin + */ + destroy() { + this.player.elements.container.removeEventListener('click', this.onClickOnContainer) + } +} diff --git a/tests/html5.spec.ts b/tests/html5.spec.ts index 70db3dea..8b8a16a3 100644 --- a/tests/html5.spec.ts +++ b/tests/html5.spec.ts @@ -83,3 +83,52 @@ test.describe('HTML5 Player Tests', () => { expect(volume).toBe(0.1) }) }) + +test.describe('HTML5 Player Mobile Overlay Tests', () => { + test.beforeEach(async ({ page }) => { + // Force a touch-like environment before any script runs + await page.addInitScript(() => { + Object.defineProperty(navigator, 'maxTouchPoints', { + get() { + return 1 + }, + configurable: true + }) + }) + + await page.goto('http://localhost:3000/html5') + + await page.waitForFunction(() => { + const video = document.querySelector('video') + return video && video.duration > 0 + }) + }) + + test('should show control bar on first overlay tap and pause on second', async ({ page }) => { + const video = page.locator('video') + const controlBar = page.locator('.v-controlBar') + + await page.click('.v-bigPlay') + + // Wait long enough for auto-hide to hide the control bar (autoHideDelay = 3000ms) + await page.waitForTimeout(3500) + + const initiallyHidden = await controlBar.evaluate((el) => el.classList.contains('v-hidden')) + + // First overlay tap: should show the control bar but keep playback running + await page.click('.v-overlay') + const controlBarVisibleAfterFirstTap = await controlBar.evaluate((el) => + el.classList.contains('v-hidden') + ) + const pausedAfterFirstTap = await video.evaluate((v) => v.paused) + + // Second overlay tap: should pause the video + await page.click('.v-overlay') + const pausedAfterSecondTap = await video.evaluate((v) => v.paused) + + expect(initiallyHidden).toBe(true) + expect(controlBarVisibleAfterFirstTap).toBe(false) + expect(pausedAfterFirstTap).toBe(false) + expect(pausedAfterSecondTap).toBe(true) + }) +})