Skip to content

Commit 6fc086c

Browse files
authored
Merge pull request #1745 from AbdulShaikz/feat/mobile-ui-video-pinch-zoom
feat(video): add mobile pinch-to-zoom support
2 parents c893d13 + 80d557a commit 6fc086c

File tree

4 files changed

+282
-0
lines changed

4 files changed

+282
-0
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"embla-carousel-react": "^8.0.0",
5757
"framer-motion": "^11.3.28",
5858
"fuse.js": "^7.0.0",
59+
"hammerjs": "^2.0.8",
5960
"ioredis": "^5.4.1",
6061
"jose": "^5.2.2",
6162
"katex": "^0.16.11",
@@ -92,6 +93,7 @@
9293
"devDependencies": {
9394
"@testing-library/jest-dom": "^6.4.5",
9495
"@testing-library/react": "^15.0.7",
96+
"@types/hammerjs": "^2.0.46",
9597
"@types/node": "^20",
9698
"@types/react": "^18",
9799
"@types/react-big-calendar": "^1.8.9",

pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/globals.css

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,53 @@
8484
font-family: inherit !important;
8585
max-width: 100%;
8686
background-color: #000; /* Ensure background color matches video content */
87+
overflow: hidden !important;
88+
transform-style: preserve-3d;
89+
}
90+
91+
/* Video element styles */
92+
.video-js .vjs-tech {
93+
border-radius: 0.75rem !important;
94+
transform-origin: center !important;
95+
backface-visibility: hidden;
96+
-webkit-backface-visibility: hidden;
97+
transition: transform 0.2s ease-out;
98+
}
99+
100+
/* Zoomed state styles */
101+
.video-js .vjs-tech.zoomed {
102+
perspective: 1000;
103+
-webkit-perspective: 1000;
104+
touch-action: none;
105+
}
106+
107+
/* Zoom level indicator */
108+
.video-js .vjs-zoom-level {
109+
position: absolute;
110+
top: 16px;
111+
left: 50%;
112+
transform: translateX(-50%);
113+
background: rgba(0, 0, 0, 0.7);
114+
color: white;
115+
padding: 4px 8px;
116+
border-radius: 4px;
117+
font-size: 14px;
118+
opacity: 0;
119+
transition: opacity 0.3s;
120+
z-index: 2;
121+
pointer-events: none;
122+
font-family: inherit !important;
123+
}
124+
125+
.video-js.vjs-fullscreen .vjs-tech {
126+
object-fit: contain !important;
127+
background-color: black;
128+
}
129+
130+
.video-js.vjs-fullscreen {
131+
display: flex;
132+
align-items: center;
133+
justify-content: center;
87134
}
88135

89136
/* Control Bar Background */

src/components/VideoPlayer2.tsx

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import React, { useEffect, useRef, FunctionComponent, useState } from 'react';
33
import videojs from 'video.js';
44
import Player from 'video.js/dist/types/player';
5+
import Hammer from 'hammerjs';
56
import 'video.js/dist/video-js.css';
67
import 'videojs-contrib-eme';
78
import 'videojs-mobile-ui/dist/videojs-mobile-ui.css';
@@ -30,6 +31,19 @@ interface VideoPlayerProps {
3031
onVideoEnd: () => void;
3132
}
3233

34+
interface TransformState {
35+
scale: number;
36+
lastScale: number;
37+
translateX: number;
38+
translateY: number;
39+
lastPanX: number;
40+
lastPanY: number;
41+
}
42+
43+
interface ZoomIndicator extends HTMLDivElement {
44+
timeoutId?: ReturnType<typeof setTimeout>;
45+
}
46+
3347
const PLAYBACK_RATES: number[] = [0.5, 1, 1.25, 1.5, 1.75, 2];
3448
const VOLUME_LEVELS: number[] = [0, 0.2, 0.4, 0.6, 0.8, 1.0];
3549

