feat: Implement basic general folder support (#2051)

- Related: #1748
This commit is contained in:
Dermot Duffy
2025-05-21 19:59:21 -07:00
committed by GitHub
parent 2eb0d9e35e
commit c6a4c8aea2
350 changed files with 12837 additions and 4509 deletions
@@ -0,0 +1,81 @@
import { ViewManagerInterface } from '../../card-controller/view/types';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import { QueryClassifier } from '../../view/query-classifier';
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
// The minimum width of a (folder) thumbnail with details enabled. This is
// shorter than for regular camera media as this will consist of just a name.
export const FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 200;
export class FolderGalleryController {
private _host: HTMLElement;
public constructor(host: HTMLElement) {
this._host = host;
}
public setThumbnailSize(size?: number): void {
this._host.style.setProperty(
'--advanced-camera-card-thumbnail-size',
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
);
}
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
return !thumbnailConfig
? THUMBNAIL_WIDTH_DEFAULT
: thumbnailConfig.show_details
? FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
: thumbnailConfig.size;
}
public getColumnCountRoundMethod(
thumbnailConfig?: MediaGalleryThumbnailsConfig,
): GalleryColumnCountRoundMethod {
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
}
public itemClickHandler(
viewManager: ViewManagerInterface,
item: ViewItem,
ev: Event,
): void {
stopEventFromActivatingCardWideActions(ev);
const view = viewManager.getView();
if (!view) {
return;
}
if (ViewItemClassifier.isMedia(item)) {
viewManager.setViewByParameters({
params: {
view: 'media',
queryResults: view.queryResults
?.clone()
.selectResultIfFound((result) => result === item),
},
});
} else if (
ViewItemClassifier.isFolder(item) &&
QueryClassifier.isFolderQuery(view.query)
) {
const rawQuery = view.query.getQuery();
const id = item.getID();
if (!rawQuery || !id) {
return;
}
viewManager.setViewByParametersWithExistingQuery({
params: {
query: view.query.clone().setQuery({
folder: rawQuery.folder,
path: [...rawQuery.path, { id }],
}),
},
});
}
}
}
@@ -0,0 +1,250 @@
import { LitElement, ReactiveController } from 'lit';
import { throttle } from 'lodash-es';
import { GalleryExtendEvent } from '../../components/gallery/types';
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
import { scrollIntoView } from '../../utils/scroll';
import { sleep } from '../../utils/sleep';
const GALLERY_MIN_EXTENSION_SECONDS = 0.5;
export type GalleryColumnCountRoundMethod = 'ceil' | 'floor';
interface GalleryCoreOptions {
columnWidth?: number;
columnCountRoundMethod?: GalleryColumnCountRoundMethod;
extendUp?: boolean;
extendDown?: boolean;
}
export class GalleryCoreController implements ReactiveController {
private _host: LitElement;
private _intersectionObserver: IntersectionObserver;
private _resizeObserver: ResizeObserver;
private _options: GalleryCoreOptions | null = null;
private _touchScrollYPosition: number | null = null;
// Wheel / touch events may be voluminous, throttle extension calls.
private _throttledExtendUp = throttle(
this._extendUp.bind(this),
GALLERY_MIN_EXTENSION_SECONDS * 1000,
{
leading: true,
trailing: false,
},
);
private _getSlot: () => HTMLSlotElement | null;
private _getSentintelBottom: () => HTMLElement | null;
private _showLoaderTop: (show: boolean) => void;
private _showSentinelBottom: (show: boolean) => void;
private _wasEverNonEmpty = false;
constructor(
host: LitElement,
getSlot: () => HTMLSlotElement | null,
getSentinelBottom: () => HTMLElement | null,
showLoaderTopCallback: (show: boolean) => void,
showSentinelBottomCallback: (show: boolean) => void,
) {
this._host = host;
this._host.addController(this);
this._getSlot = getSlot;
this._getSentintelBottom = getSentinelBottom;
this._showLoaderTop = showLoaderTopCallback;
this._showSentinelBottom = showSentinelBottomCallback;
this._resizeObserver = new ResizeObserver(() => this._setColumnCount());
this._intersectionObserver = new IntersectionObserver(
async (entries: IntersectionObserverEntry[]): Promise<void> => {
if (entries.some((entry) => entry.isIntersecting)) {
await this._extendDown();
}
},
);
}
public removeController(): void {
this._host.removeController(this);
}
public setOptions(options: GalleryCoreOptions): void {
this._options = options;
this._setColumnCount();
}
public hostConnected(): void {
this._resizeObserver.observe(this._host);
// Since the scroll event does not fire if the user is already at the top of
// the container, instead we manually use the wheel and touchstart/end
// events to detect "top upwards scrolling" (to trigger an extension of the
// gallery).
this._host.addEventListener('wheel', this._wheelHandler, { passive: true });
this._host.addEventListener('touchstart', this._touchStartHandler, {
passive: true,
});
this._host.addEventListener('touchend', this._touchEndHandler);
// Request update in order to ensure the intersection observer reconnects
// with the loader sentinel.
this._host.requestUpdate();
}
public hostDisconnected(): void {
this._host.removeEventListener('wheel', this._wheelHandler);
this._host.removeEventListener('touchstart', this._touchStartHandler);
this._host.removeEventListener('touchend', this._touchEndHandler);
this._resizeObserver.disconnect();
this._intersectionObserver.disconnect();
}
public hostUpdated(): void {
const sentinel = this._getSentintelBottom();
this._intersectionObserver.disconnect();
if (sentinel) {
this._intersectionObserver.observe(sentinel);
}
}
private _setColumnCount(): void {
if (!this._options?.columnWidth) {
return;
}
const roundFunc =
this._options.columnCountRoundMethod === 'ceil' ? Math.ceil : Math.floor;
const columns = Math.max(
1,
roundFunc(this._host.clientWidth / this._options.columnWidth),
);
this._host.style.setProperty(
'--advanced-camera-card-gallery-columns',
String(columns),
);
}
private _touchStartHandler = (ev: TouchEvent): void => {
// Remember the Y touch position on touch start, so that we can calculate if
// the user gestured upwards or downards on touchend.
if (ev.touches.length === 1) {
this._touchScrollYPosition = ev.touches[0].screenY;
} else {
this._touchScrollYPosition = null;
}
};
private _touchEndHandler = async (ev: TouchEvent): Promise<void> => {
if (
!this._host.scrollTop &&
ev.changedTouches.length === 1 &&
this._touchScrollYPosition !== null
) {
if (ev.changedTouches[0].screenY > this._touchScrollYPosition) {
await this._throttledExtendUp();
}
}
this._touchScrollYPosition = null;
};
private _wheelHandler = async (ev: WheelEvent): Promise<void> => {
if (!this._host.scrollTop && ev.deltaY < 0) {
await this._throttledExtendUp();
}
};
private async _extendUp(): Promise<void> {
if (!this._options?.extendUp) {
return;
}
this._showLoaderTop(true);
const start = new Date();
await this._waitForExtend('up');
const delta = new Date().getTime() - start.getTime();
if (delta < GALLERY_MIN_EXTENSION_SECONDS * 1000) {
// Hidden gem: "legitimate" (?!) use of sleep() :-) These calls can return
// very quickly even with caching disabled since the time window
// constraints on the query will usually be very narrow and the backend
// can thus very quickly reply. It's often so fast it actually looks like
// a rendering issue where the progress indictor barely registers before
// it's gone again. This optional pause ensures there is at least some
// visual feedback to the user that lasts long enough they can 'feel' the
// fetch has happened.
//
// This is only applied on the 'up' extend since the 'down' extend may be
// called multiple times for large card sizes (e.g. fullscreen) where a
// delay is not desirable.
await sleep(GALLERY_MIN_EXTENSION_SECONDS - delta / 1000);
}
this._showLoaderTop(false);
}
private async _extendDown(): Promise<void> {
if (!this._options?.extendDown) {
return;
}
this._showSentinelBottom(false);
await this._waitForExtend('down');
// Sentinel will be re-shown next time the contents changes, see:
// updateContents() .
}
private async _waitForExtend(direction: 'up' | 'down'): Promise<void> {
await new Promise<void>((resolve) => {
fireAdvancedCameraCardEvent<GalleryExtendEvent>(
this._host,
`gallery:extend:${direction}`,
{ resolve },
{
bubbles: false,
composed: false,
},
);
});
}
public updateContents(): void {
const slot = this._getSlot();
if (!slot) {
return;
}
const contents = slot
.assignedElements()
.filter((element) => element instanceof HTMLElement);
const firstSelected = contents.find(
(element) => element.getAttribute('selected') !== null,
);
if (contents.length) {
if (!this._wasEverNonEmpty && firstSelected) {
// As a special case, if this is the first setting of the slot contents,
// the gallery is scrolled to the selected element (if any). This is
// only done on the first setting, as subsequent gallery extensions
// should not cause the gallery to rescroll to the item that happens to
// be selected.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
scrollIntoView(firstSelected, {
boundary: this._host,
block: 'center',
});
}
this._wasEverNonEmpty = true;
}
// Always render the bottom sentinel when the contents changes, in order to allow
// the gallery to be extended downwards.
this._showSentinelBottom(true);
}
}
@@ -0,0 +1,156 @@
import { CameraManager, ExtendedMediaQueryResult } from '../../camera-manager/manager';
import { EventQuery, MediaQuery, RecordingQuery } from '../../camera-manager/types';
import {
ViewManagerEpoch,
ViewManagerInterface,
} from '../../card-controller/view/types';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
import { errorToConsole } from '../../utils/basic';
import { ViewItem } from '../../view/item';
import { EventMediaQuery, RecordingMediaQuery } from '../../view/query';
import { QueryClassifier } from '../../view/query-classifier';
import { QueryResults } from '../../view/query-results';
import { View } from '../../view/view';
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
// The minimum width of a thumbnail with details enabled.
export const MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 300;
export class MediaGalleryController {
private _host: HTMLElement;
private _media: ViewItem[] | null = null;
public constructor(host: HTMLElement) {
this._host = host;
}
public getMedia(): ViewItem[] | null {
return this._media;
}
public setMediaFromView(newView?: View | null, oldView?: View | null): void {
const newResults = newView?.queryResults?.getResults() ?? null;
if (newResults === null) {
this._media = null;
return;
}
if (!this._media || oldView?.queryResults?.getResults() !== newResults) {
// Media gallery places the most recent media at the top (the query
// results place the most recent media at the end for use in the viewer).
// This is copied to a new array to avoid reversing the query results in
// place.
this._media = [...newResults].reverse();
}
}
public setThumbnailSize(size?: number): void {
this._host.style.setProperty(
'--advanced-camera-card-thumbnail-size',
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
);
}
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
return !thumbnailConfig
? THUMBNAIL_WIDTH_DEFAULT
: thumbnailConfig.show_details
? MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
: thumbnailConfig.size;
}
public getColumnCountRoundMethod(
thumbnailConfig?: MediaGalleryThumbnailsConfig,
): GalleryColumnCountRoundMethod {
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
}
public async extendMediaGallery(
cameraManager: CameraManager,
viewManagerEpoch: ViewManagerEpoch,
direction: 'earlier' | 'later',
useCache = true,
): Promise<void> {
const view = viewManagerEpoch.manager.getView();
if (!view) {
return;
}
const query = view.query;
const existingMedia = view.queryResults?.getResults();
if (!existingMedia || !query || !QueryClassifier.isMediaQuery(query)) {
return;
}
const rawQueries = query.getQuery() ?? null;
if (!rawQueries) {
return;
}
let extension: ExtendedMediaQueryResult<MediaQuery> | null;
try {
extension = await cameraManager.extendMediaQueries<MediaQuery>(
rawQueries,
existingMedia,
direction,
{
useCache: useCache,
},
);
} catch (e) {
errorToConsole(e as Error);
return;
}
if (extension) {
const newMediaQueries = QueryClassifier.isEventQuery(query)
? new EventMediaQuery(extension.queries as EventQuery[])
: QueryClassifier.isRecordingQuery(query)
? new RecordingMediaQuery(extension.queries as RecordingQuery[])
: /* istanbul ignore next: this path cannot be reached -- @preserve */
null;
/* istanbul ignore else: this path cannot be reached, as we explicitly
check for media queries above -- @preserve */
if (newMediaQueries) {
viewManagerEpoch.manager.setViewByParameters({
baseView: view,
params: {
query: newMediaQueries,
queryResults: new QueryResults({
results: extension.results,
}).selectResultIfFound(
(media) => media === view.queryResults?.getSelectedResult(),
),
},
});
}
}
}
public itemClickHandler(
viewManager: ViewManagerInterface,
reversedIndex: number,
ev: Event,
): void {
stopEventFromActivatingCardWideActions(ev);
const view = viewManager.getView();
if (!view || !this._media?.length) {
return;
}
viewManager.setViewByParameters({
params: {
view: 'media',
queryResults: view.queryResults?.clone().selectIndex(
// Media in the gallery is reversed vs the queryResults (see
// note above).
this._media.length - reversedIndex - 1,
),
},
});
}
}