From d5bbbe9369179c2392721cf5b93ca45fe997d780 Mon Sep 17 00:00:00 2001 From: Alex Kershaw <32389325+aza547@users.noreply.github.com> Date: Sat, 30 Nov 2024 09:35:40 +0000 Subject: [PATCH] Redesign Video List as Table (#555) * bank changes * bank changes * bank changes * bank changes * bank changes * bank changes * bank changes * bank changes * bank changes * bank changes * is it done? * changelog * changelog * wtf * tweaks * tweaks * tweaks * tweaks * bugfix * bugfix * bugfix * self review * fixes #548 * fix out of place blue focus color * fixes #556 --- CHANGELOG.md | 3 + package-lock.json | 51 ++ package.json | 1 + release/app/package.json | 2 +- src/main/Manager.ts | 9 +- src/main/types.ts | 7 +- src/main/util.ts | 37 +- src/renderer/App.tsx | 33 +- src/renderer/ArenaCompDisplay.tsx | 190 ------ src/renderer/CategoryPage.tsx | 122 +--- src/renderer/DungeonCompDisplay.tsx | 132 ---- src/renderer/DungeonInfo.tsx | 124 +--- src/renderer/GeneralSettings.tsx | 5 +- src/renderer/PovSelection.tsx | 263 -------- .../{RaidCompAndResult.tsx => RaidComp.tsx} | 95 +-- src/renderer/RaidEncounterInfo.tsx | 62 -- src/renderer/SideMenu.tsx | 6 +- src/renderer/StateManager.ts | 76 ++- src/renderer/VideoButton.tsx | 523 --------------- src/renderer/VideoPlayer.tsx | 2 +- src/renderer/components/Button/Button.tsx | 1 + src/renderer/components/Tables/Cells.tsx | 163 +++++ src/renderer/components/Tables/Headers.tsx | 88 +++ src/renderer/components/Tables/Sorting.ts | 33 + .../components/Tables/VideoSelectionTable.tsx | 626 ++++++++++++++++++ src/renderer/components/Toast/Toast.tsx | 2 +- .../Viewpoints/ViewpointButtons.tsx | 278 ++++++++ .../components/Viewpoints/ViewpointInfo.tsx | 257 +++++++ .../Viewpoints/ViewpointSelection.tsx | 218 ++++++ src/renderer/images.ts | 201 ++++-- src/renderer/rendererutils.ts | 161 ++++- src/storage/CloudClient.ts | 4 +- 32 files changed, 2168 insertions(+), 1607 deletions(-) delete mode 100644 src/renderer/ArenaCompDisplay.tsx delete mode 100644 src/renderer/DungeonCompDisplay.tsx delete mode 100644 src/renderer/PovSelection.tsx rename src/renderer/{RaidCompAndResult.tsx => RaidComp.tsx} (50%) delete mode 100644 src/renderer/RaidEncounterInfo.tsx delete mode 100644 src/renderer/VideoButton.tsx create mode 100644 src/renderer/components/Tables/Cells.tsx create mode 100644 src/renderer/components/Tables/Headers.tsx create mode 100644 src/renderer/components/Tables/Sorting.ts create mode 100644 src/renderer/components/Tables/VideoSelectionTable.tsx create mode 100644 src/renderer/components/Viewpoints/ViewpointButtons.tsx create mode 100644 src/renderer/components/Viewpoints/ViewpointInfo.tsx create mode 100644 src/renderer/components/Viewpoints/ViewpointSelection.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e41bfd..d12fc210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Changed ### Added +- Redesign the video selection panel to be more performant and useful. + ### Fixed - Fix an issue where the upload/download icons would flicker. - Relax pull grouping timer as apparently Windows does a bad job of automatically keeping you in sync with an NTP server. - [Issue 550](https://github.com/aza547/wow-recorder/issues/550) - Add the 90s Challenger's Peril correction to M+ chest calculation. +- Fix a bug where deleted videos were sometimes not correctly deleted. - Fix an issue where the CMAA 2 setting in WoW could cause blurry video. ## [6.0.4] - 2024-10-27 diff --git a/package-lock.json b/package-lock.json index 65aa3ed8..c334fcfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", + "@tanstack/react-table": "^8.20.5", "atomic-queue": "^5.0.4", "axios": "^1.6.8", "byte-size": "^8.1.0", @@ -148,6 +149,10 @@ "webpack-cli": "^4.9.2", "webpack-dev-server": "^4.8.0", "webpack-merge": "^5.8.0" + }, + "engines": { + "node": ">=14.x", + "npm": ">=7.x" } }, "node_modules/@alloc/quick-lru": { @@ -5980,6 +5985,39 @@ "node": ">=14.0.0" } }, + "node_modules/@tanstack/react-table": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz", + "integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.20.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz", + "integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@teamsupercell/typings-for-css-modules-loader": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@teamsupercell/typings-for-css-modules-loader/-/typings-for-css-modules-loader-2.5.1.tgz", @@ -31546,6 +31584,19 @@ "tslib": "^2.6.2" } }, + "@tanstack/react-table": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz", + "integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==", + "requires": { + "@tanstack/table-core": "8.20.5" + } + }, + "@tanstack/table-core": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz", + "integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==" + }, "@teamsupercell/typings-for-css-modules-loader": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@teamsupercell/typings-for-css-modules-loader/-/typings-for-css-modules-loader-2.5.1.tgz", diff --git a/package.json b/package.json index 40da2a5f..8d3616a7 100644 --- a/package.json +++ b/package.json @@ -264,6 +264,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", + "@tanstack/react-table": "^8.20.5", "atomic-queue": "^5.0.4", "axios": "^1.6.8", "byte-size": "^8.1.0", diff --git a/release/app/package.json b/release/app/package.json index 244ba283..39509ffa 100644 --- a/release/app/package.json +++ b/release/app/package.json @@ -1,6 +1,6 @@ { "name": "WarcraftRecorder", - "version": "6.0.5", + "version": "6.1.0", "description": "A World of Warcraft screen recorder", "main": "./dist/main/main.js", "author": { diff --git a/src/main/Manager.ts b/src/main/Manager.ts index ff7d6a64..97589a0f 100644 --- a/src/main/Manager.ts +++ b/src/main/Manager.ts @@ -987,7 +987,14 @@ export default class Manager { } else { // Try to just delete the video from disk try { - await deleteVideoDisk(src); + // Bit weird we have to check a boolean here given all the error handling + // going on. That's just me taking an easy way out rather than fixing this + // more elegantly. TL;DR deleteVideoDisk doesn't throw anything. + const success = await deleteVideoDisk(src); + + if (!success) { + throw new Error('Failed deleting video, will mark for delete'); + } } catch (error) { // If that didn't work for any reason, try to at least mark it for deletion, // so that it can be picked up on refresh and we won't show videos the user diff --git a/src/main/types.ts b/src/main/types.ts index f1a65762..c4b70bc5 100644 --- a/src/main/types.ts +++ b/src/main/types.ts @@ -1,4 +1,5 @@ import { Size } from 'electron'; +import { uniqueId } from 'lodash'; import { RawChallengeModeTimelineSegment } from './keystone'; import { VideoCategory } from '../types/VideoCategory'; import ConfigService from './ConfigService'; @@ -262,6 +263,10 @@ type RendererVideo = Metadata & { isProtected: boolean; cloud: boolean; multiPov: RendererVideo[]; + + // Used by frontend to uniquely identify a video, as videoName + // is identical for a disk and cloud viewpoint. + uniqueId: string; }; type SoloShuffleTimelineSegment = { @@ -310,8 +315,6 @@ type AppState = { page: Pages; category: VideoCategory; playingVideo: RendererVideo | undefined; // the video being played by the player - selectedVideoName: string | undefined; - numVideosDisplayed: number; videoFilterQuery: string; videoFullScreen: boolean; }; diff --git a/src/main/util.ts b/src/main/util.ts index 01fdc746..6d1601c7 100644 --- a/src/main/util.ts +++ b/src/main/util.ts @@ -179,19 +179,23 @@ const tryUnlink = async (file: string): Promise => { */ const deleteVideoDisk = async (videoPath: string) => { console.info('[Util] Deleting video', videoPath); - const success = await tryUnlink(videoPath); + const deletedMp4 = await tryUnlink(videoPath); - if (!success) { - // If we can't delete the video file, make sure we don't delete the metadata - // file either, which would leave the video file dangling. - return; + if (!deletedMp4) { + return false; } const metadataPath = getMetadataFileNameForVideo(videoPath); - await tryUnlink(metadataPath); + const deletedJson = await tryUnlink(metadataPath); + + if (!deletedJson) { + return false; + } const thumbnailPath = getThumbnailFileNameForVideo(videoPath); - await tryUnlink(thumbnailPath); + const deletedPng = await tryUnlink(thumbnailPath); + + return deletedPng; }; /** @@ -228,15 +232,19 @@ const loadVideoDetailsDisk = async ( const metadata = await getMetadataForVideo(video.name); const thumbnailSource = getThumbnailFileNameForVideo(video.name); + const videoName = path.basename(video.name, '.mp4'); + const uniqueId = `${videoName}-disk`; + return { ...metadata, - videoName: path.basename(video.name, '.mp4'), + videoName, mtime: video.mtime, videoSource: video.name, thumbnailSource, isProtected: Boolean(metadata.protected), cloud: false, multiPov: [], + uniqueId, }; } catch (error) { // Just log it and rethrow. Want this to be diagnosable. @@ -800,16 +808,6 @@ const markForVideoForDelete = async (videoPath: string) => { } }; -/** - * Sort alphabetically by player name. - */ -const povNameSort = (a: RendererVideo, b: RendererVideo) => { - const playerA = a.player?._name; - const playerB = b.player?._name; - if (!playerA || !playerB) return 0; - return playerA.localeCompare(playerB); -}; - /** * Convert a RendererVideo type to a Metadata type, used when downloading * videos from cloud to disk. @@ -833,6 +831,7 @@ const cloudSignedMetadataToRendererVideo = (metadata: CloudSignedMetadata) => { // For cloud videos, the signed URLs are the sources. const videoSource = metadata.signedVideoKey; const thumbnailSource = metadata.signedThumbnailKey; + const uniqueId = `${metadata.videoName}-cloud`; // We don't want the signed properties themselves. const mutable: any = metadata; @@ -847,6 +846,7 @@ const cloudSignedMetadataToRendererVideo = (metadata: CloudSignedMetadata) => { cloud: true, isProtected: Boolean(mutable.protected), mtime: 0, + uniqueId, }; return video; @@ -1025,7 +1025,6 @@ export { reverseChronologicalVideoSort, areDatesWithinSeconds, markForVideoForDelete, - povNameSort, rendererVideoToMetadata, cloudSignedMetadataToRendererVideo, exists, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 132caae2..28b60d68 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -35,15 +35,6 @@ const WarcraftRecorder = () => { const upgradeNotified = useRef(false); const { toast } = useToast(); - // The video state contains most of the frontend state, it's complex so - // frontend triggered modifications go through the StateManager class, which - // calls the React set function appropriately. - const [videoState, setVideoState] = useState([]); - - const stateManager = useRef( - StateManager.getInstance(setVideoState) - ); - const [recorderStatus, setRecorderStatus] = useState( RecStatus.WaitingForWoW ); @@ -84,12 +75,6 @@ const WarcraftRecorder = () => { page: Pages.None, category: getCategoryFromConfig(config), playingVideo: undefined, - selectedVideoName: undefined, - - // Limit the number of videos displayed for performance. User can load more - // by clicking the button, but mainline case will be to watch back recent - // videos. - numVideosDisplayed: 10, // Any text applied in the filter bar gets translated into a filter here. videoFilterQuery: '', @@ -98,6 +83,15 @@ const WarcraftRecorder = () => { videoFullScreen: false, }); + // The video state contains most of the frontend state, it's complex so + // frontend triggered modifications go through the StateManager class, which + // calls the React set function appropriately. + const [videoState, setVideoState] = useState([]); + + const stateManager = useRef( + StateManager.getInstance(setVideoState, appState, setAppState) + ); + // Used to allow for hot switching of video players when moving between POVs. const persistentProgress = useRef(0); @@ -154,6 +148,15 @@ const WarcraftRecorder = () => { ipc.on('updateCrashes', updateCrashes); }, []); + // Debugging why we needed this hurt. I think it's because when we call setAppState, it sets + // appState to undefined and reassigns it in this component. However that leaves the StateManager + // singleton with a reference pointing to undefined which breaks the frontend. So here we reapply + // the appState to the StateManager every time it updates. This is almost certainly massively + // overengineered but for now it works. + useEffect(() => { + stateManager.current.updateAppState(appState); + }, [appState]); + return ( = (props: IProps) => { - const { video } = props; - const { combatants, category } = video; - const isSoloShuffle = category === VideoCategory.SoloShuffle; - const iconSize = '18px'; - - if (combatants === undefined) { - return <>; - } - - const playerTeamID = getPlayerTeamID(video); - - const friendly = combatants.filter( - (combatant: RawCombatant) => combatant._teamID !== playerTeamID - ); - - const enemy = combatants.filter( - (combatant: RawCombatant) => combatant._teamID === playerTeamID - ); - - if (friendly.length > 5 || friendly.length === 0) { - return <>; - } - - if (enemy.length > 5 || enemy.length === 0) { - return <>; - } - - if (enemy.length !== friendly.length) { - return <>; - } - - const maybeIncludeVersus = () => { - if (isSoloShuffle) { - return <>; - } - - return ; - }; - - const renderEnemyCombatant = (combatant: RawCombatant) => { - let nameColor = 'darkgrey'; - let specIcon = Images.specImages[0]; - - if (combatant._specID !== undefined) { - const knownSpec = Object.prototype.hasOwnProperty.call( - specializationById, - combatant._specID - ); - - if (knownSpec) { - specIcon = Images.specImages[combatant._specID]; - const spec = specializationById[combatant._specID]; - nameColor = getWoWClassColor(spec.class); - } - } - - return ( - - - {combatant._name} - - - - ); - }; - - const renderFriendlyCombatant = (combatant: RawCombatant) => { - let nameColor = 'darkgrey'; - let specIcon = Images.specImages[0]; - - if (combatant._specID !== undefined) { - const knownSpec = Object.prototype.hasOwnProperty.call( - specializationById, - combatant._specID - ); - - if (knownSpec) { - specIcon = Images.specImages[combatant._specID]; - const spec = specializationById[combatant._specID]; - nameColor = getWoWClassColor(spec.class); - } - } - - return ( - - - - {combatant._name} - - - ); - }; - - return ( - - - {enemy.map(renderEnemyCombatant)} - - {maybeIncludeVersus()} - - {friendly.map(renderFriendlyCombatant)} - - - ); -}; - -export default ArenaCompDisplay; diff --git a/src/renderer/CategoryPage.tsx b/src/renderer/CategoryPage.tsx index 1e837091..cd66478b 100644 --- a/src/renderer/CategoryPage.tsx +++ b/src/renderer/CategoryPage.tsx @@ -1,9 +1,7 @@ import * as React from 'react'; -import Box from '@mui/material/Box'; import { AppState, RendererVideo } from 'main/types'; -import { List } from '@mui/material'; -import { scrollBarSx } from 'main/constants'; import { MutableRefObject } from 'react'; +import { ScrollArea } from './components/ScrollArea/ScrollArea'; import { VideoPlayer } from './VideoPlayer'; import { VideoCategory } from '../types/VideoCategory'; import SearchBar from './SearchBar'; @@ -12,14 +10,13 @@ import { useSettings } from './useSettings'; import { getFirstInCategory, getVideoCategoryFilter, - povNameSort, + povDiskFirstNameSort, } from './rendererutils'; import VideoFilter from './VideoFilter'; -import VideoButton from './VideoButton'; import StateManager from './StateManager'; import Separator from './components/Separator/Separator'; import { Button } from './components/Button/Button'; -import { cn } from './components/utils'; +import VideoSelectionTable from './components/Tables/VideoSelectionTable'; interface IProps { category: VideoCategory; @@ -44,7 +41,7 @@ const CategoryPage = (props: IProps) => { persistentProgress, playerHeight, } = props; - const { numVideosDisplayed, videoFilterQuery } = appState; + const { videoFilterQuery } = appState; const [config, setConfig] = useSettings(); const categoryFilter = getVideoCategoryFilter(category); const categoryState = videoState.filter(categoryFilter); @@ -55,9 +52,6 @@ const CategoryPage = (props: IProps) => { new VideoFilter(videoFilterQuery, video).filter() ); - const slicedState = filteredState.slice(0, numVideosDisplayed); - const moreVideosRemain = slicedState.length !== filteredState.length; - const getVideoPlayer = () => { const { playingVideo } = appState; let videoToPlay: RendererVideo; @@ -74,7 +68,7 @@ const CategoryPage = (props: IProps) => { } const povs = [firstInCategory, ...firstInCategory.multiPov].sort( - povNameSort + povDiskFirstNameSort ); [videoToPlay] = povs; @@ -91,84 +85,10 @@ const CategoryPage = (props: IProps) => { ); }; - const handleChangeVideo = (index: number) => { - const video = videoState[index]; - const povs = [video, ...video.multiPov].sort(povNameSort); - persistentProgress.current = 0; - - setAppState((prevState) => { - return { - ...prevState, - selectedVideoName: video.videoName, - playingVideo: povs[0], - }; - }); - }; - - const mapActivityToListItem = (video: RendererVideo) => { - const povs = [video, ...video.multiPov].sort(povNameSort); - const names = povs.map((v) => v.videoName); - const selected = appState.selectedVideoName - ? names.includes(appState.selectedVideoName) - : categoryState.indexOf(video) === 0; - - return ( - // eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events -
handleChangeVideo(videoState.indexOf(video))} - role="button" - > - -
- ); - }; - - const loadMoreVideos = () => { - setAppState((prevState) => { - return { - ...prevState, - numVideosDisplayed: prevState.numVideosDisplayed + 10, - }; - }); - }; - - const getShowMoreButton = () => { - return ( - - - - ); - }; - const getVideoSelection = () => { return ( <> -
+
{!isClips && ( {
- - - {slicedState.map(mapActivityToListItem)} - {moreVideosRemain && getShowMoreButton()} - - +
+ + + +
); }; diff --git a/src/renderer/DungeonCompDisplay.tsx b/src/renderer/DungeonCompDisplay.tsx deleted file mode 100644 index 4d3d3ecc..00000000 --- a/src/renderer/DungeonCompDisplay.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { Box } from '@mui/material'; -import React from 'react'; -import { specializationById } from 'main/constants'; -import { RawCombatant, RendererVideo } from 'main/types'; -import * as Images from './images'; -import { getWoWClassColor } from './rendererutils'; - -interface IProps { - video: RendererVideo; -} - -const DungeonCompDisplay: React.FC = (props: IProps) => { - const { video } = props; - let { combatants } = video; - - if (combatants === undefined || combatants.length === 0) { - return <>; - } - - if (combatants.length > 5) { - // Handle the case that there is somehow extra combatants by just taking - // the first 5. This shouldn't really happen, but initially had problems - // with outsiders bleeding into the run. - combatants = combatants.slice(0, 5); - } - - const tanksAndHeals = combatants.filter((combatant: RawCombatant) => { - if (combatant._specID === undefined) { - return false; - } - - if (specializationById[combatant._specID] === undefined) { - return false; - } - - return specializationById[combatant._specID].role !== 'damage'; - }); - - const dps = combatants.filter((combatant: RawCombatant) => { - if (combatant._specID === undefined) { - return false; - } - - if (specializationById[combatant._specID] === undefined) { - return false; - } - - return specializationById[combatant._specID].role === 'damage'; - }); - - const renderCombatant = (combatant: RawCombatant) => { - const specID = combatant._specID; - let nameColor = 'grey'; - let specIcon = Images.specImages[0]; - - if (specID !== undefined) { - specIcon = Images.specImages[specID]; - const spec = specializationById[specID]; - nameColor = getWoWClassColor(spec.class); - } - - return ( - - - - {combatant._name} - - - ); - }; - - return ( - - - {tanksAndHeals.map(renderCombatant)} - - - - {dps.map(renderCombatant)} - - - ); -}; - -export default DungeonCompDisplay; diff --git a/src/renderer/DungeonInfo.tsx b/src/renderer/DungeonInfo.tsx index 04f3eba1..1d91db92 100644 --- a/src/renderer/DungeonInfo.tsx +++ b/src/renderer/DungeonInfo.tsx @@ -2,10 +2,7 @@ import { Box } from '@mui/material'; import React from 'react'; import { RendererVideo } from 'main/types'; import { dungeonAffixesById } from 'main/constants'; -import * as Images from './images'; -import { getDungeonName, getVideoResultText } from './rendererutils'; -import ChestIcon from '../../assets/icon/chest.png'; -import DeathIcon from '../../assets/icon/death.png'; +import { affixImages } from './images'; interface IProps { video: RendererVideo; @@ -13,10 +10,7 @@ interface IProps { const DungeonInfo: React.FC = (props: IProps) => { const { video } = props; - const { affixes, deaths } = video; - const resultText = getVideoResultText(video); - const dungeonName = getDungeonName(video); - const deathCount = deaths ? deaths.length : 0; + const { affixes } = video; const renderAffixDisplay = (affixID: number) => { return ( @@ -26,117 +20,29 @@ const DungeonInfo: React.FC = (props: IProps) => { display: 'flex', flexDirection: 'row', backgroundColor: 'transparent', + alignItems: 'center', }} > - + {dungeonAffixesById[affixID]} ); }; - const renderDungeonName = () => { - return ( - - {dungeonName} - - ); - }; - - const renderDungeonLevel = () => { - return ( - - +{video.keystoneLevel || video.level} - - ); - }; - - const renderChests = () => { - return ( - - - {resultText} - - - - ); - }; - - const renderDeaths = () => { - return ( - - - {deathCount} - - - - ); - }; - - const renderDungeonResult = () => { - return ( - - {renderChests()} - {renderDeaths()} - - ); - }; - return ( = (props: IProps) => { flexDirection: 'row', backgroundColor: 'transparent', alignItems: 'center', - justifyContent: 'center', }} > - {renderDungeonResult()} - {renderDungeonName()} - {renderDungeonLevel()} - - - = (props: IProps) => { }; const setMaxStorage = (event: React.ChangeEvent) => { + const inputValue = event.target.value; + const parsedValue = parseInt(inputValue, 10); + setConfig((prevState) => { return { ...prevState, - maxStorage: parseInt(event.target.value, 10), + maxStorage: Number.isNaN(parsedValue) ? 0 : parsedValue, }; }); }; diff --git a/src/renderer/PovSelection.tsx b/src/renderer/PovSelection.tsx deleted file mode 100644 index ebd1797f..00000000 --- a/src/renderer/PovSelection.tsx +++ /dev/null @@ -1,263 +0,0 @@ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -/* eslint-disable jsx-a11y/click-events-have-key-events */ -import { Box } from '@mui/material'; -import { AppState, RendererVideo } from 'main/types'; -import CloudIcon from '@mui/icons-material/Cloud'; -import SaveIcon from '@mui/icons-material/Save'; -import { MutableRefObject } from 'react'; -import { - getPlayerName, - getPlayerSpecID, - getPlayerClass, - getWoWClassColor, - stopPropagation, -} from './rendererutils'; -import * as Images from './images'; -import { Tooltip } from './components/Tooltip/Tooltip'; -import { - ToggleGroup, - ToggleGroupItem, -} from './components/ToggleGroup/ToggleGroup'; -import { ScrollArea } from './components/ScrollArea/ScrollArea'; - -interface IProps { - povs: RendererVideo[]; - parentButtonSelected: boolean; - localPovIndex: number; - setLocalPovIndex: React.Dispatch>; - setAppState: React.Dispatch>; - persistentProgress: MutableRefObject; -} - -export default function PovSelection(props: IProps) { - const { - povs, - parentButtonSelected, - localPovIndex, - setLocalPovIndex, - setAppState, - persistentProgress, - } = props; - - /** - * A group of POVs are the same POV from the same player and may contain - * either a disk video, a cloud video or both. - */ - const getGroupListItem = (group: RendererVideo[]) => { - const diskVideos = group.filter((vid) => !vid.cloud); - const cloudVideos = group.filter((vid) => vid.cloud); - - const haveDiskVideo = diskVideos.length !== 0; - const haveCloudVideo = cloudVideos.length !== 0; - - const cloudVideo = cloudVideos[0]; - const diskVideo = diskVideos[0]; - - const cloudIndex = povs.indexOf(cloudVideo); - const diskIndex = povs.indexOf(diskVideo); - - const cloudSelected = localPovIndex === cloudIndex; - const diskSelected = localPovIndex === diskIndex; - - const cloudButtonColor = cloudSelected ? '#bb4420' : 'white'; - const diskButtonColor = diskSelected ? '#bb4420' : 'white'; - - // Safe to just use the zeroth video here, all the details we pull out - // are guarenteed to be the same for all videos in this group./ - const v = group[0]; - const name = getPlayerName(v); - const specID = getPlayerSpecID(v); - const icon = Images.specImages[specID]; - const unitClass = getPlayerClass(v); - const classColor = - unitClass === 'UNKNOWN' ? 'gray' : getWoWClassColor(unitClass); - - /** - * Update state variables following a change of selected point of view. - */ - const handleChangePov = ( - event: React.MouseEvent | undefined, - povIndex: number - ) => { - if (event) { - stopPropagation(event); - } - setLocalPovIndex(povIndex); - const video = povs[povIndex]; - - if (!parentButtonSelected) { - persistentProgress.current = 0; - } - - setAppState((prevState) => { - return { - ...prevState, - selectedVideoName: video.videoName, - playingVideo: povs[povIndex], - }; - }); - }; - - /** - * Return the cloud icon. - */ - const getCloudIcon = () => { - let opacity = 1; - let title = 'Use cloud version'; - - if (!haveCloudVideo) { - opacity = 0.2; - title = 'No cloud recording is saved'; - } - - return ( - - handleChangePov(e, cloudIndex)} - className="!pointer-events-auto" - > - - - - ); - }; - - /** - * Return the disk icon. - */ - const getDiskIcon = () => { - let opacity = 1; - let title = 'Use local disk version'; - - if (!haveDiskVideo) { - opacity = 0.2; - title = 'No disk recording is saved'; - } - - return ( - - handleChangePov(e, diskIndex)} - > - - - - ); - }; - - return ( -
-
{ - if (haveCloudVideo) { - handleChangePov(event, cloudIndex); - } else { - handleChangePov(event, diskIndex); - } - }} - > -
- - {getCloudIcon()} - {getDiskIcon()} - - class-icon - - - {name} - - -
-
-
- ); - }; - - /** - * Group the povs by name, grouping disk and cloud POVs for the - * same video into a single group. - */ - const groupByName = (arr: RendererVideo[]) => { - return arr.reduce((acc: Record, obj) => { - const { videoName } = obj; - - if (!acc[videoName]) { - acc[videoName] = []; - } - - acc[videoName].push(obj); - return acc; - }, {}); - }; - - const povsArray = Object.values(groupByName(povs)); - - return ( -
- {/* - If we don't have more than three, we don't want to render a scrollable area. - - This is because it's a faff to vertically center <3 elements within that area, so let's just forego it. - */} - {povsArray.length > 5 ? ( - <> - -
- {povsArray.map((g) => getGroupListItem(g))} -
-
-
- - ) : ( -
- {povsArray.map((g) => getGroupListItem(g))} -
- )} -
- ); -} diff --git a/src/renderer/RaidCompAndResult.tsx b/src/renderer/RaidComp.tsx similarity index 50% rename from src/renderer/RaidCompAndResult.tsx rename to src/renderer/RaidComp.tsx index 8b48a58d..9f25788f 100644 --- a/src/renderer/RaidCompAndResult.tsx +++ b/src/renderer/RaidComp.tsx @@ -2,13 +2,11 @@ import { Box } from '@mui/material'; import React from 'react'; import { RawCombatant, RendererVideo } from 'main/types'; import { specializationById } from 'main/constants'; -import { areDatesWithinSeconds, getVideoResultText } from './rendererutils'; -import * as Images from './images'; +import { roleImages } from './images'; import DeathIcon from '../../assets/icon/death.png'; interface IProps { video: RendererVideo; - raidCategoryState: RendererVideo[]; } type RoleCount = { @@ -18,9 +16,9 @@ type RoleCount = { }; const RaidCompAndResult: React.FC = (props: IProps) => { - const { video, raidCategoryState } = props; + const { video } = props; const { combatants, deaths } = video; - const resultText = getVideoResultText(video); + const deathCount = deaths ? deaths.length : 0; const roleCount: RoleCount = { @@ -46,67 +44,6 @@ const RaidCompAndResult: React.FC = (props: IProps) => { roleCount[role]++; }); - const getPullNumber = () => { - const videoDate = video.start - ? new Date(video.start) - : new Date(video.mtime); - - const dailyVideosInOrder: RendererVideo[] = []; - - raidCategoryState.forEach((neighbourVideo) => { - const bestDate = neighbourVideo.start - ? neighbourVideo.start - : neighbourVideo.mtime; - - const neighbourDate = new Date(bestDate); - - // Pulls longer than 6 hours apart are considered from different - // sessions and will reset the pull counter. - // - // This logic is really janky and should probably be rewritten. The - // problem here is that if checks for any videos within 6 hours. - // - // If there are videos on the border (e.g. day raiding) then the - // pull count can do weird things like decrement or not increment given - // the right timing conditions of the previous sessions raids. - const withinThreshold = areDatesWithinSeconds( - videoDate, - neighbourDate, - 3600 * 6 - ); - - if ( - video.encounterID === undefined || - neighbourVideo.encounterID === undefined - ) { - return; - } - - const sameEncounter = video.encounterID === neighbourVideo.encounterID; - - if ( - video.difficultyID === undefined || - neighbourVideo.difficultyID === undefined - ) { - return; - } - - const sameDifficulty = video.difficultyID === neighbourVideo.difficultyID; - - if (withinThreshold && sameEncounter && sameDifficulty) { - dailyVideosInOrder.push(neighbourVideo); - } - }); - - dailyVideosInOrder.sort((A: RendererVideo, B: RendererVideo) => { - const bestTimeA = A.start ? A.start : A.mtime; - const bestTimeB = B.start ? B.start : B.mtime; - return bestTimeA - bestTimeB; - }); - - return dailyVideosInOrder.indexOf(video) + 1; - }; - const renderCounter = (role: string) => { return ( = (props: IProps) => { = (props: IProps) => { ); }; - const renderResult = () => { - return ( - - - {`${resultText} (Pull ${getPullNumber()})`} - - - ); - }; - const renderDeaths = () => { return ( = (props: IProps) => { src={DeathIcon} sx={{ p: '2px', - height: '16px', - width: '16px', + height: '20px', + width: '20px', objectFit: 'cover', }} /> @@ -207,11 +126,11 @@ const RaidCompAndResult: React.FC = (props: IProps) => { flexDirection: 'column', alignItems: 'center', justifyContent: 'center', + p: 1, }} > {renderDeaths()} {renderRaidComp()} - {renderResult()} ); }; diff --git a/src/renderer/RaidEncounterInfo.tsx b/src/renderer/RaidEncounterInfo.tsx deleted file mode 100644 index bb0b396e..00000000 --- a/src/renderer/RaidEncounterInfo.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import Box from '@mui/material/Box'; -import { RendererVideo } from 'main/types'; -import { getInstanceDifficultyText } from './rendererutils'; - -interface IProps { - video: RendererVideo; -} - -const RaidEncounterInfo: React.FC = (props: IProps) => { - const { video } = props; - const { encounterName, zoneName } = video; - const difficultyText = getInstanceDifficultyText(video); - const unknownRaid = video.zoneName === 'Unknown Raid'; - - const renderDifficultyText = () => { - return ( - - {difficultyText} - - ); - }; - - const renderEncounterText = () => { - return ( - - {encounterName} - - ); - }; - - const renderZoneText = () => { - if (unknownRaid) { - return <>; - } - - return ( - - {zoneName} - - ); - }; - - return ( - - {renderDifficultyText()} - {renderEncounterText()} - {renderZoneText()} - - ); -}; - -export default RaidEncounterInfo; diff --git a/src/renderer/SideMenu.tsx b/src/renderer/SideMenu.tsx index 3987b058..3b6b488f 100644 --- a/src/renderer/SideMenu.tsx +++ b/src/renderer/SideMenu.tsx @@ -28,7 +28,7 @@ import { getCategoryIndex, getFirstInCategory, getVideoCategoryFilter, - povNameSort, + povDiskFirstNameSort, } from './rendererutils'; import Menu from './components/Menu'; import Separator from './components/Separator/Separator'; @@ -137,7 +137,7 @@ const SideMenu = (props: IProps) => { if (firstInCategory) { const povs = [firstInCategory, ...firstInCategory.multiPov].sort( - povNameSort + povDiskFirstNameSort ); [first] = povs; @@ -151,9 +151,7 @@ const SideMenu = (props: IProps) => { videoFilterQuery: '', page: Pages.None, category: newCategory, - selectedVideoName: first?.videoName, playingVideo: first, - numVideosDisplayed: 10, }; }); }; diff --git a/src/renderer/StateManager.ts b/src/renderer/StateManager.ts index ef01cb10..1d1b7f17 100644 --- a/src/renderer/StateManager.ts +++ b/src/renderer/StateManager.ts @@ -1,6 +1,11 @@ import { VideoCategory } from 'types/VideoCategory'; -import { RendererVideo } from '../main/types'; -import { areDatesWithinSeconds } from './rendererutils'; +import { AppState, RendererVideo } from '../main/types'; +import { + areDatesWithinSeconds, + getVideoCategoryFilter, + povDiskFirstNameSort, +} from './rendererutils'; +import VideoFilter from './VideoFilter'; /** * The video state, and utility mutation methods. @@ -14,19 +19,29 @@ export default class StateManager { private setVideoState: React.Dispatch>; + private appState: AppState; + + private setAppState: React.Dispatch>; + /** * This is a singleton which allows us to avoid complications of the useRef hook recreating * the class on each render but discarding it if it's already set; that doesn't work nicely * when we set listeners in the class. */ public static getInstance( - setVideoState: React.Dispatch> + setVideoState: React.Dispatch>, + appState: AppState, + setAppState: React.Dispatch> ) { if (StateManager.instance) { return StateManager.instance; } - StateManager.instance = new StateManager(setVideoState); + StateManager.instance = new StateManager( + setVideoState, + appState, + setAppState + ); return StateManager.instance; } @@ -35,9 +50,17 @@ export default class StateManager { * Constructor. */ constructor( - setVideoState: React.Dispatch> + setVideoState: React.Dispatch>, + appState: AppState, + setAppState: React.Dispatch> ) { this.setVideoState = setVideoState; + this.appState = appState; + this.setAppState = setAppState; + } + + public updateAppState(appState: AppState) { + this.appState = appState; } /** @@ -46,8 +69,37 @@ export default class StateManager { */ public async refresh() { this.raw = (await this.ipc.invoke('getVideoState', [])) as RendererVideo[]; + + // console.time('correlate'); const correlated = this.correlate(); + // console.timeEnd('correlate'); + + // console.time('setstate'); this.setVideoState(correlated); + // console.timeEnd('setstate'); + + const { category, videoFilterQuery, playingVideo } = this.appState; + + if (!playingVideo) { + // If we haven't yet selected a video, then select the first + // in the currently selected category. + const categoryFilter = getVideoCategoryFilter(category); + const categoryState = correlated.filter(categoryFilter); + + const filteredState = categoryState.filter((video) => + new VideoFilter(videoFilterQuery, video).filter() + ); + + const first = filteredState[0]; + const viewpoints = [first, ...first.multiPov].sort(povDiskFirstNameSort); + + this.setAppState((prevState) => { + return { + ...prevState, + playingVideo: viewpoints[0], + }; + }); + } } private correlate() { @@ -65,12 +117,7 @@ export default class StateManager { StateManager.correlateVideo(video, correlated) ); - correlated - .sort(StateManager.reverseChronologicalVideoSort) - .forEach((video) => { - video.multiPov.sort(StateManager.povNameSort); - }); - + correlated.sort(StateManager.reverseChronologicalVideoSort); return correlated; } @@ -185,13 +232,6 @@ export default class StateManager { return metricB - metricA; } - private static povNameSort(a: RendererVideo, b: RendererVideo) { - const playerA = a.player?._name; - const playerB = b.player?._name; - if (!playerA || !playerB) return 0; - return playerA.localeCompare(playerB); - } - /** * Detatches any videos attached to the multiPov property of other videos. * We need this because we correlate them in this class, but we access by diff --git a/src/renderer/VideoButton.tsx b/src/renderer/VideoButton.tsx deleted file mode 100644 index ca2b5d2f..00000000 --- a/src/renderer/VideoButton.tsx +++ /dev/null @@ -1,523 +0,0 @@ -import { Box } from '@mui/material'; -import React, { MutableRefObject, useEffect, useState } from 'react'; -import { RendererVideo, AppState } from 'main/types'; -import { VideoCategory } from 'types/VideoCategory'; -import { - CalendarDays, - Clock, - CloudDownload, - CloudUpload, - FolderOpen, - Hourglass, - Link2, - PackageX, - Trash, -} from 'lucide-react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faMessage, faStar } from '@fortawesome/free-solid-svg-icons'; -import { - faStar as faStarOutline, - faMessage as faMessageOutline, -} from '@fortawesome/free-regular-svg-icons'; -import { - getResultColor, - isArenaUtil, - isBattlegroundUtil, - isMythicPlusUtil, - isRaidUtil, - getFormattedDuration, - getVideoTime, - getVideoDate, - stopPropagation, - povNameSort, -} from './rendererutils'; -import ArenaCompDisplay from './ArenaCompDisplay'; -import DungeonCompDisplay from './DungeonCompDisplay'; -import RaidEncounterInfo from './RaidEncounterInfo'; -import BattlegroundInfo from './BattlegroundInfo'; -import DungeonInfo from './DungeonInfo'; -import ArenaInfo from './ArenaInfo'; -import RaidCompAndResult from './RaidCompAndResult'; -import TagDialog from './TagDialog'; -import PovSelection from './PovSelection'; -import { useSettings } from './useSettings'; -import StateManager from './StateManager'; -import { cn } from './components/utils'; -import { Tooltip } from './components/Tooltip/Tooltip'; -import { Button } from './components/Button/Button'; -import DeleteDialog from './DeleteDialog'; -import { useToast } from './components/Toast/useToast'; - -interface IProps { - selected: boolean; - video: RendererVideo; - stateManager: MutableRefObject; - videoState: RendererVideo[]; - setAppState: React.Dispatch>; - persistentProgress: MutableRefObject; -} - -const ipc = window.electron.ipcRenderer; - -export default function VideoButton(props: IProps) { - const { - selected, - video, - stateManager, - videoState, - setAppState, - persistentProgress, - } = props; - const [config] = useSettings(); - const formattedDuration = getFormattedDuration(video); - const isMythicPlus = isMythicPlusUtil(video); - const isRaid = isRaidUtil(video); - const isBattleground = isBattlegroundUtil(video); - const isArena = isArenaUtil(video); - const resultColor = getResultColor(video); - const videoTime = getVideoTime(video); - const videoDate = getVideoDate(video); - - const [ctrlDown, setCtrlDown] = useState(false); - const [localPovIndex, setLocalPovIndex] = useState(0); - - const { toast } = useToast(); - - const povs = [video, ...video.multiPov].sort(povNameSort); - const multiPov = povs.length > 1; - - const pov = povs[localPovIndex]; - const { videoName, cloud, thumbnailSource, isProtected, tag, videoSource } = - pov; - - // Check if we have this point of view duplicated in the other storage - // type. Don't want to be showing the download button if we have already - // got it on disk and vice versa. - const haveOnDisk = - !cloud || - povs.filter((v) => v.videoName === videoName).filter((v) => !v.cloud) - .length > 0; - - const haveInCloud = - cloud || - povs.filter((v) => v.videoName === videoName).filter((v) => v.cloud) - .length > 0; - - let tagTooltip: string = tag || 'Add a tag'; - - if (tagTooltip.length > 50) { - tagTooltip = `${tagTooltip.slice(0, 50)}...`; - } - - useEffect(() => { - if (povs.length > localPovIndex) { - return; - } - - setLocalPovIndex(0); - }, [localPovIndex, povs.length, selected]); - - /** - * Delete a video. - */ - const deleteVideo = (event: React.MouseEvent) => { - event.stopPropagation(); - - const src = cloud ? videoName : videoSource; - window.electron.ipcRenderer.sendMessage('deleteVideo', [src, cloud]); - stateManager.current.deleteVideo(pov); - - if (!selected) { - return; - } - - setLocalPovIndex(0); - persistentProgress.current = 0; - - setAppState((prevState) => { - return { - ...prevState, - selectedVideoName: undefined, - playingVideo: undefined, - }; - }); - }; - - /** - * Delete all the points of view for this video. - */ - const deleteAllPovs = (event: React.MouseEvent) => { - event.stopPropagation(); - povs.forEach((p) => { - const src = p.cloud ? p.videoName : p.videoSource; - - window.electron.ipcRenderer.sendMessage('deleteVideo', [src, p.cloud]); - - stateManager.current.deleteVideo(p); - }); - - if (!selected) { - return; - } - - setLocalPovIndex(0); - - setAppState((prevState) => { - return { - ...prevState, - selectedVideoName: undefined, - playingVideo: undefined, - }; - }); - }; - - /** - * Sets up event listeners so that users can skip the "Are you sure you want - * to delete this video?" prompt by holding CTRL. - */ - useEffect(() => { - document.addEventListener('keyup', (event: KeyboardEvent) => { - if (event.key === 'Control') { - setCtrlDown(false); - } - }); - - document.addEventListener('keydown', (event: KeyboardEvent) => { - if (event.key === 'Control') { - setCtrlDown(true); - } - }); - }); - - const protectVideo = (event: React.SyntheticEvent) => { - event.stopPropagation(); - stateManager.current.toggleProtect(pov); - const src = cloud ? videoName : videoSource; - const bool = !isProtected; - - window.electron.ipcRenderer.sendMessage('videoButton', [ - 'save', - src, - cloud, - bool, - ]); - }; - - const openLocation = (event: React.SyntheticEvent) => { - event.stopPropagation(); - - window.electron.ipcRenderer.sendMessage('videoButton', [ - 'open', - videoSource, - cloud, - ]); - }; - - const onDeleteSingle = (event: React.MouseEvent) => { - event.stopPropagation(); - - if (ctrlDown) { - deleteVideo(event); - } - }; - - const onDeleteAll = (event: React.MouseEvent) => { - event.stopPropagation(); - - if (ctrlDown) { - deleteAllPovs(event); - } - }; - - const getOpenButton = () => { - return ( - - - - ); - }; - - const uploadVideo = async () => { - ipc.sendMessage('videoButton', ['upload', videoSource]); - }; - - const getUploadButton = () => { - return ( - - - - ); - }; - - const downloadVideo = async () => { - ipc.sendMessage('videoButton', ['download', pov]); - }; - - const getDownloadButton = () => { - return ( - - - - ); - }; - - const getShareableLink = async (event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - - try { - await ipc.invoke('getShareableLink', [videoName]); - toast({ - title: 'Shareable link generated and placed in clipboard', - description: 'This link will be valid for up to 30 days.', - duration: 5000, - }); - } catch (error) { - toast({ - title: 'Failed to generate link', - description: 'Please see logs for more details', - variant: 'destructive', - duration: 5000, - }); - } - }; - - const getShareLinkButton = () => { - return ( - - - - ); - }; - - const getDeleteSingleButton = () => { - return ( - deleteVideo(e)} tooltipContent="Delete"> - - - ); - }; - - const getDeleteAllButton = () => { - return ( - deleteAllPovs(e)} - tooltipContent="Delete all points of view" - > - - - ); - }; - - return ( -
-
-
-
- video-thumbnail -
- -
- -
- - - - {isArena && } - {isMythicPlus && } - {isRaid && } - {isBattleground && } - - - - - - {isArena && } - {isMythicPlus && } - {isRaid && ( - v.category === VideoCategory.Raids - )} - /> - )} - - - - -
- -
- - - {formattedDuration} - -
-
- - -
- - - {videoTime} - -
-
- - -
- - - {videoDate} - -
-
-
- - -
- - - - - - - - - {cloud && getShareLinkButton()} - {!cloud && getOpenButton()} - {cloud && !haveOnDisk && getDownloadButton()} - {!cloud && - !haveInCloud && - config.cloudUpload && - getUploadButton()} - {getDeleteSingleButton()} - {multiPov && getDeleteAllButton()} -
-
-
-
-
- ); -} diff --git a/src/renderer/VideoPlayer.tsx b/src/renderer/VideoPlayer.tsx index 5e87b2e5..8ad96124 100644 --- a/src/renderer/VideoPlayer.tsx +++ b/src/renderer/VideoPlayer.tsx @@ -595,7 +595,7 @@ export const VideoPlayer = (props: IProps) => { return (
- + {secToMmSs(current)} / {secToMmSs(max)}
diff --git a/src/renderer/components/Button/Button.tsx b/src/renderer/components/Button/Button.tsx index 1d279161..59f8731f 100644 --- a/src/renderer/components/Button/Button.tsx +++ b/src/renderer/components/Button/Button.tsx @@ -27,6 +27,7 @@ const buttonVariants = cva( sm: 'h-9 rounded-md px-3 text-xs', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', + xl: 'h-14 w-14', }, }, defaultVariants: { diff --git a/src/renderer/components/Tables/Cells.tsx b/src/renderer/components/Tables/Cells.tsx new file mode 100644 index 00000000..1c54a6bd --- /dev/null +++ b/src/renderer/components/Tables/Cells.tsx @@ -0,0 +1,163 @@ +import { CellContext, Row } from '@tanstack/react-table'; +import { RendererVideo } from 'main/types'; +import { + getVideoResultText, + getResultColor, + getFormattedDuration, + dateToHumanReadable, + stopPropagation, + countUniqueViewpoints, + getPlayerClass, + getPlayerName, + getPlayerRealm, + getPlayerSpecID, + getWoWClassColor, + povDiskFirstNameSort, +} from 'renderer/rendererutils'; +import KeyboardDoubleArrowUpIcon from '@mui/icons-material/KeyboardDoubleArrowUp'; +import KeyboardDoubleArrowDownIcon from '@mui/icons-material/KeyboardDoubleArrowDown'; +import { Box } from '@mui/material'; +import { specializationById } from 'main/constants'; +import { specImages } from 'renderer/images'; +import { Button } from '../Button/Button'; + +export const populateResultCell = ( + info: CellContext +) => { + const video = info.getValue() as RendererVideo; + const resultText = getVideoResultText(video); + const resultColor = getResultColor(video); + + return ( + + {resultText} + + ); +}; + +export const populateDurationCell = ( + info: CellContext +) => { + const rawValue = info.getValue() as RendererVideo; + return getFormattedDuration(rawValue); +}; + +export const populateEncounterNameCell = ( + info: CellContext +) => { + const encounter = info.getValue() as RendererVideo; + return
{encounter}
; +}; + +export const populateMapCell = (info: CellContext) => { + const map = info.getValue() as RendererVideo; + return
{map}
; +}; + +export const populateDateCell = (info: CellContext) => { + const date = info.getValue() as Date; + return dateToHumanReadable(date); +}; + +export const populateTagCell = (info: CellContext) => { + const tag = info.getValue() as RendererVideo; + return
{tag}
; +}; + +export const populateDetailsCell = ( + ctx: CellContext +) => { + const { row } = ctx; + + return ( + + ); +}; + +export const populateLevelCell = ( + info: CellContext +) => { + const video = info.getValue() as RendererVideo; + return `+${video.keystoneLevel || video.level}`; +}; + +export const populateViewpointCell = ( + info: CellContext +) => { + const video = info.getValue() as RendererVideo; + const count = countUniqueViewpoints(video); + + // Prioritize the any videos with a disk copy as that's likely to be the + // local users viewpoint so most relevant to them. + const povs = [video, ...video.multiPov].sort(povDiskFirstNameSort); + const first = povs[0]; + const { player } = first; + + if (!player || !player._specID) { + // We don't have enough to render a spec icon and name so + // just return the viewpoint count. + return
{count}
; + } + + const playerName = getPlayerName(first); + const playerClass = getPlayerClass(first); + const playerClassColor = getWoWClassColor(playerClass); + const playerSpecID = getPlayerSpecID(first); + const specIcon = specImages[playerSpecID as keyof typeof specImages]; + + const renderSpecAndName = () => { + return ( + <> + +
+ {playerName} +
+ + ); + }; + + const renderRemainingCount = () => { + if (count > 1) return
{`+${count - 1}`}
; + return <>; + }; + + return ( +
+ {renderSpecAndName()} + {renderRemainingCount()} +
+ ); +}; diff --git a/src/renderer/components/Tables/Headers.tsx b/src/renderer/components/Tables/Headers.tsx new file mode 100644 index 00000000..041491db --- /dev/null +++ b/src/renderer/components/Tables/Headers.tsx @@ -0,0 +1,88 @@ +import { + CalendarDays, + Eye, + Gamepad2, + Hash, + Hourglass, + MapPinned, + MessageSquare, + Swords, + Trophy, +} from 'lucide-react'; + +export const EncounterHeader = () => ( + + + Encounter + +); + +export const ResultHeader = () => ( + + + Result + +); + +export const PullHeader = () => ( + + + Pull + +); + +export const DifficultyHeader = () => ( + + + Difficulty + +); + +export const DurationHeader = () => ( + + + Duration + +); + +export const DateHeader = () => ( + + + Date + +); + +export const ViewpointsHeader = () => ( + + + Viewpoints + +); + +export const MapHeader = () => ( + + + Map + +); + +export const LevelHeader = () => ( + + + Difficulty + +); + +export const TypeHeader = () => ( + + + Type + +); + +export const TagHeader = () => ( + + + Tag + +); diff --git a/src/renderer/components/Tables/Sorting.ts b/src/renderer/components/Tables/Sorting.ts new file mode 100644 index 00000000..5747b880 --- /dev/null +++ b/src/renderer/components/Tables/Sorting.ts @@ -0,0 +1,33 @@ +import { Row } from '@tanstack/react-table'; +import { RendererVideo } from 'main/types'; +import { + countUniqueViewpoints, + getVideoResultText, +} from 'renderer/rendererutils'; + +export const resultSort = (a: Row, b: Row) => { + const resultA = getVideoResultText(a.original); + const resultB = getVideoResultText(b.original); + return resultB.localeCompare(resultA); +}; + +export const levelSort = (a: Row, b: Row) => { + const resultA = a.original.keystoneLevel || a.original.level || 0; + const resultB = b.original.keystoneLevel || b.original.level || 0; + return resultA - resultB; +}; + +export const durationSort = (a: Row, b: Row) => { + const resultA = a.original.duration; + const resultB = b.original.duration; + return resultA - resultB; +}; + +export const viewPointCountSort = ( + a: Row, + b: Row +) => { + const resultA = countUniqueViewpoints(a.original); + const resultB = countUniqueViewpoints(b.original); + return resultA - resultB; +}; diff --git a/src/renderer/components/Tables/VideoSelectionTable.tsx b/src/renderer/components/Tables/VideoSelectionTable.tsx new file mode 100644 index 00000000..a045526f --- /dev/null +++ b/src/renderer/components/Tables/VideoSelectionTable.tsx @@ -0,0 +1,626 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import { AppState, RendererVideo } from 'main/types'; + +import { + Cell, + ColumnDef, + ExpandedState, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getSortedRowModel, + Header, + Row, + useReactTable, +} from '@tanstack/react-table'; +import { + Fragment, + MutableRefObject, + useEffect, + useMemo, + useState, +} from 'react'; +import ViewpointSelection from 'renderer/components/Viewpoints/ViewpointSelection'; +import ViewpointInfo from 'renderer/components/Viewpoints/ViewpointInfo'; +import ViewpointButtons from 'renderer/components/Viewpoints/ViewpointButtons'; +import StateManager from 'renderer/StateManager'; +import RaidCompAndResult from 'renderer/RaidComp'; +import { VideoCategory } from 'types/VideoCategory'; +import DungeonInfo from 'renderer/DungeonInfo'; +import { ArrowDown, ArrowUp } from 'lucide-react'; +import { + getDungeonName, + getInstanceDifficultyText, + getPullNumber, + povDiskFirstNameSort, + videoToDate, +} from '../../rendererutils'; +import { + DateHeader, + DifficultyHeader, + DurationHeader, + EncounterHeader, + LevelHeader, + MapHeader, + PullHeader, + ResultHeader, + TagHeader, + TypeHeader, + ViewpointsHeader, +} from './Headers'; +import { + durationSort, + levelSort, + resultSort, + viewPointCountSort, +} from './Sorting'; +import { + populateDateCell, + populateDetailsCell, + populateDurationCell, + populateEncounterNameCell, + populateLevelCell, + populateMapCell, + populateResultCell, + populateTagCell, + populateViewpointCell, +} from './Cells'; + +interface IProps { + videoState: RendererVideo[]; + appState: AppState; + setAppState: React.Dispatch>; + stateManager: MutableRefObject; + persistentProgress: MutableRefObject; +} + +/** + * Table component for displaying available videos. Includes category appropriate + * columns for a quick overview, the ability to sort by column, and the option to + * expand a specific activity for more details and controls. + */ +const VideoSelectionTable = (props: IProps) => { + const { + videoState, + appState, + setAppState, + stateManager, + persistentProgress, + } = props; + + const [expanded, setExpanded] = useState({}); + + /** + * Reset expanded on changing category. Probably this could be + * higher in the stack rather than running post-render of a new category. + */ + useEffect(() => { + setExpanded({}); + }, [appState.category]); + + /** + * Mark the row as selected and update the video player to play the first + * viewpoint. + */ + const onRowClick = (row: Row) => { + const video = row.original; + const povs = [video, ...video.multiPov].sort(povDiskFirstNameSort); + + persistentProgress.current = 0; + + setAppState((prevState) => { + return { + ...prevState, + playingVideo: povs[0], + }; + }); + }; + + /** + * Select the row and expand it. + */ + const onRowDoubleClick = (row: Row) => { + onRowClick(row); + row.getToggleExpandedHandler()(); + }; + + /** + * The raid table columns, the data access, sorting functions + * and any display transformations. + */ + const raidColumns = useMemo[]>( + () => [ + { + id: 'Encounter', + accessorKey: 'encounterName', + header: EncounterHeader, + cell: populateEncounterNameCell, + }, + { + id: 'Result', + accessorFn: (v) => v, + sortingFn: resultSort, + header: ResultHeader, + cell: populateResultCell, + }, + { + id: 'Pull', + accessorFn: (v) => getPullNumber(v, videoState), + header: PullHeader, + }, + { + id: 'Difficulty', + accessorFn: (v) => getInstanceDifficultyText(v), + header: DifficultyHeader, + }, + { + id: 'Duration', + accessorFn: (v) => v, + sortingFn: durationSort, + header: DurationHeader, + cell: populateDurationCell, + }, + { + id: 'Date', + accessorFn: (v) => videoToDate(v), + header: DateHeader, + cell: populateDateCell, + }, + { + id: 'Viewpoints', + accessorFn: (v) => v, + header: ViewpointsHeader, + cell: populateViewpointCell, + sortingFn: viewPointCountSort, + }, + { + id: 'Details', + size: 50, + cell: populateDetailsCell, + }, + ], + [videoState] + ); + + /** + * The arena table columns, the data access, sorting functions + * and any display transformations. + */ + const arenaColumns = useMemo[]>( + () => [ + { + id: 'Map', + accessorKey: 'zoneName', + header: MapHeader, + cell: populateMapCell, + }, + { + id: 'Result', + accessorFn: (v) => v, + sortingFn: resultSort, + header: ResultHeader, + cell: populateResultCell, + }, + { + id: 'Duration', + accessorFn: (v) => v, + sortingFn: durationSort, + header: DurationHeader, + cell: populateDurationCell, + }, + { + id: 'Date', + accessorFn: (v) => videoToDate(v), + header: DateHeader, + cell: populateDateCell, + }, + { + id: 'Viewpoints', + accessorFn: (v) => v, + header: ViewpointsHeader, + cell: populateViewpointCell, + sortingFn: viewPointCountSort, + }, + { + id: 'Details', + size: 50, + cell: populateDetailsCell, + }, + ], + [] + ); + + /** + * The dungeon table columns, the data access, sorting functions + * and any display transformations. + */ + const dungeonColumns = useMemo[]>( + () => [ + { + id: 'Map', + accessorFn: getDungeonName, + header: MapHeader, + cell: populateMapCell, + }, + { + id: 'Result', + accessorFn: (v) => v, + sortingFn: resultSort, + header: ResultHeader, + cell: populateResultCell, + }, + { + id: 'Level', + accessorFn: (v) => v, + sortingFn: levelSort, + header: LevelHeader, + cell: populateLevelCell, + }, + { + id: 'Duration', + accessorFn: (v) => v, + sortingFn: durationSort, + header: DurationHeader, + cell: populateDurationCell, + }, + { + id: 'Date', + accessorFn: (v) => videoToDate(v), + header: DateHeader, + cell: populateDateCell, + }, + { + id: 'Viewpoints', + accessorFn: (v) => v, + header: ViewpointsHeader, + cell: populateViewpointCell, + sortingFn: viewPointCountSort, + }, + { + id: 'Details', + size: 50, + cell: populateDetailsCell, + }, + ], + [] + ); + + /** + * The battleground table columns, the data access, sorting functions + * and any display transformations. + */ + const battlegroundColumns = useMemo[]>( + () => [ + { + id: 'Map', + accessorKey: 'zoneName', + header: MapHeader, + cell: populateMapCell, + }, + { + id: 'Result', + accessorFn: (v) => v, + sortingFn: resultSort, + header: ResultHeader, + cell: populateResultCell, + }, + { + id: 'Duration', + accessorFn: (v) => v, + sortingFn: durationSort, + header: DurationHeader, + cell: populateDurationCell, + }, + { + id: 'Date', + accessorFn: (v) => videoToDate(v), + header: DateHeader, + cell: populateDateCell, + }, + { + id: 'Viewpoints', + accessorFn: (v) => v, + header: ViewpointsHeader, + cell: populateViewpointCell, + sortingFn: viewPointCountSort, + }, + { + id: 'Details', + size: 50, + cell: populateDetailsCell, + }, + ], + [] + ); + + /** + * The battleground table columns, the data access, sorting functions + * and any display transformations. + */ + const clipsColumns = useMemo[]>( + () => [ + { + id: 'Type', + accessorKey: 'parentCategory', + header: TypeHeader, + cell: (info) => info.getValue(), + }, + { + id: 'Tag', + accessorFn: (v) => v.tag, + header: TagHeader, + cell: populateTagCell, + }, + { + id: 'Duration', + accessorFn: (v) => v, + sortingFn: durationSort, + header: DurationHeader, + cell: populateDurationCell, + }, + { + id: 'Date', + accessorFn: (v) => videoToDate(v), + header: DateHeader, + cell: populateDateCell, + }, + { + id: 'Viewpoints', + accessorFn: (v) => v, + header: ViewpointsHeader, + cell: populateViewpointCell, + sortingFn: viewPointCountSort, + }, + { + id: 'Details', + size: 50, + cell: populateDetailsCell, + }, + ], + [] + ); + + const { category, playingVideo } = appState; + let columns; + + switch (category) { + case VideoCategory.Raids: + columns = raidColumns; + break; + case VideoCategory.MythicPlus: + columns = dungeonColumns; + break; + case VideoCategory.Battlegrounds: + columns = battlegroundColumns; + break; + case VideoCategory.Clips: + columns = clipsColumns; + break; + case VideoCategory.TwoVTwo: + case VideoCategory.ThreeVThree: + case VideoCategory.FiveVFive: + case VideoCategory.Skirmish: + case VideoCategory.SoloShuffle: + columns = arenaColumns; + break; + default: + throw new Error('Unrecognized category'); + } + + /** + * Prepare the headless table, with sorting and row expansion. This is where + * the data is passed in to be rendered. + */ + const table = useReactTable({ + columns, + data: videoState, + state: { expanded }, + getRowId: (row) => row.uniqueId, + onExpandedChange: setExpanded, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowCanExpand: () => true, + getExpandedRowModel: getExpandedRowModel(), + }); + + /** + * Render an individual header. + */ + const renderIndividualHeader = (header: Header) => { + let tooltip; + + if (header.column.getNextSortingOrder() === 'asc') { + tooltip = 'Click to sort ascending'; + } else if (header.column.getNextSortingOrder() === 'desc') { + tooltip = 'Click to sort descending'; + } else { + tooltip = 'Click to clear sort'; + } + + return ( + +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? null} +
+ + ); + }; + + /** + * Render the header of the selection table. + */ + const renderTableHeader = () => { + const groups = table.getHeaderGroups(); + const { headers } = groups[0]; + + return ( + + {headers.map(renderIndividualHeader)} + + ); + }; + + /** + * Render a cell in the base row. + */ + const renderBaseCell = (cell: Cell) => { + const width = cell.column.getSize(); + + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + }; + + /** + * Render the base row. + */ + const renderBaseRow = (row: Row, selected: boolean) => { + const cells = row.getVisibleCells(); + let className = 'cursor-pointer hover:bg-secondary/80 '; + + if (selected) { + className += 'bg-secondary/80'; + } + + return ( + onRowClick(row)} + onDoubleClick={() => onRowDoubleClick(row)} + > + {cells.map(renderBaseCell)} + + ); + }; + + /** + * Renders content specific content. Not all content types are equal here. + */ + const renderContentSpecificInfo = (row: Row) => { + if (category === VideoCategory.Raids) { + return ( +
+ +
+ ); + } + + if (category === VideoCategory.MythicPlus) { + return ( +
+ +
+ ); + } + + return <>; + }; + + /** + * Render the expanded row. + */ + const renderExpandedRow = (row: Row) => { + const cells = row.getVisibleCells(); + const povs = [row.original, ...row.original.multiPov]; + const selected = Boolean( + povs.find((p) => p.videoName === playingVideo?.videoName) + ); + + const borderClass = selected + ? 'border border-t-0 rounded-b-sm' + : 'border rounded-sm'; + + return ( + + +
+
+ +
+
+ {renderContentSpecificInfo(row)} +
+ + +
+
+
+ + + ); + }; + + /** + * Render an individual row of the table. + */ + const renderRow = (row: Row) => { + const povs = [row.original, ...row.original.multiPov]; + const selected = Boolean( + povs.find((p) => p.videoName === playingVideo?.videoName) + ); + + return ( + + {renderBaseRow(row, selected)} + {row.getIsExpanded() && renderExpandedRow(row)} + + ); + }; + + /** + * Render the body of the selection table. + */ + const renderTableBody = () => { + const { rows } = table.getRowModel(); + return {rows.map(renderRow)}; + }; + + /** + * Render the whole component. + */ + const renderTable = () => { + return ( +
+ + {renderTableHeader()} + {renderTableBody()} +
+
+ ); + }; + + return renderTable(); +}; + +export default VideoSelectionTable; diff --git a/src/renderer/components/Toast/Toast.tsx b/src/renderer/components/Toast/Toast.tsx index d9c6643a..21dc77ca 100644 --- a/src/renderer/components/Toast/Toast.tsx +++ b/src/renderer/components/Toast/Toast.tsx @@ -82,7 +82,7 @@ const ToastClose = React.forwardRef< >; + persistentProgress: MutableRefObject; + stateManager: MutableRefObject; +} + +const ipc = window.electron.ipcRenderer; + +export default function ViewpointButtons(props: IProps) { + const { appState, setAppState, persistentProgress, video, stateManager } = + props; + const povs = [video, ...video.multiPov].sort(povDiskFirstNameSort); + + const [ctrlDown, setCtrlDown] = useState(false); + const multiPov = povs.length > 1; + + /** + * Sets up event listeners so that users can skip the "Are you sure you want + * to delete this video?" prompt by holding CTRL. + */ + useEffect(() => { + document.addEventListener('keyup', (event: KeyboardEvent) => { + if (event.key === 'Control') { + setCtrlDown(false); + } + }); + + document.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Control') { + setCtrlDown(true); + } + }); + }); + + const { playingVideo } = appState; + + let videoToShow = povs.find((p) => p.uniqueId === playingVideo?.uniqueId); + + if (!videoToShow) { + [videoToShow] = povs; + } + + const { cloud, videoName, videoSource, isProtected } = videoToShow; + + const getTagButton = () => { + const { tag } = videoToShow; + + let tagTooltip: string = tag || 'Add a tag'; + + if (tagTooltip.length > 50) { + tagTooltip = `${tagTooltip.slice(0, 50)}...`; + } + + return ( + + + + ); + }; + + const protectVideo = (event: React.SyntheticEvent) => { + event.stopPropagation(); + stateManager.current.toggleProtect(videoToShow); + const src = cloud ? videoName : videoSource; + const bool = !isProtected; + + window.electron.ipcRenderer.sendMessage('videoButton', [ + 'save', + src, + cloud, + bool, + ]); + }; + + const getProtectVideoButton = () => { + return ( + + + + ); + }; + + const getShareableLink = async (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + + try { + await ipc.invoke('getShareableLink', [videoName]); + toast({ + title: 'Shareable link generated and placed in clipboard', + description: 'This link will be valid for up to 30 days.', + duration: 5000, + }); + } catch (error) { + toast({ + title: 'Failed to generate link', + description: 'Please see logs for more details', + variant: 'destructive', + duration: 5000, + }); + } + }; + + const getShareLinkButton = () => { + return ( + + + + ); + }; + + const openLocation = (event: React.SyntheticEvent) => { + event.stopPropagation(); + + window.electron.ipcRenderer.sendMessage('videoButton', [ + 'open', + videoSource, + cloud, + ]); + }; + + const getOpenButton = () => { + return ( + + + + ); + }; + + const deleteVideo = (event: React.MouseEvent) => { + event.stopPropagation(); + + const src = cloud ? videoName : videoSource; + window.electron.ipcRenderer.sendMessage('deleteVideo', [src, cloud]); + stateManager.current.deleteVideo(videoToShow); + persistentProgress.current = 0; + + setAppState((prevState) => { + return { + ...prevState, + playingVideo: undefined, + }; + }); + }; + + const onDeleteSingle = (event: React.MouseEvent) => { + event.stopPropagation(); + + if (ctrlDown) { + deleteVideo(event); + } + }; + + const getDeleteSingleButton = () => { + return ( + deleteVideo(e)} tooltipContent="Delete"> + + + ); + }; + + const deleteAllPovs = (event: React.MouseEvent) => { + event.stopPropagation(); + + povs.forEach((p) => { + const src = p.cloud ? p.videoName : p.videoSource; + window.electron.ipcRenderer.sendMessage('deleteVideo', [src, p.cloud]); + stateManager.current.deleteVideo(p); + }); + + setAppState((prevState) => { + return { + ...prevState, + playingVideo: undefined, + }; + }); + }; + + const onDeleteAll = (event: React.MouseEvent) => { + event.stopPropagation(); + + if (ctrlDown) { + deleteAllPovs(event); + } + }; + + const getDeleteAllButton = () => { + return ( + deleteAllPovs(e)} + tooltipContent="Delete all points of view" + > + + + ); + }; + + return ( +
+ {getTagButton()} + {getProtectVideoButton()} + {cloud && getShareLinkButton()} + {!cloud && getOpenButton()} + {getDeleteSingleButton()} + {multiPov && getDeleteAllButton()} +
+ ); +} diff --git a/src/renderer/components/Viewpoints/ViewpointInfo.tsx b/src/renderer/components/Viewpoints/ViewpointInfo.tsx new file mode 100644 index 00000000..93d6df20 --- /dev/null +++ b/src/renderer/components/Viewpoints/ViewpointInfo.tsx @@ -0,0 +1,257 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import { Box } from '@mui/material'; +import { AppState, RendererVideo } from 'main/types'; +import CloudIcon from '@mui/icons-material/Cloud'; +import SaveIcon from '@mui/icons-material/Save'; +import { useSettings } from 'renderer/useSettings'; +import { CloudDownload, CloudUpload } from 'lucide-react'; +import { MutableRefObject } from 'react'; +import { ToggleGroup, ToggleGroupItem } from '../ToggleGroup/ToggleGroup'; +import { + getPlayerClass, + getWoWClassColor, + getPlayerName, + getPlayerRealm, + getPlayerSpecID, + povDiskFirstNameSort, +} from '../../rendererutils'; +import { specImages } from '../../images'; +import { Tooltip } from '../Tooltip/Tooltip'; + +const ipc = window.electron.ipcRenderer; + +interface IProps { + video: RendererVideo; + appState: AppState; + setAppState: React.Dispatch>; + persistentProgress: MutableRefObject; +} + +export default function ViewpointInfo(props: IProps) { + const { video, appState, setAppState, persistentProgress } = props; + const povs = [video, ...video.multiPov].sort(povDiskFirstNameSort); + const { playingVideo } = appState; + const [config] = useSettings(); + const { cloudUpload } = config; + + let videoToShow = povs.find((p) => p.uniqueId === playingVideo?.uniqueId); + + if (!videoToShow) { + [videoToShow] = povs; + } + + const { cloud, videoName, videoSource } = videoToShow; + + const haveOnDisk = + !cloud || + povs.filter((v) => v.videoName === videoName).filter((v) => !v.cloud) + .length > 0; + + const haveInCloud = + cloud || + povs.filter((v) => v.videoName === videoName).filter((v) => v.cloud) + .length > 0; + + const playerName = getPlayerName(videoToShow); + const playerRealm = getPlayerRealm(videoToShow); + const playerClass = getPlayerClass(videoToShow); + const playerClassColor = getWoWClassColor(playerClass); + const playerSpecID = getPlayerSpecID(videoToShow); + const specIcon = specImages[playerSpecID as keyof typeof specImages]; + + const { player } = videoToShow; + + if (!player) { + return <>; + } + + const playerViewpoints = povs.filter((p) => p.player?._name === player._name); + const diskVideo = playerViewpoints.find((vid) => !vid.cloud); + const cloudVideo = playerViewpoints.find((vid) => vid.cloud); + + const setPlayingVideo = (v: RendererVideo | undefined) => { + if (!v) { + return; + } + + const sameActivity = appState.playingVideo?.uniqueHash === v.uniqueHash; + + if (!sameActivity) { + persistentProgress.current = 0; + } + + setAppState((prevState) => { + return { + ...prevState, + playingVideo: v, + }; + }); + }; + + const downloadVideo = async () => { + ipc.sendMessage('videoButton', ['download', videoToShow]); + }; + + const getDownloadButton = () => { + return ( + + + + + + ); + }; + + const uploadVideo = async () => { + ipc.sendMessage('videoButton', ['upload', videoSource]); + }; + + const getUploadButton = () => { + return ( + + + + + + ); + }; + + /** + * Return the cloud icon. + */ + const getCloudIcon = () => { + const isSelected = videoToShow.uniqueId === cloudVideo?.uniqueId; + const color = cloudVideo ? 'white' : 'gray'; + const opacity = isSelected ? 1 : 0.3; + + if (!haveInCloud && cloudUpload) { + return getUploadButton(); + } + + return ( + + setPlayingVideo(cloudVideo)} + className="h-[40px] w-[40px]" + > + + + + ); + }; + + /** + * Return the disk icon. + */ + const getDiskIcon = () => { + const isSelected = videoToShow.uniqueId !== cloudVideo?.uniqueId; + const color = diskVideo ? 'white' : 'gray'; + const opacity = isSelected ? 1 : 0.3; + + if (!haveOnDisk && haveInCloud && cloudUpload) { + return getDownloadButton(); + } + + return ( + + setPlayingVideo(diskVideo)} + className="h-[40px] w-[40px]" + > + + + + ); + }; + + const getVideoSourceToggle = () => { + return ( +
+ + {getCloudIcon()} + {haveOnDisk && getDiskIcon()} + {!haveOnDisk && getDownloadButton()} + +
+ ); + }; + + const getPlayerInfo = () => { + return ( + <> + + + + {playerName} + + + + {playerRealm} + + + + ); + }; + + return ( + + {getVideoSourceToggle()} + {getPlayerInfo()} + + ); +} diff --git a/src/renderer/components/Viewpoints/ViewpointSelection.tsx b/src/renderer/components/Viewpoints/ViewpointSelection.tsx new file mode 100644 index 00000000..c63b16ac --- /dev/null +++ b/src/renderer/components/Viewpoints/ViewpointSelection.tsx @@ -0,0 +1,218 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import { Box } from '@mui/material'; +import { AppState, RawCombatant, RendererVideo } from 'main/types'; +import { X } from 'lucide-react'; +import { specializationById, WoWCharacterClassType } from 'main/constants'; +import { MutableRefObject } from 'react'; +import { + getWoWClassColor, + stopPropagation, + combatantNameSort, + getPlayerClass, + isArenaUtil, + isSoloShuffleUtil, + povDiskFirstNameSort, +} from '../../rendererutils'; +import { specImages } from '../../images'; + +interface IProps { + video: RendererVideo; + appState: AppState; + setAppState: React.Dispatch>; + persistentProgress: MutableRefObject; +} + +export default function ViewpointSelection(props: IProps) { + const { video, appState, setAppState, persistentProgress } = props; + const povs = [video, ...video.multiPov].sort(povDiskFirstNameSort); + const { player, combatants } = povs[0]; + + const isArena = isArenaUtil(povs[0]); + + const mapCombatants = (combatant: RawCombatant) => { + const name = combatant._name; + const matches = povs.filter((p) => p.player?._name === name); + + let cloudVideo: RendererVideo | null = null; + let diskVideo: RendererVideo | null = null; + + let unitClass: WoWCharacterClassType = 'UNKNOWN'; + let currentlySelected = false; + + const { playingVideo } = appState; + + if (matches.length > 0) { + // We only bother to get a class if we have a match. That way the + // combatants we have a viewpoint for will be colored, else they will + // be gray. + const v = matches[0]; + unitClass = getPlayerClass(v); + } + + matches.forEach((rv: RendererVideo) => { + if (rv.cloud) { + cloudVideo = rv; + currentlySelected = + currentlySelected || playingVideo?.videoName === cloudVideo.videoName; + } else { + diskVideo = rv; + currentlySelected = + currentlySelected || playingVideo?.videoName === diskVideo.videoName; + } + }); + + let specIcon: string | undefined; + + if (combatant._specID !== undefined) { + const knownSpec = Object.prototype.hasOwnProperty.call( + specializationById, + combatant._specID + ); + + if (knownSpec) { + specIcon = specImages[combatant._specID as keyof typeof specImages]; + } + } + + const handleChangePov = ( + event: React.MouseEvent | undefined + ) => { + if (event) { + stopPropagation(event); + } + + let selection: RendererVideo | undefined; + + // When a user clicks on the raid frame selection, prioritize + // a disk video if it's available. Disk videos are slightly + // more responsive and can be clipped. + if (diskVideo) { + selection = diskVideo; + } else if (cloudVideo) { + selection = cloudVideo; + } + + if (!selection) { + return; + } + + const sameActivity = + appState.playingVideo?.uniqueHash === selection.uniqueHash; + + if (!sameActivity) { + persistentProgress.current = 0; + } + + setAppState((prevState) => { + return { + ...prevState, + playingVideo: selection, + }; + }); + }; + + const classColor = + unitClass === 'UNKNOWN' ? 'gray' : getWoWClassColor(unitClass); + + const cursor = cloudVideo || diskVideo ? 'cursor-pointer' : ''; + const selected = currentlySelected + ? 'border-2 border-[#bb4420] rounded-sm' + : ''; + + return ( +
+ + {specIcon && ( + + )} + + {name} + + +
+ ); + }; + + const renderVersusSelection = () => { + if (!player) { + return <>; + } + + const friendly = combatants.filter((c) => c._teamID === player._teamID); + const enemy = combatants.filter((c) => c._teamID !== player._teamID); + let gridClass = 'grid my-1 mx-1 max-w-[500px] '; + + // some tailwind shenanigans going on here when I try to do this more dynamically. + // pretty sure it's scanning these files to decide what to bundle so needs these + // hardcoded. + if (friendly.length === 2) { + gridClass += 'grid-cols-2'; + } else if (friendly.length === 3) { + gridClass += 'grid-cols-3'; + } else { + gridClass += 'grid-cols-5'; + } + + const renderVsIcon = () => { + return ( +
+ +
+ ); + }; + + return ( +
+
{friendly.map(mapCombatants)}
+ {!isSoloShuffleUtil(povs[0]) && renderVsIcon()} +
{enemy.map(mapCombatants)}
+
+ ); + }; + + if (isArena) { + // For arena modes we split the teams. + return renderVersusSelection(); + } + + return ( +
+ {combatants.sort(combatantNameSort).map(mapCombatants)} +
+ ); +} diff --git a/src/renderer/images.ts b/src/renderer/images.ts index 4b32ce9d..7adb2141 100644 --- a/src/renderer/images.ts +++ b/src/renderer/images.ts @@ -1,52 +1,175 @@ -import { dungeonAffixesById, specializationById } from '../main/constants'; - -import UnknownIcon from '../../assets/icon/wowNotFound.png'; import TankIcon from '../../assets/roles/tank.png'; import HealerIcon from '../../assets/roles/healer.png'; import DamageIcon from '../../assets/roles/damage.png'; -interface ImageObject { - [id: number]: string; -} +import spec0 from '../../assets/specs/0.png'; +import spec103 from '../../assets/specs/103.png'; +import spec105 from '../../assets/specs/105.png'; +import spec1468 from '../../assets/specs/1468.png'; +import spec250 from '../../assets/specs/250.png'; +import spec252 from '../../assets/specs/252.png'; +import spec254 from '../../assets/specs/254.png'; +import spec256 from '../../assets/specs/256.png'; +import spec258 from '../../assets/specs/258.png'; +import spec260 from '../../assets/specs/260.png'; +import spec262 from '../../assets/specs/262.png'; +import spec264 from '../../assets/specs/264.png'; +import spec266 from '../../assets/specs/266.png'; +import spec268 from '../../assets/specs/268.png'; +import spec270 from '../../assets/specs/270.png'; +import spec581 from '../../assets/specs/581.png'; +import spec63 from '../../assets/specs/63.png'; +import spec65 from '../../assets/specs/65.png'; +import spec70 from '../../assets/specs/70.png'; +import spec72 from '../../assets/specs/72.png'; +import spec102 from '../../assets/specs/102.png'; +import spec104 from '../../assets/specs/104.png'; +import spec1467 from '../../assets/specs/1467.png'; +import spec1473 from '../../assets/specs/1473.png'; +import spec251 from '../../assets/specs/251.png'; +import spec253 from '../../assets/specs/253.png'; +import spec255 from '../../assets/specs/255.png'; +import spec257 from '../../assets/specs/257.png'; +import spec259 from '../../assets/specs/259.png'; +import spec261 from '../../assets/specs/261.png'; +import spec263 from '../../assets/specs/263.png'; +import spec265 from '../../assets/specs/265.png'; +import spec267 from '../../assets/specs/267.png'; +import spec269 from '../../assets/specs/269.png'; +import spec577 from '../../assets/specs/577.png'; +import spec62 from '../../assets/specs/62.png'; +import spec64 from '../../assets/specs/64.png'; +import spec66 from '../../assets/specs/66.png'; +import spec71 from '../../assets/specs/71.png'; +import spec73 from '../../assets/specs/73.png'; -interface StrImageObject { - [name: string]: string; -} +import affix1 from '../../assets/affixes/1.jpg'; +import affix11 from '../../assets/affixes/11.jpg'; +import affix12 from '../../assets/affixes/12.jpg'; +import affix121 from '../../assets/affixes/121.jpg'; +import affix123 from '../../assets/affixes/123.jpg'; +import affix128 from '../../assets/affixes/128.jpg'; +import affix130 from '../../assets/affixes/130.jpg'; +import affix133 from '../../assets/affixes/133.jpg'; +import affix135 from '../../assets/affixes/135.jpg'; +import affix137 from '../../assets/affixes/137.jpg'; +import affix144 from '../../assets/affixes/144.jpg'; +import affix146 from '../../assets/affixes/146.jpg'; +import affix148 from '../../assets/affixes/148.jpg'; +import affix153 from '../../assets/affixes/153.jpg'; +import affix159 from '../../assets/affixes/159.jpg'; +import affix2 from '../../assets/affixes/2.jpg'; +import affix4 from '../../assets/affixes/4.jpg'; +import affix6 from '../../assets/affixes/6.jpg'; +import affix8 from '../../assets/affixes/8.jpg'; +import affix10 from '../../assets/affixes/10.jpg'; +import affix117 from '../../assets/affixes/117.jpg'; +import affix120 from '../../assets/affixes/120.jpg'; +import affix122 from '../../assets/affixes/122.jpg'; +import affix124 from '../../assets/affixes/124.jpg'; +import affix13 from '../../assets/affixes/13.jpg'; +import affix131 from '../../assets/affixes/131.jpg'; +import affix134 from '../../assets/affixes/134.jpg'; +import affix136 from '../../assets/affixes/136.jpg'; +import affix14 from '../../assets/affixes/14.jpg'; +import affix145 from '../../assets/affixes/145.jpg'; +import affix147 from '../../assets/affixes/147.jpg'; +import affix152 from '../../assets/affixes/152.jpg'; +import affix158 from '../../assets/affixes/158.jpg'; +import affix160 from '../../assets/affixes/160.jpg'; +import affix3 from '../../assets/affixes/3.jpg'; +import affix5 from '../../assets/affixes/5.jpg'; +import affix7 from '../../assets/affixes/7.jpg'; +import affix9 from '../../assets/affixes/9.jpg'; -const specIDs = Object.keys(specializationById).map((v) => parseInt(v, 10)); -const specImages: ImageObject = { - 0: UnknownIcon, +const specImages = { + 0: spec0, + 103: spec103, + 105: spec105, + 1468: spec1468, + 250: spec250, + 252: spec252, + 254: spec254, + 256: spec256, + 258: spec258, + 260: spec260, + 262: spec262, + 264: spec264, + 266: spec266, + 268: spec268, + 270: spec270, + 581: spec581, + 63: spec63, + 65: spec65, + 70: spec70, + 72: spec72, + 102: spec102, + 104: spec104, + 1467: spec1467, + 1473: spec1473, + 251: spec251, + 253: spec253, + 255: spec255, + 257: spec257, + 259: spec259, + 261: spec261, + 263: spec263, + 265: spec265, + 267: spec267, + 269: spec269, + 577: spec577, + 62: spec62, + 64: spec64, + 66: spec66, + 71: spec71, + 73: spec73, }; -specIDs.forEach((id) => { - try { - specImages[id] = require(`../../assets/specs/${id}.png`); - } catch (e) { - console.error( - `[Images] Unable to load image resource that was expected to exist.\n`, - e - ); - } -}); - -const affixIDs = Object.keys(dungeonAffixesById).map((v) => parseInt(v, 10)); -const affixImages: ImageObject = {}; - -affixIDs.forEach((id) => { - try { - affixImages[id] = require(`../../assets/affixes/${id}.jpg`); - } catch (e) { - console.error( - `[Images] Unable to load image resource that was expected to exist.\n`, - e - ); - } -}); - -const roleImages: StrImageObject = { +const roleImages = { tank: TankIcon, healer: HealerIcon, damage: DamageIcon, }; -export { specImages, affixImages, roleImages }; +const affixImages = { + 1: affix1, + 11: affix11, + 12: affix12, + 121: affix121, + 123: affix123, + 128: affix128, + 130: affix130, + 133: affix133, + 135: affix135, + 137: affix137, + 144: affix144, + 146: affix146, + 148: affix148, + 153: affix153, + 159: affix159, + 2: affix2, + 4: affix4, + 6: affix6, + 8: affix8, + 10: affix10, + 117: affix117, + 120: affix120, + 122: affix122, + 124: affix124, + 13: affix13, + 131: affix131, + 134: affix134, + 136: affix136, + 14: affix14, + 145: affix145, + 147: affix147, + 152: affix152, + 158: affix158, + 160: affix160, + 3: affix3, + 5: affix5, + 7: affix7, + 9: affix9, +}; + +export { roleImages, specImages, affixImages }; diff --git a/src/renderer/rendererutils.ts b/src/renderer/rendererutils.ts index 0b606e17..d1c47eac 100644 --- a/src/renderer/rendererutils.ts +++ b/src/renderer/rendererutils.ts @@ -29,12 +29,14 @@ import { RendererVideo, SoloShuffleTimelineSegment, VideoMarker, + RawCombatant, } from 'main/types'; import { ambiguate } from 'parsing/logutils'; import { VideoCategory } from 'types/VideoCategory'; import { ESupportedEncoders } from 'main/obsEnums'; import { PTTEventType, PTTKeyPressEvent } from 'types/KeyTypesUIOHook'; import { ConfigurationSchema } from 'main/configSchema'; +import { Renderer } from 'react-dom'; const getVideoResult = (video: RendererVideo): boolean => { return video.result; @@ -378,13 +380,13 @@ const getResultColor = (video: RendererVideo) => { // a better way to pass it through. Generated with: https://cssgradient.io/. // The key is the number of wins. const soloShuffleResultColors = [ - 'rgb(53, 164, 50, 0.3)', - 'rgb(46, 171, 27, 0.3)', - 'rgb(112, 170, 30, 0.3)', - 'rgb(171, 150, 30, 0.3)', - 'rgb(171, 86, 26, 0.3)', - 'rgb(175, 50, 23, 0.3)', - 'rgb(156, 21, 21, 0.3)', + 'rgb(53, 164, 50)', + 'rgb(46, 171, 27)', + 'rgb(112, 170, 30)', + 'rgb(171, 150, 30)', + 'rgb(171, 86, 26)', + 'rgb(175, 50, 23)', + 'rgb(156, 21, 21)', ].reverse(); return soloShuffleResultColors[soloShuffleRoundsWon]; @@ -507,6 +509,39 @@ const getVideoTime = (video: RendererVideo) => { return timeAsString; }; +const videoToDate = (video: RendererVideo) => { + let date; + + if (video.clippedAt) { + date = new Date(video.clippedAt); + } else if (video.start) { + date = new Date(video.start); + } else { + date = new Date(video.mtime); + } + + return date; +}; + +const dateToHumanReadable = (date: Date) => { + const day = date.getDate(); + const month = months[date.getMonth()].slice(0, 3); + // const year = date.getFullYear().toString().slice(2, 4); + const dateAsString = `${day} ${month}`; + + const hours = date + .getHours() + .toLocaleString('en-US', { minimumIntegerDigits: 2 }); + + const mins = date + .getMinutes() + .toLocaleString('en-US', { minimumIntegerDigits: 2 }); + + const timeAsString = `${hours}:${mins}`; + + return `${timeAsString} ${dateAsString}`; +}; + const getVideoDate = (video: RendererVideo) => { let date; @@ -760,7 +795,7 @@ const getVideoResultText = (video: RendererVideo): string => { return 'Depleted'; } - return String(upgradeLevel); + return String(`+${upgradeLevel}`); } if (isRaidUtil(video)) { @@ -812,9 +847,28 @@ const stopPropagation = (event: React.MouseEvent) => { event.preventDefault(); }; -const povNameSort = (a: RendererVideo, b: RendererVideo) => { +const povDiskFirstNameSort = (a: RendererVideo, b: RendererVideo) => { + const diskA = !a.cloud; + const diskB = !b.cloud; + + if (diskA && !diskB) { + return -1; + } + + if (diskB && !diskA) { + return 1; + } + const playerA = a.player?._name; const playerB = b.player?._name; + + if (!playerA || !playerB) return 0; + return playerA.localeCompare(playerB); +}; + +const combatantNameSort = (a: RawCombatant, b: RawCombatant) => { + const playerA = a._name; + const playerB = b._name; if (!playerA || !playerB) return 0; return playerA.localeCompare(playerB); }; @@ -824,24 +878,81 @@ const areDatesWithinSeconds = (d1: Date, d2: Date, sec: number) => { return differenceMilliseconds <= sec * 1000; }; -const countUniquePovs = (povs: RendererVideo[]) => { - let uniquePovs = 0; - const seenPovs: string[] = []; +const toFixedDigits = (n: number, d: number) => + n.toLocaleString('en-US', { minimumIntegerDigits: d, useGrouping: false }); - for (let i = 0; i < povs.length; i++) { - const name = povs[i].player?._name; +const getPullNumber = ( + video: RendererVideo, + raidCategoryState: RendererVideo[] +) => { + const videoDate = video.start ? new Date(video.start) : new Date(video.mtime); + + const dailyVideosInOrder: RendererVideo[] = []; + + raidCategoryState.forEach((neighbourVideo) => { + const bestDate = neighbourVideo.start + ? neighbourVideo.start + : neighbourVideo.mtime; + + const neighbourDate = new Date(bestDate); + + // Pulls longer than 6 hours apart are considered from different + // sessions and will reset the pull counter. + // + // This logic is really janky and should probably be rewritten. The + // problem here is that if checks for any videos within 6 hours. + // + // If there are videos on the border (e.g. day raiding) then the + // pull count can do weird things like decrement or not increment given + // the right timing conditions of the previous sessions raids. + const withinThreshold = areDatesWithinSeconds( + videoDate, + neighbourDate, + 3600 * 6 + ); - if (name && !seenPovs.includes(name)) { - uniquePovs++; - seenPovs.push(name); + if ( + video.encounterID === undefined || + neighbourVideo.encounterID === undefined + ) { + return; } - } - return uniquePovs; + const sameEncounter = video.encounterID === neighbourVideo.encounterID; + + if ( + video.difficultyID === undefined || + neighbourVideo.difficultyID === undefined + ) { + return; + } + + const sameDifficulty = video.difficultyID === neighbourVideo.difficultyID; + + if (withinThreshold && sameEncounter && sameDifficulty) { + dailyVideosInOrder.push(neighbourVideo); + } + }); + + dailyVideosInOrder.sort((A: RendererVideo, B: RendererVideo) => { + const bestTimeA = A.start ? A.start : A.mtime; + const bestTimeB = B.start ? B.start : B.mtime; + return bestTimeA - bestTimeB; + }); + + return dailyVideosInOrder.indexOf(video) + 1; }; -const toFixedDigits = (n: number, d: number) => - n.toLocaleString('en-US', { minimumIntegerDigits: d, useGrouping: false }); +const countUniqueViewpoints = (video: RendererVideo) => { + const povs = [video, ...video.multiPov]; + + const unique = povs.filter( + (item, index, self) => + self.findIndex((i) => i.player?._name === item.player?._name) === index + ); + + return unique.length; +}; export { getFormattedDuration, @@ -891,8 +1002,12 @@ export { getCategoryIndex, getFirstInCategory, stopPropagation, - povNameSort, + povDiskFirstNameSort, areDatesWithinSeconds, - countUniquePovs, toFixedDigits, + getPullNumber, + combatantNameSort, + countUniqueViewpoints, + videoToDate, + dateToHumanReadable, }; diff --git a/src/storage/CloudClient.ts b/src/storage/CloudClient.ts index 1a0117fa..1e9177b3 100644 --- a/src/storage/CloudClient.ts +++ b/src/storage/CloudClient.ts @@ -12,8 +12,8 @@ import { import path from 'path'; import AuthError from '../utils/AuthError'; -const devMode = - process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'; +const devMode = false; + // process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'; /** * A client for retrieving resources from the cloud.