Skip to content

Commit 4daa8ae

Browse files
committed
Add zoom control to slideshow
1 parent d389ef4 commit 4daa8ae

4 files changed

Lines changed: 169 additions & 38 deletions

File tree

src/components/slideshow/slideshow.js

Lines changed: 32 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import dom from '../../utils/dom';
2020
import './style.scss';
2121
import 'material-design-icons-iconfont';
2222
import '../../elements/emby-button/paper-icon-button-light';
23+
import { getIcon } from './slideshowhelper';
24+
import ZoomControl from './zoomControl';
2325

2426
/**
2527
* Name of transition event.
@@ -106,20 +108,6 @@ function getImgUrl(item, user) {
106108
}
107109
}
108110

109-
/**
110-
* Generates a button using the specified icon, classes and properties.
111-
* @param {string} icon - Name of the material icon on the button
112-
* @param {string} cssClass - CSS classes to assign to the button
113-
* @param {boolean} canFocus - Flag to set the tabindex attribute on the button to -1.
114-
* @param {boolean} autoFocus - Flag to set the autofocus attribute on the button.
115-
* @returns {string} The HTML markup of the button.
116-
*/
117-
function getIcon(icon, cssClass, canFocus, autoFocus) {
118-
const tabIndex = canFocus ? '' : ' tabindex="-1"';
119-
autoFocus = autoFocus ? ' autofocus' : '';
120-
return '<button is="paper-icon-button-light" class="autoSize ' + cssClass + '"' + tabIndex + autoFocus + '><span class="material-icons slideshowButtonIcon ' + icon + '" aria-hidden="true"></span></button>';
121-
}
122-
123111
/**
124112
* Sets the viewport meta tag to enable or disable scaling by the user.
125113
* @param {boolean} scalable - Flag to set the scalability of the viewport.
@@ -144,6 +132,8 @@ export default function (options) {
144132
let hideTimeout;
145133
/** Last coordinates of the mouse pointer. */
146134
let lastMouseMoveData;
135+
/** Instance of zoom control **/
136+
let zoomControl;
147137