@@ -96,6 +110,207 @@ export const VideoPlayer: FunctionComponent<VideoPlayerProps> = ({
96110
return pipButtonContainer;
97111
};
98112

113+
const setupZoomFeatures = (player: any) => {
114+
115+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
116+
117+
const videoEl = player.el().querySelector('video');
118+
const container = player.el();
119+
120+
const transformState: TransformState = {
121+
scale: 1,
122+
lastScale: 1,
123+
translateX: 0,
124+
translateY: 0,
125+
lastPanX: 0,
126+
lastPanY: 0
127+
};
128+
129+
// Zoom indicator
130+
const zoomIndicator = document.createElement('div') as ZoomIndicator;
131+
zoomIndicator.className = 'vjs-zoom-level';
132+
container.appendChild(zoomIndicator);
133+
134+
// Optimized boundary calculation with memoization
135+
const calculateBoundaries = (() => {
136+
let lastDimensions: { width: number; height: number };
137+
138+
return () => {
139+
const containerRect = container.getBoundingClientRect();
140+
const videoAspect = videoEl.videoWidth / videoEl.videoHeight;
141+
142+
// returning cached values if dimensions haven't changed
143+
if (lastDimensions?.width === containerRect.width &&
144+
lastDimensions?.height === containerRect.height) {
145+
return lastDimensions;
146+
}
147+
148+
const containerAspect = containerRect.width / containerRect.height;
149+
let actualWidth = containerRect.width;
150+
let actualHeight = containerRect.height;
151+
152+
actualWidth = containerAspect > videoAspect
153+
? actualHeight * videoAspect
154+
: actualWidth;
155+
actualHeight = containerAspect > videoAspect
156+
? actualHeight
157+
: actualWidth / videoAspect;
158+
159+
lastDimensions = {
160+
width: actualWidth,
161+
height: actualHeight
162+
};
163+
164+
return lastDimensions;
165+
};
166+
})();
167+
168+
// Unified gesture handler
169+
const handleGestureControl = (e: HammerInput) => {
170+
const target = e.srcEvent.target as HTMLElement;
171+
const isControlBar = target.closest('.vjs-control-bar');
172+
173+
if (!isControlBar && player.isFullscreen()) {
174+
e.srcEvent.preventDefault();
175+
e.srcEvent.stopPropagation();
176+
}
177+
};
178+
179+
// Configuring Hammer with proper types
180+
const hammer = new Hammer.Manager(container, {
181+
touchAction: 'none',
182+
inputClass: Hammer.TouchInput
183+
});
184+
185+
hammer.add(new Hammer.Pinch());
186+
hammer.add(new Hammer.Pan({
187+
threshold: 0,
188+
direction: Hammer.DIRECTION_ALL
189+
}));
190+
191+
// Optimized transform update with boundary enforcement
192+
const updateTransform = () => {
193+
const boundaries = calculateBoundaries();
194+
const maxX = (boundaries.width * (transformState.scale - 1)) / 2;
195+
const maxY = (boundaries.height * (transformState.scale - 1)) / 2;
196+
197+
transformState.translateX = Math.min(Math.max(
198+
transformState.translateX,
199+
-maxX
200+
), maxX);
201+
202+
transformState.translateY = Math.min(Math.max(
203+
transformState.translateY,
204+
-maxY
205+
), maxY);
206+
207+
videoEl.style.transform = `
208+
scale(${transformState.scale})
209+
translate3d(
210+
${transformState.translateX / transformState.scale}px,
211+
${transformState.translateY / transformState.scale}px,
212+
0
213+
)`;
214+
};
215+
216+
// Unified pinch handler
217+
hammer.on('pinchstart pinchmove', (e) => {
218+
handleGestureControl(e);
219+
220+
if (!player.isFullscreen()) return;
221+
222+
if (e.type === 'pinchstart') {
223+
transformState.lastScale = transformState.scale;
224+
videoEl.classList.add('zoomed');
225+
return;
226+
}
227+
228+
transformState.scale = Math.min(
229+
Math.max(transformState.lastScale * e.scale, 1),
230+
3
231+
);
232+
233+
updateTransform();
234+
showZoomLevel();
235+
});
236+
237+
// Unified pan handler
238+
hammer.on('panstart panmove', (e) => {
239+
handleGestureControl(e);
240+
241+
if (transformState.scale <= 1) return;
242+
243+
if (e.type === 'panstart') {
244+
transformState.lastPanX = e.center.x;
245+
transformState.lastPanY = e.center.y;
246+
videoEl.style.transition = 'none';
247+
return;
248+
}
249+
250+
const deltaX = e.center.x - transformState.lastPanX;
251+
const deltaY = e.center.y - transformState.lastPanY;
252+
253+
transformState.translateX += deltaX;
254+
transformState.translateY += deltaY;
255+
256+
transformState.lastPanX = e.center.x;
257+
transformState.lastPanY = e.center.y;
258+
259+
updateTransform();
260+
});
261+
262+
// Optimized zoom indicator
263+
const showZoomLevel = () => {
264+
zoomIndicator.textContent = `${transformState.scale.toFixed(1)}x`;
265+
zoomIndicator.style.opacity = '1';
266+
267+
if (zoomIndicator.timeoutId) clearTimeout(zoomIndicator.timeoutId);
268+
269+
zoomIndicator.timeoutId = setTimeout(() => {
270+
zoomIndicator.style.opacity = '0';
271+
}, 1000);
272+
};
273+
274+
// Reset handler with animation frame
275+
const resetZoom = () => {
276+
transformState.scale = 1;
277+
transformState.translateX = 0;
278+
transformState.translateY = 0;
279+
280+
requestAnimationFrame(() => {
281+
videoEl.style.transition = 'transform 0.3s ease-out';
282+
updateTransform();
283+
videoEl.classList.remove('zoomed');
284+
});
285+
};
286+
287+
// Adding resize observer for responsive boundaries
288+
const resizeObserver = new ResizeObserver(() => {
289+
if (player.isFullscreen()) updateTransform();
290+
});
291+
292+
resizeObserver.observe(container);
293+
294+
// Reset zoom when exiting fullscreen
295+
player.on('fullscreenchange', () => {
296+
if (!player.isFullscreen()) {
297+
resetZoom();
298+
}
299+
});
300+
301+
// Cleanup function
302+
const cleanup = () => {
303+
resizeObserver.disconnect();
304+
hammer.destroy();
305+
if (zoomIndicator.timeoutId) clearTimeout(zoomIndicator.timeoutId);
306+
container.removeChild(zoomIndicator);
307+
resetZoom();
308+
};
309+
310+
player.on('dispose', cleanup);
311+
return cleanup;
312+
};
313+
99314
useEffect(() => {
100315
if (!player) return;
101316

@@ -420,6 +635,7 @@ export const VideoPlayer: FunctionComponent<VideoPlayerProps> = ({
420635
() => {
421636
player.mobileUi(); // mobile ui #https://github.com/mister-ben/videojs-mobile-ui
422637
player.eme(); // Initialize EME
638+
setupZoomFeatures(player);
423639
player.seekButtons({
424640
forward: 15,
425641
back: 15,

0 commit comments

Comments
 (0)