feat: Add basic fullscreen support for iPhone (#1857)

- Closes #1444
This commit is contained in:
Dermot Duffy
2025-01-27 07:23:07 -08:00
committed by GitHub
parent d1afbc95b6
commit 3e8e688b36
44 changed files with 1150 additions and 308 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ export class CardElementManager {
);
this._api.getConditionsManager()?.setState({
user_agent: navigator.userAgent,
userAgent: navigator.userAgent,
});
// Manually request query string execute as the card will be
+35 -24
View File
@@ -17,25 +17,25 @@ import {
ViewDisplayMode,
} from '../config/types';
import { localize } from '../localize/localize';
import { FrigateCardError } from '../types';
import { FrigateCardError, MediaLoadedInfo } from '../types';
import { desparsifyArrays } from '../utils/basic';
import { isCompanionApp } from '../utils/companion';
import { CardConditionAPI, KeysState, MicrophoneState } from './types';
interface ConditionState {
view?: string;
fullscreen?: boolean;
expand?: boolean;
export interface ConditionState {
camera?: string;
state?: HassEntities;
media_loaded?: boolean;
displayMode?: ViewDisplayMode;
triggered?: Set<string>;
expand?: boolean;
fullscreen?: boolean;
interaction?: boolean;
microphone?: MicrophoneState;
user?: CurrentUser;
keys?: KeysState;
user_agent?: string;
mediaLoadedInfo?: MediaLoadedInfo | null;
microphone?: MicrophoneState;
state?: HassEntities;
triggered?: Set<string>;
user?: CurrentUser;
userAgent?: string;
view?: string;
}
class OverrideConfigurationError extends FrigateCardError {}
@@ -152,7 +152,10 @@ export interface ConditionsManagerEpoch {
manager: Readonly<ConditionsManager>;
}
export type ConditionsManagerListener = () => void;
export type ConditionsManagerListener = (
newState: ConditionState,
oldState?: ConditionState,
) => void;
export class ConditionsManager {
protected _api: CardConditionAPI;
@@ -166,7 +169,7 @@ export class ConditionsManager {
// the configuration needs to consume it.
protected _hasHAStateConditions = false;
protected _mediaQueries: MediaQueryList[] = [];
protected _mediaQueryTrigger = () => this._triggerChange();
protected _mediaQueryTrigger = () => this._triggerChange(this._state);
constructor(api: CardConditionAPI, listener?: ConditionsManagerListener) {
this._api = api;
@@ -177,6 +180,14 @@ export class ConditionsManager {
];
}
public addListener(listener: ConditionsManagerListener): void {
this._listeners.push(listener);
}
public removeListener(listener: ConditionsManagerListener): void {
this._listeners = this._listeners.filter((l) => l !== listener);
}
public removeConditions(): void {
this._mediaQueries.forEach((mql) =>
mql.removeEventListener('change', this._mediaQueryTrigger),
@@ -233,12 +244,12 @@ export class ConditionsManager {
if (Object.keys(state).every((key) => isEqual(state[key], this._state[key]))) {
return;
}
const oldState = this._state;
this._state = {
...this._state,
...oldState,
...state,
};
this._triggerChange();
this._triggerChange(oldState);
}
public getState(): ConditionState {
@@ -313,8 +324,8 @@ export class ConditionsManager {
return !!state.user && conditionObj.users.includes(state.user.id);
case 'media_loaded':
return (
state.media_loaded !== undefined &&
conditionObj.media_loaded === state.media_loaded
state.mediaLoadedInfo !== undefined &&
conditionObj.media_loaded === !!state.mediaLoadedInfo
);
case 'screen':
return window.matchMedia(conditionObj.media_query).matches;
@@ -352,12 +363,12 @@ export class ConditionsManager {
);
case 'user_agent':
return (
!!state.user_agent &&
(!conditionObj.user_agent || conditionObj.user_agent === state.user_agent) &&
!!state.userAgent &&
(!conditionObj.user_agent || conditionObj.user_agent === state.userAgent) &&
(conditionObj.companion === undefined ||
conditionObj.companion === isCompanionApp(state.user_agent)) &&
conditionObj.companion === isCompanionApp(state.userAgent)) &&
(conditionObj.user_agent_re === undefined ||
new RegExp(conditionObj.user_agent_re).test(state.user_agent))
new RegExp(conditionObj.user_agent_re).test(state.userAgent))
);
}
}
@@ -366,8 +377,8 @@ export class ConditionsManager {
return { manager: this };
}
protected _triggerChange(): void {
protected _triggerChange(oldState?: ConditionState): void {
this._epoch = this._createEpoch();
this._listeners.forEach((listener) => listener());
this._listeners.forEach((listener) => listener(this._state, oldState));
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ import { ConfigManager } from './config/config-manager';
import { DefaultManager } from './default-manager';
import { DownloadManager } from './download-manager';
import { ExpandManager } from './expand-manager';
import { FullscreenManager } from './fullscreen-manager';
import { FullscreenManager } from './fullscreen/fullscreen-manager';
import { HASSManager } from './hass/hass-manager';
import { InitializationManager } from './initialization-manager';
import { InteractionManager } from './interaction-manager';
+1 -1
View File
@@ -23,7 +23,7 @@ export class ExpandManager {
public setExpanded(expanded: boolean): void {
if (expanded && this._api.getFullscreenManager().isInFullscreen()) {
// Fullscreen and expanded mode are mutually exclusive.
this._api.getFullscreenManager().stopFullscreen();
this._api.getFullscreenManager().setFullscreen(false);
}
this._expanded = expanded;
-55
View File
@@ -1,55 +0,0 @@
import screenfull from 'screenfull';
import { CardFullscreenAPI } from './types';
export class FullscreenManager {
protected _api: CardFullscreenAPI;
constructor(api: CardFullscreenAPI) {
this._api = api;
}
public initialize(): void {
this._setConditionState();
}
public connect(): void {
if (screenfull.isEnabled) {
screenfull.on('change', this._fullscreenHandler);
}
}
public disconnect(): void {
if (screenfull.isEnabled) {
screenfull.off('change', this._fullscreenHandler);
}
}
public isInFullscreen(): boolean {
return screenfull.isEnabled && screenfull.isFullscreen;
}
public toggleFullscreen(): void {
screenfull.toggle(this._api.getCardElementManager().getElement());
}
public stopFullscreen(): void {
screenfull.exit();
}
protected _setConditionState(): void {
this._api.getConditionsManager()?.setState({
fullscreen: this.isInFullscreen(),
});
}
protected _fullscreenHandler = (): void => {
this._api.getExpandManager().setExpanded(false);
this._setConditionState();
// Re-render after a change to fullscreen mode to take advantage of
// the expanded screen real-estate (vs staying in aspect-ratio locked
// modes).
this._api.getCardElementManager().update();
};
}
+24
View File
@@ -0,0 +1,24 @@
import screenfull from 'screenfull';
import { WebkitHTMLVideoElement } from '../../types';
import { CardFullscreenAPI } from '../types';
import { ScreenfullFullScreenProvider } from './screenfull';
import { FullscreenHandler, FullscreenProvider } from './types';
import { WebkitFullScreenProvider } from './webkit';
export class FullscreenProviderFactory {
public static create(
api: CardFullscreenAPI,
handler: FullscreenHandler,
): FullscreenProvider | null {
if (screenfull.isEnabled) {
return new ScreenfullFullScreenProvider(api, handler);
}
const video = document.createElement('video') as Partial<WebkitHTMLVideoElement>;
if (!!video.webkitEnterFullscreen) {
return new WebkitFullScreenProvider(api, handler);
}
return null;
}
}
@@ -0,0 +1,67 @@
import { isBeingCasted } from '../../utils/casting';
import { CardFullscreenAPI } from '../types';
import { FullscreenProviderFactory } from './factory';
import { FullscreenProvider } from './types';
export class FullscreenManager {
protected _api: CardFullscreenAPI;
protected _provider: FullscreenProvider | null;
constructor(api: CardFullscreenAPI, provider?: FullscreenProvider) {
this._api = api;
this._provider =
provider ?? FullscreenProviderFactory.create(api, this._fullscreenHandler);
}
public connect(): void {
this._provider?.connect();
}
public disconnect(): void {
this._provider?.disconnect();
}
public setFullscreen(fullscreen: boolean): void {
this._provider?.setFullscreen(fullscreen);
}
public isInFullscreen(): boolean {
return this._provider?.isInFullscreen() ?? false;
}
public isSupported(): boolean {
if (isBeingCasted()) {
return false;
}
return this._provider?.isSupported() ?? false;
}
public initialize(): void {
this._setConditionState();
}
public toggleFullscreen(): void {
if (this._provider?.isInFullscreen()) {
this._provider.setFullscreen(false);
} else {
this._provider?.setFullscreen(true);
}
}
protected _fullscreenHandler = (): void => {
this._api.getExpandManager().setExpanded(false);
this._setConditionState();
// Re-render after a change to fullscreen mode to take advantage of
// the expanded screen real-estate (vs staying in aspect-ratio locked
// modes).
this._api.getCardElementManager().update();
};
protected _setConditionState(): void {
this._api.getConditionsManager()?.setState({
fullscreen: this.isInFullscreen(),
});
}
}
@@ -0,0 +1,12 @@
import { CardFullscreenAPI } from '../types';
import { FullscreenHandler } from './types';
export class FullscreenProviderBase {
protected _api: CardFullscreenAPI;
protected _handler: FullscreenHandler;
constructor(api: CardFullscreenAPI, handler: FullscreenHandler) {
this._api = api;
this._handler = handler;
}
}
@@ -0,0 +1,40 @@
import screenfull from 'screenfull';
import { FullscreenProviderBase } from '../provider';
import { FullscreenProvider } from '../types';
export class ScreenfullFullScreenProvider
extends FullscreenProviderBase
implements FullscreenProvider
{
public connect(): void {
if (screenfull.isEnabled) {
screenfull.on('change', this._handler);
}
}
public disconnect(): void {
if (screenfull.isEnabled) {
screenfull.off('change', this._handler);
}
}
public isInFullscreen(): boolean {
return screenfull.isEnabled && screenfull.isFullscreen;
}
public isSupported(): boolean {
return screenfull.isEnabled;
}
public setFullscreen(fullscreen: boolean): void {
if (!this.isSupported()) {
return;
}
if (fullscreen) {
screenfull.request(this._api.getCardElementManager().getElement());
} else {
screenfull.exit();
}
}
}
+11
View File
@@ -0,0 +1,11 @@
export type FullscreenHandler = () => void;
export interface FullscreenProvider {
connect(): void;
disconnect(): void;
isInFullscreen(): boolean;
isSupported(): boolean;
setFullscreen(fullscreen: boolean): void;
}
@@ -0,0 +1,92 @@
import { WebkitHTMLVideoElement } from '../../../types';
import { Timer } from '../../../utils/timer';
import { ConditionState } from '../../conditions-manager';
import { FullscreenProviderBase } from '../provider';
import { FullscreenProvider } from '../types';
const WEBKIT_PLAY_SECONDS = 0.5;
/**
* Fullscreen implementation for webkit based browsers that do not support the
* standard Fullscreen API (expected to be exclusively iOS on an iPhone).
* See: https://github.com/dermotduffy/frigate-hass-card/issues/1444
*/
export class WebkitFullScreenProvider
extends FullscreenProviderBase
implements FullscreenProvider
{
protected _playTimer = new Timer();
public connect(): void {
this._api.getConditionsManager().addListener(this._conditionChangeHandler);
}
public disconnect(): void {
this._api.getConditionsManager().removeListener(this._conditionChangeHandler);
}
protected _conditionChangeHandler = (
newState: ConditionState,
oldState?: ConditionState,
): void => {
if (
newState.mediaLoadedInfo?.player?.getFullscreenElement() !==
oldState?.mediaLoadedInfo?.player?.getFullscreenElement()
) {
const oldElement = oldState?.mediaLoadedInfo?.player?.getFullscreenElement();
oldElement?.removeEventListener('webkitbeginfullscreen', this._handler);
oldElement?.removeEventListener('webkitendfullscreen', this._endHandler);
const newElement = newState.mediaLoadedInfo?.player?.getFullscreenElement();
newElement?.addEventListener('webkitbeginfullscreen', this._handler);
newElement?.addEventListener('webkitendfullscreen', this._endHandler);
}
};
protected _getVideoElement():
| (HTMLVideoElement & Partial<WebkitHTMLVideoElement>)
| null {
const element = this._api
.getMediaLoadedInfoManager()
.get()
?.player?.getFullscreenElement();
return element instanceof HTMLVideoElement ? element : null;
}
public isInFullscreen(): boolean {
return !!this._getVideoElement()?.webkitDisplayingFullscreen;
}
public isSupported(): boolean {
return !!this._getVideoElement()?.webkitSupportsFullscreen;
}
public setFullscreen(fullscreen: boolean): void {
if (!this.isSupported()) {
return;
}
const video = this._getVideoElement();
if (fullscreen) {
video?.webkitEnterFullscreen?.();
} else {
video?.webkitExitFullscreen?.();
}
}
protected _endHandler = (): void => {
this._handler();
// Webkit on iPhone stops the video when exiting fullscreen (why!). This
// hack workaround has the disadvantage that there's no way to tell if the
// video was legitimately paused before they user exited fullscreen (as the
// common path is for exit fullscreen to not involve
// setFullscreen(...) being called at all, but rather the user simply
// clicking the 'X' which then fires this event). That's probably the rare
// case though.
this._playTimer.start(WEBKIT_PLAY_SECONDS, () => {
this._getVideoElement()?.play();
});
};
}
+7 -7
View File
@@ -16,21 +16,21 @@ export class MediaLoadedInfoManager {
this.clear();
}
public set(mediaInfo: MediaLoadedInfo): void {
if (!isValidMediaLoadedInfo(mediaInfo)) {
public set(mediaLoadedInfo: MediaLoadedInfo): void {
if (!isValidMediaLoadedInfo(mediaLoadedInfo)) {
return;
}
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card media load: `,
mediaInfo,
mediaLoadedInfo,
);
this._current = mediaInfo;
this._lastKnown = mediaInfo;
this._current = mediaLoadedInfo;
this._lastKnown = mediaLoadedInfo;
this._api.getConditionsManager().setState({ media_loaded: true });
this._api.getConditionsManager().setState({ mediaLoadedInfo: mediaLoadedInfo });
// Fresh media information may change how the card is rendered.
this._api.getStyleManager().setExpandedMode();
@@ -47,7 +47,7 @@ export class MediaLoadedInfoManager {
public clear(): void {
this._current = null;
this._api.getConditionsManager().setState({ media_loaded: false });
this._api.getConditionsManager().setState({ mediaLoadedInfo: null });
}
public has(): boolean {
+3 -1
View File
@@ -11,7 +11,7 @@ import type { ConfigManager } from './config/config-manager';
import type { DefaultManager } from './default-manager';
import type { DownloadManager } from './download-manager';
import type { ExpandManager } from './expand-manager';
import type { FullscreenManager } from './fullscreen-manager';
import type { FullscreenManager } from './fullscreen/fullscreen-manager';
import type { HASSManager } from './hass/hass-manager';
import type { InitializationManager } from './initialization-manager';
import type { InteractionManager } from './interaction-manager';
@@ -150,6 +150,7 @@ export interface CardFullscreenAPI {
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getExpandManager(): ExpandManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMediaPlayerManager(): MediaPlayerManager;
}
@@ -205,6 +206,7 @@ export interface CardMediaLoadedAPI {
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getFullscreenManager(): FullscreenManager;
getStyleManager(): StyleManager;
}