148138
/**
149139
* Creates the HTML markup for the dialog and the OSD.
@@ -193,18 +183,23 @@ export default function (options) {
193183

194184
if (!actionButtonsOnTop) {
195185
html += '<div class="slideshowBottomBar hide">';
196-
186+
html += '<div class="slideshowBottomBarLeft">';
197187
html += getIcon('play_arrow', 'btnSlideshowPause slideshowButton', true, true);
198188
if (appHost.supports(AppFeature.FileDownload) && slideshowOptions?.user.Policy.EnableContentDownloading) {
199189
html += getIcon('file_download', 'btnDownload slideshowButton', true);
200190
}
201191
if (appHost.supports(AppFeature.Sharing)) {
202192
html += getIcon('share', 'btnShare slideshowButton', true);
203193
}
194+
html += '</div>';
195+
196+
html += '<div class="slideshowBottomBarRight">';
197+
html += ZoomControl.getHtml();
204198
if (screenfull.isEnabled) {
205199
html += getIcon('fullscreen', 'btnFullscreen', true);
206200
html += getIcon('fullscreen_exit', 'btnFullscreenExit hide', true);
207201
}
202+
html += '</div>';
208203

209204
html += '</div>';
210205
}
@@ -306,31 +301,26 @@ export default function (options) {
306301
const zoomImage = slideEl.querySelector('.swiper-zoom-fakeimg');
307302

308303
if (zoomImage) {
304+
zoomImage.classList.add('swiper-zoom-fakeimg-hidden');
309305
zoomImage.style.width = zoomImage.style.height = scale * 100 + '%';
310306

311-
if (scale > 1) {
312-
if (zoomImage.classList.contains('swiper-zoom-fakeimg-hidden')) {
313-
// Await for Swiper style changes
314-
setTimeout(() => {
315-
const callback = () => {
316-
imageEl.removeEventListener(transitionEndEventName, callback);
317-
zoomImage.classList.remove('swiper-zoom-fakeimg-hidden');
318-
};
319-
320-
// Swiper set 'transition-duration: 300ms' for auto zoom
321-
// and 'transition-duration: 0s' for touch zoom
322-
const transitionDuration = parseFloat(imageEl.style.transitionDuration.replace(/[a-z]/i, ''));
323-
324-
if (transitionDuration > 0) {
325-
imageEl.addEventListener(transitionEndEventName, callback);
326-
} else {
327-
callback();
328-
}
329-
}, 0);
307+
// Await for Swiper style changes
308+
setTimeout(() => {
309+
const callback = () => {
310+
imageEl.removeEventListener(transitionEndEventName, callback);
311+
zoomImage.classList.remove('swiper-zoom-fakeimg-hidden');
312+
};
313+
314+
// Swiper set 'transition-duration: 300ms' for auto zoom
315+
// and 'transition-duration: 0s' for touch zoom
316+
const transitionDuration = parseFloat(imageEl.style.transitionDuration.replace(/[a-z]/i, ''));
317+
318+
if (transitionDuration > 0) {
319+
imageEl.addEventListener(transitionEndEventName, callback);
320+
} else {
321+
callback();
330322
}
331-
} else {
332-
zoomImage.classList.add('swiper-zoom-fakeimg-hidden');
333-
}
323+
}, 0);
334324
}
335325
}
336326

@@ -353,6 +343,7 @@ export default function (options) {
353343
// eslint-disable-next-line import/no-unresolved
354344
import('swiper/bundle').then(({ Swiper }) => {
355345
swiperInstance = new Swiper(dialogElement.querySelector('.slideshowSwiperContainer'), {
346+
init: false,
356347
direction: 'horizontal',
357348
// Loop is disabled due to the virtual slides option not supporting it.
358349
loop: false,
@@ -390,6 +381,10 @@ export default function (options) {
390381
swiperInstance.on('zoomChange', onZoomChange);
391382
}
392383

384+
zoomControl = new ZoomControl(dialogElement, swiperInstance, slides);
385+
zoomControl.bindEvents();
386+
swiperInstance.init();
387+
393388
if (swiperInstance.autoplay?.running) onAutoplayStart();
394389
});
395390
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Generates a button using the specified icon, classes and properties.
3+
* @param icon - Name of the material icon on the button
4+
* @param cssClass - CSS classes to assign to the button
5+
* @param canFocus - Flag to set the tabindex attribute on the button to -1.
6+
* @param autoFocusFlag - Flag to set the autofocus attribute on the button.
7+
* @returns The HTML markup of the button.
8+
*/
9+
export function getIcon(icon: string, cssClass: string, canFocus?: boolean, autoFocusFlag?: boolean) {
10+
const tabIndex = canFocus ? '' : ' tabindex="-1"';
11+
const autoFocus = autoFocusFlag ? ' autofocus' : '';
12+
return '<button is="paper-icon-button-light" class="autoSize ' + cssClass + '"'
13+
+ tabIndex + autoFocus + '><span class="material-icons slideshowButtonIcon '
14+
+ icon + '" aria-hidden="true"></span></button>';
15+
}

src/components/slideshow/style.scss

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,14 @@
7777
display: flex;
7878
flex-direction: row;
7979
align-items: center;
80-
justify-content: center;
80+
justify-content: space-between;
81+
}
82+
83+
.slideshowBottomBarLeft,
84+
.slideshowBottomBarRight {
85+
display: flex;
86+
flex-direction: row;
87+
align-items: center;
8188
}
8289

