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)
+ })
+})