Repository: dermotduffy/frigate-hass-card
Pull request #1702 (2024-12-02 04:50:12)

perf: Change media fetches to be asynchronous to view render
This is a fairly non-trivial change in terms of consequence, so a greater than average chance something breaks. This is necessary since some cameras (e.g. Reolink) are materially slower to fetch media, and this change substantially improves card responsiveness.


Patch:
diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts
index bbd303fe..89b0e8f5 100644
--- a/src/camera-manager/manager.ts
+++ b/src/camera-manager/manager.ts
@@ -691,6 +691,8 @@ export class CameraManager {
       _queries,
       ', Results:',
       results,
+      ', Options:',
+      engineOptions ?? {},
       ']',
     );
     return results;
diff --git a/src/card-controller/view/factory.ts b/src/card-controller/view/factory.ts
index 69a8fadc..db6c5693 100644
--- a/src/card-controller/view/factory.ts
+++ b/src/card-controller/view/factory.ts
@@ -1,4 +1,3 @@
-import { sub } from 'date-fns';
 import {
   FRIGATE_CARD_VIEW_DEFAULT,
   FrigateCardConfig,
@@ -6,27 +5,17 @@ import {
   ViewDisplayMode,
 } from '../../config/types';
 import { localize } from '../../localize/localize';
-import { ClipsOrSnapshotsOrAll } from '../../types';
-import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
 import { View, ViewParameters } from '../../view/view';
 import { getCameraIDsForViewName } from '../../view/view-to-cameras';
 import { CardViewAPI } from '../types';
-import { QueryExecutor } from './query-executor';
-import {
-  QueryExecutorOptions,
-  QueryWithResults,
-  ViewFactoryOptions,
-  ViewIncompatible,
-  ViewNoCameraError,
-} from './types';
+import { applyViewModifiers } from './modifiers';
+import { ViewFactoryOptions, ViewIncompatible, ViewNoCameraError } from './types';
 
 export class ViewFactory {
   protected _api: CardViewAPI;
-  protected _executor: QueryExecutor;
 
-  constructor(api: CardViewAPI, executor?: QueryExecutor) {
+  constructor(api: CardViewAPI) {
     this._api = api;
-    this._executor = executor ?? new QueryExecutor(api);
   }
 
   public getViewDefault(options?: ViewFactoryOptions): View | null {
@@ -91,7 +80,7 @@ export class ViewFactory {
       );
 
       // Reset to the default camera.
-      cameraID = viewCameraIDs.keys().next().value;
+      cameraID = viewCameraIDs.keys().next().value ?? null;
     }
 
     if (!cameraID) {
@@ -149,239 +138,18 @@ export class ViewFactory {
       ? options.baseView.evolve(viewParameters)
       : new View(viewParameters);
 
-    if (options?.modifiers) {
-      options.modifiers.forEach((modifier) => modifier.modify(view));
-    }
-    return view;
-  }
+    applyViewModifiers(view, options?.modifiers);
 
-  public async getViewDefaultWithNewQuery(
-    options?: ViewFactoryOptions,
-  ): Promise<View | null> {
-    return this._executeNewQuery(this.getViewDefault(options), {
-      ...options,
-      queryExecutorOptions: {
-        useCache: false,
-        ...options?.queryExecutorOptions,
-      },
-    });
-  }
-
-  public async getViewByParametersWithNewQuery(
-    options?: ViewFactoryOptions,
-  ): Promise<View | null> {
-    return this._executeNewQuery(this.getViewByParameters(options), {
-      ...options,
-      queryExecutorOptions: {
-        useCache: false,
-        ...options?.queryExecutorOptions,
-      },
-    });
-  }
-
-  public async getViewByParametersWithExistingQuery(
-    options?: ViewFactoryOptions,
-  ): Promise<View | null> {
-    const view = this.getViewByParameters(options);
-    if (view?.query) {
-      view.queryResults = await this._executor.execute(
-        view.query,
-        options?.queryExecutorOptions,
-      );
-    }
     return view;
   }
 
-  protected async _executeNewQuery(
-    view: View | null,
-    options?: ViewFactoryOptions,
-  ): Promise<View | null> {
-    const config = this._api.getConfigManager().getConfig();
-    if (
-      !config ||
-      /* istanbul ignore next: this path cannot be reached as the only way for
-         view to be null here, is if the config is also null -- @preserve */
-      !view
-    ) {
-      return null;
-    }
-
-    const executeMediaQuery = async (
-      mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
-    ): Promise<boolean> => {
-      /* istanbul ignore if: this path cannot be reached -- @preserve */
-      if (!mediaType) {
-        return false;
-      }
-      return await this._executeMediaQuery(
-        view,
-        mediaType === 'recordings' ? 'recordings' : 'events',
-        {
-          eventsMediaType: mediaType === 'recordings' ? undefined : mediaType,
-          executorOptions: options?.queryExecutorOptions,
-        },
-      );
-    };
-
-    // Implementation note: For new queries, if the query itself fails that is
-    // just ignored and the view is returned anyway (e.g. if the user changes to
-    // live but the thumbnail fetch fails, it is better to change to live and
-    // show no thumbnails than not change to live).
-    const mediaType = view.getDefaultMediaType();
-    const baseView = options?.baseView;
-    const switchingToGalleryFromViewer =
-      baseView?.isViewerView() && view.isGalleryView();
-
-    const alreadyHasMatchingQuery =
-      mediaType === MediaQueriesClassifier.getMediaType(baseView?.query);
-
-    if (
-      switchingToGalleryFromViewer &&
-      alreadyHasMatchingQuery &&
-      baseView?.query &&
-      baseView?.queryResults
-    ) {
-      // If the user is currently using the viewer, and then switches to the
-      // gallery we make an attempt to keep the query/queryResults the same so
-      // the gallery can be used to click back and forth to the viewer, and the
-      // selected media can be centered in the gallery. See the matching code in
-      // `updated()` in `gallery.ts`. We specifically must ensure that the new
-      // target media of the gallery (e.g. clips, snapshots or recordings) is
-      // equal to the queries that are currently used in the viewer.
-      //
-      // See: https://github.com/dermotduffy/frigate-hass-card/issues/885
-      view.query = baseView.query;
-      view.queryResults = baseView.queryResults;
-    } else {
-      switch (view.view) {
-        case 'live':
-          if (config.live.controls.thumbnails.mode !== 'none') {
-            await executeMediaQuery(
-              config.live.controls.thumbnails.media_type === 'recordings'
-                ? 'recordings'
-                : config.live.controls.thumbnails.events_media_type,
-            );
-          }
-          break;
-
-        case 'media':
-          // If the user is looking at media in the `media` view and then
-          // changes camera (via the menu) it should default to showing clips
-          // for the new camera.
-          if (baseView && view.camera !== baseView.camera) {
-            await executeMediaQuery('clips');
-          }
-          break;
-
-        // Gallery views:
-        case 'clips':
-        case 'snapshots':
-        case 'recordings':
-          await executeMediaQuery(mediaType);
-          break;
-
-        // Viewer views:
-        case 'clip':
-        case 'snapshot':
-        case 'recording':
-          if (config.media_viewer.controls.thumbnails.mode !== 'none') {
-            await executeMediaQuery(mediaType);
-          }
-          break;
-      }
-    }
-
-    this._setOrRemoveTimelineWindow(view);
-    this._setOrRemoveSeekTime(
-      view,
-      options?.queryExecutorOptions?.selectResult?.time?.time,
-    );
-    return view;
-  }
-
-  protected _setOrRemoveTimelineWindow(view: View): void {
-    if (view.is('live')) {
-      // For live views, always force the timeline to now, regardless of
-      // presence or not of events.
-      const now = new Date();
-      const liveConfig = this._api.getConfigManager().getConfig()?.live;
-
-      /* istanbul ignore if: this if branch cannot be reached as if the config is
-         empty this function is never called -- @preserve */
-      if (!liveConfig) {
-        return;
-      }
-
-      view.mergeInContext({
-        // Force the window to start at the most recent time, not
-        // necessarily when the most recent event/recording was:
-        // https://github.com/dermotduffy/frigate-hass-card/issues/1301
-        timeline: {
-          window: {
-            start: sub(now, {
-              seconds: liveConfig.controls.timeline.window_seconds,
-            }),
-            end: now,
-          },
-        },
-      });
-    } else {
-      // For non-live views stick to default timeline behavior (will select and
-      // scroll to event).
-      view.removeContextProperty('timeline', 'window');
-    }
-  }
-
-  protected _setOrRemoveSeekTime(view: View, time?: Date): void {
-    if (time) {
-      view.mergeInContext({
-        mediaViewer: {
-          seek: time,
-        },
-      });
-    } else {
-      view.removeContextProperty('mediaViewer', 'seek');
-    }
-  }
-
-  protected async _executeMediaQuery(
-    view: View,
-    mediaType: 'events' | 'recordings',
-    options?: {
-      eventsMediaType?: ClipsOrSnapshotsOrAll;
-      executorOptions?: QueryExecutorOptions;
-    },
-  ): Promise<boolean> {
-    const queryWithResults: QueryWithResults | null =
-      mediaType === 'events'
-        ? await this._executor.executeDefaultEventQuery({
-            ...(!view.isGrid() && { cameraID: view.camera }),
-            eventsMediaType: options?.eventsMediaType,
-            executorOptions: options?.executorOptions,
-          })
-        : mediaType === 'recordings'
-          ? await this._executor.executeDefaultRecordingQuery({
-              ...(!view.isGrid() && { cameraID: view.camera }),
-              executorOptions: options?.executorOptions,
-            })
-          : /* istanbul ignore next -- @preserve */
-            null;
-    if (!queryWithResults) {
-      return false;
-    }
-
-    view.query = queryWithResults.query;
-    view.queryResults = queryWithResults.queryResults;
-    return true;
-  }
-
   public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
     return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
   }
 
   protected _getDefaultDisplayModeForView(
     viewName: FrigateCardView,
-    config?: FrigateCardConfig,
+    config: FrigateCardConfig,
   ): ViewDisplayMode {
     let mode: ViewDisplayMode | null = null;
     switch (viewName) {
@@ -389,10 +157,10 @@ export class ViewFactory {
       case 'clip':
       case 'recording':
       case 'snapshot':
-        mode = config?.media_viewer.display?.mode ?? null;
+        mode = config.media_viewer.display?.mode ?? null;
         break;
       case 'live':
-        mode = config?.live.display?.mode ?? null;
+        mode = config.live.display?.mode ?? null;
         break;
     }
     return mode ?? 'single';
diff --git a/src/card-controller/view/modifiers/index.ts b/src/card-controller/view/modifiers/index.ts
new file mode 100644
index 00000000..cba86f3d
--- /dev/null
+++ b/src/card-controller/view/modifiers/index.ts
@@ -0,0 +1,9 @@
+import { View } from '../../../view/view';
+import { ViewModifier } from '../types';
+
+export const applyViewModifiers = (
+  view: View,
+  modifiers?: ViewModifier[] | null,
+): void => {
+  modifiers?.forEach((modifier) => modifier.modify(view));
+};
diff --git a/src/card-controller/view/modifiers/set-query.ts b/src/card-controller/view/modifiers/set-query.ts
new file mode 100644
index 00000000..8057c2fd
--- /dev/null
+++ b/src/card-controller/view/modifiers/set-query.ts
@@ -0,0 +1,26 @@
+import { MediaQueries } from '../../../view/media-queries';
+import { MediaQueriesResults } from '../../../view/media-queries-results';
+import { View } from '../../../view/view';
+import { ViewModifier } from '../types';
+
+export class SetQueryViewModifier implements ViewModifier {
+  protected _query?: MediaQueries | null;
+  protected _queryResults?: MediaQueriesResults | null;
+
+  constructor(options?: {
+    query?: MediaQueries | null;
+    queryResults?: MediaQueriesResults | null;
+  }) {
+    this._query = options?.query;
+    this._queryResults = options?.queryResults;
+  }
+
+  public modify(view: View): void {
+    if (this._query !== undefined) {
+      view.query = this._query;
+    }
+    if (this._queryResults !== undefined) {
+      view.queryResults = this._queryResults;
+    }
+  }
+}
diff --git a/src/card-controller/view/query-executor.ts b/src/card-controller/view/query-executor.ts
index 8e3df7e1..08cab553 100644
--- a/src/card-controller/view/query-executor.ts
+++ b/src/card-controller/view/query-executor.ts
@@ -9,7 +9,7 @@ import {
 } from '../../view/media-queries';
 import { MediaQueriesResults } from '../../view/media-queries-results';
 import { CardViewAPI } from '../types';
-import { QueryExecutorOptions, QueryWithResults } from './types';
+import { QueryExecutorOptions, QueryExecutorResult } from './types';
 
 export class QueryExecutor {
   protected _api: CardViewAPI;
@@ -22,7 +22,7 @@ export class QueryExecutor {
     cameraID?: string;
     eventsMediaType?: ClipsOrSnapshotsOrAll;
     executorOptions?: QueryExecutorOptions;
-  }): Promise<QueryWithResults | null> {
+  }): Promise<QueryExecutorResult | null> {
     const capabilitySearch: CapabilitySearchOptions =
       !options?.eventsMediaType || options?.eventsMediaType === 'all'
         ? {
@@ -61,7 +61,7 @@ export class QueryExecutor {
   public async executeDefaultRecordingQuery(options?: {
     cameraID?: string;
     executorOptions?: QueryExecutorOptions;
-  }): Promise<QueryWithResults | null> {
+  }): Promise<QueryExecutorResult | null> {
     const cameraManager = this._api.getCameraManager();
     const cameraIDs = options?.cameraID
       ? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings')
diff --git a/src/card-controller/view/types.ts b/src/card-controller/view/types.ts
index a1aae634..5b769935 100644
--- a/src/card-controller/view/types.ts
+++ b/src/card-controller/view/types.ts
@@ -26,7 +26,7 @@ export interface QueryExecutorOptions {
   useCache?: boolean;
 }
 
-export interface QueryWithResults {
+export interface QueryExecutorResult {
   query: MediaQueries;
   queryResults: MediaQueriesResults;
 }
@@ -73,7 +73,7 @@ export interface ViewManagerInterface {
   setViewWithMergedContext(context: ViewContext | null): void;
 
   isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean;
-  hasMajorMediaChange(oldView?: View | null): boolean;
+  hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean;
 }
 
 export class ViewNoCameraError extends FrigateCardError {}
diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts
index 7b40de0e..e63512db 100644
--- a/src/card-controller/view/view-manager.ts
+++ b/src/card-controller/view/view-manager.ts
@@ -2,21 +2,42 @@ import { ViewContext } from 'view';
 import { FrigateCardView } from '../../config/types';
 import { log } from '../../utils/debug';
 import { getStreamCameraID } from '../../utils/substream';
+import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
 import { View } from '../../view/view';
 import { getCameraIDsForViewName } from '../../view/view-to-cameras';
 import { CardViewAPI } from '../types';
 import { ViewFactory } from './factory';
-import { ViewFactoryOptions, ViewManagerEpoch, ViewManagerInterface } from './types';
+import {
+  QueryExecutorOptions,
+  ViewFactoryOptions,
+  ViewManagerEpoch,
+  ViewManagerInterface,
+  ViewModifier,
+} from './types';
+import { ViewQueryExecutor } from './view-query-executor';
+import { applyViewModifiers } from './modifiers';
 
 export class ViewManager implements ViewManagerInterface {
   protected _view: View | null = null;
-  protected _factory: ViewFactory;
+  protected _viewFactory: ViewFactory;
+  protected _viewQueryExecutor: ViewQueryExecutor;
   protected _api: CardViewAPI;
   protected _epoch: ViewManagerEpoch = this._createEpoch();
 
-  constructor(api: CardViewAPI, factory?: ViewFactory) {
+  // Used to mark as a view as "loading" with a given index. Each subsequent
+  // async update will use a higher index.
+  protected _loadingIndex = 1;
+
+  constructor(
+    api: CardViewAPI,
+    options?: {
+      viewFactory?: ViewFactory;
+      viewQueryExecutor?: ViewQueryExecutor;
+    },
+  ) {
     this._api = api;
-    this._factory = factory ?? new ViewFactory(api);
+    this._viewFactory = options?.viewFactory ?? new ViewFactory(api);
+    this._viewQueryExecutor = options?.viewQueryExecutor ?? new ViewQueryExecutor(api);
   }
 
   public getEpoch(): ViewManagerEpoch {
@@ -42,63 +63,168 @@ export class ViewManager implements ViewManagerInterface {
   }
 
   setViewDefault = (options?: ViewFactoryOptions): void =>
-    this._setViewGeneric(this._factory.getViewDefault.bind(this._factory), options);
+    this._setViewGeneric(
+      this._viewFactory.getViewDefault.bind(this._viewFactory),
+      options,
+    );
 
   setViewByParameters = (options?: ViewFactoryOptions): void =>
-    this._setViewGeneric(this._factory.getViewByParameters.bind(this._factory), options);
+    this._setViewGeneric(
+      this._viewFactory.getViewByParameters.bind(this._viewFactory),
+      options,
+    );
 
   setViewDefaultWithNewQuery = async (options?: ViewFactoryOptions): Promise<void> =>
-    await this._setViewGenericAsync(
-      this._factory.getViewDefaultWithNewQuery.bind(this._factory),
+    await this._setViewThenModifyAsync(
+      this._viewFactory.getViewDefault.bind(this._viewFactory),
+      this._viewQueryExecutor.getNewQueryModifiers.bind(this._viewQueryExecutor),
       options,
     );
 
   setViewByParametersWithNewQuery = async (
     options?: ViewFactoryOptions,
   ): Promise<void> =>
-    await this._setViewGenericAsync(
-      this._factory.getViewByParametersWithNewQuery.bind(this._factory),
+    await this._setViewThenModifyAsync(
+      this._viewFactory.getViewByParameters.bind(this._viewFactory),
+      this._viewQueryExecutor.getNewQueryModifiers.bind(this._viewQueryExecutor),
       options,
     );
 
   setViewByParametersWithExistingQuery = async (
     options?: ViewFactoryOptions,
   ): Promise<void> =>
-    await this._setViewGenericAsync(
-      this._factory.getViewByParametersWithExistingQuery.bind(this._factory),
+    await this._setViewThenModifyAsync(
+      this._viewFactory.getViewByParameters.bind(this._viewFactory),
+      this._viewQueryExecutor.getExistingQueryModifiers.bind(this._viewQueryExecutor),
       options,
     );
 
   protected _setViewGeneric(
-    factoryFunc: (options?: ViewFactoryOptions) => View | null,
+    viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
     options?: ViewFactoryOptions,
   ): void {
     let view: View | null = null;
     try {
-      view = factoryFunc({
+      view = viewFactoryFunc({
         baseView: this._view,
         ...options,
       });
     } catch (e) {
-      return this._api.getMessageManager().setErrorIfHigherPriority(e);
+      this._api.getMessageManager().setErrorIfHigherPriority(e);
     }
     view && this._setView(view);
   }
 
-  protected async _setViewGenericAsync(
-    factoryFunc: (options?: ViewFactoryOptions) => Promise<View | null>,
+  protected _markViewLoadingQuery(view: View, index: number): View {
+    return view.mergeInContext({ loading: { query: index } });
+  }
+  protected _markViewAsNotLoadingQuery(view: View): View {
+    return view.removeContextProperty('loading', 'query');
+  }
+
+  protected async _setViewThenModifyAsync(
+    viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
+    viewModifiersFunc: (
+      view: View,
+      queryExecutorOptions?: QueryExecutorOptions,
+    ) => Promise<ViewModifier[] | null>,
     options?: ViewFactoryOptions,
   ): Promise<void> {
-    let view: View | null = null;
+    let initialView: View | null = null;
     try {
-      view = await factoryFunc({
+      initialView = viewFactoryFunc({
         baseView: this._view,
         ...options,
+        params: {
+          query: null,
+          queryResults: null,
+          ...options?.params,
+        },
       });
     } catch (e) {
-      return this._api.getMessageManager().setErrorIfHigherPriority(e);
+      this._api.getMessageManager().setErrorIfHigherPriority(e);
     }
-    view && this._setView(view);
+
+    if (!initialView) {
+      return;
+    }
+
+    if (this._view && this._shouldAdoptQueryAndResults(initialView)) {
+      initialView.query = this._view.query;
+      initialView.queryResults = this._view.queryResults;
+      this._markViewAsNotLoadingQuery(initialView);
+      this._setView(initialView);
+      return;
+    }
+
+    // Mark the view as loading with the current value of _updateIndex. This is
+    // used to ensure that the loading state is subsequently only removed for
+    // _this_ async update.
+    const loadingIndex = this._loadingIndex++;
+    this._markViewLoadingQuery(initialView, loadingIndex);
+
+    this._setView(initialView);
+
+    let viewModifiers: ViewModifier[] | null = null;
+    let error: Error | null = null;
+    try {
+      viewModifiers = await viewModifiersFunc(
+        initialView,
+        options?.queryExecutorOptions,
+      );
+    } catch (e) {
+      error = e as Error;
+    }
+
+    if (this._view && this.hasMajorMediaChange(this._view, initialView)) {
+      // If there has been a major media change in the time async operations
+      // have occurred, ignore the result. For example: A slow Reolink query is
+      // dispatched, the user changes the view in the interim, then the query
+      // returns -- it should not be applied, nor should any errors be shown. On
+      // the contrary, small changes such as the user zooming in are fine to
+      // merge into the resultant view.
+      if (this._view.context?.loading?.query === loadingIndex) {
+        this._setView(this._markViewAsNotLoadingQuery(this._view.clone()));
+      }
+      return;
+    }
+
+    if (error) {
+      this._api.getMessageManager().setErrorIfHigherPriority(error);
+      return;
+    }
+
+    /* istanbul ignore if: the if path cannot be reached as the view is set
+    above -- @preserve */
+    if (!this._view) {
+      return;
+    }
+
+    const newView = this._view.clone();
+    if (this._view.context?.loading?.query === loadingIndex) {
+      this._markViewAsNotLoadingQuery(newView);
+    }
+    applyViewModifiers(newView, viewModifiers);
+    this._setView(newView);
+  }
+
+  protected _shouldAdoptQueryAndResults(newView: View): boolean {
+    // If the user is currently using the viewer, and then switches to the
+    // gallery we make an attempt to keep the query/queryResults the same so
+    // the gallery can be used to click back and forth to the viewer, and the
+    // selected media can be centered in the gallery. See the matching code in
+    // `updated()` in `gallery.ts`. We specifically must ensure that the new
+    // target media of the gallery (e.g. clips, snapshots or recordings) is
+    // equal to the queries that are currently used in the viewer.
+    //
+    // See: https://github.com/dermotduffy/frigate-hass-card/issues/885
+
+    const switchingFromViewerToGallery =
+      this._view?.isViewerView() && newView?.isGalleryView();
+    const newMediaType = newView?.getDefaultMediaType();
+    const alreadyHasMatchingQuery =
+      MediaQueriesClassifier.getMediaType(this._view?.query) === newMediaType;
+    return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
   }
 
   public setViewWithMergedContext(context: ViewContext | null): void {
@@ -116,20 +242,23 @@ export class ViewManager implements ViewManagerInterface {
    * @param oldView The previous view.
    * @returns True if the view change is a real media change.
    */
-  public hasMajorMediaChange(oldView?: View | null): boolean {
+  public hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean {
+    const compareView = newView ?? this._view;
+
     return (
-      !!oldView !== !!this._view ||
-      oldView?.view !== this._view?.view ||
-      oldView?.camera !== this._view?.camera ||
+      !!oldView !== !!compareView ||
+      oldView?.view !== compareView?.view ||
+      oldView?.camera !== compareView?.camera ||
       // When in live mode, take overrides (substreams) into account in deciding
       // if this is a major media change.
-      (this._view?.view === 'live' &&
+      (compareView?.view === 'live' &&
         oldView &&
-        getStreamCameraID(oldView) !== getStreamCameraID(this._view)) ||
+        getStreamCameraID(oldView) !== getStreamCameraID(compareView)) ||
       // When in the live view, the queryResults contain the events that
       // happened in the past -- not reflective of the actual live media viewer
       // the user is seeing.
-      (this._view?.view !== 'live' && oldView?.queryResults !== this._view?.queryResults)
+      (compareView?.view !== 'live' &&
+        oldView?.queryResults !== compareView?.queryResults)
     );
   }
 
@@ -139,12 +268,14 @@ export class ViewManager implements ViewManagerInterface {
     // query actions (called at least once per render cycle).
     // Related: https://github.com/dermotduffy/frigate-hass-card/issues/1200
     if (!this._api.getQueryStringManager().hasViewRelatedActionsToRun()) {
-      await this.setViewDefaultWithNewQuery({ failSafe: true });
+      // This is not awaited to allow the initialization to complete before the
+      // query is answered.
+      this.setViewDefaultWithNewQuery({ failSafe: true });
     }
     return true;
   };
 
-  protected _setView(view: View | null): void {
+  protected _setView(view: Readonly<View> | null): void {
     const oldView = this._view;
 
     log(
diff --git a/src/card-controller/view/view-query-executor.ts b/src/card-controller/view/view-query-executor.ts
new file mode 100644
index 00000000..942caa41
--- /dev/null
+++ b/src/card-controller/view/view-query-executor.ts
@@ -0,0 +1,178 @@
+import { sub } from 'date-fns';
+import { ClipsOrSnapshotsOrAll } from '../../types';
+import { View } from '../../view/view';
+import { CardViewAPI } from '../types';
+import { MergeContextViewModifier } from './modifiers/merge-context';
+import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property';
+import { SetQueryViewModifier } from './modifiers/set-query';
+import { QueryExecutor } from './query-executor';
+import { QueryExecutorOptions, ViewModifier } from './types';
+
+/**
+ * This class executes media queries and returns an array of ViewModifiers that
+ * can be applied to a view. This allows a view to be set when the user acts,
+ * and if a query is made as part of this view the result can be applied later.
+ */
+export class ViewQueryExecutor {
+  protected _api: CardViewAPI;
+  protected _executor: QueryExecutor;
+
+  constructor(api: CardViewAPI, executor?: QueryExecutor) {
+    this._api = api;
+    this._executor = executor ?? new QueryExecutor(api);
+  }
+
+  public async getExistingQueryModifiers(
+    view: View,
+    queryExecutorOptions?: QueryExecutorOptions,
+  ): Promise<ViewModifier[] | null> {
+    return view.query
+      ? [
+          new SetQueryViewModifier({
+            queryResults: await this._executor.execute(view.query, queryExecutorOptions),
+          }),
+        ]
+      : [];
+  }
+
+  public async getNewQueryModifiers(
+    view: View,
+    queryExecutorOptions?: QueryExecutorOptions,
+  ): Promise<ViewModifier[] | null> {
+    return await this._executeNewQuery(view, {
+      useCache: false,
+      ...queryExecutorOptions,
+    });
+  }
+
+  protected async _executeNewQuery(
+    view: View,
+    queryExecutorOptions?: QueryExecutorOptions,
+  ): Promise<ViewModifier[] | null> {
+    const config = this._api.getConfigManager().getConfig();
+    if (!config) {
+      return null;
+    }
+
+    const mediaType = view?.getDefaultMediaType();
+    const viewModifiers: ViewModifier[] = [];
+
+    const executeMediaQuery = async (
+      mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
+    ): Promise<ViewModifier[]> => {
+      /* istanbul ignore if: this path cannot be reached -- @preserve */
+      if (!mediaType) {
+        return [];
+      }
+
+      const results =
+        mediaType === 'recordings'
+          ? await this._executor.executeDefaultRecordingQuery({
+              ...(!view.isGrid() && { cameraID: view.camera }),
+              executorOptions: queryExecutorOptions,
+            })
+          : mediaType === 'clips' || mediaType === 'snapshots' || mediaType === 'all'
+            ? await this._executor.executeDefaultEventQuery({
+                ...(!view.isGrid() && { cameraID: view.camera }),
+                eventsMediaType: mediaType,
+                executorOptions: queryExecutorOptions,
+              })
+            : /* istanbul ignore next -- @preserve */
+              null;
+
+      return results ? [new SetQueryViewModifier(results)] : [];
+    };
+
+    switch (view.view) {
+      case 'live':
+        if (config.live.controls.thumbnails.mode !== 'none') {
+          viewModifiers.push(
+            ...(await executeMediaQuery(
+              config.live.controls.thumbnails.media_type === 'recordings'
+                ? 'recordings'
+                : config.live.controls.thumbnails.events_media_type,
+            )),
+          );
+        }
+        break;
+
+      case 'media':
+        // If the user is looking at media in the `media` view and then
+        // changes camera (via the menu) it should default to showing clips
+        // for the new camera.
+        viewModifiers.push(...(await executeMediaQuery('clips')));
+        break;
+
+      // Gallery views:
+      case 'clips':
+      case 'snapshots':
+      case 'recordings':
+        viewModifiers.push(...(await executeMediaQuery(mediaType)));
+        break;
+
+      // Viewer views:
+      case 'clip':
+      case 'snapshot':
+      case 'recording':
+        if (config.media_viewer.controls.thumbnails.mode !== 'none') {
+          viewModifiers.push(...(await executeMediaQuery(mediaType)));
+        }
+        break;
+    }
+
+    viewModifiers.push(...this._getTimelineWindowViewModifier(view));
+    viewModifiers.push(
+      ...this._getSeekTimeModifier(queryExecutorOptions?.selectResult?.time?.time),
+    );
+    return viewModifiers;
+  }
+
+  protected _getTimelineWindowViewModifier(view: View): ViewModifier[] {
+    if (view.is('live')) {
+      // For live views, always force the timeline to now, regardless of
+      // presence or not of events.
+      const now = new Date();
+      const liveConfig = this._api.getConfigManager().getConfig()?.live;
+
+      /* istanbul ignore if: this if branch cannot be reached as if the config is
+         empty this function is never called -- @preserve */
+      if (!liveConfig) {
+        return [];
+      }
+
+      return [
+        new MergeContextViewModifier({
+          // Force the window to start at the most recent time, not
+          // necessarily when the most recent event/recording was:
+          // https://github.com/dermotduffy/frigate-hass-card/issues/1301
+          timeline: {
+            window: {
+              start: sub(now, {
+                seconds: liveConfig.controls.timeline.window_seconds,
+              }),
+              end: now,
+            },
+          },
+        }),
+      ];
+    } else {
+      // For non-live views stick to default timeline behavior (will select and
+      // scroll to event).
+      return [new RemoveContextPropertyViewModifier('timeline', 'window')];
+    }
+  }
+
+  protected _getSeekTimeModifier(time?: Date): ViewModifier[] {
+    if (time) {
+      return [
+        new MergeContextViewModifier({
+          mediaViewer: {
+            seek: time,
+          },
+        }),
+      ];
+    } else {
+      return [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')];
+    }
+  }
+}
diff --git a/src/components/gallery.ts b/src/components/gallery.ts
index 3a32ff9d..36da8b25 100644
--- a/src/components/gallery.ts
+++ b/src/components/gallery.ts
@@ -401,10 +401,14 @@ export class FrigateCardGalleryCore extends LitElement {
       // handle (as typical), but rather directly rendering the message into the
       // gallery. This is to allow the filter to still be available when a given
       // filter selection returns no media.
+      const loadingMedia = !!view?.context?.loading?.query;
       return renderMessage({
         type: 'info',
-        message: localize('common.no_media'),
+        message: loadingMedia
+          ? localize('error.awaiting_media')
+          : localize('common.no_media'),
         icon: 'mdi:multimedia',
+        dotdotdot: loadingMedia,
       });
     }
 
diff --git a/src/components/viewer/index.ts b/src/components/viewer/index.ts
index 1f0df565..5c10ff30 100644
--- a/src/components/viewer/index.ts
+++ b/src/components/viewer/index.ts
@@ -56,10 +56,15 @@ export class FrigateCardViewer extends LitElement {
       // Directly render an error message (instead of dispatching it upwards)
       // to preserve the mini-timeline if the user pans into an area with no
       // media.
+      const loadingMedia =
+        !!this.viewManagerEpoch.manager.getView()?.context?.loading?.query;
       return renderMessage({
         type: 'info',
-        message: localize('common.no_media'),
+        message: loadingMedia
+          ? localize('error.awaiting_media')
+          : localize('common.no_media'),
         icon: 'mdi:multimedia',
+        dotdotdot: loadingMedia,
       });
     }
 
diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json
index 743d63c4..5dce88f6 100644
--- a/src/localize/languages/ca.json
+++ b/src/localize/languages/ca.json
@@ -127,6 +127,39 @@
         },
         "url": "URL de la interfície d'usuari de MotionEye"
       },
+      "proxy": {
+        "dynamic": "",
+        "editor_label": "",
+        "media": {
+          "auto": "",
+          "editor_label": "",
+          "false": "",
+          "true": ""
+        },
+        "ssl_ciphers": {
+          "auto": "",
+          "default": "",
+          "editor_label": "",
+          "insecure": "",
+          "intermediate": "",
+          "modern": ""
+        },
+        "ssl_verification": {
+          "auto": "",
+          "editor_label": "",
+          "false": "",
+          "true": ""
+        }
+      },
+      "reolink": {
+        "editor_label": "",
+        "media_resolution": {
+          "editor_label": "",
+          "high": "",
+          "low": ""
+        },
+        "url": ""
+      },
       "title": "Títol d'aquesta càmera (detecció automàtica de l'entitat)",
       "triggers": {
         "editor_label": "Opcions d'activació",
@@ -574,7 +607,9 @@
   },
   "error": {
     "awaiting_live": "S'està esperant que es carregui la transmissió en directe ...",
+    "awaiting_media": "",
     "camera_initialization": "",
+    "camera_initialization_reolink": "",
     "could_not_render_elements": "No s'han pogut representar els elements de la imatge",
     "could_not_resolve": "No s'ha pogut resoldre l'URL multimèdia",
     "diagnostics": "Diagnòstic de targetes. Reviseu la informació confidencial abans de compartir-la",
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json
index 789dcc7f..0c52e71f 100644
--- a/src/localize/languages/en.json
+++ b/src/localize/languages/en.json
@@ -607,6 +607,7 @@
   },
   "error": {
     "awaiting_live": "Waiting for live stream to load ...",
+    "awaiting_media": "Waiting for media to load",
     "camera_initialization": "Camera initialization failed",
     "camera_initialization_reolink": "Could not initialize Reolink camera",
     "could_not_render_elements": "Could not render picture elements",
diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json
index f990fd57..8aa41db8 100644
--- a/src/localize/languages/fr.json
+++ b/src/localize/languages/fr.json
@@ -127,6 +127,39 @@
         },
         "url": "URL de l’interface MotionEye"
       },
+      "proxy": {
+        "dynamic": "",
+        "editor_label": "",
+        "media": {
+          "auto": "",
+          "editor_label": "",
+          "false": "",
+          "true": ""
+        },
+        "ssl_ciphers": {
+          "auto": "",
+          "default": "",
+          "editor_label": "",
+          "insecure": "",
+          "intermediate": "",
+          "modern": ""
+        },
+        "ssl_verification": {
+          "auto": "",
+          "editor_label": "",
+          "false": "",
+          "true": ""
+        }
+      },
+      "reolink": {
+        "editor_label": "",
+        "media_resolution": {
+          "editor_label": "",
+          "high": "",
+          "low": ""
+        },
+        "url": ""
+      },
       "title": "Titre de cette caméra (détection automatique à partir de l'entité)",
       "triggers": {
         "editor_label": "Modèle de répertoire des films",
@@ -574,7 +607,9 @@
   },
   "error": {
     "awaiting_live": "En attente du flux en direct...",
+    "awaiting_media": "",
     "camera_initialization": "",
+    "camera_initialization_reolink": "",
     "could_not_render_elements": "Impossible de restituer les éléments de l'image",
     "could_not_resolve": "Impossible de résoudre l'URL du média",
     "diagnostics": "Diagnostic de la carte. Veuillez enlever les informations confidentielles avant de les partager",
diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json
index b9446c32..a455579a 100644
--- a/src/localize/languages/it.json
+++ b/src/localize/languages/it.json
@@ -127,6 +127,39 @@
         },
         "url": "URL dell'interfaccia utente di MotionEye"
       },
+      "proxy": {
+        "dynamic": "",
+        "editor_label": "",
+        "media": {
+          "auto": "",
+          "editor_label": "",
+          "false": "",
+          "true": ""
+        },
+        "ssl_ciphers": {
+          "auto": "",
+          "default": "",
+          "editor_label": "",
+          "insecure": "",
+          "intermediate": "",
+          "modern": ""
+        },
+        "ssl_verification": {
+          "auto": "",
+          "editor_label": "",
+          "false": "",
+          "true": ""
+        }
+      },
+      "reolink": {
+        "editor_label": "",
+        "media_resolution": {
+          "editor_label": "",
+          "high": "",
+          "low": ""
+        },
+        "url": ""
+      },
       "title": "Titolo per questa telecamera (Autoidentificato dall'entità)",
       "triggers": {
         "editor_label": "Trigger Opzioni",
@@ -574,7 +607,9 @@
   },
   "error": {
     "awaiting_live": "",
+    "awaiting_media": "",
     "camera_initialization": "",
+    "camera_initialization_reolink": "",
     "could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine",
     "could_not_resolve": "Impossibile risolvere l'URL dei media",
     "diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere",
diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json
index e820e875..7bb4b6b3 100644
--- a/src/localize/languages/pt-BR.json
+++ b/src/localize/languages/pt-BR.json
@@ -127,6 +127,39 @@
         },
         "url": "URL da interface de usuário do MotionEye"
       },
+      "proxy": {
+        "dynamic": "",
+        "editor_label": "",
+        "media": {
+          "auto": "",
+          "editor_label": "",
+          "false": "",
+          "true": ""
+        },
+        "ssl_ciphers": {
+          "auto": "",
+          "default": "",
+          "editor_label": "",
+          "insecure": "",
+          "intermediate": "",
+          "modern": ""
+        },
+        "ssl_verification": {
+          "auto": "",
+          "editor_label": "",
+          "false": "",
+          "true": ""
+        }
+      },
+      "reolink": {
+        "editor_label": "",
+        "media_resolution": {
+          "editor_label": "",
+          "high": "",
+          "low": ""
+        },
+        "url": ""
+      },
       "title": "Título para esta câmera (detectado automaticamente pela entidade)",
       "triggers": {
         "editor_label": "Opções de acionamento",
@@ -574,7 +607,9 @@
   },
   "error": {
     "awaiting_live": "",
+    "awaiting_media": "",
     "camera_initialization": "",
+    "camera_initialization_reolink": "",
     "could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
     "could_not_resolve": "Não foi possível resolver o URL de mídia",
     "diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar",
diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json
index 6306ff65..f11e7e5e 100644
--- a/src/localize/languages/pt-PT.json
+++ b/src/localize/languages/pt-PT.json
@@ -127,6 +127,39 @@
         },
         "url": "Link"
       },
+      "proxy": {
+        "dynamic": "",
+        "editor_label": "",
+        "media": {
+          "auto": "",
+          "editor_label": "",
+          "false": "",
+          "true": ""
+        },
+        "ssl_ciphers": {
+          "auto": "",
+          "default": "",
+          "editor_label": "",
+          "insecure": "",
+          "intermediate": "",
+          "modern": ""
+        },
+        "ssl_verification": {
+          "auto": "",
+          "editor_label": "",
+          "false": "",
+          "true": ""
+        }
+      },
+      "reolink": {
+        "editor_label": "",
+        "media_resolution": {
+          "editor_label": "",
+          "high": "",
+          "low": ""
+        },
+        "url": ""
+      },
       "title": "Título para esta câmera (detectado automaticamente pela entidade)",
       "triggers": {
         "editor_label": "Opções de activação",
@@ -574,7 +607,9 @@
   },
   "error": {
     "awaiting_live": "",
+    "awaiting_media": "",
     "camera_initialization": "",
+    "camera_initialization_reolink": "",
     "could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
     "could_not_resolve": "Não foi possível resolver o URL de mídia",
     "diagnostics": "Diagnósticos do cartão. Reveja as informações confidenciais antes de partilhar",
diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts
index b8483a8b..5b6c4e46 100644
--- a/src/utils/diagnostics.ts
+++ b/src/utils/diagnostics.ts
@@ -57,7 +57,7 @@ export interface Diagnostics {
   };
 }
 
-export const getIntegrationDiagnostics = async (
+const getIntegrationDiagnostics = async (
   integration: string,
   hass?: HomeAssistant,
 ): Promise<IntegrationDiagnostics> => {
diff --git a/src/view/view.ts b/src/view/view.ts
index 8ba9d28e..36c12024 100644
--- a/src/view/view.ts
+++ b/src/view/view.ts
@@ -4,6 +4,14 @@ import { FrigateCardView, ViewDisplayMode } from '../config/types.js';
 import { MediaQueries } from './media-queries';
 import { MediaQueriesResults } from './media-queries-results';
 
+declare module 'view' {
+  interface ViewContext {
+    loading?: {
+      query?: unknown;
+    };
+  }
+}
+
 interface ViewEvolveParameters {
   view?: FrigateCardView;
   camera?: string;


Test patch:
diff --git a/tests/card-controller/view/factory.test.ts b/tests/card-controller/view/factory.test.ts
index 6a293be7..c1c770af 100644
--- a/tests/card-controller/view/factory.test.ts
+++ b/tests/card-controller/view/factory.test.ts
@@ -1,15 +1,7 @@
-import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
-import { mock } from 'vitest-mock-extended';
-import { QueryType } from '../../../src/camera-manager/types';
+import { describe, expect, it, vi } from 'vitest';
 import { ViewFactory } from '../../../src/card-controller/view/factory';
-import { QueryExecutor } from '../../../src/card-controller/view/query-executor';
 import { ViewModifier } from '../../../src/card-controller/view/types';
 import { FrigateCardView, ViewDisplayMode } from '../../../src/config/types';
-import {
-  EventMediaQueries,
-  RecordingMediaQueries,
-} from '../../../src/view/media-queries';
-import { MediaQueriesResults } from '../../../src/view/media-queries-results';
 import { View } from '../../../src/view/view';
 import {
   createCameraManager,
@@ -329,496 +321,36 @@ describe('getViewByParameters', () => {
   });
 
   describe('should get correct default display mode', () => {
-    describe.each([['single' as const], ['grid' as const]])(
-      '%s',
-      (displayMode: ViewDisplayMode) => {
-        it.each([
-          ['media' as const],
-          ['clip' as const],
-          ['recording' as const],
-          ['snapshot' as const],
-          ['live' as const],
-        ])('%s', (viewName: FrigateCardView) => {
-          const api = createPopulatedAPI({
-            media_viewer: {
-              display: {
-                mode: displayMode,
-              },
-            },
-            live: {
-              display: {
-                mode: displayMode,
-              },
-            },
-          });
-
-          const factory = new ViewFactory(api);
-          expect(
-            factory.getViewByParameters({
-              params: {
-                view: viewName,
-              },
-            })?.displayMode,
-          ).toBe(displayMode);
-        });
-      },
-    );
-  });
-});
-
-describe('getViewByParametersWithNewQuery', () => {
-  it('should not execute query without config', async () => {
-    const factory = new ViewFactory(createCardAPI());
-    expect(await factory.getViewByParametersWithNewQuery()).toBeNull();
-  });
-
-  describe('with a live view', () => {
-    beforeAll(() => {
-      vi.useFakeTimers();
-      vi.setSystemTime(new Date('2024-07-21T13:22:06Z'));
-    });
-
-    afterAll(() => {
-      vi.useRealTimers();
-    });
-
-    describe('should set timeline window', async () => {
-      it('should set timeline to now for live views', async () => {
-        const executor = mock<QueryExecutor>();
-        const factory = new ViewFactory(createPopulatedAPI(), executor);
-        const view = await factory.getViewByParametersWithNewQuery({
-          params: {
-            view: 'live',
-          },
-        });
-
-        expect(view?.context).toEqual({
-          timeline: {
-            window: {
-              start: new Date('2024-07-21T12:22:06.000Z'),
-              end: new Date('2024-07-21T13:22:06.000Z'),
-            },
-          },
-        });
-      });
-
-      it('should unset timeline for non-live views', async () => {
-        const executor = mock<QueryExecutor>();
-        const factory = new ViewFactory(createPopulatedAPI(), executor);
-        const view = await factory.getViewByParametersWithNewQuery({
-          baseView: createView({
-            context: {
-              timeline: {
-                window: {
-                  start: new Date('2024-07-21T12:22:06.000Z'),
-                  end: new Date('2024-07-21T13:22:06.000Z'),
-                },
-              },
-            },
-          }),
-          params: {
-            view: 'clip',
-          },
-        });
-
-        expect(view?.context).toEqual({ timeline: {} });
-      });
-    });
-
-    it('should not fetch anything if configured for no thumbnails', async () => {
-      const executor = mock<QueryExecutor>();
-      const factory = new ViewFactory(
-        createPopulatedAPI({
-          live: {
-            controls: {
-              thumbnails: {
-                mode: 'none' as const,
-              },
-            },
+    describe.each([
+      ['single' as const, { mode: 'single' as const }],
+      ['grid' as const, { mode: 'grid' as const }],
+      ['single' as const, undefined],
+    ])('%s', (expectedDisplayMode: ViewDisplayMode, displayConfig?: unknown) => {
+      it.each([
+        ['media' as const],
+        ['clip' as const],
+        ['recording' as const],
+        ['snapshot' as const],
+        ['live' as const],
+      ])('%s', (viewName: FrigateCardView) => {
+        const api = createPopulatedAPI({
+          media_viewer: {
+            display: displayConfig,
           },
-        }),
-        executor,
-      );
-      const view = await factory.getViewByParametersWithNewQuery({
-        params: {
-          view: 'live',
-        },
-      });
-
-      expect(view?.query).toBeNull();
-      expect(view?.queryResults).toBeNull();
-      expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
-      expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
-    });
-
-    it('should fetch events', async () => {
-      const executor = mock<QueryExecutor>();
-      const query = new EventMediaQueries();
-      const queryResults = new MediaQueriesResults();
-
-      executor.executeDefaultEventQuery.mockResolvedValue({
-        query: query,
-        queryResults: queryResults,
-      });
-
-      const factory = new ViewFactory(createPopulatedAPI(), executor);
-      const view = await factory.getViewByParametersWithNewQuery({
-        params: {
-          view: 'live',
-        },
-      });
-
-      expect(view?.query).toBe(query);
-      expect(view?.queryResults).toBe(queryResults);
-      expect(executor.executeDefaultEventQuery).toBeCalledWith({
-        cameraID: 'camera.office',
-        eventsMediaType: 'all',
-        executorOptions: {
-          useCache: false,
-        },
-      });
-      expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
-    });
-
-    it('should fetch recordings', async () => {
-      const executor = mock<QueryExecutor>();
-      const query = new RecordingMediaQueries();
-      const queryResults = new MediaQueriesResults();
-
-      executor.executeDefaultRecordingQuery.mockResolvedValue({
-        query: query,
-        queryResults: queryResults,
-      });
-
-      const factory = new ViewFactory(
-        createPopulatedAPI({
           live: {
-            controls: {
-              thumbnails: {
-                media_type: 'recordings',
-              },
-            },
-          },
-        }),
-        executor,
-      );
-      const view = await factory.getViewByParametersWithNewQuery({
-        params: {
-          view: 'live',
-        },
-      });
-
-      expect(view?.query).toBe(query);
-      expect(view?.queryResults).toBe(queryResults);
-      expect(executor.executeDefaultEventQuery).not.toBeCalled();
-      expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
-        cameraID: 'camera.office',
-        executorOptions: {
-          useCache: false,
-        },
-      });
-    });
-  });
-
-  describe('with a media view', () => {
-    it('should do nothing with same camera', async () => {
-      const executor = mock<QueryExecutor>();
-      const factory = new ViewFactory(createPopulatedAPI(), executor);
-      const baseView = new View({
-        view: 'media',
-        camera: 'camera.office',
-      });
-      const view = await factory.getViewByParametersWithNewQuery({
-        baseView: baseView,
-        params: {
-          view: 'media',
-        },
-      });
-
-      expect(view?.query).toBeNull();
-      expect(view?.queryResults).toBeNull();
-      expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
-      expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
-    });
-
-    it('should fetch clips with different camera', async () => {
-      const executor = mock<QueryExecutor>();
-      const query = new EventMediaQueries();
-      const queryResults = new MediaQueriesResults();
-
-      executor.executeDefaultEventQuery.mockResolvedValue({
-        query: query,
-        queryResults: queryResults,
-      });
-
-      const factory = new ViewFactory(createPopulatedAPI(), executor);
-      const baseView = new View({
-        view: 'media',
-        camera: 'camera.office',
-      });
-      const view = await factory.getViewByParametersWithNewQuery({
-        baseView: baseView,
-        params: {
-          view: 'media',
-          camera: 'camera.kitchen',
-        },
-      });
-
-      expect(view?.query).toBe(query);
-      expect(view?.queryResults).toBe(queryResults);
-      expect(executor.executeDefaultEventQuery).toBeCalledWith({
-        cameraID: 'camera.kitchen',
-        eventsMediaType: 'clips',
-        executorOptions: {
-          useCache: false,
-        },
-      });
-      expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
-    });
-  });
-
-  describe('with an events-based view', () => {
-    it.each([
-      ['clip' as const, 'clips' as const],
-      ['clips' as const, 'clips' as const],
-      ['snapshot' as const, 'snapshots' as const],
-      ['snapshots' as const, 'snapshots' as const],
-    ])(
-      '%s',
-      async (viewName: FrigateCardView, eventsMediaType: 'clips' | 'snapshots') => {
-        const executor = mock<QueryExecutor>();
-        const query = new EventMediaQueries();
-        const queryResults = new MediaQueriesResults();
-
-        executor.executeDefaultEventQuery.mockResolvedValue({
-          query: query,
-          queryResults: queryResults,
-        });
-
-        const factory = new ViewFactory(createPopulatedAPI(), executor);
-        const view = await factory.getViewByParametersWithNewQuery({
-          params: {
-            view: viewName,
-          },
-        });
-
-        expect(view?.query).toBe(query);
-        expect(view?.queryResults).toBe(queryResults);
-        expect(executor.executeDefaultEventQuery).toBeCalledWith({
-          cameraID: 'camera.office',
-          eventsMediaType: eventsMediaType,
-          executorOptions: {
-            useCache: false,
-          },
-        });
-        expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
-      },
-    );
-  });
-
-  describe('with an recordings-based view', () => {
-    it.each([['recording' as const], ['recordings' as const]])(
-      '%s',
-      async (viewName: FrigateCardView) => {
-        const executor = mock<QueryExecutor>();
-        const query = new RecordingMediaQueries();
-        const queryResults = new MediaQueriesResults();
-
-        executor.executeDefaultRecordingQuery.mockResolvedValue({
-          query: query,
-          queryResults: queryResults,
-        });
-
-        const factory = new ViewFactory(createPopulatedAPI(), executor);
-        const view = await factory.getViewByParametersWithNewQuery({
-          params: {
-            view: viewName,
-          },
-        });
-
-        expect(view?.query).toBe(query);
-        expect(view?.queryResults).toBe(queryResults);
-        expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
-        expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
-          cameraID: 'camera.office',
-          executorOptions: {
-            useCache: false,
+            display: displayConfig,
           },
         });
-      },
-    );
-  });
-
-  describe('with an media viewer view', () => {
-    it('hould not fetch anything if configured for no thumbnails', async () => {
-      const executor = mock<QueryExecutor>();
-      const factory = new ViewFactory(
-        createPopulatedAPI({
-          media_viewer: {
-            controls: {
-              thumbnails: {
-                mode: 'none' as const,
-              },
-            },
-          },
-        }),
-        executor,
-      );
-      const view = await factory.getViewByParametersWithNewQuery({
-        params: {
-          view: 'clip',
-        },
-      });
-
-      expect(view?.query).toBeNull();
-      expect(view?.queryResults).toBeNull();
-      expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
-      expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
-    });
-  });
-
-  describe('when changing to gallery from the media viewer', () => {
-    it('should adopt query and results', async () => {
-      const executor = mock<QueryExecutor>();
-      const factory = new ViewFactory(createPopulatedAPI(), executor);
-
-      const baseView = new View({
-        view: 'media',
-        camera: 'camera.office',
-        query: new EventMediaQueries([
-          {
-            type: QueryType.Event,
-            cameraIDs: new Set(['camera.office']),
-            hasClip: true,
-          },
-        ]),
-        queryResults: new MediaQueriesResults(),
-      });
-
-      const view = await factory.getViewByParametersWithNewQuery({
-        baseView: baseView,
-        params: {
-          view: 'clips',
-        },
-      });
 
-      expect(view?.query).toBe(baseView.query);
-      expect(view?.queryResults).toBe(baseView.queryResults);
-      expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
-      expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
-    });
-  });
-
-  describe('when set or remove seek time', () => {
-    it('should set seek time when results are selected based on time', async () => {
-      const now = new Date();
-      const executor = mock<QueryExecutor>();
-      const factory = new ViewFactory(createPopulatedAPI(), executor);
-
-      const view = await factory.getViewByParametersWithNewQuery({
-        params: {
-          view: 'clips',
-        },
-        queryExecutorOptions: {
-          selectResult: {
-            time: {
-              time: now,
-            },
-          },
-        },
-      });
-
-      expect(view?.context).toEqual({
-        mediaViewer: {
-          seek: now,
-        },
-      });
-    });
-
-    it('should remove seek time when results are not selected based on time', async () => {
-      const executor = mock<QueryExecutor>();
-      const factory = new ViewFactory(createPopulatedAPI(), executor);
-
-      const view = await factory.getViewByParametersWithNewQuery({
-        baseView: new View({
-          view: 'clips',
-          camera: 'camera.office',
-          context: {
-            mediaViewer: {
-              seek: new Date(),
+        const factory = new ViewFactory(api);
+        expect(
+          factory.getViewByParameters({
+            params: {
+              view: viewName,
             },
-          },
-        }),
-        params: {
-          view: 'clips',
-        },
+          })?.displayMode,
+        ).toBe(expectedDisplayMode);
       });
-
-      expect(view?.context?.mediaViewer?.seek).toBeUndefined();
     });
   });
 });
-
-describe('getViewDefaultWithNewQuery', () => {
-  it('should fetch events', async () => {
-    const executor = mock<QueryExecutor>();
-    const query = new EventMediaQueries();
-    const queryResults = new MediaQueriesResults();
-
-    executor.executeDefaultEventQuery.mockResolvedValue({
-      query: query,
-      queryResults: queryResults,
-    });
-
-    const factory = new ViewFactory(createPopulatedAPI(), executor);
-    const view = await factory.getViewDefaultWithNewQuery();
-
-    expect(view?.query).toBe(query);
-    expect(view?.queryResults).toBe(queryResults);
-    expect(executor.executeDefaultEventQuery).toBeCalledWith({
-      cameraID: 'camera.office',
-      eventsMediaType: 'all',
-      executorOptions: {
-        useCache: false,
-      },
-    });
-    expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
-  });
-});
-
-describe('getViewByParametersWithExistingQuery', () => {
-  it('should not execute anything when query is absent', async () => {
-    const executor = mock<QueryExecutor>();
-    const factory = new ViewFactory(createPopulatedAPI(), executor);
-    const view = await factory.getViewByParametersWithExistingQuery({
-      params: {
-        view: 'live',
-        camera: 'camera.office',
-      },
-    });
-
-    expect(view?.query).toBeNull();
-    expect(view?.queryResults).toBeNull();
-    expect(executor.executeDefaultEventQuery).not.toBeCalled();
-    expect(executor.executeDefaultRecordingQuery).not.toBeCalled();
-  });
-
-  it('should set query results', async () => {
-    const executor = mock<QueryExecutor>();
-    const queryResults = new MediaQueriesResults();
-    executor.execute.mockResolvedValue(queryResults);
-
-    const factory = new ViewFactory(createPopulatedAPI(), executor);
-    const query = new RecordingMediaQueries();
-    const view = await factory.getViewByParametersWithExistingQuery({
-      params: {
-        view: 'live',
-        camera: 'camera.office',
-        query: query,
-      },
-    });
-
-    expect(view?.query).toBe(query);
-    expect(view?.queryResults).toBe(queryResults);
-  });
-});
diff --git a/tests/card-controller/view/modifiers/index.test.ts b/tests/card-controller/view/modifiers/index.test.ts
new file mode 100644
index 00000000..d5f38a13
--- /dev/null
+++ b/tests/card-controller/view/modifiers/index.test.ts
@@ -0,0 +1,32 @@
+import { expect, it } from 'vitest';
+import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers';
+import { MergeContextViewModifier } from '../../../../src/card-controller/view/modifiers/merge-context';
+import { SetQueryViewModifier } from '../../../../src/card-controller/view/modifiers/set-query';
+import { EventMediaQueries } from '../../../../src/view/media-queries';
+import { MediaQueriesResults } from '../../../../src/view/media-queries-results';
+import { createView } from '../../../test-utils';
+
+it('should apply view modifiers', () => {
+  const view = createView();
+
+  const query = new EventMediaQueries();
+  const queryResults = new MediaQueriesResults();
+
+  const context = {
+    timeline: { window: { start: new Date(), end: new Date() } },
+  };
+
+  const modifiers = [
+    new SetQueryViewModifier({
+      query: query,
+      queryResults: queryResults,
+    }),
+    new MergeContextViewModifier(context),
+  ];
+
+  applyViewModifiers(view, modifiers);
+
+  expect(view.query).toBe(query);
+  expect(view.queryResults).toBe(queryResults);
+  expect(view.context).toEqual(context);
+});
diff --git a/tests/card-controller/view/modifiers/set-query.test.ts b/tests/card-controller/view/modifiers/set-query.test.ts
new file mode 100644
index 00000000..85bfeb2e
--- /dev/null
+++ b/tests/card-controller/view/modifiers/set-query.test.ts
@@ -0,0 +1,30 @@
+import { expect, it } from 'vitest';
+import { SetQueryViewModifier } from '../../../../src/card-controller/view/modifiers/set-query';
+import { createView } from '../../../test-utils';
+import { EventMediaQueries } from '../../../../src/view/media-queries';
+import { MediaQueriesResults } from '../../../../src/view/media-queries-results';
+
+it('should do nothing without arguments', () => {
+  const view = createView();
+
+  const modifier = new SetQueryViewModifier();
+  modifier.modify(view);
+
+  expect(view.query).toBeNull();
+  expect(view.queryResults).toBeNull();
+});
+
+it('should set query and results', () => {
+  const view = createView();
+  const query = new EventMediaQueries();
+  const queryResults = new MediaQueriesResults();
+
+  const modifier = new SetQueryViewModifier({
+    query: query,
+    queryResults: queryResults,
+  });
+  modifier.modify(view);
+
+  expect(view.query).toBe(query);
+  expect(view.queryResults).toBe(queryResults);
+});
diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts
index 809e0d6a..0cb2461c 100644
--- a/tests/card-controller/view/view-manager.test.ts
+++ b/tests/card-controller/view/view-manager.test.ts
@@ -13,6 +13,15 @@ import {
   createStore,
   createView,
 } from '../../test-utils';
+import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor';
+import { EventMediaQueries } from '../../../src/view/media-queries';
+import { QueryType } from '../../../src/camera-manager/types';
+import { SetQueryViewModifier } from '../../../src/card-controller/view/modifiers/set-query';
+import { View } from '../../../src/view/view';
+import {
+  QueryExecutorOptions,
+  ViewModifier,
+} from '../../../src/card-controller/view/types';
 
 describe('should act correctly when view is set', () => {
   it('basic view', () => {
@@ -26,7 +35,7 @@ describe('should act correctly when view is set', () => {
     factory.getViewDefault.mockReturnValue(view);
 
     const api = createCardAPI();
-    const manager = new ViewManager(api, factory);
+    const manager = new ViewManager(api, { viewFactory: factory });
 
     manager.setViewDefault();
 
@@ -53,7 +62,7 @@ describe('should act correctly when view is set', () => {
     factory.getViewDefault.mockReturnValue(view_1);
 
     const api = createCardAPI();
-    const manager = new ViewManager(api, factory);
+    const manager = new ViewManager(api, { viewFactory: factory });
 
     manager.setViewDefault();
 
@@ -82,7 +91,7 @@ it('setViewWithMergedContext', () => {
   const api = createCardAPI();
   const factory = mock<ViewFactory>();
 
-  const manager = new ViewManager(api, factory);
+  const manager = new ViewManager(api, { viewFactory: factory });
   const context: ViewContext = { timeline: {} };
 
   // Setting context with no existing view does nothing.
@@ -104,14 +113,14 @@ it('setViewWithMergedContext', () => {
 
 it('getEpoch', () => {
   const factory = mock<ViewFactory>();
-  const manager = new ViewManager(createCardAPI(), factory);
+  const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
   expect(manager.getEpoch()).toBeTruthy();
   expect(manager.getEpoch().manager).toBe(manager);
 });
 
 it('reset', () => {
   const factory = mock<ViewFactory>();
-  const manager = new ViewManager(createCardAPI(), factory);
+  const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
 
   manager.reset();
   expect(manager.getView()).toBeNull();
@@ -133,7 +142,7 @@ it('setViewDefault', () => {
   const factory = mock<ViewFactory>();
   factory.getViewDefault.mockReturnValue(createView());
 
-  const manager = new ViewManager(createCardAPI(), factory);
+  const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
   manager.setViewDefault();
 
   expect(manager.getView()?.view).toBe('live');
@@ -144,7 +153,7 @@ it('setViewByParameters', () => {
   const factory = mock<ViewFactory>();
   factory.getViewByParameters.mockReturnValue(createView());
 
-  const manager = new ViewManager(createCardAPI(), factory);
+  const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
   manager.setViewByParameters();
 
   expect(manager.getView()?.view).toBe('live');
@@ -152,10 +161,16 @@ it('setViewByParameters', () => {
 });
 
 it('setViewDefaultWithNewQuery', async () => {
-  const factory = mock<ViewFactory>();
-  factory.getViewDefaultWithNewQuery.mockResolvedValue(createView());
+  const viewFactory = mock<ViewFactory>();
+  viewFactory.getViewDefault.mockReturnValue(createView());
 
-  const manager = new ViewManager(createCardAPI(), factory);
+  const viewQueryExecutor = mock<ViewQueryExecutor>();
+  viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
+
+  const manager = new ViewManager(createCardAPI(), {
+    viewFactory: viewFactory,
+    viewQueryExecutor: viewQueryExecutor,
+  });
   await manager.setViewDefaultWithNewQuery();
 
   expect(manager.getView()?.view).toBe('live');
@@ -163,10 +178,16 @@ it('setViewDefaultWithNewQuery', async () => {
 });
 
 it('setViewByParametersWithNewQuery', async () => {
-  const factory = mock<ViewFactory>();
-  factory.getViewByParametersWithNewQuery.mockResolvedValue(createView());
+  const viewFactory = mock<ViewFactory>();
+  viewFactory.getViewByParameters.mockReturnValue(createView());
+
+  const viewQueryExecutor = mock<ViewQueryExecutor>();
+  viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
 
-  const manager = new ViewManager(createCardAPI(), factory);
+  const manager = new ViewManager(createCardAPI(), {
+    viewFactory: viewFactory,
+    viewQueryExecutor: viewQueryExecutor,
+  });
   await manager.setViewByParametersWithNewQuery();
 
   expect(manager.getView()?.view).toBe('live');
@@ -174,10 +195,17 @@ it('setViewByParametersWithNewQuery', async () => {
 });
 
 it('setViewByParametersWithExistingQuery', async () => {
-  const factory = mock<ViewFactory>();
-  factory.getViewByParametersWithExistingQuery.mockResolvedValue(createView());
+  const viewFactory = mock<ViewFactory>();
+  viewFactory.getViewByParameters.mockReturnValue(createView());
+
+  const viewQueryExecutor = mock<ViewQueryExecutor>();
+  viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
+
+  const manager = new ViewManager(createCardAPI(), {
+    viewFactory: viewFactory,
+    viewQueryExecutor: viewQueryExecutor,
+  });
 
-  const manager = new ViewManager(createCardAPI(), factory);
   await manager.setViewByParametersWithExistingQuery();
 
   expect(manager.getView()?.view).toBe('live');
@@ -185,33 +213,57 @@ it('setViewByParametersWithExistingQuery', async () => {
 });
 
 describe('should handle exceptions', () => {
-  it('non-async', () => {
-    const factory = mock<ViewFactory>();
+  it('should handle exceptions in sync calls', () => {
     const error = new Error();
-    factory.getViewDefault.mockImplementation(() => {
+    const viewFactory = mock<ViewFactory>();
+    viewFactory.getViewDefault.mockImplementation(() => {
       throw error;
     });
 
     const api = createCardAPI();
-    const manager = new ViewManager(api, factory);
+    const manager = new ViewManager(api, { viewFactory: viewFactory });
     manager.setViewDefault();
 
     expect(manager.hasView()).toBeFalsy();
     expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
   });
 
-  it('async', async () => {
-    const factory = mock<ViewFactory>();
+  it('should handle viewFactory exceptions in async calls', async () => {
     const error = new Error();
-    factory.getViewByParametersWithNewQuery.mockRejectedValue(error);
+    const viewFactory = mock<ViewFactory>();
+    viewFactory.getViewDefault.mockImplementation(() => {
+      throw error;
+    });
 
     const api = createCardAPI();
-    const manager = new ViewManager(api, factory);
-    await manager.setViewByParametersWithNewQuery();
+    const manager = new ViewManager(api, { viewFactory: viewFactory });
+    await manager.setViewDefaultWithNewQuery();
 
     expect(manager.hasView()).toBeFalsy();
     expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
   });
+
+  it('should handle viewQueryExecutor exceptions in async calls', async () => {
+    const error = new Error();
+    const viewFactory = mock<ViewFactory>();
+    viewFactory.getViewDefault.mockReturnValue(createView());
+    const viewQueryExecutor = mock<ViewQueryExecutor>();
+    viewQueryExecutor.getNewQueryModifiers.mockRejectedValue(error);
+
+    const api = createCardAPI();
+    const manager = new ViewManager(api, {
+      viewFactory: viewFactory,
+      viewQueryExecutor: viewQueryExecutor,
+    });
+
+    await manager.setViewDefaultWithNewQuery();
+
+    // The initial view will have been set.
+    expect(manager.hasView()).toBeTruthy();
+
+    // But an error will also be generated.
+    expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
+  });
 });
 
 describe('isViewSupportedByCamera', () => {
@@ -265,7 +317,7 @@ describe('hasMajorMediaChange', () => {
     const factory = mock<ViewFactory>();
     factory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
 
-    const manager = new ViewManager(createCardAPI(), factory);
+    const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
     manager.setViewDefault();
 
     expect(manager.hasMajorMediaChange(createView({ view: 'clips' }))).toBeTruthy();
@@ -275,7 +327,7 @@ describe('hasMajorMediaChange', () => {
     const factory = mock<ViewFactory>();
     factory.getViewDefault.mockReturnValue(createView({ camera: 'camera-1' }));
 
-    const manager = new ViewManager(createCardAPI(), factory);
+    const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
     manager.setViewDefault();
 
     expect(manager.hasMajorMediaChange(createView({ camera: 'camera-2' }))).toBeTruthy();
@@ -293,7 +345,7 @@ describe('hasMajorMediaChange', () => {
       createView({ context: { live: { overrides: overrides_1 } } }),
     );
 
-    const manager = new ViewManager(createCardAPI(), factory);
+    const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
     manager.setViewDefault();
 
     expect(
@@ -315,7 +367,7 @@ describe('hasMajorMediaChange', () => {
       createView({ view: 'clips', context: { live: { overrides: overrides_1 } } }),
     );
 
-    const manager = new ViewManager(createCardAPI(), factory);
+    const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
     manager.setViewDefault();
 
     expect(
@@ -335,7 +387,7 @@ describe('hasMajorMediaChange', () => {
       createView({ view: 'media', queryResults: queryResults_1 }),
     );
 
-    const manager = new ViewManager(createCardAPI(), factory);
+    const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
     manager.setViewDefault();
 
     expect(
@@ -353,42 +405,317 @@ describe('hasMajorMediaChange', () => {
     const factory = mock<ViewFactory>();
     factory.getViewDefault.mockReturnValue(createView({ queryResults: queryResults_1 }));
 
-    const manager = new ViewManager(createCardAPI(), factory);
+    const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
     manager.setViewDefault();
 
     expect(
       manager.hasMajorMediaChange(createView({ queryResults: queryResults_2 })),
     ).toBeFalsy();
   });
+});
 
-  describe('should initialize', () => {
-    it('without querystring', async () => {
-      const api = createCardAPI();
-      const factory = mock<ViewFactory>();
-      const manager = new ViewManager(api, factory);
+describe('should initialize', () => {
+  it('without querystring', async () => {
+    const view = createView({
+      view: 'live',
+      camera: 'camera',
+    });
+
+    const viewFactory = mock<ViewFactory>();
+    viewFactory.getViewDefault.mockReturnValue(view);
+
+    const api = createCardAPI();
+    const manager = new ViewManager(api, {
+      viewFactory: viewFactory,
+    });
+
+    expect(await manager.initialize()).toBeTruthy();
+
+    expect(manager.getView()).toBe(view);
+  });
+
+  it('with querystring', async () => {
+    const api = createCardAPI();
+    const factory = mock<ViewFactory>();
+    const manager = new ViewManager(api, { viewFactory: factory });
+    vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue(
+      true,
+    );
+
+    expect(await manager.initialize()).toBeTruthy();
 
-      const view = createView({
-        view: 'live',
-        camera: 'camera',
+    expect(manager.hasView()).toBeFalsy();
+  });
+});
+
+it('should adopt query and results when changing to gallery from viewer', async () => {
+  const baseView = createView({
+    view: 'media',
+    camera: 'camera.office',
+    query: new EventMediaQueries([
+      {
+        type: QueryType.Event,
+        cameraIDs: new Set(['camera.office']),
+        hasClip: true,
+      },
+    ]),
+    queryResults: new MediaQueriesResults(),
+  });
+
+  const viewFactory = mock<ViewFactory>();
+  viewFactory.getViewDefault
+    .mockReturnValueOnce(baseView)
+    .mockReturnValueOnce(createView({ view: 'clips' }));
+
+  const viewQueryExecutor = mock<ViewQueryExecutor>();
+  viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
+
+  const manager = new ViewManager(createCardAPI(), {
+    viewFactory: viewFactory,
+    viewQueryExecutor: viewQueryExecutor,
+  });
+
+  manager.setViewDefault();
+  expect(manager.getView()?.is('media')).toBeTruthy();
+
+  await manager.setViewDefaultWithNewQuery({
+    params: {
+      view: 'clips',
+    },
+  });
+
+  expect(manager.getView()?.is('clips')).toBeTruthy();
+  expect(manager.getView()?.query).toBe(baseView.query);
+  expect(manager.getView()?.queryResults).toBe(baseView.queryResults);
+  expect(viewQueryExecutor.getNewQueryModifiers).not.toHaveBeenCalled();
+  expect(viewQueryExecutor.getExistingQueryModifiers).not.toHaveBeenCalled();
+});
+
+describe('should apply async view modifications', () => {
+  it('should apply modifications successfully', async () => {
+    const viewFactory = mock<ViewFactory>();
+    viewFactory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
+
+    const query = new EventMediaQueries();
+    const queryResults = new MediaQueriesResults();
+
+    const viewQueryExecutor = mock<ViewQueryExecutor>();
+    viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([
+      new SetQueryViewModifier({
+        query: query,
+        queryResults: queryResults,
+      }),
+    ]);
+
+    const manager = new ViewManager(createCardAPI(), {
+      viewFactory: viewFactory,
+      viewQueryExecutor: viewQueryExecutor,
+    });
+
+    await manager.setViewDefaultWithNewQuery();
+
+    expect(manager.getView()?.query).toBe(query);
+    expect(manager.getView()?.queryResults).toBe(queryResults);
+    expect(manager.getView()?.context?.loading?.query).toBeUndefined();
+  });
+
+  it('should not apply modifications if there is a major media change', async () => {
+    const viewFactory = mock<ViewFactory>();
+    viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
+
+    const query = new EventMediaQueries();
+    const queryResults = new MediaQueriesResults();
+
+    const viewQueryExecutor = mock<ViewQueryExecutor>();
+
+    const manager = new ViewManager(createCardAPI(), {
+      viewFactory: viewFactory,
+      viewQueryExecutor: viewQueryExecutor,
+    });
+
+    viewQueryExecutor.getNewQueryModifiers.mockImplementation(
+      async (
+        // eslint-disable-next-line @typescript-eslint/no-unused-vars
+        _view: View,
+        // eslint-disable-next-line @typescript-eslint/no-unused-vars
+        _executorqueryExecutorOptions?: QueryExecutorOptions,
+      ): Promise<ViewModifier[] | null> => {
+        // Simulate a major media change while the async operation is running.
+        viewFactory.getViewDefault.mockReturnValueOnce(
+          createView({ view: 'clips', context: { loading: { query: 1 } } }),
+        );
+        manager.setViewDefault();
+
+        // Now return the modifiers (which should be ignored since there has
+        // been a major change in the meantime).
+        return [
+          new SetQueryViewModifier({
+            query: query,
+            queryResults: queryResults,
+          }),
+        ];
+      },
+    );
+
+    await manager.setViewDefaultWithNewQuery();
+
+    // View set during the async operation should not be touched.
+    expect(manager.getView()?.is('clips')).toBeTruthy();
+    expect(manager.getView()?.query).toBeNull();
+    expect(manager.getView()?.queryResults).toBeNull();
+    expect(manager.getView()?.context?.loading?.query).toBeUndefined();
+  });
+
+  describe('should manage loading state correctly', () => {
+    it('should mark as not loading when with major media change', async () => {
+      const viewFactory = mock<ViewFactory>();
+      viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
+      const viewQueryExecutor = mock<ViewQueryExecutor>();
+
+      const manager = new ViewManager(createCardAPI(), {
+        viewFactory: viewFactory,
+        viewQueryExecutor: viewQueryExecutor,
+      });
+
+      viewQueryExecutor.getNewQueryModifiers.mockImplementation(
+        async (
+          // eslint-disable-next-line @typescript-eslint/no-unused-vars
+          _view: View,
+          // eslint-disable-next-line @typescript-eslint/no-unused-vars
+          _executorqueryExecutorOptions?: QueryExecutorOptions,
+        ): Promise<ViewModifier[] | null> => {
+          expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
+
+          // Simulate a major media change while the async operation is running.
+          viewFactory.getViewDefault.mockReturnValueOnce(
+            createView({ view: 'clips', context: { loading: { query: 1 } } }),
+          );
+          manager.setViewDefault();
+
+          // Now return the modifiers (which should be ignored since there has
+          // been a major change in the meantime).
+          return [];
+        },
+      );
+
+      await manager.setViewDefaultWithNewQuery();
+
+      expect(manager.getView()?.context?.loading?.query).toBeUndefined();
+    });
+
+    it('should not change loading status if something else is being loaded', async () => {
+      const viewFactory = mock<ViewFactory>();
+      viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
+      const viewQueryExecutor = mock<ViewQueryExecutor>();
+
+      const manager = new ViewManager(createCardAPI(), {
+        viewFactory: viewFactory,
+        viewQueryExecutor: viewQueryExecutor,
       });
-      factory.getViewDefaultWithNewQuery.mockResolvedValue(view);
 
-      expect(await manager.initialize()).toBeTruthy();
+      viewQueryExecutor.getNewQueryModifiers.mockImplementation(
+        async (
+          // eslint-disable-next-line @typescript-eslint/no-unused-vars
+          _view: View,
+          // eslint-disable-next-line @typescript-eslint/no-unused-vars
+          _executorqueryExecutorOptions?: QueryExecutorOptions,
+        ): Promise<ViewModifier[] | null> => {
+          expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
+
+          // Simulate a major media change while the async operation is running.
+          viewFactory.getViewDefault.mockReturnValueOnce(
+            createView({ view: 'clips', context: { loading: { query: 2 } } }),
+          );
+          manager.setViewDefault();
+
+          // Now return the modifiers (which should be ignored since there has
+          // been a major change in the meantime).
+          return [];
+        },
+      );
+
+      await manager.setViewDefaultWithNewQuery();
 
-      expect(manager.getView()).toBe(view);
+      expect(manager.getView()?.context?.loading?.query).toBe(2);
     });
 
-    it('with querystring', async () => {
-      const api = createCardAPI();
-      const factory = mock<ViewFactory>();
-      const manager = new ViewManager(api, factory);
-      vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue(
-        true,
+    it('should not change loading status if it is unexpected', async () => {
+      const viewFactory = mock<ViewFactory>();
+      viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
+      const viewQueryExecutor = mock<ViewQueryExecutor>();
+
+      const manager = new ViewManager(createCardAPI(), {
+        viewFactory: viewFactory,
+        viewQueryExecutor: viewQueryExecutor,
+      });
+
+      viewQueryExecutor.getNewQueryModifiers.mockImplementation(
+        async (
+          // eslint-disable-next-line @typescript-eslint/no-unused-vars
+          _view: View,
+          // eslint-disable-next-line @typescript-eslint/no-unused-vars
+          _executorqueryExecutorOptions?: QueryExecutorOptions,
+        ): Promise<ViewModifier[] | null> => {
+          expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
+
+          // Simulate another view update (without a major media change)
+          viewFactory.getViewDefault.mockReturnValueOnce(
+            createView({ context: { loading: { query: 100 } } }),
+          );
+          manager.setViewDefault();
+
+          // Now return the modifiers (which should be ignored since there has
+          // been a major change in the meantime).
+          return [];
+        },
       );
 
-      expect(await manager.initialize()).toBeTruthy();
+      await manager.setViewDefaultWithNewQuery();
 
-      expect(manager.hasView()).toBeFalsy();
+      expect(manager.getView()?.context?.loading?.query).toBe(100);
     });
   });
 });
+
+it('should adopt query and results when changing to gallery from viewer', async () => {
+  const baseView = createView({
+    view: 'media',
+    camera: 'camera.office',
+    query: new EventMediaQueries([
+      {
+        type: QueryType.Event,
+        cameraIDs: new Set(['camera.office']),
+        hasClip: true,
+      },
+    ]),
+    queryResults: new MediaQueriesResults(),
+  });
+
+  const viewFactory = mock<ViewFactory>();
+  viewFactory.getViewDefault
+    .mockReturnValueOnce(baseView)
+    .mockReturnValueOnce(createView({ view: 'clips' }));
+
+  const viewQueryExecutor = mock<ViewQueryExecutor>();
+  viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
+
+  const manager = new ViewManager(createCardAPI(), {
+    viewFactory: viewFactory,
+    viewQueryExecutor: viewQueryExecutor,
+  });
+
+  manager.setViewDefault();
+  expect(manager.getView()?.is('media')).toBeTruthy();
+
+  await manager.setViewDefaultWithNewQuery({
+    params: {
+      view: 'clips',
+    },
+  });
+
+  expect(manager.getView()?.is('clips')).toBeTruthy();
+  expect(manager.getView()?.query).toBe(baseView.query);
+  expect(manager.getView()?.queryResults).toBe(baseView.queryResults);
+  expect(viewQueryExecutor.getNewQueryModifiers).not.toHaveBeenCalled();
+  expect(viewQueryExecutor.getExistingQueryModifiers).not.toHaveBeenCalled();
+});
diff --git a/tests/card-controller/view/view-query-executor.test.ts b/tests/card-controller/view/view-query-executor.test.ts
new file mode 100644
index 00000000..33cf150f
--- /dev/null
+++ b/tests/card-controller/view/view-query-executor.test.ts
@@ -0,0 +1,432 @@
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import { applyViewModifiers } from '../../../src/card-controller/view/modifiers';
+import { QueryExecutor } from '../../../src/card-controller/view/query-executor';
+import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor';
+import { FrigateCardView } from '../../../src/config/types';
+import {
+  EventMediaQueries,
+  RecordingMediaQueries,
+} from '../../../src/view/media-queries';
+import { MediaQueriesResults } from '../../../src/view/media-queries-results';
+import { View } from '../../../src/view/view';
+import { createCardAPI, createView } from '../../test-utils';
+import { createPopulatedAPI } from './test-utils';
+
+describe('ViewQueryExecutor', () => {
+  describe('getExistingQueryModifiers', () => {
+    it('should return modifier with result when query present', async () => {
+      const executor = mock<QueryExecutor>();
+      const viewQueryExecutor = new ViewQueryExecutor(createCardAPI(), executor);
+
+      const query = new EventMediaQueries();
+      const queryResults = new MediaQueriesResults();
+      const view = createView({
+        query: query,
+      });
+
+      executor.execute.mockResolvedValue(queryResults);
+
+      const queryExecutorOptions = {};
+      const modifiers = await viewQueryExecutor.getExistingQueryModifiers(
+        view,
+        queryExecutorOptions,
+      );
+
+      applyViewModifiers(view, modifiers);
+
+      expect(view.query).toBe(query);
+      expect(view.queryResults).toBe(queryResults);
+      expect(executor.execute).toBeCalledWith(query, queryExecutorOptions);
+    });
+
+    it('should not return modifier when query absent', async () => {
+      const executor = mock<QueryExecutor>();
+      const viewQueryExecutor = new ViewQueryExecutor(createCardAPI(), executor);
+
+      const view = createView();
+
+      const queryExecutorOptions = {};
+      const modifiers = await viewQueryExecutor.getExistingQueryModifiers(
+        view,
+        queryExecutorOptions,
+      );
+
+      expect(modifiers?.length).toBe(0);
+
+      applyViewModifiers(view, modifiers);
+
+      expect(view.query).toBeNull();
+      expect(view.queryResults).toBeNull();
+      expect(executor.execute).not.toBeCalled();
+    });
+  });
+
+  describe('getNewQueryModifiers', () => {
+    it('should return null without config', async () => {
+      const factory = new ViewQueryExecutor(createCardAPI());
+      expect(await factory.getNewQueryModifiers(createView())).toBeNull();
+    });
+
+    describe('with a live view', () => {
+      beforeAll(() => {
+        vi.useFakeTimers();
+        vi.setSystemTime(new Date('2024-07-21T13:22:06Z'));
+      });
+
+      afterAll(() => {
+        vi.useRealTimers();
+      });
+
+      it('should set query and queryResults for events', async () => {
+        const query = new EventMediaQueries();
+        const queryResults = new MediaQueriesResults();
+
+        const executor = mock<QueryExecutor>();
+        executor.executeDefaultEventQuery.mockResolvedValue({
+          query: query,
+          queryResults: queryResults,
+        });
+
+        const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor);
+        const view = createView({ view: 'live', camera: 'camera.office' });
+        const queryExecutorOptions = {};
+
+        const modifiers = await viewQueryExecutor.getNewQueryModifiers(
+          view,
+          queryExecutorOptions,
+        );
+        applyViewModifiers(view, modifiers);
+
+        expect(view?.query).toBe(query);
+        expect(view?.queryResults).toBe(queryResults);
+        expect(executor.executeDefaultEventQuery).toBeCalledWith({
+          cameraID: 'camera.office',
+          eventsMediaType: 'all',
+          executorOptions: {
+            useCache: false,
+          },
+        });
+        expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
+      });
+
+      it('should set query and queryResults for recordings', async () => {
+        const query = new RecordingMediaQueries();
+        const queryResults = new MediaQueriesResults();
+
+        const executor = mock<QueryExecutor>();
+        executor.executeDefaultRecordingQuery.mockResolvedValue({
+          query: query,
+          queryResults: queryResults,
+        });
+
+        const viewQueryExecutor = new ViewQueryExecutor(
+          createPopulatedAPI({
+            live: {
+              controls: {
+                thumbnails: {
+                  media_type: 'recordings',
+                },
+              },
+            },
+          }),
+          executor,
+        );
+        const view = createView({ view: 'live', camera: 'camera.office' });
+        const queryExecutorOptions = {};
+
+        const modifiers = await viewQueryExecutor.getNewQueryModifiers(
+          view,
+          queryExecutorOptions,
+        );
+        applyViewModifiers(view, modifiers);
+
+        expect(view?.query).toBe(query);
+        expect(view?.queryResults).toBe(queryResults);
+        expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
+          cameraID: 'camera.office',
+          executorOptions: {
+            useCache: false,
+          },
+        });
+        expect(executor.executeDefaultEventQuery).not.toBeCalled();
+      });
+
+      describe('should set timeline window', async () => {
+        it('should set timeline to now for live views', async () => {
+          const executor = mock<QueryExecutor>();
+          const viewQueryExecutor = new ViewQueryExecutor(
+            createPopulatedAPI(),
+            executor,
+          );
+          const view = createView({ view: 'live', camera: 'camera.office' });
+
+          const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
+          applyViewModifiers(view, modifiers);
+
+          expect(view?.context).toEqual({
+            timeline: {
+              window: {
+                start: new Date('2024-07-21T12:22:06.000Z'),
+                end: new Date('2024-07-21T13:22:06.000Z'),
+              },
+            },
+          });
+        });
+
+        it('should unset timeline for non-live views', async () => {
+          const executor = mock<QueryExecutor>();
+          const viewQueryExecutor = new ViewQueryExecutor(
+            createPopulatedAPI(),
+            executor,
+          );
+          const view = createView({
+            view: 'clips',
+            camera: 'camera.office',
+            context: {
+              timeline: {
+                window: {
+                  start: new Date('2024-07-21T12:22:06.000Z'),
+                  end: new Date('2024-07-21T13:22:06.000Z'),
+                },
+              },
+            },
+          });
+
+          const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
+          applyViewModifiers(view, modifiers);
+
+          expect(view?.context).toEqual({ timeline: {} });
+        });
+      });
+
+      it('should not fetch anything if configured for no thumbnails', async () => {
+        const executor = mock<QueryExecutor>();
+        const viewQueryExecutor = new ViewQueryExecutor(
+          createPopulatedAPI({
+            live: {
+              controls: {
+                thumbnails: {
+                  mode: 'none' as const,
+                },
+              },
+            },
+          }),
+          executor,
+        );
+
+        const view = createView({
+          view: 'live',
+          camera: 'camera.office',
+        });
+
+        const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
+        applyViewModifiers(view, modifiers);
+
+        expect(view?.query).toBeNull();
+        expect(view?.queryResults).toBeNull();
+        expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
+        expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('with a media view', () => {
+      it('should set query and queryResults for events', async () => {
+        const executor = mock<QueryExecutor>();
+        const query = new EventMediaQueries();
+        const queryResults = new MediaQueriesResults();
+
+        executor.executeDefaultEventQuery.mockResolvedValue({
+          query: query,
+          queryResults: queryResults,
+        });
+
+        const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor);
+        const view = new View({
+          view: 'media',
+          camera: 'camera.office',
+        });
+
+        const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
+        applyViewModifiers(view, modifiers);
+
+        expect(view?.query).toBe(query);
+        expect(view?.queryResults).toBe(queryResults);
+        expect(executor.executeDefaultEventQuery).toBeCalledWith({
+          cameraID: 'camera.office',
+          eventsMediaType: 'clips',
+          executorOptions: {
+            useCache: false,
+          },
+        });
+        expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('with an events-based view', () => {
+      it.each([
+        ['clip' as const, 'clips' as const],
+        ['clips' as const, 'clips' as const],
+        ['snapshot' as const, 'snapshots' as const],
+        ['snapshots' as const, 'snapshots' as const],
+      ])(
+        '%s',
+        async (viewName: FrigateCardView, eventsMediaType: 'clips' | 'snapshots') => {
+          const executor = mock<QueryExecutor>();
+          const query = new EventMediaQueries();
+          const queryResults = new MediaQueriesResults();
+
+          executor.executeDefaultEventQuery.mockResolvedValue({
+            query: query,
+            queryResults: queryResults,
+          });
+
+          const viewQueryExecutor = new ViewQueryExecutor(
+            createPopulatedAPI(),
+            executor,
+          );
+          const view = new View({
+            view: viewName,
+            camera: 'camera.office',
+          });
+
+          const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
+          applyViewModifiers(view, modifiers);
+
+          expect(view?.query).toBe(query);
+          expect(view?.queryResults).toBe(queryResults);
+          expect(executor.executeDefaultEventQuery).toBeCalledWith({
+            cameraID: 'camera.office',
+            eventsMediaType: eventsMediaType,
+            executorOptions: {
+              useCache: false,
+            },
+          });
+          expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
+        },
+      );
+    });
+
+    describe('with a recordings-based view', () => {
+      it.each([['recording' as const], ['recordings' as const]])(
+        '%s',
+        async (viewName: FrigateCardView) => {
+          const executor = mock<QueryExecutor>();
+          const query = new RecordingMediaQueries();
+          const queryResults = new MediaQueriesResults();
+
+          executor.executeDefaultRecordingQuery.mockResolvedValue({
+            query: query,
+            queryResults: queryResults,
+          });
+
+          const viewQueryExecutor = new ViewQueryExecutor(
+            createPopulatedAPI(),
+            executor,
+          );
+          const view = new View({
+            view: viewName,
+            camera: 'camera.office',
+          });
+
+          const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
+          applyViewModifiers(view, modifiers);
+
+          expect(view?.query).toBe(query);
+          expect(view?.queryResults).toBe(queryResults);
+          expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
+          expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
+            cameraID: 'camera.office',
+            executorOptions: {
+              useCache: false,
+            },
+          });
+        },
+      );
+    });
+
+    describe('with an media viewer view', () => {
+      it('should not fetch anything if configured for no thumbnails', async () => {
+        const executor = mock<QueryExecutor>();
+
+        const viewQueryExecutor = new ViewQueryExecutor(
+          createPopulatedAPI({
+            media_viewer: {
+              controls: {
+                thumbnails: {
+                  mode: 'none' as const,
+                },
+              },
+            },
+          }),
+          executor,
+        );
+        const view = new View({
+          view: 'clip',
+          camera: 'camera.office',
+        });
+
+        const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
+        applyViewModifiers(view, modifiers);
+
+        expect(view?.query).toBeNull();
+        expect(view?.queryResults).toBeNull();
+        expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
+        expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when setting or removing seek time', () => {
+      it('should set seek time when results are selected based on time', async () => {
+        const now = new Date();
+        const executor = mock<QueryExecutor>();
+        const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor);
+
+        const view = new View({
+          view: 'clip',
+          camera: 'camera.office',
+        });
+
+        const queryExecutorOptions = {
+          selectResult: {
+            time: {
+              time: now,
+            },
+          },
+        };
+
+        const modifiers = await viewQueryExecutor.getNewQueryModifiers(
+          view,
+          queryExecutorOptions,
+        );
+        applyViewModifiers(view, modifiers);
+
+        expect(view?.context).toEqual({
+          mediaViewer: {
+            seek: now,
+          },
+        });
+      });
+
+      it('should remove seek time when results are not selected based on time', async () => {
+        const executor = mock<QueryExecutor>();
+        const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor);
+
+        const view = new View({
+          view: 'clip',
+          camera: 'camera.office',
+          context: {
+            mediaViewer: {
+              seek: new Date(),
+            },
+          },
+        });
+
+        const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
+        applyViewModifiers(view, modifiers);
+
+        expect(view?.context?.mediaViewer?.seek).toBeUndefined();
+      });
+    });
+  });
+});