8390
.slideshowTopBar {
@@ -140,3 +147,8 @@
140147
.swiper-zoom-fakeimg-hidden {
141148
display: none;
142149
}
150+
151+
.zoomControl {
152+
display: flex;
153+
align-items: center;
154+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type Swiper from 'swiper';
2+
import { getIcon } from './slideshowhelper';
3+
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
4+
5+
const buttonZoomStep = 0.5;
6+
const minZoom = 0.1;
7+
const maxZoom = 3;
8+
9+
export default class ZoomControl {
10+
swiper: Swiper;
11+
dialog: HTMLDivElement;
12+
slides: BaseItemDto[];
13+
14+
static getHtml() {
15+
return `\
16+
<div class="zoomControl">\
17+
${getIcon('zoom_out', 'btnZoomOut', true)}\
18+
<div class="sliderContainer"><input is="emby-slider" type="range" step="0.001" min="${minZoom}" max="${maxZoom}" value="1" class="zoomSlider"/></div>\
19+
${getIcon('zoom_in', 'btnZoomIn', true)}\
20+
</div>\
21+
`;
22+
}
23+
24+
constructor(dialog: HTMLDivElement, swiper: Swiper, slides: BaseItemDto[]) {
25+
this.dialog = dialog;
26+
this.swiper = swiper;
27+
this.slides = slides;
28+
}
29+
30+
bindEvents() {
31+
const zoomSlider = this.dialog.querySelector<HTMLInputElement>('.zoomSlider');
32+
if (zoomSlider) {
33+
zoomSlider.addEventListener('input', (e) => {
34+
const initialRealScale = this.getInitialRealScale();
35+
if (e.target && initialRealScale) {
36+
const realScale = Number((e.target as HTMLInputElement).value);
37+
this.swiper.zoom.in(realScale / initialRealScale);
38+
}
39+
});
40+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
41+
(zoomSlider as any).getBubbleText = (_: number, value: number) => `${Math.trunc(value * 100)}%`;
42+
}
43+
this.dialog.querySelector<HTMLButtonElement>('.btnZoomOut')?.addEventListener('click', () => {
44+
const initialRealScale = this.getInitialRealScale();
45+
if (initialRealScale) {
46+
this.swiper.zoom.in(Math.max(this.swiper.zoom.scale - buttonZoomStep, Math.min(initialRealScale, minZoom) / initialRealScale));
47+
}
48+
});
49+
this.dialog.querySelector<HTMLButtonElement>('.btnZoomIn')?.addEventListener('click', () => {
50+
const initialRealScale = this.getInitialRealScale();
51+
if (initialRealScale) {
52+
this.swiper.zoom.in(Math.min(this.swiper.zoom.scale + buttonZoomStep, Math.max(initialRealScale, maxZoom) / initialRealScale));
53+
}
54+
});
55+
56+
this.swiper.on('zoomChange', (_: Swiper, scale: number) => {
57+
this.updateZoomControl(scale);
58+
});
59+
60+
this.swiper.on('slidesUpdated', () => {
61+
this.updateZoomControl();
62+
});
63+
}
64+
65+
private getCurrentImageElement() {
66+
const currentItemId = this.slides[this.swiper.activeIndex].Id;
67+
const activeSlide = this.dialog.querySelector(`[data-itemid="${currentItemId}"]`);
68+
return activeSlide?.querySelector<HTMLImageElement>('img');
69+
}
70+
71+
private getInitialRealScale() {
72+
const imageElement = this.getCurrentImageElement();
73+
if (imageElement) {
74+
return imageElement.width / imageElement.naturalWidth;
75+
}
76+
}
77+
78+
private updateZoomControl(scale?: number) {
79+
const updateZoomControlAfterImageLoaded = () => {
80+
const initialRealScale = this.getInitialRealScale();
81+
if (initialRealScale) {
82+
const currentRealScale = (scale ?? this.swiper.zoom.scale) * initialRealScale;
83+
const zoomOutButton = this.dialog.querySelector<HTMLButtonElement>('.btnZoomOut');
84+
if (zoomOutButton) {
85+
zoomOutButton.disabled = currentRealScale <= Math.min(initialRealScale, minZoom);
86+
}
87+
const zoomInButton = this.dialog.querySelector<HTMLButtonElement>('.btnZoomIn');
88+
if (zoomInButton) {
89+
zoomInButton.disabled = currentRealScale >= Math.max(initialRealScale, maxZoom);
90+
}
91+
const zoomSlider = this.dialog.querySelector<HTMLInputElement>('.zoomSlider');
92+
if (zoomSlider) {
93+
zoomSlider.max = Math.max(initialRealScale, maxZoom).toFixed(3);
94+
zoomSlider.min = Math.min(initialRealScale, minZoom).toFixed(3);
95+
zoomSlider.value = currentRealScale.toString();
96+
}
97+
}
98+
};
99+
100+
const imageElement = this.getCurrentImageElement();
101+
if (imageElement) {
102+
if (!imageElement.complete) {
103+
imageElement.onload = updateZoomControlAfterImageLoaded;
104+
} else {
105+
updateZoomControlAfterImageLoaded();
106+
}
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)