Skip to content

Commit

Permalink
Break up ResultRow #3 - BaseItemResultRow
Browse files Browse the repository at this point in the history
  • Loading branch information
danrahn committed Mar 10, 2024
1 parent 326493e commit dd247e8
Show file tree
Hide file tree
Showing 2 changed files with 183 additions and 168 deletions.
180 changes: 180 additions & 0 deletions Client/Script/BaseItemResultRow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { $, $$ } from './Common.js';
import { ClientMovieData } from './ClientDataExtensions.js';
import { ContextualLog } from '../../Shared/ConsoleLog.js';
import { getSvgIcon } from './SVGHelper.js';
import Icons from './Icons.js';
import MarkerBreakdown from '../../Shared/MarkerBreakdown.js';
import { ResultRow } from './ResultRow.js';
import { ThemeColors } from './ThemeColors.js';
import Tooltip from './Tooltip.js';

/** @typedef {!import('../ClientDataExtensions').MediaItemWithMarkerTable} MediaItemWithMarkerTable */


const Log = new ContextualLog('BaseItemRow');

/**
* Class with functionality shared between "base" media types, i.e. movies and episodes.
*/
export default class BaseItemResultRow extends ResultRow {
/** Current MarkerBreakdown key. See MarkerCacheManager.js's BaseItemNode */
#markerCountKey = 0;

/**
* @param {MediaItemWithMarkerTable} mediaItem
* @param {string} [className] */
constructor(mediaItem, className) {
super(mediaItem, className);
if (mediaItem && mediaItem.markerBreakdown()) {
// Episodes are loaded differently from movies. It's only expected that movies have a valid value
// here. Episodes set this when creating the marker table for the first time.
Log.assert(mediaItem instanceof ClientMovieData, 'mediaItem instanceof ClientMovieData');
this.#markerCountKey = MarkerBreakdown.keyFromMarkerCount(
mediaItem.markerBreakdown().totalIntros(),
mediaItem.markerBreakdown().totalCredits());
}
}

/** @returns {MediaItemWithMarkerTable} */
baseItem() { return this.mediaItem(); }

