diff --git a/src/App.vue b/src/App.vue index 2b3b2a5..05d40fa 100644 --- a/src/App.vue +++ b/src/App.vue @@ -29,12 +29,13 @@ import Sidebar from './components/Sidebar.vue'; import ChatBox from './components/ChatBox.vue'; import {useStore} from "@/store"; import {ActionTypes} from "@/store/action-types"; -import {clearHeadCache, parseUrl} from '@/util'; +import {parseUrl} from '@/util'; import {hideSplash, showSplash, showSplashError} from '@/util/splash'; import {MutationTypes} from "@/store/mutation-types"; import {LiveAtlasServerDefinition, LiveAtlasUIElement} from "@/index"; import LoginModal from "@/components/login/LoginModal.vue"; import {notify} from "@kyvg/vue3-notification"; +import {clearPlayerImageCache} from "@/util/images"; export default defineComponent({ name: 'App', @@ -171,7 +172,7 @@ export default defineComponent({ return; } - clearHeadCache(); + clearPlayerImageCache(); loadingAttempts.value = 0; window.history.replaceState({}, '', newServer.id); loadConfiguration(); @@ -201,7 +202,7 @@ export default defineComponent({ } }); watch(playerImageUrl, () => { - clearHeadCache(); + clearPlayerImageCache(); }); handleUrl(); diff --git a/src/components/PlayerImage.vue b/src/components/PlayerImage.vue index 855a17f..9d9f2bc 100644 --- a/src/components/PlayerImage.vue +++ b/src/components/PlayerImage.vue @@ -24,7 +24,7 @@ import {computed, defineComponent, watch} from "@vue/runtime-core"; import {LiveAtlasPlayer} from "@/index"; import {onMounted, ref} from "vue"; import {useStore} from "@/store"; -import {getMinecraftHead} from "@/util"; +import {getPlayerImage} from "@/util/images"; export default defineComponent({ name: 'PlayerImage', @@ -47,7 +47,7 @@ export default defineComponent({ if (imagesEnabled.value) { try { - const result = await getMinecraftHead(props.player, 'small'); + const result = await getPlayerImage(props.player, 'small'); image.value = result.src; } catch (e) { } diff --git a/src/index.d.ts b/src/index.d.ts index 85e352a..0427d30 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -251,7 +251,7 @@ interface LiveAtlasCircleMarker extends LiveAtlasPathMarker { radius: PointTuple; } -interface HeadQueueEntry { +interface PlayerImageQueueEntry { cacheKey: string; name: string; uuid?: string; @@ -277,7 +277,7 @@ interface LiveAtlasComponentConfig { markers?: LiveAtlasPlayerMarkerConfig; showImages: boolean; grayHiddenPlayers: boolean; - imageUrl: (entry: HeadQueueEntry) => string; + imageUrl: (entry: PlayerImageQueueEntry) => string; }; coordinatesControl?: CoordinatesControlOptions; clockControl?: ClockControlOptions; @@ -298,7 +298,7 @@ interface LiveAtlasPartialComponentConfig { markers?: LiveAtlasPlayerMarkerConfig; showImages?: boolean; grayHiddenPlayers?: boolean; - imageUrl?: (entry: HeadQueueEntry) => string; + imageUrl?: (entry: PlayerImageQueueEntry) => string; }; coordinatesControl?: CoordinatesControlOptions; clockControl?: ClockControlOptions; diff --git a/src/leaflet/icon/PlayerIcon.ts b/src/leaflet/icon/PlayerIcon.ts index 865d01b..c8eb972 100644 --- a/src/leaflet/icon/PlayerIcon.ts +++ b/src/leaflet/icon/PlayerIcon.ts @@ -18,9 +18,9 @@ */ import {BaseIconOptions, Icon, Layer, LayerOptions, Util} from 'leaflet'; -import {getImagePixelSize, getMinecraftHead} from '@/util'; import defaultImage from '@/assets/images/player_face.png'; import {LiveAtlasPlayer, LiveAtlasPlayerImageSize} from "@/index"; +import {getImagePixelSize, getPlayerImage} from "@/util/images"; const playerImage: HTMLImageElement = document.createElement('img'); playerImage.src = defaultImage; @@ -122,7 +122,7 @@ export class PlayerIcon extends Layer implements Icon { } updateImage() { - getMinecraftHead(this._player, this.options.imageSize).then(head => { + getPlayerImage(this._player, this.options.imageSize).then(head => { this._playerImage!.src = head.src; }).catch(() => {}); } diff --git a/src/providers/OverviewerMapProvider.ts b/src/providers/OverviewerMapProvider.ts index 147105f..19c4ac2 100644 --- a/src/providers/OverviewerMapProvider.ts +++ b/src/providers/OverviewerMapProvider.ts @@ -30,7 +30,6 @@ import {MutationTypes} from "@/store/mutation-types"; import MapProvider from "@/providers/MapProvider"; import { getBoundsFromPoints, - getDefaultMinecraftHead, getMiddle, guessWorldDimension, runSandboxed, @@ -43,6 +42,7 @@ import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; import {OverviewerProjection} from "@/leaflet/projection/OverviewerProjection"; import {LiveAtlasMarkerType} from "@/util/markers"; import {useStore} from "@/store"; +import {getDefaultPlayerImage} from "@/util/images"; export default class OverviewerMapProvider extends MapProvider { private configurationAbort?: AbortController = undefined; @@ -247,7 +247,7 @@ export default class OverviewerMapProvider extends MapProvider { //Not used by Overviewer players: { markers: undefined, - imageUrl: getDefaultMinecraftHead, + imageUrl: getDefaultPlayerImage, showImages: false, grayHiddenPlayers: false, }, diff --git a/src/providers/Pl3xmapMapProvider.ts b/src/providers/Pl3xmapMapProvider.ts index cba2fa2..4f5a43c 100644 --- a/src/providers/Pl3xmapMapProvider.ts +++ b/src/providers/Pl3xmapMapProvider.ts @@ -32,12 +32,13 @@ import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; import {MutationTypes} from "@/store/mutation-types"; import MapProvider from "@/providers/MapProvider"; import {ActionTypes} from "@/store/action-types"; -import {getBoundsFromPoints, getDefaultMinecraftHead, getMiddle, stripHTML, titleColoursRegex} from "@/util"; +import {getBoundsFromPoints, getMiddle, stripHTML, titleColoursRegex} from "@/util"; import {LiveAtlasMarkerType} from "@/util/markers"; import {PointTuple} from "leaflet"; import ConfigurationError from "@/errors/ConfigurationError"; import {Pl3xmapTileLayer} from "@/leaflet/tileLayer/Pl3xmapTileLayer"; import {LiveAtlasTileLayer, LiveAtlasTileLayerOptions} from "@/leaflet/tileLayer/LiveAtlasTileLayer"; +import {getDefaultPlayerImage} from "@/util/images"; export default class Pl3xmapMapProvider extends MapProvider { private configurationAbort?: AbortController = undefined; @@ -120,7 +121,7 @@ export default class Pl3xmapMapProvider extends MapProvider { components: { players: { markers: undefined, - imageUrl: getDefaultMinecraftHead, + imageUrl: getDefaultPlayerImage, grayHiddenPlayers: true, showImages: true, } @@ -214,7 +215,7 @@ export default class Pl3xmapMapProvider extends MapProvider { players: { markers: undefined, //Configured per-world - imageUrl: getDefaultMinecraftHead, + imageUrl: getDefaultPlayerImage, //Not configurable showImages: true, diff --git a/src/store/mutations.ts b/src/store/mutations.ts index 063ca3f..dda4e48 100644 --- a/src/store/mutations.ts +++ b/src/store/mutations.ts @@ -40,8 +40,9 @@ import { LiveAtlasUIModal, LiveAtlasSidebarSectionState, LiveAtlasMarker, LiveAtlasMapViewTarget } from "@/index"; -import {getDefaultMinecraftHead, getGlobalMessages} from "@/util"; +import {getGlobalMessages} from "@/util"; import {getServerMapProvider} from "@/util/config"; +import {getDefaultPlayerImage} from "@/util/images"; export type CurrentMapPayload = { worldName: string; @@ -559,7 +560,7 @@ export const mutations: MutationTree & Mutations = { markers: undefined, showImages: true, grayHiddenPlayers: true, - imageUrl: getDefaultMinecraftHead, + imageUrl: getDefaultPlayerImage, }; state.components.coordinatesControl = undefined; state.components.clockControl = undefined; diff --git a/src/store/state.ts b/src/store/state.ts index 7fbee2e..38fcef2 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -39,7 +39,8 @@ import { LiveAtlasMarker, LiveAtlasMapViewTarget } from "@/index"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; -import {getDefaultMinecraftHead, getMessages} from "@/util"; +import {getMessages} from "@/util"; +import {getDefaultPlayerImage} from "@/util/images"; export type State = { version: string; @@ -162,7 +163,7 @@ export const state: State = { showImages: false, // (world-settings.x.player-tracker.heads-url in squaremap) - imageUrl: getDefaultMinecraftHead, + imageUrl: getDefaultPlayerImage, }, // Settings for coordinates control diff --git a/src/util.ts b/src/util.ts index ed5a73a..b8d5d0d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -14,29 +14,21 @@ * limitations under the License. */ -import defaultImage from '@/assets/images/player_face.png'; import {useStore} from "@/store"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; import { Coordinate, - HeadQueueEntry, - LiveAtlasBounds, LiveAtlasDimension, + LiveAtlasBounds, + LiveAtlasDimension, LiveAtlasGlobalMessageConfig, LiveAtlasLocation, LiveAtlasMessageConfig, - LiveAtlasPlayer, - LiveAtlasPlayerImageSize, } from "@/index"; import {notify} from "@kyvg/vue3-notification"; import {globalMessages, serverMessages} from "../messages"; const documentRange = document.createRange(), - brToSpaceRegex = /
/g, - headCache = new Map(), - headUnresolvedCache = new Map>(), - headsLoading = new Set(), - - headQueue: HeadQueueEntry[] = []; + brToSpaceRegex = /
/g; export const titleColoursRegex = /ยง[0-9a-f]/ig; export const netherWorldNameRegex = /[_\s]?nether([\s_]|$)/i; @@ -59,84 +51,6 @@ export const getMinecraftTime = (serverTime: number) => { }; } -export const getImagePixelSize = (imageSize: LiveAtlasPlayerImageSize) => { - switch(imageSize) { - case 'large': - case 'body': - return 32; - - case 'small': - default: - return 16; - } -} - -export const getMinecraftHead = (player: LiveAtlasPlayer | string, size: LiveAtlasPlayerImageSize): Promise => { - const account = typeof player === 'string' ? player : player.name, - uuid = typeof player === 'string' ? undefined : player.uuid, - cacheKey = `${account}-${size}`; - - if(headCache.has(cacheKey)) { - return Promise.resolve(headCache.get(cacheKey) as HTMLImageElement); - } - - if(headUnresolvedCache.has(cacheKey)) { - return headUnresolvedCache.get(cacheKey) as Promise; - } - - const promise = new Promise((resolve, reject) => { - const faceImage = new Image(); - - faceImage.onload = function() { - headCache.set(cacheKey, faceImage); - headsLoading.delete(cacheKey); - tickHeadQueue(); - resolve(faceImage); - }; - - faceImage.onerror = function(e) { - console.warn(`Failed to retrieve face of ${account} with size ${size}!`); - headsLoading.delete(cacheKey); - tickHeadQueue(); - reject(e); - }; - - headQueue.push({ - name: account, - uuid, - size, - cacheKey, - image: faceImage, - }); - }).finally(() => headUnresolvedCache.delete(cacheKey)) as Promise; - - headUnresolvedCache.set(cacheKey, promise); - tickHeadQueue(); - - return promise; -} - -export const getDefaultMinecraftHead = () => { - return defaultImage; -} - -const tickHeadQueue = () => { - if(headsLoading.size > 8 || !headQueue.length) { - return; - } - - const head = headQueue.pop() as HeadQueueEntry; - - headsLoading.add(head.cacheKey); - head.image.src = useStore().state.components.players.imageUrl(head); - - tickHeadQueue(); -} - -export const clearHeadCache = () => { - headCache.clear(); -} - export const parseUrl = (url: URL) => { const query = new URLSearchParams(url.search), hash = url.hash.replace('#', ''); diff --git a/src/util/dynmap.ts b/src/util/dynmap.ts index b21fe77..b2c4e28 100644 --- a/src/util/dynmap.ts +++ b/src/util/dynmap.ts @@ -32,8 +32,7 @@ import { } from "@/index"; import {getPoints} from "@/util/areas"; import { - decodeHTMLEntities, getBounds, getImagePixelSize, - getMiddle, guessWorldDimension, + decodeHTMLEntities, getBounds, getMiddle, guessWorldDimension, stripHTML, titleColoursRegex } from "@/util"; @@ -53,6 +52,7 @@ import { import {PointTuple} from "leaflet"; import {LiveAtlasMarkerType} from "@/util/markers"; import {DynmapProjection} from "@/leaflet/projection/DynmapProjection"; +import {getImagePixelSize} from "@/util/images"; export function buildServerConfig(response: Options): LiveAtlasServerConfig { let title = 'Dynmap'; diff --git a/src/util/images.ts b/src/util/images.ts new file mode 100644 index 0000000..0cd77f6 --- /dev/null +++ b/src/util/images.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2022 James Lyne + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import defaultImage from "@/assets/images/player_face.png"; +import {PlayerImageQueueEntry, LiveAtlasPlayer, LiveAtlasPlayerImageSize} from "@/index"; +import {useStore} from "@/store"; + +const playerImageCache = new Map(), + playerImageUnresolvedCache = new Map>(), + playerImagesLoading = new Set(), + + playerImageQueue: PlayerImageQueueEntry[] = []; + +/** + * Returns the corresponding pixel size for the given {@see LiveAtlasPlayerImageSize} + * @param {LiveAtlasPlayerImageSize} imageSize The image size to get the pixel size for + * @returns The pixel size + */ +export const getImagePixelSize = (imageSize: LiveAtlasPlayerImageSize) => { + switch (imageSize) { + case 'large': + case 'body': + return 32; + + case 'small': + default: + return 16; + } +} + +/** + * Creates an {@see HTMLImageElement} containing an image representing the given {@see LiveAtlasPlayer} + * at the given {@see LiveAtlasPlayerImageSize} and ensures it has loaded successfully + * + * If an image has previously been loaded for the same player and image size, a cached copy of the image element + * will be returned; Otherwise an attempt will be made to load the player image from the URL specified by the current + * {@see LiveAtlasMapProvider} + * + * The number of concurrent image loads is limited and additional loads will be queued. If this method is called + * with the same player and image size multiple times, the load will only be queued once and the same element will be + * returned for all calls. + * + * @param {LiveAtlasPlayer} player The player to retrieve the image for + * @param {LiveAtlasPlayerImageSize} size The image size to retrieve + * @returns {Promise} A promise which will resolve to a {@see HTMLImageElement} with the loaded player + * image as the src. The promise will reject if the image fails to load + */ +export const getPlayerImage = (player: LiveAtlasPlayer | string, size: LiveAtlasPlayerImageSize): Promise => { + const account = typeof player === 'string' ? player : player.name, + uuid = typeof player === 'string' ? undefined : player.uuid, + cacheKey = `${account}-${size}`; + + if (playerImageCache.has(cacheKey)) { + return Promise.resolve(playerImageCache.get(cacheKey) as HTMLImageElement); + } + + if (playerImageUnresolvedCache.has(cacheKey)) { + return playerImageUnresolvedCache.get(cacheKey) as Promise; + } + + const promise = new Promise((resolve, reject) => { + const faceImage = new Image(); + + faceImage.onload = function () { + playerImageCache.set(cacheKey, faceImage); + playerImagesLoading.delete(cacheKey); + tickPlayerImageQueue(); + resolve(faceImage); + }; + + faceImage.onerror = function (e) { + console.warn(`Failed to retrieve face of ${account} with size ${size}!`); + playerImagesLoading.delete(cacheKey); + tickPlayerImageQueue(); + reject(e); + }; + + playerImageQueue.push({ + name: account, + uuid, + size, + cacheKey, + image: faceImage, + }); + }).finally(() => playerImageUnresolvedCache.delete(cacheKey)) as Promise; + + playerImageUnresolvedCache.set(cacheKey, promise); + tickPlayerImageQueue(); + + return promise; +} + +/** + * Returns the default "Steve" player image. This image can be used as a placeholder whilst waiting for + * {@see getPlayerImage} to complete + * @returns The default player image + */ +export const getDefaultPlayerImage = () => { + return defaultImage; +} + +/** + * Ticks the player image load queue, starting additional image loads if any are queued and the concurrent load limit + * hasn't been hit + */ +const tickPlayerImageQueue = () => { + if (playerImagesLoading.size > 8 || !playerImageQueue.length) { + return; + } + + const image = playerImageQueue.pop() as PlayerImageQueueEntry; + + playerImagesLoading.add(image.cacheKey); + image.image.src = useStore().state.components.players.imageUrl(image); + + tickPlayerImageQueue(); +} + +/** + * Clears the player image cache + * Future calls to {@see getPlayerImage} will result in fresh image loads + */ +export const clearPlayerImageCache = () => { + playerImageCache.clear(); +}