@@ -0,0 +1,38 @@
|
||||
import {FullscreenAdapter} from '@common/player/state/fullscreen/fullscreen-adapter';
|
||||
import {IS_IPHONE} from '@common/utils/platform';
|
||||
|
||||
export function createIphoneFullscreenAdapter(
|
||||
host: HTMLVideoElement,
|
||||
onChange: () => void
|
||||
): FullscreenAdapter {
|
||||
return {
|
||||
/**
|
||||
* @link https://developer.apple.com/documentation/webkitjs/htmlvideoelement/1631913-webkitpresentationmode
|
||||
*/
|
||||
isFullscreen: () => {
|
||||
return host.webkitPresentationMode === 'fullscreen';
|
||||
},
|
||||
/**
|
||||
* @link https://developer.apple.com/documentation/webkitjs/htmlvideoelement/1628805-webkitsupportsfullscreen
|
||||
*/
|
||||
canFullScreen: () => {
|
||||
return (
|
||||
IS_IPHONE &&
|
||||
typeof host.webkitSetPresentationMode === 'function' &&
|
||||
(host.webkitSupportsFullscreen ?? false)
|
||||
);
|
||||
},
|
||||
enter: () => {
|
||||
return host.webkitSetPresentationMode?.('fullscreen');
|
||||
},
|
||||
exit: () => {
|
||||
return host.webkitSetPresentationMode?.('inline');
|
||||
},
|
||||
bindEvents: () => {
|
||||
host.removeEventListener('webkitpresentationmodechanged', onChange);
|
||||
},
|
||||
unbindEvents: () => {
|
||||
host.addEventListener('webkitpresentationmodechanged', onChange);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {FullscreenAdapter} from '@common/player/state/fullscreen/fullscreen-adapter';
|
||||
import fscreen from 'fscreen';
|
||||
|
||||
export function createNativeFullscreenAdapter(
|
||||
host: HTMLElement,
|
||||
onChange: () => void
|
||||
): FullscreenAdapter {
|
||||
host = host.closest('.fullscreen-host') ?? host;
|
||||
return {
|
||||
isFullscreen: () => {
|
||||
if (fscreen.fullscreenElement === host) return true;
|
||||
try {
|
||||
// Throws in iOS Safari...
|
||||
return host.matches(
|
||||
// @ts-expect-error - `fullscreenPseudoClass` is missing from `@types/fscreen`.
|
||||
fscreen.fullscreenPseudoClass
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
canFullScreen: () => {
|
||||
return fscreen.fullscreenEnabled;
|
||||
},
|
||||
enter: () => {
|
||||
return fscreen.requestFullscreen(host);
|
||||
},
|
||||
exit: () => {
|
||||
return fscreen.exitFullscreen();
|
||||
},
|
||||
bindEvents: () => {
|
||||
fscreen.addEventListener('fullscreenchange', onChange);
|
||||
fscreen.addEventListener('fullscreenerror', onChange);
|
||||
},
|
||||
unbindEvents: () => {
|
||||
fscreen.removeEventListener('fullscreenchange', onChange);
|
||||
fscreen.removeEventListener('fullscreenerror', onChange);
|
||||
},
|
||||
};
|
||||
}
|
||||
8
common/resources/client/player/state/fullscreen/fullscreen-adapter.ts
Executable file
8
common/resources/client/player/state/fullscreen/fullscreen-adapter.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
export interface FullscreenAdapter {
|
||||
isFullscreen: () => boolean;
|
||||
canFullScreen: () => boolean;
|
||||
enter: () => void;
|
||||
exit: () => void;
|
||||
bindEvents: () => void;
|
||||
unbindEvents: () => void;
|
||||
}
|
||||
112
common/resources/client/player/state/fullscreen/fullscreen-slice.ts
Executable file
112
common/resources/client/player/state/fullscreen/fullscreen-slice.ts
Executable file
@@ -0,0 +1,112 @@
|
||||
import {StateCreator} from 'zustand';
|
||||
import {
|
||||
PlayerState,
|
||||
ProviderListeners,
|
||||
} from '@common/player/state/player-state';
|
||||
import {ScreenOrientation} from '@common/player/state/fullscreen/screen-orientation';
|
||||
import {IS_IPHONE} from '@common/utils/platform';
|
||||
import {FullscreenAdapter} from '@common/player/state/fullscreen/fullscreen-adapter';
|
||||
import {createNativeFullscreenAdapter} from '@common/player/state/fullscreen/create-native-fullscreen-adapter';
|
||||
import {createIphoneFullscreenAdapter} from '@common/player/state/fullscreen/create-iphone-fullscreen-adapter';
|
||||
import {PipSlice} from '@common/player/state/pip/pip-slice';
|
||||
|
||||
export interface FullscreenSlice {
|
||||
isFullscreen: boolean;
|
||||
canFullscreen: boolean;
|
||||
enterFullscreen: () => void;
|
||||
exitFullscreen: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
initFullscreen: () => void;
|
||||
destroyFullscreen: () => void;
|
||||
}
|
||||
|
||||
type BaseSliceCreator = StateCreator<
|
||||
FullscreenSlice & PlayerState & PipSlice,
|
||||
[['zustand/immer', unknown]],
|
||||
[],
|
||||
FullscreenSlice
|
||||
>;
|
||||
|
||||
type StoreLice = BaseSliceCreator extends (...a: infer U) => infer R
|
||||
? (...a: [...U, Set<Partial<ProviderListeners>>]) => R
|
||||
: never;
|
||||
|
||||
const iPhoneProviderBlacklist = ['youtube'];
|
||||
|
||||
export const createFullscreenSlice: StoreLice = (set, get) => {
|
||||
let subscription: () => void | undefined;
|
||||
const orientation = new ScreenOrientation();
|
||||
let adapter: FullscreenAdapter | undefined;
|
||||
|
||||
const onFullscreenChange = async () => {
|
||||
const isFullscreen = adapter?.isFullscreen();
|
||||
if (isFullscreen) {
|
||||
// lock orientation to landscape
|
||||
orientation.lock();
|
||||
} else {
|
||||
orientation.unlock();
|
||||
}
|
||||
set({isFullscreen});
|
||||
};
|
||||
|
||||
const isSupported = (): boolean => {
|
||||
// iPhone only allows putting video element in fullscreen, and
|
||||
// there's no way to get access to it with YouTube iframe api
|
||||
if (IS_IPHONE && iPhoneProviderBlacklist.includes(get().providerName!)) {
|
||||
return false;
|
||||
}
|
||||
return adapter?.canFullScreen() ?? false;
|
||||
};
|
||||
|
||||
return {
|
||||
isFullscreen: false,
|
||||
canFullscreen: false,
|
||||
enterFullscreen: () => {
|
||||
if (!isSupported() || adapter?.isFullscreen()) return;
|
||||
|
||||
// exit pip if it's active
|
||||
if (get().isPip) {
|
||||
get().exitPip();
|
||||
}
|
||||
return adapter?.enter();
|
||||
},
|
||||
exitFullscreen: () => {
|
||||
if (!adapter?.isFullscreen()) return;
|
||||
return adapter.exit();
|
||||
},
|
||||
toggleFullscreen: () => {
|
||||
if (get().isFullscreen) {
|
||||
get().exitFullscreen();
|
||||
} else {
|
||||
get().enterFullscreen();
|
||||
}
|
||||
},
|
||||
initFullscreen: () => {
|
||||
subscription = get().subscribe({
|
||||
providerReady: ({el}) => {
|
||||
// when changing adapters, remove previous adapter events and exit fullscreen
|
||||
adapter?.unbindEvents();
|
||||
if (get().isFullscreen) {
|
||||
adapter?.exit();
|
||||
}
|
||||
// create new adapter, and if fullscreen is supported, bind events
|
||||
adapter = IS_IPHONE
|
||||
? createIphoneFullscreenAdapter(
|
||||
el as HTMLVideoElement,
|
||||
onFullscreenChange
|
||||
)
|
||||
: createNativeFullscreenAdapter(el, onFullscreenChange);
|
||||
const canFullscreen = isSupported();
|
||||
set({canFullscreen});
|
||||
if (canFullscreen) {
|
||||
adapter.bindEvents();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
destroyFullscreen: () => {
|
||||
get().exitFullscreen();
|
||||
subscription?.();
|
||||
},
|
||||
};
|
||||
};
|
||||
86
common/resources/client/player/state/fullscreen/screen-orientation.ts
Executable file
86
common/resources/client/player/state/fullscreen/screen-orientation.ts
Executable file
@@ -0,0 +1,86 @@
|
||||
export class ScreenOrientation {
|
||||
protected currentLock: OrientationLockType | undefined;
|
||||
|
||||
async lock(lockType: OrientationLockType = 'landscape') {
|
||||
if (!this.canOrientScreen() || this.currentLock) return;
|
||||
try {
|
||||
await (screen.orientation as any).lock(lockType);
|
||||
this.currentLock = lockType;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async unlock() {
|
||||
if (!this.canOrientScreen() || !this.currentLock) return;
|
||||
await screen.orientation.unlock();
|
||||
}
|
||||
|
||||
canOrientScreen(): boolean {
|
||||
return (
|
||||
screen.orientation != null &&
|
||||
!!(screen.orientation as any).lock &&
|
||||
!!screen.orientation.unlock
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type OrientationLockType =
|
||||
/**
|
||||
* Any is an orientation that means the screen can be locked to any one of portrait-primary,
|
||||
* portrait-secondary, landscape-primary and landscape-secondary.
|
||||
*/
|
||||
| 'any'
|
||||
|
||||
/**
|
||||
* Landscape is an orientation where the screen width is greater than the screen height and
|
||||
* depending on platform convention locking the screen to landscape can represent
|
||||
* landscape-primary, landscape-secondary or both.
|
||||
*/
|
||||
| 'landscape'
|
||||
|
||||
/**
|
||||
* Landscape-primary is an orientation where the screen width is greater than the screen height.
|
||||
* If the device's natural orientation is landscape, then it is in landscape-primary when held
|
||||
* in that position. If the device's natural orientation is portrait, the user agent sets
|
||||
* landscape-primary from the two options as shown in the screen orientation values table.
|
||||
*/
|
||||
| 'landscape-primary'
|
||||
|
||||
/**
|
||||
* Landscape-secondary is an orientation where the screen width is greater than the screen
|
||||
* height. If the device's natural orientation is landscape, it is in landscape-secondary when
|
||||
* rotated 180º from its natural orientation. If the device's natural orientation is portrait,
|
||||
* the user agent sets landscape-secondary from the two options as shown in the screen
|
||||
* orientation values table.
|
||||
*/
|
||||
| 'landscape-secondary'
|
||||
|
||||
/**
|
||||
* Natural is an orientation that refers to either portrait-primary or landscape-primary
|
||||
* depending on the device's usual orientation. This orientation is usually provided by the
|
||||
* underlying operating system.
|
||||
*/
|
||||
| 'natural'
|
||||
|
||||
/**
|
||||
* Portrait is an orientation where the screen width is less than or equal to the screen height
|
||||
* and depending on platform convention locking the screen to portrait can represent
|
||||
* portrait-primary, portrait-secondary or both.
|
||||
*/
|
||||
| 'portrait'
|
||||
|
||||
/**
|
||||
* Portrait-primary is an orientation where the screen width is less than or equal to the screen
|
||||
* height. If the device's natural orientation is portrait, then it is in portrait-primary when
|
||||
* held in that position. If the device's natural orientation is landscape, the user agent sets
|
||||
* portrait-primary from the two options as shown in the screen orientation values table.
|
||||
*/
|
||||
| 'portrait-primary'
|
||||
|
||||
/**
|
||||
* Portrait-secondary is an orientation where the screen width is less than or equal to the
|
||||
* screen height. If the device's natural orientation is portrait, then it is in
|
||||
* portrait-secondary when rotated 180º from its natural position. If the device's natural
|
||||
* orientation is landscape, the user agent sets portrait-secondary from the two options as
|
||||
* shown in the screen orientation values table.
|
||||
*/
|
||||
| 'portrait-secondary';
|
||||
Reference in New Issue
Block a user