currentKey() { return this.#markerCountKey; }
/** @param {number} key */
setCurrentKey(key) { this.#markerCountKey = key; }

/**
* Handles common keyboard input on rows with marker tables.
* @param {KeyboardEvent} e */
onBaseItemResultRowKeydown(e) {
if (this.ignoreRowClick(e)) {
return;
}

// Like clicking, ctrl+arrow expands/collapses all.
if (e.ctrlKey) {
switch (e.key) {
case 'ArrowRight':
return this.showHideMarkerTables(false /*hide*/);
case 'ArrowLeft':
return this.showHideMarkerTables(true /*hide*/);
}
}

if (e.altKey || e.shiftKey || e.ctrlKey) {
return;
}

switch (e.key) {
case ' ':
e.preventDefault();
// fallthrough
case 'Enter':
{
// Movie marker tables might not exist yet. In that case we want to show the table since we're
// guaranteed to be hidden anyway, and showHideMarkerTable takes care of ensuring we have all
// the data we need.
return this.showHideMarkerTable(this.baseItem().markerTable().isVisible());
}
case 'ArrowRight':
// Note: this is async for movies. If this ever changes to have additional
// logic, make sure that's accounted for.
return this.showHideMarkerTable(false /*hide*/);
case 'ArrowLeft':
return this.showHideMarkerTable(true /*hide*/);
case 'ArrowUp':
case 'ArrowDown':
{
const parentSibling = e.key === 'ArrowUp' ?
e.target.parentElement.previousSibling :
e.target.parentElement.nextSibling;
const sibling = $$('.tabbableRow', parentSibling);
if (sibling) {
e.preventDefault();
sibling.focus();
}
break;
}
case 'h':
{
/** @type {HTMLElement} */
const child = $$('.tabbableRow', e.target.parentElement.parentElement);
child?.scrollIntoView({ behavior : 'smooth', block : 'nearest' });
return child?.focus({ preventScroll : true });
}
case 'e':
{
/** @type {HTMLElement} */
const rows = $('.tabbableRow', e.target.parentElement.parentElement);
if (rows) {
rows[rows.length - 1].scrollIntoView({ behavior : 'smooth', block : 'nearest' });
rows[rows.length - 1].focus({ preventScroll : true });
}
break;
}
}
}

/**
* Returns the expand/contract arrow element */
getExpandArrow() {
return getSvgIcon(Icons.Arrow, ThemeColors.Primary, { class : 'markerExpand collapsed' });
}

/**
* Rotates the expand/contract arrow after showing/hiding the marker table.
* @param {boolean} hide Whether the marker table is being hidden */
updateExpandArrow(hide) {
$$('.markerExpand', this.html()).classList[hide ? 'add' : 'remove']('collapsed');
}

/**
* Expands or contracts the marker table for this row.
* @param {boolean} hide
* @param {boolean} bulk Whether we're in a bulk show/hide operation
* @param {boolean} animate Whether to animate the visibility change. NOTE: even if set to true,
* the row won't be animated if we think it's off-screen. */
showHideMarkerTable(hide, bulk = false, animate = true) {
const promise = this.baseItem().markerTable().setVisibility(!hide, bulk, animate);
this.updateExpandArrow(hide);

// Should really only be necessary on hide, but hide tooltips on both show and hide
Tooltip.dismiss();
return promise;
}

showHideMarkerTables(_hide) { Log.error(`BaseItemRow classes must override showHideMarkerTables `); }

/**
* Show/hide all marker tables for a given list of base items.
* @param {boolean} hide
* @param {BaseItemResultRow[]} items */
static ShowHideMarkerTables(hide, items) {
let count = 0;
const bodyRect = document.body.getBoundingClientRect();
const isVisible = (/** @type {BaseItemResultRow} */ episode) => {
const rect = episode.html().getBoundingClientRect();
return rect.top < bodyRect.height && rect.y + rect.height > 0;
};

/** @type {Promise<void>[]} */
const animations = [];

// Improve perf a bit by doing the following:
// * If the row isn't visible at the time of execution, don't animate the expansion/contraction.
// * For visible rows, stagger the expansion/contraction so we don't try animating 30 rows at once.
// With the current duration of 150ms, a 25ms timer will ensure we only have at most 6 rows
// animating at once. As an added benefit, I think the staggered approach looks cleaner.
for (const item of items) {
const delay = count++;
if (isVisible(item)) {
animations.push(new Promise(r => {
setTimeout(() => { item.showHideMarkerTable(hide, true /*bulk*/); r(); }, delay * 25);
}));
} else {
animations.push(item.showHideMarkerTable(hide, true /*bulk*/, false /*animate*/));
}
}

return Promise.all(animations);
}
}
171 changes: 3 additions & 168 deletions Client/Script/ResultRow.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { $, $$, appendChildren, buildNode, clearEle, clickOnEnterCallback, pad0, plural } from './Common.js';
import { $$, appendChildren, buildNode, clearEle, clickOnEnterCallback, pad0, plural } from './Common.js';
import { ContextualLog } from '../../Shared/ConsoleLog.js';

import { ClientEpisodeData, ClientMovieData } from './ClientDataExtensions.js';
import { errorMessage, errorResponseOverlay, errorToast } from './ErrorHandling.js';
import { FilterDialog, FilterSettings, SortConditions, SortOrder } from './FilterDialog.js';
import { UISection, UISections } from './ResultSections.js';
import BaseItemResultRow from './BaseItemResultRow.js';
import BulkActionResultRow from './BulkActionResultRow.js';
import { BulkActionType } from './BulkActionCommon.js';
import ButtonCreator from './ButtonCreator.js';
import { ClientEpisodeData } from './ClientDataExtensions.js';
import { ClientSettings } from './ClientSettings.js';
import { getSvgIcon } from './SVGHelper.js';
import Icons from './Icons.js';
Expand Down Expand Up @@ -1033,172 +1034,6 @@ class SeasonResultRow extends ResultRow {
}
}

/**
* Class with functionality shared between "base" media types, i.e. movies and episodes.
*/
class BaseItemResultRow extends ResultRow {
/** Current MarkerBreakdown key. See MarkerCacheManager.js's BaseItemNode */
#markerCountKey = 0;

/**
* @param {MediaItemWithMarkerTable} mediaItem
* @param {string} [className] */
constructor(mediaItem, className) {
super(mediaItem, className);
if (mediaItem && mediaItem.markerBreakdown()) {
// Episodes are loaded differently from movies. It's only expected that movies have a valid value
// here. Episodes set this when creating the marker table for the first time.
Log.assert(mediaItem instanceof ClientMovieData, 'mediaItem instanceof ClientMovieData');
this.#markerCountKey = MarkerBreakdown.keyFromMarkerCount(
mediaItem.markerBreakdown().totalIntros(),
mediaItem.markerBreakdown().totalCredits());
}
}

/** @returns {MediaItemWithMarkerTable} */
baseItem() { return this.mediaItem(); }

