Skip to content

Commit

Permalink
Include intro/credits info in marker breakdown
Browse files Browse the repository at this point in the history
The existing marker breakdown only shows the number of episodes have a
total number of markers, it doesn't break it down by marker type. Make
that distinction with a questionable change to bit-shift credits counts
so that a single number can represent a unique (intro, credits) group.

This is one part of unlocking future work to allow filtering by whether
something has a specific type of marker.

Another part of this change is to share the MarkerBreakdown class so
there's a bit more structure to the client-side breakdown instead of raw
objects. This complicated the SerializedPlexType to PlexType conversion,
and while what this commit does works, it's be nice to make it cleaner.

As a part of the additional breakdown, add intro/credits info to the
extended marker stats tooltip, and add intro/credits specific breakdown
charts (and fix single-point pie charts).
  • Loading branch information
danrahn committed Feb 23, 2023
1 parent e135755 commit a2d15a9
Show file tree
Hide file tree
Showing 17 changed files with 535 additions and 179 deletions.
17 changes: 11 additions & 6 deletions Client/Script/ClientDataExtensions.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Log } from "../../Shared/ConsoleLog.js";
import MarkerBreakdown from "../../Shared/MarkerBreakdown.js";
import { EpisodeData, MarkerData, MovieData, PlexData } from "../../Shared/PlexTypes.js";
import { $$ } from "./Common.js";
import MarkerTable from "./MarkerTable.js";
import { EpisodeResultRow, MovieResultRow } from "./ResultRow.js";