currentKey() { return this.#markerCountKey; }
/** @param {number} key */
setCurrentKey(key) { this.#markerCountKey = key; }

/**
* Handles common keyboard input on rows with marker tables.
* @param {KeyboardEvent} e */
onBaseItemResultRowKeydown(e) {
if (this.ignoreRowClick(e)) {
return;
}

// Like clicking, ctrl+arrow expands/collapses all.
if (e.ctrlKey) {
switch (e.key) {
case 'ArrowRight':
return this.showHideMarkerTables(false /*hide*/);
case 'ArrowLeft':
return this.showHideMarkerTables(true /*hide*/);
}
}

if (e.altKey || e.shiftKey || e.ctrlKey) {
return;
}

switch (e.key) {
case ' ':
e.preventDefault();
// fallthrough
case 'Enter':
{
// Movie marker tables might not exist yet. In that case we want to show the table since we're
// guaranteed to be hidden anyway, and showHideMarkerTable takes care of ensuring we have all
// the data we need.
return this.showHideMarkerTable(this.baseItem().markerTable().isVisible());
}
case 'ArrowRight':
// Note: this is async for movies. If this ever changes to have additional
// logic, make sure that's accounted for.
return this.showHideMarkerTable(false /*hide*/);
case 'ArrowLeft':
return this.showHideMarkerTable(true /*hide*/);
case 'ArrowUp':
case 'ArrowDown':
{
const parentSibling = e.key === 'ArrowUp' ?
e.target.parentElement.previousSibling :
e.target.parentElement.nextSibling;
const sibling = $$('.tabbableRow', parentSibling);
if (sibling) {
e.preventDefault();
sibling.focus();
}
break;
}
case 'h':
{
/** @type {HTMLElement} */
const child = $$('.tabbableRow', e.target.parentElement.parentElement);
child?.scrollIntoView({ behavior : 'smooth', block : 'nearest' });
return child?.focus({ preventScroll : true });
}
case 'e':
{
/** @type {HTMLElement} */
const rows = $('.tabbableRow', e.target.parentElement.parentElement);
if (rows) {
rows[rows.length - 1].scrollIntoView({ behavior : 'smooth', block : 'nearest' });
rows[rows.length - 1].focus({ preventScroll : true });
}
break;
}
}
}

/**
* Returns the expand/contract arrow element */
getExpandArrow() {
return getSvgIcon(Icons.Arrow, ThemeColors.Primary, { class : 'markerExpand collapsed' });
}

/**
* Rotates the expand/contract arrow after showing/hiding the marker table.
* @param {boolean} hide Whether the marker table is being hidden */
updateExpandArrow(hide) {
$$('.markerExpand', this.html()).classList[hide ? 'add' : 'remove']('collapsed');
}

/**
* Expands or contracts the marker table for this row.
* @param {boolean} hide
* @param {boolean} bulk Whether we're in a bulk show/hide operation
* @param {boolean} animate Whether to animate the visibility change. NOTE: even if set to true,
* the row won't be animated if we think it's off-screen. */
showHideMarkerTable(hide, bulk=false, animate=true) {
const promise = this.baseItem().markerTable().setVisibility(!hide, bulk, animate);
this.updateExpandArrow(hide);

// Should really only be necessary on hide, but hide tooltips on both show and hide
Tooltip.dismiss();
return promise;
}

showHideMarkerTables(_hide) { Log.error(`BaseItemRow classes must override showHideMarkerTables `); }

/**
* Show/hide all marker tables for a given list of base items.
* @param {boolean} hide
* @param {BaseItemResultRow[]} items */
static ShowHideMarkerTables(hide, items) {
let count = 0;
const bodyRect = document.body.getBoundingClientRect();
const isVisible = (/** @type {BaseItemResultRow} */episode) => {
const rect = episode.html().getBoundingClientRect();
return rect.top < bodyRect.height && rect.y + rect.height > 0;
};

/** @type {Promise<void>[]} */
const animations = [];

// Improve perf a bit by doing the following:
// * If the row isn't visible at the time of execution, don't animate the expansion/contraction.
// * For visible rows, stagger the expansion/contraction so we don't try animating 30 rows at once.
// With the current duration of 150ms, a 25ms timer will ensure we only have at most 6 rows
// animating at once. As an added benefit, I think the staggered approach looks cleaner.
for (const item of items) {
const delay = count++;
if (isVisible(item)) {
animations.push(new Promise(r => {
setTimeout(() => { item.showHideMarkerTable(hide, true /*bulk*/); r(); }, delay * 25);
}));
} else {
animations.push(item.showHideMarkerTable(hide, true /*bulk*/, false /*animate*/));
}
}

return Promise.all(animations);
}
}

/**
* A result row for a single episode of a show.
*/
Expand Down

0 comments on commit dd247e8

Please sign in to comment.