Expand Down Expand Up @@ -43,20 +45,22 @@ class ClientMovieData extends MovieData {
* serialized {@linkcode MarkerData} for the episode. */
createMarkerTable(parentRow, serializedMarkers) {
if (this.#markerTable != null) {
Log.warn('The marker table already exists, we shouldn\'t be creating a new one!');
// This is expected if the result has appeared in multiple search results.
// Assume we're in a good state and ignore this, but reset the parent and make
// sure the table is in its initial hidden state.
this.#markerTable.setParent(parentRow);
$$('table', this.#markerTable.table()).classList.add('hidden');
return;
}

const markers = [];
for (const marker of serializedMarkers) {
markers.push(new MarkerData().setFromJson(marker));
}

const mov = parentRow.movie();

// Marker breakdown is currently overkill for movies, since it only ever has a single item inside of it.
// If intros/credits are ever separated though, this will do the right thing.
const markerCount = mov?.markerBreakdown ? Object.keys(mov.markerBreakdown).reduce((sum, k) => sum + k * mov.markerBreakdown[k]) : 0;
this.#markerTable = new MarkerTable(markers, parentRow, true /*lazyLoad*/, markerCount);
this.#markerTable = new MarkerTable(markers, parentRow, true /*lazyLoad*/, parentRow.currentKey());
}

/**
Expand Down Expand Up @@ -97,7 +101,7 @@ class ClientEpisodeData extends EpisodeData {
/**
* Creates the marker table for this episode.
* @param {EpisodeResultRow} parentRow The UI associated with this episode.
* @param {{[metadataId: number]: Object[]}} serializedMarkers Map of episode ids to an array of
* @param {SerializedMarkerData[]} serializedMarkers Map of episode ids to an array of
* serialized {@linkcode MarkerData} for the episode. */
createMarkerTable(parentRow, serializedMarkers) {
if (this.#markerTable != null) {
Expand All @@ -109,6 +113,7 @@ class ClientEpisodeData extends EpisodeData {
markers.push(new MarkerData().setFromJson(marker));
}

parentRow.setCurrentKey(markers.reduce((acc, marker) => acc + MarkerBreakdown.deltaFromType(1, marker.markerType), 0));
this.#markerTable = new MarkerTable(markers, parentRow);
}

Expand Down
93 changes: 82 additions & 11 deletions Client/Script/MarkerBreakdownChart.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
import MarkerBreakdown from "../../Shared/MarkerBreakdown.js";
import { $, appendChildren, buildNode, errorResponseOverlay, plural, ServerCommand } from "./Common.js";
import { Chart, PieChartOptions } from "./inc/Chart.js";
import Overlay from "./inc/Overlay.js";
import Tooltip from "./inc/Tooltip.js";
import PlexClientState from "./PlexClientState.js";

/**
* Available charts
* @enum */
const BreakdownType = {
/**@readonly*/ Combined : 0,
/**@readonly*/ Intros : 1,
/**@readonly*/ Credits : 2,
};

/**
* Titles for the above chart types. */
const BreakdownTitles = {
[BreakdownType.Combined] : 'Marker Breakdown',
[BreakdownType.Intros] : 'Intro Breakdown',
[BreakdownType.Credits] : 'Credits Breakdown',
};

/**
* Tooltip labels for the given BreakdownType. */
const DataLabels = {
[BreakdownType.Combined] : 'Marker',
[BreakdownType.Intros] : 'Intro Marker',
[BreakdownType.Credits] : 'Credits Marker',
}

class MarkerBreakdownManager {

/** @type {MarkerBreakdown} */
#currentBreakdown = null;

/** Create a new marker breakdown manager for this session. */
constructor() {
const stats = $('#markerBreakdown');
Expand All @@ -18,6 +47,7 @@ class MarkerBreakdownManager {
* The initial request may take some time for large libraries, so first show an overlay
* letting the user know something's actually happening. */
async #getBreakdown() {
this.#currentBreakdown = null;
Overlay.show(
appendChildren(buildNode('div'),
buildNode('h2', {}, 'Marker Breakdown'),
Expand All @@ -28,35 +58,51 @@ class MarkerBreakdownManager {
'Cancel');

try {
const markerStats = await ServerCommand.getMarkerStats(PlexClientState.GetState().activeSection());
MarkerBreakdownManager.#showMarkerBreakdown(markerStats);
const rawBreakdown = await ServerCommand.getMarkerStats(PlexClientState.GetState().activeSection());
this.#currentBreakdown = new MarkerBreakdown().initFromRawBreakdown(rawBreakdown);
this.#showMarkerBreakdown(BreakdownType.Combined);
} catch (err) {
errorResponseOverlay('Failed to show breakdown', err);
}
}

/**
* Displays a pie chart of the data from the server.
* @param {{[markerCount : number] : number }} response The marker breakdown data */
static #showMarkerBreakdown(response) {
* @param {MarkerBreakdown} breakdown The marker breakdown data */
#showMarkerBreakdown(breakdownType) {
const overlay = $('#mainOverlay');
if (!overlay) {
if (!overlay || !this.#currentBreakdown) {
Log.verbose('Overlay is gone, not showing stats');
return; // User closed out of window
}

/** @type {import("./inc/Chart").ChartDataPoint[]} */
let dataPoints = [];
for (const [bucket, value] of Object.entries(response)) {
dataPoints.push({ value : value, label : plural(bucket, 'Marker') });
let chartData;
switch (breakdownType) {
case BreakdownType.Combined:
chartData = this.#currentBreakdown.collapsedBuckets();
break;
case BreakdownType.Intros:
chartData = this.#currentBreakdown.introBuckets();
break;
case BreakdownType.Credits:
chartData = this.#currentBreakdown.creditsBuckets();
break;
default:
throw new Error(`Invalid breakdown type ${breakdownType}`);
}

for (const [bucket, value] of Object.entries(chartData)) {
dataPoints.push({ value : value, label : plural(bucket, DataLabels[breakdownType]) });
}

const radius = Math.min(Math.min(400, window.innerWidth / 2 - 40), window.innerHeight / 2 - 200);
let options = new PieChartOptions(dataPoints, radius);
options.title = 'Marker Breakdown';
const chartSelect = this.#buildOptions(breakdownType);
options.colorMap = { // Set colors for 0 and 1, use defaults for everything else
'0 Markers' : '#a33e3e',
'1 Marker' : '#2e832e'
[`0 ${DataLabels[breakdownType]}s`] : '#a33e3e',
[`1 ${DataLabels[breakdownType]}`] : '#2e832e'
};
options.sortFn = (a, b) => parseInt(a.label) - parseInt(b.label);
options.labelOptions = { count : true, percentage : true };
Expand All @@ -69,7 +115,32 @@ class MarkerBreakdownManager {
const opacity = parseFloat(getComputedStyle(overlay).opacity);
const delay = (1 - opacity) * 250;
Overlay.build({ dismissible : true, centered : true, delay : delay, noborder : true, closeButton : true },
appendChildren(buildNode('div', { style : 'text-align: center' }), chart));
appendChildren(buildNode('div', { style : 'text-align: center' }),
appendChildren(buildNode('div', { style : 'padding-bottom: 20px' }), chartSelect),
chart));
}

/**
* Build the dropdown that controls what specific chart is displayed.
* @param {number} breakdownType */
#buildOptions(breakdownType) {
const sel = buildNode('select', { id : 'chartBreakdownType', class : 'fancySelect' }, 0, { change : this.#onChartTypeChange.bind(this) });
for (const option of Object.values(BreakdownType)) {
const optNode = buildNode('option', { value : option }, BreakdownTitles[option]);
if (option == breakdownType) {
optNode.setAttribute('selected', 'selected');
}

sel.appendChild(optNode);
}

return sel;
}

/**
* Draw a new chart based on the option selected in the dropdown. */
#onChartTypeChange() {
this.#showMarkerBreakdown(parseInt($('#chartBreakdownType').value));
}
}

Expand Down
83 changes: 62 additions & 21 deletions Client/Script/MarkerTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import Overlay from './inc/Overlay.js';
import ButtonCreator from './ButtonCreator.js';
import { ExistingMarkerRow, MarkerRow, NewMarkerRow } from './MarkerTableRow.js';
import TableElements from './TableElements.js';
import { ResultRow } from './ResultRow.js';
import { BaseItemResultRow } from './ResultRow.js';
import MarkerBreakdown from '../../Shared/MarkerBreakdown.js';

/**
* The UI representation of an episode's markers. Handles adding, editing, and removing markers for a single episode.
Expand All @@ -20,7 +21,7 @@ class MarkerTable {

/**
* The episode/movie UI that this table is attached to.
* @type {ResultRow} */
* @type {BaseItemResultRow} */
#parentRow;

/**
Expand All @@ -37,13 +38,14 @@ class MarkerTable {
* The number of markers we expect in this table before actually populating it.
* Only used by movies.
* @type {number?} */
#cachedMarkerCount = undefined;
#cachedMarkerCountKey = undefined;

/**
* @param {MarkerData[]} markers The markers to add to this table.
* @param {ResultRow} parentRow The episode/movie UI that this table is attached to.
* @param {boolean} [lazyLoad=false] Whether we expect our marker data to come in later, so don't populate the table yet. */
constructor(markers, parentRow, lazyLoad=false, cachedMarkerCount=0) {
* @param {BaseItemResultRow} parentRow The episode/movie UI that this table is attached to.
* @param {boolean} [lazyLoad=false] Whether we expect our marker data to come in later, so don't populate the table yet.
* @param {number} [cachedMarkerCountKey] If we're lazy loading, this captures the number of credits and intros that we expect the table to have. */
constructor(markers, parentRow, lazyLoad=false, cachedMarkerCountKey=0) {
this.#parentRow = parentRow;
this.#markers = markers.sort((a, b) => a.start - b.start);
let container = buildNode('div', { class : 'tableHolder' });
Expand Down Expand Up @@ -72,7 +74,7 @@ class MarkerTable {
rows.appendChild(markerRow.row());
}
} else {
this.#cachedMarkerCount = cachedMarkerCount;
this.#cachedMarkerCountKey = cachedMarkerCountKey;
}

rows.appendChild(TableElements.spanningTableRow(ButtonCreator.textButton('Add Marker', this.#onMarkerAdd.bind(this))));
Expand All @@ -81,6 +83,18 @@ class MarkerTable {
this.#html = container;
}

/**
* Sets the new parent of this table. Used for movies, where this table
* is cached on the ClientMovieData, which can survive multiple searches,
* but the ResultRow is different every time, so this needs to be reattached.
* @param {BaseItemResultRow} parentRow */
setParent(parentRow) {
this.#parentRow = parentRow;
for (const row of this.#rows) {
row.setParent(parentRow);
}
}

/**
* @param {MarkerData[]} markers */
lazyInit(markers) {
Expand All @@ -97,26 +111,45 @@ class MarkerTable {
tbody.insertBefore(TableElements.noMarkerRow(), addMarkerRow);
}

let newKey = 0;
for (const marker of markers) {
const markerRow = new ExistingMarkerRow(marker, this.#parentRow);
this.#rows.push(markerRow);

tbody.insertBefore(markerRow.row(), addMarkerRow);
newKey += MarkerBreakdown.deltaFromType(1, marker.markerType);
}

// Cyclical issues here. We want to check cachedMarkerCount against the marker length to
// update the marker text if needed, but we won't get an accurate count if cachedMarkerCount
// isn't undefined.
const cachedCount = this.#cachedMarkerCount;
this.#cachedMarkerCount = undefined;
this.#parentRow.updateMarkerBreakdown(markers.length - cachedCount);
this.#cachedMarkerCountKey = undefined;
this.#parentRow.updateMarkerBreakdown();
}

/**
* Return whether this table has real data, or just a placeholder marker count. */
hasRealData() { return this.#cachedMarkerCountKey === undefined; }

/** @returns {HTMLElement} The raw HTML of the marker table. */
table() { return this.#html; }

/** @returns {number} The number of markers this episode has (not including in-progress additions). */
markerCount() { return this.#cachedMarkerCount !== undefined ? this.#cachedMarkerCount : this.#markers.length; }
markerCount() {
if (this.#cachedMarkerCountKey === undefined) {
return this.#markers.length;
}

return MarkerBreakdown.markerCountFromKey(this.#cachedMarkerCountKey);
}

/** @returns {number} */
markerKey() {
if (this.#cachedMarkerCountKey === undefined) {
// TODO: Replace base item's MarkerBreakdown with a single-key class so this doesn't have to be calculated
// from scratch every time.
return this.#markers.reduce((acc, marker) => acc + MarkerBreakdown.deltaFromType(1, marker.markerType), 0);
}

return this.#cachedMarkerCountKey;
}

/**
* Returns whether a marker the user wants to add/edit is valid.
Expand Down Expand Up @@ -162,12 +195,12 @@ class MarkerTable {
* @param {MarkerData} newMarker The marker to add.
* @param {HTMLElement?} oldRow The temporary row used to create the marker, if any. */
addMarker(newMarker, oldRow) {
if (this.#cachedMarkerCount !== undefined) {
if (this.#cachedMarkerCountKey !== undefined) {
// Assume that addMarker calls coming in when our table isn't initialized
// is coming from purge restores and just update the count/breakdown.
Log.tmi(`Got an addMarker call without an initialized table, updating cache count.`);
++this.#cachedMarkerCount;
this.#parentRow.updateMarkerBreakdown(1 /*delta*/);
this.#cachedMarkerCountKey += MarkerBreakdown.deltaFromType(1, newMarker.markerType);
this.#parentRow.updateMarkerBreakdown();
return;
}

Expand Down Expand Up @@ -196,7 +229,7 @@ class MarkerTable {
this.#rows.splice(newIndex, 0, newRow);
this.#markers.splice(newIndex, 0, newMarker);
tableBody.insertBefore(newRow.row(), tableBody.children[newIndex]);
this.#parentRow.updateMarkerBreakdown(1 /*delta*/);
this.#parentRow.updateMarkerBreakdown();
}

/**
Expand Down Expand Up @@ -229,7 +262,7 @@ class MarkerTable {
}

this.#markers.splice(newIndex, 0, updatedItem);
this.#parentRow.updateMarkerBreakdown(0 /*delta*/); // This edit might update the purge status.
this.#parentRow.updateMarkerBreakdown(); // This edit might update the purge status.
return; // Same position, no rearranging needed.
}

Expand All @@ -243,7 +276,7 @@ class MarkerTable {

this.#rows.splice(newIndex, 0, this.#rows.splice(oldIndex, 1)[0]);
this.#markers.splice(newIndex, 0, updatedItem);
this.#parentRow.updateMarkerBreakdown(0 /*delta*/); // This edit might update the purge status.
this.#parentRow.updateMarkerBreakdown(); // This edit might update the purge status.
}

/**
Expand All @@ -252,6 +285,14 @@ class MarkerTable {
* marker that's in {@linkcode this.markers}, but a standalone copy.
* @param {HTMLElement} [row=null] The HTML row for the deleted marker. */
deleteMarker(deletedMarker, row=null) {
if (this.#cachedMarkerCountKey !== undefined) {
// Assume that deleteMarker calls coming in when our table isn't initialized
// is coming from purge restores and just update the count/breakdown.
Log.tmi(`Got an addMarker call without an initialized table, updating cache count.`);
this.#cachedMarkerCountKey += MarkerBreakdown.deltaFromType(-1, deletedMarker.markerType);
this.#parentRow.updateMarkerBreakdown();
return;
}
const oldIndex = this.#markers.findIndex(x => x.id == deletedMarker.id);
let tableBody = this.#tbody();
if (this.#markers.length == 1) {
Expand All @@ -275,7 +316,7 @@ class MarkerTable {
tableBody.removeChild(row);
this.#markers.splice(oldIndex, 1);
this.#rows.splice(oldIndex, 1);
this.#parentRow.updateMarkerBreakdown(-1 /*delta*/);
this.#parentRow.updateMarkerBreakdown();
}

/**
Expand Down
Loading

0 comments on commit a2d15a9

Please sign in to comment.