Son aktivite 22 hours ago

\georgia-reborn\scripts\Base 修复碟面BUG 波形+可视化

wojack's Avatar wojack bu gisti düzenledi 22 hours ago. Düzenlemeye git

Değişiklik yok

wojack's Avatar wojack bu gisti düzenledi 22 hours ago. Düzenlemeye git

2 files changed, 6815 insertions

gr-details.js(dosya oluşturuldu)

@@ -0,0 +1,2356 @@
1 + /////////////////////////////////////////////////////////////////////////////////
2 + // * Georgia-ReBORN: A Clean - Full Dynamic Color Reborn - Foobar2000 Player * //
3 + // * Description: Georgia-ReBORN Details * //
4 + // * Author: TT * //
5 + // * Website: https://github.com/TT-ReBORN/Georgia-ReBORN * //
6 + // * Version: 3.0-x64-DEV * //
7 + // * Dev. started: 22-12-2017 * //
8 + // * Last change: 02-05-2026 * //
9 + /////////////////////////////////////////////////////////////////////////////////
10 +
11 +
12 + 'use strict';
13 +
14 +
15 + ////////////////////////////////
16 + // * DETAILS USER INTERFACE * //
17 + ////////////////////////////////
18 + /**
19 + * A class that is responsible for the Details panel.
20 + */
21 + class Details {
22 + /**
23 + * Creates the `Details` instance.
24 + */
25 + constructor() {
26 + // * GEOMETRY * //
27 + // #region GEOMETRY
28 + /** @public @type {number} The size of the disc art shadow. */
29 + this.discArtShadow = SCALE(6);
30 + /** @public @type {number} The margin width from the edge of the player to start of the metadata grid strings. */
31 + this.gridMarginLeft = grm.ui.edgeMargin;
32 + /** @public @type {number} The margin width from the edge of the metadata grid to the end of the metadata grid strings. */
33 + this.gridMarginRight = SCALE(20);
34 + /** @public @type {number} The spacing between grid lines in the metadata grid. */
35 + this.gridLineSpacing = SCALE(30);
36 + /** @public @type {number} The horizontal spacing between the track number and the artist in the metadata grid. */
37 + this.gridTrackNumSpacing = SCALE(8);
38 + /** @public @type {number} The height of the metadata grid tooltip area. */
39 + this.gridTooltipHeight = SCALE(100);
40 + /** @public @type {number} The top starting fixed position of the metadata grid. */
41 + this.gridTopStart = 0;
42 + /** @public @type {number} The top dynamic position of the metadata grid. */
43 + this.gridTop = 0;
44 + /** @public @type {number} The width of the metadata grid content. */
45 + this.gridContentWidth = 0;
46 + /** @public @type {number} The width of the country flag size in the metadata grid. */
47 + this.gridFlagSizeW = 0;
48 + /** @public @type {number} The white space size for the country flag in the metadata grid. */
49 + this.gridFlagSizeWhiteSpace = 0;
50 + /** @public @type {number} The text rectangle for string calculation in the metadata grid. */
51 + this.gridTxtRec = 0;
52 + /** @public @type {number} The top position of the artist in the metadata grid. */
53 + this.gridArtistTop = 0;
54 + /** @public @type {number} The bottom position of the artist in the metadata grid. */
55 + this.gridArtistBottom = 0;
56 + /** @public @type {object} The calculated artist wrap info for the metadata grid. */
57 + this.gridArtistWrapInfo = {};
58 + /** @public @type {boolean} The state when the artist string exceeds the available lines in the metadata grid. */
59 + this.gridArtistWrapLinesExceed = false;
60 + /** @public @type {number} The width of the wrap space within the artist string in the metadata grid. */
61 + this.gridArtistWrapWidth = 0;
62 + /** @public @type {number} The width of the artist in the metadata grid. */
63 + this.gridArtistWidth = 0;
64 + /** @public @type {number} The height of the artist in the metadata grid. */
65 + this.gridArtistHeight = 0;
66 + /** @public @type {number} The text rectangle for artist string calculation in the metadata grid. */
67 + this.gridArtistTxtRec = 0;
68 + /** @public @type {number} The number of lines for the artist text in the metadata grid. */
69 + this.gridArtistNumLines = 0;
70 + /** @public @type {number} The height of the artist number of lines in the metadata grid. */
71 + this.gridArtistNumLinesHeight = 0;
72 + /** @public @type {number} The top position of the track title in the metadata grid. */
73 + this.gridTitleTop = 0;
74 + /** @public @type {number} The bottom position of the track title in the metadata grid. */
75 + this.gridTitleBottom = 0;
76 + /** @public @type {number} The width of the track number in the metadata grid. */
77 + this.gridTrackNumWidth = 0;
78 + /** @public @type {object} The calculated track title wrap info for the metadata grid. */
79 + this.gridTitleWrapInfo = {};
80 + /** @public @type {boolean} The state when the track title string exceeds the available lines in the metadata grid. */
81 + this.gridTitleWrapLinesExceed = false;
82 + /** @public @type {number} The width of the wrap space within the track title string in the metadata grid. */
83 + this.gridTitleWrapWidth = 0;
84 + /** @public @type {number} The width of the track title in the metadata grid. */
85 + this.gridTitleWidth = 0;
86 + /** @public @type {number} The height of the track title in the metadata grid. */
87 + this.gridTitleHeight = 0;
88 + /** @public @type {number} The text rectangle for track title string calculation in the metadata grid. */
89 + this.gridTitleTxtRec = 0;
90 + /** @public @type {number} The number of lines for the track title text in the metadata grid. */
91 + this.gridTitleNumLines = 0;
92 + /** @public @type {number} The height of the track title number of lines in the metadata grid. */
93 + this.gridTitleNumLinesHeight = 0;
94 + /** @public @type {number} The top position of the album in the metadata grid. */
95 + this.gridAlbumTop = 0;
96 + /** @public @type {number} The bottom position of the album in the metadata grid. */
97 + this.gridAlbumBottom = 0;
98 + /** @public @type {object} The calculated album wrap info for the metadata grid. */
99 + this.gridAlbumWrapInfo = {};
100 + /** @public @type {boolean} The state when the album string exceeds the available lines in the metadata grid. */
101 + this.gridAlbumWrapLinesExceed = false;
102 + /** @public @type {number} The width of the wrap space within the album string in the metadata grid. */
103 + this.gridAlbumWrapWidth = 0;
104 + /** @public @type {number} The width of the album in the metadata grid. */
105 + this.gridAlbumWidth = 0;
106 + /** @public @type {number} The height of the album in the metadata grid. */
107 + this.gridAlbumHeight = 0;
108 + /** @public @type {number} The text rectangle for album string calculation in the metadata grid. */
109 + this.gridAlbumTxtRec = 0;
110 + /** @public @type {number} The number of lines for the album text in the metadata grid. */
111 + this.gridAlbumNumLines = 0;
112 + /** @public @type {number} The height of the album number of lines in the metadata grid. */
113 + this.gridAlbumNumLinesHeight = 0;
114 + /** @public @type {number} The margin between grid columns in the metadata grid. */
115 + this.gridColumnMargin = SCALE(10);
116 + /** @public @type {number} The top position of the grid columns in the metadata grid. */
117 + this.gridColumnTop = 0;
118 + /** @public @type {number} The height of the grid column cell in the metadata grid. */
119 + this.gridColumnCellHeight = 0;
120 + /** @public @type {number} The width of the key strings column in the metadata grid. */
121 + this.gridColumnKeyWidth = 0;
122 + /** @public @type {number} The height of the key strings in the metadata grid. */
123 + this.gridColumnKeyHeight = 0;
124 + /** @public @type {number} The bottom position of the key strings in the metadata grid. */
125 + this.gridColumnKeyBottom = 0;
126 + /** @public @type {number} The width of the value strings column in the metadata grid. */
127 + this.gridColumnValueWidth = 0;
128 + /** @public @type {number} The height of the value strings in the metadata grid. */
129 + this.gridColumnValueHeight = 0;
130 + /** @public @type {number} The left position of the value strings column in the metadata grid. */
131 + this.gridColumnValueLeft = 0;
132 + /** @public @type {number} The bottom position of the value strings in the metadata grid. */
133 + this.gridColumnValueBottom = 0;
134 +
135 + // * TIMELINE * //
136 + // #region TIMELINE
137 + /** @public @type {number} The x-coordinate of the timeline. */
138 + this.timelineX = 0;
139 + /** @public @type {number} The y-coordinate of the timeline. */
140 + this.timelineY = 0;
141 + /** @public @type {number} The width of the timeline. */
142 + this.timelineW = 0;
143 + /** @public @type {number} The height of the timeline. */
144 + this.timelineH = SCALE(8);
145 + /** @public @type {number} The color of the played portion of the timeline. */
146 + this.timelinePlayCol = RGBA(255, 255, 255, 150);
147 + /** @public @type {number} The ratio of the first played segment in the timeline. */
148 + this.timelineFirstPlayedRatio = 0;
149 + /** @public @type {number} The ratio of the last played segment in the timeline. */
150 + this.timelineLastPlayedRatio = 0;
151 + /** @public @type {number} The percentage of the first played segment in the timeline. */
152 + this.timelineFirstPlayedPercent = 0.33;
153 + /** @public @type {number} The percentage of the last played segment in the timeline. */
154 + this.timelineLastPlayedPercent = 0.66;
155 + /** @public @type {number[]} The percentages of the played times on the timeline. */
156 + this.timelinePlayedTimesPercents = [];
157 + /** @public @type {number[]} The actual played times on the timeline. */
158 + this.timelinePlayedTimes = [];
159 + /** @public @type {number} The width of the timeline line. */
160 + this.timelineLineWidth = HD_4K(2, 3);
161 + /** @public @type {number} The extra left space on the timeline. */
162 + this.timelineExtraLeftSpace = SCALE(3);
163 + /** @public @type {number} The draw width of the timeline. */
164 + this.timelineDrawWidth = 0;
165 + /** @public @type {number} The leeway of the timeline. */
166 + this.timelineLeeway = 0;
167 + // #endregion
168 +
169 + // * CACHE * //
170 + // #region CACHE
171 + /** @public @type {object} The caching object of the calculated text wrap space for the metadata grid. */
172 + this.cachedGridWrapSpace = {};
173 + /** @public @type {boolean} The calculated metadata grid metrics saved so we don't have to recalculate every on every on_paint unless size or metadata changed. */
174 + this.cachedGridMetrics = false;
175 + /** @public @type {number} The left edge of the record labels in Details. Saved so we don't have to recalculate every on every on_paint unless size has changed. */
176 + this.cachedLabelLastLeftEdge = 0;
177 + /** @public @type {number} The last label height of the record labels in Details. Saved so we don't have to recalculate every on every on_paint unless size has changed. */
178 + this.cachedLabelLastHeight = 0;
179 + // #endregion
180 +
181 + // * IMAGES * //
182 + // #region IMAGES
183 + /** @public @type {GdiBitmap} The disc art image used in Details. */
184 + this.discArt = null;
185 + /** @public @type {GdiBitmap} The disc art album cover image used in Details. */
186 + this.discArtCover = null;
187 + /** @public @type {GdiBitmap[]} The array of disc art images used in Details. */
188 + this.discArtArray = [];
189 + /** @public @type {number} The scale factor of the disc art used in Details. */
190 + this.discArtScaleFactor = 0;
191 + /** @private @type {{image: GdiBitmap|null, size: number}} The shadow behind the disc art used in Details. */
192 + this.discArtShadowImg = { image: null, size: 0 }
193 + /** @public @type {object} The disc art position used in Details (offset from albumArtSize). */
194 + this.discArtSize = new ImageSize(0, 0, 0, 0);
195 + /** @public @type {GdiBitmap} The rotated disc art from the RotateImg helper used in Details. */
196 + this.discArtRotation = null;
197 + /** @public @type {number} The global index of current discArtArray img to draw used in Details. */
198 + this.discArtRotationIndex = 0;
199 + /** @private @type {GdiBitmap} The release country flag image shown in the metadata grid in Details. */
200 + this.gridReleaseFlagImg = null;
201 + /** @private @type {GdiBitmap} The codec logo image shown in the metadata grid in Details. */
202 + this.gridCodecLogo = null;
203 + /** @private @type {GdiBitmap} The channel logo image shown in the metadata grid in Details. */
204 + this.gridChannelLogo = null;
205 + /** @public @type {GdiBitmap} The band logo image used in Details. */
206 + this.bandLogo = null;
207 + /** @public @type {GdiBitmap} The inverted band logo image shown in Details. */
208 + this.bandLogoInverted = null;
209 + /** @public @type {GdiBitmap[]} The array of record label images used in Details. */
210 + this.labelLogo = [];
211 + /** @public @type {GdiBitmap[]} The array of inverted record label images used in Details. */
212 + this.labelLogoInverted = [];
213 + /** @private @type {GdiBitmap} The shadow behind labels used in Details. */
214 + this.labelShadowImg = null;
215 + // #endregion
216 +
217 + // * STATE * //
218 + // #region STATE
219 + /** @private @type {boolean} The state when disc art was found on hard drive used in Details. */
220 + this.discArtFound = false;
221 + /** @public @type {boolean} The last.fm logo image displayed when we %lastfm_play_count% > 0, shown in the metadata grid in Details. */
222 + this.playCountVerifiedByLastFm = false;
223 + /** @public @type {object} The boundary section object contains check functions for different sections of the metadata grid. */
224 + this.gridSectionBounds = {
225 + artist: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridArtistTop && y <= this.gridArtistBottom,
226 + title: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridTitleTop && y <= this.gridTitleBottom,
227 + album: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridAlbumTop && y <= this.gridAlbumBottom,
228 + tagKey: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridColumnKeyWidth) && y >= this.gridAlbumBottom && y <= this.gridColumnKeyBottom,
229 + tagValue: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridColumnKeyWidth + this.gridColumnValueWidth) && y >= this.gridAlbumBottom && y <= this.gridColumnValueBottom,
230 + timeline: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.timelineY - SCALE(10) && y < this.timelineY + this.timelineH + SCALE(10),
231 + grid: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridArtistTop && y <= this.gridColumnValueBottom
232 + };
233 + /** @private @type {string} The text content of the grid tooltip. */
234 + this.gridTooltipText = '';
235 + /** @private @type {string} The text content of the grid timeline tooltip. */
236 + this.gridTimelineTooltipText = '';
237 + // #endregion
238 +
239 + // * TIMERS * //
240 + // #region TIMERS
241 + /** @public @type {number} The disc art rotation timer when disc art spins while song is playing. */
242 + this.discArtRotationTimer = null;
243 + // #endregion
244 + }
245 +
246 + // * PLUBLIC METHODS - DRAW * //
247 + // #region PUBLIC METHODS - DRAW
248 + /**
249 + * Draws the Details panel.
250 + * @param {GdiGraphics} gr - The GDI graphics object.
251 + */
252 + drawDetails(gr) {
253 + this.drawBackground(gr);
254 + this.drawDiscArt(gr);
255 + this.drawGrid(gr);
256 + this.drawBandLogo(gr);
257 + this.drawLabelLogo(gr);
258 + }
259 +
260 + /**
261 + * Draws the Details background.
262 + * @param {GdiGraphics} gr - The GDI graphics object.
263 + */
264 + drawBackground(gr) {
265 + if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.displayDetails) {
266 + return;
267 + }
268 +
269 + gr.SetTextRenderingHint(TextRenderingHint.AntiAliasGridFit);
270 + gr.SetSmoothingMode(SmoothingMode.None);
271 +
272 + if (grm.ui.isStreaming && grm.ui.noArtwork || !grm.ui.albumArt && grm.ui.noArtwork) {
273 + gr.FillSolidRect(0, grm.ui.topMenuHeight, grm.ui.ww, grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight, grCol.detailsBg);
274 + } else {
275 + gr.FillSolidRect(0, grm.ui.albumArtSize.y, grSet.noDiscArtBg && !this.discArt ? grm.ui.ww : grm.ui.albumArtSize.x, grm.ui.albumArtSize.h, grCol.detailsBg);
276 + }
277 +
278 + if (grm.ui.albumArt && grSet.styleBlend && grCol.imgBlended) {
279 + gr.DrawImage(grCol.imgBlended, 0, 0, grm.ui.ww, grm.ui.wh, 0, 0, grCol.imgBlended.Width, grCol.imgBlended.Height);
280 + }
281 +
282 + gr.SetSmoothingMode(SmoothingMode.HighQuality);
283 + }
284 +
285 + /**
286 + * Draws the disc art in Details.
287 + * @param {GdiGraphics} gr - The GDI graphics object.
288 + */
289 + drawDiscArt(gr) {
290 + if (grSet.layout !== 'default' || !grSet.displayDiscArt || !grm.ui.displayDetails || grm.ui.noAlbumArtStub ||
291 + this.discArtSize.y < grm.ui.albumArtSize.y || this.discArtSize.h > grm.ui.albumArtSize.h) {
292 + return;
293 + }
294 +
295 + grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 碟片');
296 +
297 + if (!this.discArtRotation) {
298 + this.setDiscArtRotation();
299 + }
300 +
301 + const applyOpacity = !grm.ui.displayLyrics && grm.ui.albumArtSize.w < grm.ui.ww * 0.66;
302 + const albumArtOpacity = applyOpacity ? grSet.detailsAlbumArtOpacity : 255;
303 +
304 + if (!grSet.discArtOnTop || grm.ui.displayLyrics) {
305 + this.drawDiscArtImage(gr);
306 + if (this.discArtRotation && grSet.detailsAlbumArtDiscAreaOpacity !== 255) {
307 + const discArtOpacity = applyOpacity ? grSet.detailsAlbumArtDiscAreaOpacity : 255;
308 + this.createDiscArtAlbumArtMask(gr, grm.ui.albumArtSize.x, grm.ui.albumArtSize.y, grm.ui.albumArtSize.w, grm.ui.albumArtSize.h, 0, 0, grm.ui.albumArtScaled.Width, grm.ui.albumArtScaled.Height, 0, discArtOpacity);
309 + } else {
310 + grm.ui.drawAlbumArt(gr, albumArtOpacity);
311 + }
312 + } else { // * Draw discArt on top of front cover
313 + grm.ui.drawAlbumArt(gr, albumArtOpacity);
314 + this.drawDiscArtImage(gr);
315 + }
316 +
317 + grm.debug.setDebugProfile(false, 'print', '绘图 -> 碟片');
318 + }
319 +
320 + /**
321 + * Draws the disc art image and its shadow (if applicable).
322 + * @param {GdiGraphics} gr - The GDI graphics object.
323 + */
324 + drawDiscArtImage(gr) {
325 + const discArtImg = this.discArtArray[this.discArtRotationIndex] || this.discArtRotation;
326 +
327 + if (!grSet.filterAlbumArt && grm.ui.discArtImageDisplayed || !discArtImg) {
328 + return;
329 + }
330 +
331 + if (this.discArtShadowImg.image) {
332 + const shadowImg = this.discArtShadowImg.image;
333 + gr.DrawImage(shadowImg, -this.discArtShadow, grm.ui.albumArtSize.y - this.discArtShadow, shadowImg.Width, shadowImg.Height, 0, 0, shadowImg.Width, shadowImg.Height);
334 + }
335 +
336 + gr.DrawImage(discArtImg, this.discArtSize.x, this.discArtSize.y, this.discArtSize.w, this.discArtSize.h, 0, 0, discArtImg.Width, discArtImg.Height, 0);
337 + }
338 +
339 + /**
340 + * Draws the metadata grid on the left side in the Details panel.
341 + * @param {GdiGraphics} gr - The GDI graphics object.
342 + */
343 + drawGrid(gr) {
344 + if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.displayDetails) return;
345 +
346 + grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 元数据表格');
347 +
348 + gr.SetSmoothingMode(SmoothingMode.HighQuality);
349 + gr.SetInterpolationMode(InterpolationMode.HighQualityBicubic);
350 +
351 + this.setGridMetrics(gr);
352 + this.gridTop = this.gridTopStart;
353 +
354 + if (this.gridContentWidth > 150) {
355 + const spacing = Math.floor(this.gridLineSpacing * 0.33);
356 + const spacing2 = Math.floor(this.gridLineSpacing * 0.5);
357 +
358 + // * Artist
359 + if (grSet.showGridArtist_layout) {
360 + this.gridTop += this.drawGridArtist(gr) + spacing;
361 + }
362 +
363 + // * Title
364 + if (grSet.showGridTitle_layout) {
365 + this.gridTop += this.drawGridTitle(gr) + spacing;
366 + } else if (!grSet.showGridArtist_layout) {
367 + this.gridTop += this.drawGridAlbum(gr) + spacing;
368 + }
369 +
370 + // * Timeline
371 + if (grSet.showGridTimeline_layout) {
372 + this.setGridTimelineSize(this.gridMarginLeft, this.gridTop + spacing, grm.ui.albumArtSize.x - this.gridMarginLeft * 2, this.timelineH);
373 + this.drawGridTimeline(gr);
374 + this.gridTop += this.timelineH + this.gridLineSpacing;
375 + }
376 +
377 + // * Album
378 + if (grSet.showGridArtist_layout || grSet.showGridTitle_layout) {
379 + this.gridTop += this.drawGridAlbum(gr) + spacing2;
380 + }
381 +
382 + // * Columns key and value
383 + this.drawGridColumns(gr);
384 + }
385 +
386 + gr.SetInterpolationMode(InterpolationMode.Default);
387 +
388 + grm.debug.setDebugProfile(false, 'print', '绘图 -> 元数据表格');
389 + }
390 +
391 + /**
392 + * Draws the custom metadata grid menu.
393 + * @param {GdiGraphics} gr - The GDI graphics object.
394 + */
395 + drawGridMenu(gr) {
396 + if (!grm.ui.displayMetadataGridMenu || grSet.layout !== 'default') return;
397 +
398 + const x = grm.ui.albumArtSize.x - 1;
399 + const y = grm.ui.topMenuHeight;
400 + const width = grm.ui.ww;
401 + const height = grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight;
402 +
403 + gr.FillSolidRect(x, y, width, height, pl.col.bg);
404 + for (const c of CustomMenu.controlList) c.draw(gr);
405 +
406 + if (CustomMenu.activeControl && CustomMenu.activeControl instanceof CustomMenuDropDown && CustomMenu.activeControl.isSelectUp) {
407 + CustomMenu.activeControl.draw(gr);
408 + }
409 + }
410 +
411 + /**
412 + * Draws the artist on the metadata grid in the Details panel.
413 + * @param {GdiGraphics} gr - The GDI graphics object.
414 + * @returns {number} The height of the artist.
415 + */
416 + drawGridArtist(gr) {
417 + if (!grStr.artist) return 0;
418 +
419 + // * Apply better anti-aliasing on smaller font sizes in HD res
420 + gr.SetTextRenderingHint(!RES._4K && (grSet.gridArtistFontSize_layout < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit);
421 +
422 + const artistColor = ['white', 'black', 'reborn', 'random'].includes(grSet.theme) ? grCol.detailsText : grSet.theme === 'cream' ? pl.col.header_artist_normal : pl.col.header_artist_playing;
423 + DrawString(gr, grm.ui.getFormattedString('gridArtist'), grFont.gridArtist, artistColor, this.gridMarginLeft, Math.round(this.gridTop), this.gridContentWidth, this.gridArtistNumLinesHeight, Stringformat.Trim_Ellipsis_Char);
424 +
425 + // * Artist country flags
426 + if (grStr.artist && grSet.showGridArtistFlags_layout) {
427 + grm.ui.drawArtistCountryFlag(gr, 'metadataGrid');
428 + }
429 +
430 + this.gridArtistTop = this.gridTop;
431 + this.gridArtistBottom = this.gridTop + this.gridArtistNumLinesHeight;
432 + return this.gridArtistNumLinesHeight;
433 + }
434 +
435 + /**
436 + * Draws the track title on the metadata grid in the Details panel.
437 + * @param {GdiGraphics} gr - The GDI graphics object.
438 + * @returns {number} The height of the title.
439 + */
440 + drawGridTitle(gr) {
441 + if (!grStr.title) return 0;
442 +
443 + // * Apply better anti-aliasing on smaller font sizes in HD res
444 + gr.SetTextRenderingHint(!RES._4K && (grSet.gridTitleFontSize_layout < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit);
445 +
446 + DrawString(gr, grm.ui.getFormattedString('gridTitle'), grFont.gridTitle, grCol.detailsText, this.gridMarginLeft, Math.round(this.gridTop), this.gridContentWidth, this.gridTitleNumLinesHeight, Stringformat.Trim_Ellipsis_Char);
447 +
448 + this.gridTitleTop = this.gridTop;
449 + this.gridTitleBottom = this.gridTop + this.gridTitleNumLinesHeight;
450 + return this.gridTitleNumLinesHeight;
451 + }
452 +
453 + /**
454 + * Draws the album on the metadata grid in the Details panel.
455 + * @param {GdiGraphics} gr - The GDI graphics object.
456 + * @returns {number} The height of the album.
457 + */
458 + drawGridAlbum(gr) {
459 + if (!grStr.album) return 0;
460 +
461 + // * Apply better anti-aliasing on smaller font sizes in HD res
462 + gr.SetTextRenderingHint(!RES._4K && (grSet.gridAlbumFontSize_layout < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit);
463 +
464 + DrawString(gr, grStr.album, grFont.gridAlbum, grCol.detailsText, this.gridMarginLeft, Math.round(this.gridTop), this.gridContentWidth, this.gridAlbumNumLinesHeight, Stringformat.Trim_Ellipsis_Char);
465 +
466 + this.gridAlbumTop = this.gridTop;
467 + this.gridAlbumBottom = this.gridTop + this.gridAlbumNumLinesHeight;
468 + return this.gridAlbumNumLinesHeight;
469 + }
470 +
471 + /**
472 + * Draws the column key and column value on the metadata grid in the Details panel.
473 + * @param {GdiGraphics} gr - The GDI graphics object.
474 + */
475 + drawGridColumns(gr) {
476 + for (let k = 0; k < grStr.grid.length; k++) {
477 + this.gridColumnKey = grStr.grid[k].label;
478 + this.gridColumnValue = grStr.grid[k].val;
479 + this.gridTxtRec = gr.MeasureString(this.gridColumnValue, grFont.gridVal, 0, 0, this.gridColumnValueWidth, grm.ui.wh);
480 + this.gridColumnCellHeight = this.gridTxtRec.Height + 5;
481 + this.gridColumnTop = this.gridTop;
482 +
483 + let gridShowLastFmImage = false;
484 + let gridShowReleaseFlagImage = false;
485 + let gridShowCodecLogoImage = false;
486 + let gridShowChannelLogoImage = false;
487 + let gridDropShadow = false;
488 + let gridValueColor = grCol.detailsText;
489 +
490 + if (this.gridColumnValue.length) {
491 + const columnKey = {
492 + '目录': () => {
493 + gridShowReleaseFlagImage = grSet.showGridReleaseFlags_layout;
494 + if (grSet.showGridReleaseFlags_layout === 'logo') {
495 + this.gridColumnValue = this.gridColumnValue.replace($('%releasecountry%'), '');
496 + }
497 + },
498 + '发行地区': () => {
499 + gridShowReleaseFlagImage = grSet.showGridReleaseFlags_layout;
500 + if (grSet.showGridReleaseFlags_layout === 'logo') this.gridColumnValue = '';
501 + },
502 + '编解码': () => {
503 + gridShowCodecLogoImage = grSet.showGridCodecLogo_layout;
504 + this.gridColumnValue = grSet.showGridCodecLogo_layout === 'logo' ? '' : this.getCodecString();
505 + this.gridColumnCellHeight = this.gridColumnValueHeight + 5;
506 + },
507 + '声道': () => {
508 + gridShowChannelLogoImage = grSet.showGridChannelLogo_layout;
509 + this.gridColumnValue = grSet.showGridChannelLogo_layout === 'logo' ? '' : this.getChannelString($('%channels%'));
510 + this.gridColumnCellHeight = this.gridColumnValueHeight + 5;
511 + },
512 + '热门': () => {
513 + gridValueColor = grCol.detailsHotness;
514 + gridDropShadow = true;
515 + },
516 + '播放次数': () => {
517 + gridShowLastFmImage = true;
518 + },
519 + '评级': () => {
520 + gridValueColor = grCol.detailsRating;
521 + gridDropShadow = true;
522 + },
523 + 'default': () => {
524 + let matchCount = 0;
525 + // * On small player sizes, there is no space for all metadata entries.
526 + // * Hide them and only display entries from basicMeta.
527 + if (this.basicMetadataDisplay(this.gridColumnKey)) {
528 + this.gridColumnValue = '';
529 + this.gridColumnKey = '';
530 + matchCount++;
531 + }
532 + this.gridTop -= this.gridColumnCellHeight * matchCount;
533 + }
534 + };
535 + (columnKey[this.gridColumnKey] || columnKey.default)();
536 +
537 + if (this.gridTop + this.gridTxtRec.Height < grm.ui.albumArtSize.y + grm.ui.albumArtSize.h) {
538 + // * Apply better anti-aliasing on smaller font sizes in HD res
539 + gr.SetTextRenderingHint(!RES._4K && (grSet.gridKeyFontSize_layout < 17 || grSet.gridValueFontSize_layout + SCALE(1) < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit);
540 +
541 + if (gridDropShadow) {
542 + const gridBorderWidth = SCALE(0.5);
543 + gr.DrawString(this.gridColumnValue, grFont.gridVal, grCol.primary_rgb_s050, Math.round(this.gridColumnValueLeft + gridBorderWidth), Math.round(this.gridTop + gridBorderWidth), this.gridColumnValueWidth, this.gridColumnCellHeight, StringFormat(0, 0, 4));
544 + gr.DrawString(this.gridColumnValue, grFont.gridVal, grCol.primary_rgb_s050, Math.round(this.gridColumnValueLeft - gridBorderWidth), Math.round(this.gridTop + gridBorderWidth), this.gridColumnValueWidth, this.gridColumnCellHeight, StringFormat(0, 0, 4));
545 + gr.DrawString(this.gridColumnValue, grFont.gridVal, grCol.primary_rgb_s050, Math.round(this.gridColumnValueLeft + gridBorderWidth), Math.round(this.gridTop - gridBorderWidth), this.gridColumnValueWidth, this.gridColumnCellHeight, StringFormat(0, 0, 4));
546 + gr.DrawString(this.gridColumnValue, grFont.gridVal, grCol.primary_rgb_s050, Math.round(this.gridColumnValueLeft - gridBorderWidth), Math.round(this.gridTop - gridBorderWidth), this.gridColumnValueWidth, this.gridColumnCellHeight, StringFormat(0, 0, 4));
547 + }
548 + gr.DrawString(this.gridColumnKey, grFont.gridKey, grCol.detailsText, this.gridMarginLeft, Math.round(this.gridTop), this.gridColumnKeyWidth, this.gridColumnCellHeight, Stringformat.Trim_Ellipsis_Char);
549 + gr.DrawString(this.gridColumnValue, grFont.gridVal, gridValueColor, this.gridColumnValueLeft, Math.round(this.gridTop), this.gridColumnValueWidth, this.gridColumnCellHeight, StringFormat(0, 0, 4));
550 +
551 + // * Release flag
552 + if (gridShowReleaseFlagImage) {
553 + this.drawGridReleaseFlag(gr);
554 + }
555 + // * Codec logo
556 + if (gridShowCodecLogoImage) {
557 + this.drawGridCodecLogo(gr);
558 + }
559 + // * Channel logo
560 + if (gridShowChannelLogoImage) {
561 + this.drawGridChannelLogo(gr);
562 + }
563 + // * Last.fm logo
564 + if (gridShowLastFmImage) {
565 + this.drawGridLastfmLogo(gr);
566 + }
567 + this.gridTop += this.gridColumnCellHeight + 5;
568 + }
569 + }
570 + }
571 + }
572 +
573 + /**
574 + * Draws an image on the metadata grid in the Details panel.
575 + * @param {GdiGraphics} gr - The GDI graphics object.
576 + * @param {GdiBitmap} image - The image to draw.
577 + * @param {boolean} showLogoOnly - Whether to show only the logo.
578 + * @param {number} xOffset - The offset added to x position.
579 + * @param {number} yOffset - The offset added to y position.
580 + * @param {number} cellHeightAdjustment - The adjustment applied to cell height.
581 + */
582 + drawGridImage(gr, image, showLogoOnly, xOffset = 0, yOffset = 0, cellHeightAdjustment = 0) {
583 + if (image == null) return;
584 +
585 + // Calculate metrics and ratios
586 + const gridColumnValueMetrics = gr.MeasureString(showLogoOnly ? '' : this.gridColumnValue, grFont.gridVal, 0, 0, this.gridColumnValueWidth, grm.ui.wh);
587 + const heightRatio = (gr.CalcTextHeight(showLogoOnly ? 'Ag' : this.gridColumnValue, grFont.gridVal) - cellHeightAdjustment) / image.Height;
588 + const logoHeight = Math.round(image.Height * heightRatio);
589 + const logoWidth = Math.round(image.Width * heightRatio);
590 +
591 + // Get the width of the last line
592 + const newLineWidth = gr.EstimateLineWrap(this.gridColumnValue, grFont.gridVal, this.gridTxtRec.Lines === 1 ? this.gridColumnValueWidth : this.gridTxtRec.Width);
593 + const lastLineIndex = newLineWidth.length - 1;
594 + const lastLineWidth = newLineWidth[lastLineIndex] || gridColumnValueMetrics.Width;
595 +
596 + // Initial positions
597 + const stringWidth = lastLineWidth + xOffset;
598 + let xPos = this.gridColumnValueLeft + stringWidth;
599 + let yPos = this.gridTop + yOffset;
600 +
601 + // Adjust positions if the logo width exceeds the grid column width and move logo to the next line
602 + if (xPos + logoWidth > this.gridColumnValueLeft + this.gridColumnValueWidth) {
603 + const textHeight = gr.CalcTextHeight('Ag', grFont.gridVal);
604 + xPos = this.gridColumnValueLeft;
605 + yPos += textHeight;
606 + this.gridTxtRec = { ...this.gridTxtRec, Lines: this.gridTxtRec.Lines + 1, Height: this.gridTxtRec.Height + textHeight };
607 + this.gridColumnCellHeight = this.gridTxtRec.Height + 5;
608 + }
609 +
610 + gr.DrawImage(image, xPos, yPos, logoWidth, logoHeight, 0, 0, image.Width, image.Height);
611 + }
612 +
613 + /**
614 + * Draws the release flag on the metadata grid in the Details panel.
615 + * @param {GdiGraphics} gr - The GDI graphics object.
616 + */
617 + drawGridReleaseFlag(gr) {
618 + if (this.gridReleaseFlagImg == null) return;
619 +
620 + const logoOnly = grSet.showGridReleaseFlags_layout === 'logo' && this.gridColumnKey === '发行地区';
621 + const lineHeight = this.gridTxtRec.Height / this.gridTxtRec.Lines;
622 + const yCorr = (this.gridTxtRec.Lines - 1) * lineHeight;
623 +
624 + this.drawGridImage(gr, this.gridReleaseFlagImg, logoOnly, SCALE(logoOnly ? 0 : 8), logoOnly ? 0 : yCorr);
625 + }
626 +
627 + /**
628 + * Draws the codec logo on the metadata grid in the Details panel.
629 + * @param {GdiGraphics} gr - The GDI graphics object.
630 + */
631 + drawGridCodecLogo(gr) {
632 + if (this.gridCodecLogo == null) {
633 + this.loadGridCodecLogo();
634 + if (this.gridCodecLogo == null) return;
635 + }
636 +
637 + const logoOnly = grSet.showGridCodecLogo_layout === 'logo';
638 + this.drawGridImage(gr, this.gridCodecLogo, logoOnly, SCALE(logoOnly ? 0 : 8), logoOnly ? -1 : 2);
639 + }
640 +
641 + /**
642 + * Draws the channel logo on the metadata grid in the Details panel.
643 + * @param {GdiGraphics} gr - The GDI graphics object.
644 + */
645 + drawGridChannelLogo(gr) {
646 + if (this.gridChannelLogo == null) {
647 + this.loadGridChannelLogo();
648 + if (this.gridChannelLogo == null) return;
649 + }
650 +
651 + const logoOnly = grSet.showGridChannelLogo_layout === 'logo';
652 + this.drawGridImage(gr, this.gridChannelLogo, logoOnly, SCALE(logoOnly ? 0 : 8), logoOnly ? -1 : 2);
653 + }
654 +
655 + /**
656 + * Draws the last.fm logo on the metadata grid in the Details panel.
657 + * @param {GdiGraphics} gr - The GDI graphics object.
658 + */
659 + drawGridLastfmLogo(gr) {
660 + if (!this.playCountVerifiedByLastFm) return;
661 +
662 + const lastFmImg = gdi.Image(grPath.lastFmImageRed);
663 + const lastFmWhiteImg = gdi.Image(grPath.lastFmImageWhite);
664 + const lastFmLogo = grCol.lightBgDetails ? lastFmImg : lastFmWhiteImg;
665 + const lineHeight = this.gridTxtRec.Height / this.gridTxtRec.Lines;
666 + const yCorr = (this.gridTxtRec.Lines - 1) * lineHeight;
667 +
668 + this.drawGridImage(gr, lastFmLogo, false, SCALE(8), yCorr, 6);
669 + }
670 +
671 + /**
672 + * Draws the band logo on the bottom left side in the Details panel.
673 + * @param {GdiGraphics} gr - The GDI graphics object.
674 + */
675 + drawBandLogo(gr) {
676 + if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.albumArt || grSet.layout !== 'default' || !grm.ui.displayDetails ||
677 + grSet.lyricsLayout === 'full' && grm.ui.displayLyrics) {
678 + return;
679 + }
680 +
681 + grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 艺术家标识');
682 +
683 + const availableSpace = grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - this.gridTop;
684 + const logo = grCol.lightBgDetails || grm.ui.noAlbumArtStub ? (this.bandLogoInverted || this.bandLogo) : this.bandLogo;
685 +
686 + if (logo && availableSpace > 75) {
687 + let logoWidth = Math.min(HD_4K(logo.Width / 2, logo.Width), grm.ui.albumArtSize.x - grm.ui.ww * 0.05);
688 + const heightScale = Math.min(logoWidth / logo.Width, availableSpace / logo.Height);
689 + logoWidth = logo.Width * heightScale; // Adjust logoWidth after heightScale is potentially updated
690 +
691 + const logoX = Math.round(grm.ui.isStreaming ? SCALE(40) : grm.ui.albumArtSize.x / 2 - logoWidth / 2);
692 + const logoY = Math.round(grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - (logo.Height * heightScale)) - HD_4K(4, 24);
693 + const logoW = Math.round(logoWidth);
694 + const logoH = Math.round(logo.Height * heightScale);
695 +
696 + gr.DrawImage(logo, logoX, logoY, logoW, logoH, 0, 0, logo.Width, logo.Height, 0);
697 + }
698 +
699 + grm.debug.setDebugProfile(false, 'print', '绘图 -> 艺术家标识');
700 + }
701 +
702 + /**
703 + * Draws the label logo on the bottom right side in the Details panel.
704 + * @param {GdiGraphics} gr - The GDI graphics object.
705 + */
706 + drawLabelLogo(gr) {
707 + if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.albumArt || grSet.layout !== 'default' || !grm.ui.displayDetails ||
708 + grSet.lyricsLayout === 'full' && grm.ui.displayLyrics) {
709 + return;
710 + }
711 +
712 + grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 唱片公司标识');
713 +
714 + if (this.labelLogo.length > 0) {
715 + const lightBg = grSet.labelArtOnBg ? grCol.lightBgMain : grCol.lightBgDetails;
716 + const labels = lightBg || grm.ui.noAlbumArtStub ? (this.labelLogoInverted.length ? this.labelLogoInverted : this.labelLogo) : this.labelLogo;
717 + const rightSideGap = 20; // How close last label is to right edge
718 + const leftEdgeGap = (grm.ui.albumArtOffCenter ? 20 : 40) * HD_4K(1, 1.8); // Space between art and label
719 + const leftEdgeWidth = HD_4K(30, 45); // How far label background extends on left
720 + const maxLabelWidth = SCALE(200);
721 + let leftEdge = 0;
722 + let topEdge = 0;
723 + let totalLabelWidth = 0;
724 + let labelAreaWidth = 0;
725 + let labelSpacing = 0;
726 + let labelWidth;
727 + let labelHeight;
728 +
729 + for (const label of labels) {
730 + if (label.Width > maxLabelWidth) {
731 + totalLabelWidth += maxLabelWidth;
732 + } else {
733 + totalLabelWidth += RES._4K && label.Width < 200 ? label.Width * 2 : label.Width;
734 + }
735 + }
736 + if (!this.cachedLabelLastLeftEdge) { // We don't want to recalculate this every screen refresh
737 + grm.debug.debugLog('图标 => 重新计算最后左边缘');
738 + this.shadowImgLabel = null;
739 + labelWidth = Math.round(totalLabelWidth / labels.length);
740 + labelHeight = Math.round(labels[0].Height * labelWidth / labels[0].Width); // Might be recalc'd below
741 + if (grm.ui.albumArt) {
742 + if (this.discArt && grSet.displayDiscArt) {
743 + leftEdge = Math.round(Math.max(grm.ui.albumArtSize.x + grm.ui.albumArtScaled.Width + 5, grm.ui.ww * 0.975 - totalLabelWidth + 1));
744 + const discCenter = {};
745 + discCenter.x = Math.round(this.discArtSize.x + this.discArtSize.w / 2);
746 + discCenter.y = Math.round(this.discArtSize.y + this.discArtSize.h / 2);
747 + const radius = discCenter.y - this.discArtSize.y;
748 + const radiusSquared = radius * radius;
749 + let posValid = false;
750 +
751 + while (!posValid) {
752 + const allLabelsWidth = Math.max(Math.min(Math.round((grm.ui.ww - leftEdge - rightSideGap) / labels.length), maxLabelWidth), 50);
753 + //console.log("leftEdge = " + leftEdge + ", grm.ui.ww-leftEdge-10 = " + (grm.ui.ww-leftEdge-10) + ", allLabelsWidth=" + allLabelsWidth);
754 + const maxWidth = RES._4K && labels[0].Width < 200 ? labels[0].Width * 2 : labels[0].Width;
755 + labelWidth = (allLabelsWidth > maxWidth) ? maxWidth : allLabelsWidth;
756 + labelHeight = Math.round(labels[0].Height * labelWidth / labels[0].Width); // Width is based on height scale
757 + topEdge = Math.round(grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - labelHeight);
758 +
759 + const a = topEdge - discCenter.y + 1; // Adding 1 to a and b so that the border just touches the edge of the discArt
760 + const b = leftEdge - discCenter.x + 1;
761 +
762 + if ((a * a + b * b) > radiusSquared) {
763 + posValid = true;
764 + } else {
765 + leftEdge += 4;
766 + }
767 + }
768 + } else {
769 + leftEdge = Math.round(Math.max(grm.ui.albumArtSize.x + grm.ui.albumArtSize.w + leftEdgeWidth + leftEdgeGap, grm.ui.ww * 0.975 - totalLabelWidth + 1));
770 + }
771 + } else {
772 + leftEdge = Math.round(grm.ui.ww * 0.975 - totalLabelWidth);
773 + }
774 + labelAreaWidth = grm.ui.ww - leftEdge - rightSideGap;
775 + this.cachedLabelLastLeftEdge = leftEdge;
776 + this.cachedLabelLastHeight = labelHeight;
777 + }
778 + else { // Already calculated
779 + leftEdge = this.cachedLabelLastLeftEdge;
780 + labelHeight = this.cachedLabelLastHeight;
781 + labelAreaWidth = grm.ui.ww - leftEdge - rightSideGap;
782 + }
783 + if (labelAreaWidth >= SCALE(50)) {
784 + if (labels.length > 1) {
785 + labelSpacing = Math.min(12, Math.max(3, Math.round((labelAreaWidth / (labels.length - 1)) * 0.048))); // Spacing should be proportional, and between 3 and 12 pixels
786 + }
787 + // console.log('labelAreaWidth = ' + labelAreaWidth + ", labelSpacing = " + labelSpacing);
788 + const allLabelsWidth = Math.max(Math.min(Math.round((labelAreaWidth - (labelSpacing * (labels.length - 1))) / labels.length), maxLabelWidth), 50); // allLabelsWidth must be between 50 and 200 pixels wide
789 + const origLabelHeight = labelHeight;
790 + let labelX = leftEdge;
791 + topEdge = grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - labelHeight - 20;
792 +
793 + if (!grSet.labelArtOnBg && !grSet.noDiscArtBg || grSet.noDiscArtBg && grSet.displayDiscArt && this.discArt) {
794 + if (!['black', 'nblue', 'ngreen', 'nred', 'ngold'].includes(grSet.theme)) {
795 + if (!this.shadowImgLabel) {
796 + this.shadowImgLabel = ShadowRect(this.discArtShadow, this.discArtShadow, grm.ui.ww - labelX + leftEdgeWidth, labelHeight + 40, this.discArtShadow, grCol.shadow);
797 + }
798 + gr.DrawImage(this.shadowImgLabel, labelX - leftEdgeWidth - this.discArtShadow, topEdge - 20 - this.discArtShadow, grm.ui.ww - labelX + leftEdgeWidth + 2 * this.discArtShadow, labelHeight + 40 + 2 * this.discArtShadow,
799 + 0, 0, this.shadowImgLabel.Width, this.shadowImgLabel.Height);
800 + }
801 + gr.SetSmoothingMode(SmoothingMode.None); // Disable smoothing
802 + gr.FillSolidRect(labelX - leftEdgeWidth, topEdge - 20, grm.ui.ww - labelX + leftEdgeWidth, labelHeight + 40, grCol.detailsBg);
803 + gr.DrawRect(labelX - leftEdgeWidth, topEdge - 20, grm.ui.ww - labelX + leftEdgeWidth, labelHeight + 40 - 1, 1, grCol.shadow);
804 + gr.SetSmoothingMode(SmoothingMode.HighQuality);
805 + }
806 + for (let i = 0; i < labels.length; i++) {
807 + // allLabelsWidth can never be greater than 200, so if a label image is 161 pixels wide, never draw it wider than 161
808 + const maxWidth = RES._4K && labels[i].Width < 200 ? labels[i].Width * 2 : labels[i].Width;
809 + labelWidth = (allLabelsWidth > maxWidth) ? maxWidth : allLabelsWidth;
810 + labelHeight = Math.round(labels[i].Height * labelWidth / labels[i].Width); // Width is based on height scale
811 +
812 + gr.DrawImage(labels[i], labelX, Math.round(topEdge + origLabelHeight / 2 - labelHeight / 2), labelWidth, labelHeight, 0, 0, this.labelLogo[i].Width, this.labelLogo[i].Height);
813 + labelX += labelWidth + labelSpacing;
814 + }
815 + }
816 + }
817 +
818 + grm.debug.setDebugProfile(false, 'print', '绘图 -> 唱片公司标识');
819 + }
820 + // #endregion
821 +
822 + // * PLUBLIC METHODS - METRICS * //
823 + // #region PUBLIC METHODS - METRICS
824 + /**
825 + * Sets the metadata grid metrics and caches all calculated values.
826 + * @param {GdiGraphics} gr - The GDI graphics object.
827 + */
828 + setGridMetrics(gr) {
829 + if (this.cachedGridMetrics) return;
830 +
831 + const metricsPromises = [
832 + new Promise((resolve) => this.setGridMainMetrics(gr, resolve)),
833 + new Promise((resolve) => this.setGridTextMetrics(gr, resolve))
834 + ];
835 +
836 + Promise.all(metricsPromises).then(() => {
837 + this.cachedGridMetrics = this.gridColumnValueBottom > this.gridColumnTop && !grm.display.hasPlayerSizeChanged();
838 + });
839 + }
840 +
841 + /**
842 + * Sets the metadata grid main sizes.
843 + * This includes calculating margins, content width, and column dimensions.
844 + * @param {GdiGraphics} gr - The GDI graphics object.
845 + * @param {Function} metricsCalculated - The callback function to be executed after calculations are finished.
846 + */
847 + setGridMainMetrics(gr, metricsCalculated) {
848 + this.discArtShadow = SCALE(6);
849 + this.gridTooltipHeight = SCALE(100);
850 + this.timelineH = SCALE(8);
851 +
852 + this.gridMarginLeft = grm.ui.edgeMargin;
853 + this.gridTopStart = grm.ui.albumArtSize.y ? grm.ui.albumArtSize.y + grm.ui.edgeMargin : grm.ui.topMenuHeight + grm.ui.edgeMargin;
854 + this.gridTop = this.gridTopStart;
855 + this.gridContentWidth = Math.floor((!grm.ui.albumArt && this.discArt ? this.discArtSize.x : grm.ui.albumArtSize.x) - grm.ui.edgeMargin * 1.5);
856 +
857 + this.gridColumnKeyWidth = CalcGridMaxTextWidth(gr, grStr.grid, grFont.gridKey);
858 + this.gridColumnKeyHeight = gr.MeasureString('Ag', grFont.gridKey, 0, 0, this.gridContentWidth, grm.ui.wh).Height;
859 + this.gridColumnKeyBottom = this.gridColumnTop + this.gridColumnKeyHeight;
860 +
861 + this.gridColumnValueWidth = this.gridContentWidth - this.gridColumnMargin - this.gridColumnKeyWidth + SCALE(5);
862 + this.gridColumnValueHeight = gr.MeasureString('Ag', grFont.gridVal, 0, 0, this.gridContentWidth, grm.ui.wh).Height;
863 + this.gridColumnValueLeft = this.gridMarginLeft + this.gridColumnKeyWidth + this.gridColumnMargin;
864 + this.gridColumnValueBottom = this.gridColumnTop + this.gridColumnValueHeight;
865 +
866 + metricsCalculated();
867 + }
868 +
869 + /**
870 + * Sets the metadata grid text sizes.
871 + * This includes calculating wrap information and dimensions for artist, title, album, and other text elements based on the grid configuration.
872 + * @param {GdiGraphics} gr - The GDI graphics object.
873 + * @param {Function} metricsCalculated - The callback function to be executed after calculations are finished.
874 + */
875 + setGridTextMetrics(gr, metricsCalculated) {
876 + if (grSet.showGridArtist_layout) {
877 + this.gridFlagSizeW = grm.ui.getFlagSizeWidth('metadataGrid');
878 + this.gridFlagSizeWhiteSpace = grm.ui.getFlagSizeWhiteSpace('metadataGrid');
879 + this.gridArtistWrapInfo = CalcWrapSpace(gr, grStr.artist, grFont.gridArtist, this.gridContentWidth, this.cachedGridWrapSpace);
880 + this.gridArtistWrapLinesExceed = this.gridArtistWrapInfo.lineCount > 2;
881 + this.gridArtistWrapWidth = this.gridArtistWrapInfo.totalWrapSpace - this.gridFlagSizeW;
882 + this.gridArtistWidth = Math.ceil(gr.MeasureString(grStr.artist, grFont.gridArtist, 0, 0, 0, 0, Stringformat.Trim_Ellipsis_Char | Stringformat.Measure_Trailing_Spaces).Width + this.gridFlagSizeW + this.gridArtistWrapWidth);
883 + this.gridArtistHeight = gr.MeasureString(grStr.artist, grFont.gridArtist, 0, 0, 0, 0).Height;
884 + this.gridArtistTxtRec = gr.MeasureString(grStr.artist, grFont.gridArtist, 0, 0, grSet.showGridArtistFlags_layout && grm.ui.flagImgs.length ? this.gridContentWidth - this.gridFlagSizeW : this.gridContentWidth, grm.ui.wh);
885 + this.gridArtistNumLines = Math.min(2, this.gridArtistTxtRec.Lines);
886 + this.gridArtistNumLinesHeight = gr.CalcTextHeight(grStr.artist, grFont.gridArtist) * this.gridArtistNumLines;
887 + }
888 + if (grSet.showGridTitle_layout) {
889 + this.gridTrackNumWidth = Math.ceil(gr.MeasureString(grStr.tracknum, grFont.gridTrackNumber, 0, 0, 0, 0).Width);
890 + this.gridTitleWrapInfo = CalcWrapSpace(gr, grm.ui.getFormattedString('gridTitle'), grFont.gridTitle, this.gridContentWidth, this.cachedGridWrapSpace);
891 + this.gridTitleWrapLinesExceed = this.gridTitleWrapInfo.lineCount > 2;
892 + this.gridTitleWrapWidth = this.gridTitleWrapInfo.totalWrapSpace;
893 + this.gridTitleWidth = Math.ceil(gr.MeasureString(grStr.title, grFont.gridTitle, 0, 0, 0, 0, Stringformat.Trim_Ellipsis_Char | Stringformat.Measure_Trailing_Spaces).Width + this.gridTrackNumWidth + this.gridTrackNumSpacing + this.gridTitleWrapWidth);
894 + this.gridTitleHeight = gr.MeasureString(grStr.title, grFont.gridTitle, 0, 0, 0, 0).Height;
895 + this.gridTitleTxtRec = gr.MeasureString(grm.ui.getFormattedString('gridTitle'), grFont.gridTitle, 0, 0, this.gridContentWidth, grm.ui.wh);
896 + this.gridTitleNumLines = Math.min(2, this.gridTitleTxtRec.Lines);
897 + this.gridTitleNumLinesHeight = gr.CalcTextHeight(grStr.title, grFont.gridTitle) * this.gridTitleNumLines;
898 + }
899 + this.gridAlbumWrapInfo = CalcWrapSpace(gr, grStr.album, grFont.gridAlbum, this.gridContentWidth, this.cachedGridWrapSpace);
900 + this.gridAlbumWrapLinesExceed = this.gridAlbumWrapInfo.lineCount > (grSet.showGridArtist_layout || grSet.showGridTitle_layout ? 2 : 3);
901 + this.gridAlbumWrapWidth = this.gridAlbumWrapInfo.totalWrapSpace;
902 + this.gridAlbumWidth = Math.ceil(gr.MeasureString(grStr.album, grFont.gridAlbum, 0, 0, 0, 0, Stringformat.Trim_Ellipsis_Char | Stringformat.Measure_Trailing_Spaces).Width) + this.gridAlbumWrapWidth;
903 + this.gridAlbumHeight = gr.MeasureString(grStr.album, grFont.gridAlbum, 0, 0, 0, 0).Height;
904 + this.gridAlbumTxtRec = gr.MeasureString(grStr.album, grFont.gridAlbum, 0, 0, this.gridContentWidth, grm.ui.wh);
905 + this.gridAlbumNumLines = Math.min(grSet.showGridArtist_layout || grSet.showGridTitle_layout ? 2 : 3, this.gridAlbumTxtRec.Lines);
906 + this.gridAlbumNumLinesHeight = gr.CalcTextHeight(grStr.album, grFont.gridAlbum) * this.gridAlbumNumLines;
907 +
908 + metricsCalculated();
909 + }
910 + // #endregion
911 +
912 + // * PUBLIC METHODS - COMMON * //
913 + // #region PUBLIC METHODS - COMMON
914 + /**
915 + * Clears individual cache properties, the specified cache type, or all caches.
916 + * @param {string} [type] - The type of cache to clear. Can be 'metrics', 'discArt', 'codecLogo', 'channelLogo', 'bandLogo', 'labelLogo'. If not provided, all caches will be cleared.
917 + * @param {string} [property] - The specific property to clear within the cache type. Applicable only if `type` is provided.
918 + * @param {boolean} [clearArtCache] - Whether to clear everything in the artCache object.
919 + * @param {boolean} [keepDiscArt] - Whether to keep the disc art. This is considered only when `type` is 'discArt' or not provided (clearing all caches).
920 + * @example
921 + * // Clear an individual property within a specific cache type
922 + * clearCache('metrics', 'cachedGridMetrics');
923 + * @example
924 + * // Clear a specific cache type
925 + * clearCache('metrics');
926 + * @example
927 + * // Clear all caches
928 + * clearCache();
929 + * @example
930 + * // Clear all caches and the artCache
931 + * clearCache(undefined, undefined, true);
932 + */
933 + clearCache(type, property, clearArtCache, keepDiscArt) {
934 + const cacheActions = {
935 + metrics: () => {
936 + this.cachedGridWrapSpace = {};
937 + this.cachedGridMetrics = false;
938 + this.cachedLabelLastLeftEdge = 0;
939 + this.cachedLabelLastHeight = 0;
940 + },
941 + discArt: () => {
942 + this.discArt = keepDiscArt ? this.discArt : null;
943 + this.discArtCover = null;
944 + this.discArtArray = [];
945 + this.discArtRotation = null;
946 + },
947 + codecLogo: () => {
948 + this.gridCodecLogo = null;
949 + },
950 + channelLogo: () => {
951 + this.gridChannelLogo = null;
952 + },
953 + bandLogo: () => {
954 + this.bandLogo = null;
955 + this.bandLogoInverted = null;
956 + },
957 + labelLogo: () => {
958 + this.labelLogo = [];
959 + this.labelLogoInverted = [];
960 + }
961 + };
962 +
963 + if (clearArtCache) {
964 + grm.artCache && grm.artCache.clear();
965 + grm.debug.debugLog('详情缓存 => 已清除图片缓存');
966 + }
967 +
968 + if (type) {
969 + // * Clear individual cache property
970 + if (property && Object.hasOwnProperty.call(this, property)) {
971 + this[property] = null;
972 + grm.debug.debugLog(`详情缓存 => 已清除缓存类型 "${type}" 中的属性 "${property}"`);
973 + }
974 + // * Clear specific cache type
975 + else if (cacheActions[type]) {
976 + cacheActions[type]();
977 + grm.debug.debugLog(`详情缓存 => 已清除缓存类型 "${type}"`);
978 + }
979 + return;
980 + }
981 +
982 + // * Clear all caches
983 + for (const action in cacheActions) {
984 + cacheActions[action]();
985 + }
986 + grm.debug.debugLog('详情缓存 => 已清除全部缓存');
987 + }
988 +
989 + /**
990 + * Clears timers based on the timer type.
991 + * @param {string} [type] - The type of timer to clear. If not provided, all timers will be cleared.
992 + * - 'discArt'.
993 + */
994 + clearTimer(type) {
995 + const timers = {
996 + discArt: {
997 + timer: this.discArtRotationTimer,
998 + clear: clearInterval,
999 + log: '计时器 => 碟片旋转计时器已清除'
1000 + }
1001 + };
1002 +
1003 + const clearTimerByType = (type) => {
1004 + const { timer, clear, log } = timers[type];
1005 + if (timer) {
1006 + clear(timer);
1007 + timers[type].timer = null;
1008 + }
1009 + grm.debug.debugLog(log);
1010 + };
1011 +
1012 + if (type && timers[type]) {
1013 + clearTimerByType(type);
1014 + } else {
1015 + for (const key in timers) {
1016 + clearTimerByType(key);
1017 + }
1018 + }
1019 + }
1020 + // #endregion
1021 +
1022 + // * PUBLIC METHODS - METADATA GRID * //
1023 + // #region PUBLIC METHODS - METADATA GRID
1024 + /**
1025 + * Initializes the metadata grid menu and toggles its open/close state.
1026 + */
1027 + initGridMenuState() {
1028 + if (grSet.layout !== 'default') {
1029 + const msg = grm.msg.getMessage('main', 'metadataGridLiveEdit');
1030 + fb.ShowPopupMessage(msg, '元数据表格实时编辑');
1031 + return;
1032 + }
1033 +
1034 + grm.ui.displayMetadataGridMenu = !grm.ui.displayMetadataGridMenu;
1035 + grm.ui.displayCustomThemeMenu = false;
1036 +
1037 + if (grm.ui.displayMetadataGridMenu) {
1038 + if (!grm.ui.displayDetails) {
1039 + grm.ui.displayDetails = true;
1040 + grm.ui.displayPlaylist = false;
1041 + grm.ui.displayLibrary = false;
1042 + grm.ui.displayBiography = false;
1043 + grm.ui.resizeArtwork(true);
1044 + }
1045 +
1046 + grm.gridMenu.initMetadataGridMenu(1);
1047 + }
1048 +
1049 + grm.button.initButtonState();
1050 + window.Repaint();
1051 + }
1052 +
1053 + /**
1054 + * Determines whether basic metadata should be displayed based on the grid column width.
1055 + * @param {string} gridColumnKey - The grid column key.
1056 + * @returns {boolean} True if basic metadata should be displayed, otherwise false.
1057 + */
1058 + basicMetadataDisplay(gridColumnKey) {
1059 + const resolutions = [
1060 + { displayRes: 'HD', maxW: 1250, maxH: 800 },
1061 + { displayRes: 'QHD', maxW: 1350, maxH: 900 },
1062 + { displayRes: '4K', maxW: 2350, maxH: 1550 }
1063 + ];
1064 +
1065 + const basicMeta = ['年份', '唱片公司', '流派', '编解码', '声道', '来源', '数据', '播放次数', '评级'];
1066 + const smallRes = resolutions.some(res => grSet.displayRes === res.displayRes && (grm.ui.ww < res.maxW || grm.ui.wh < res.maxH));
1067 +
1068 + return grSet.autoHideGridMetadata && grSet.layout === 'default' && smallRes && !basicMeta.includes(gridColumnKey);
1069 + }
1070 +
1071 + /**
1072 + * 获取编解码器字符串,如果编解码器是DTS,则返回'DCA'.
1073 + * @returns {string} 编解码器字符串或'DCA' 如果编解码器是 DTS.
1074 + */
1075 + getCodecString() {
1076 + const codec = $('$lower($if2(%codec%,$ext(%path%)))');
1077 + if (['dts', 'dca (dts coherent acoustics)'].includes(codec)) {
1078 + return 'DCA'; // 如果编解码是DTS,则仅显示DCA缩写
1079 + }
1080 + return codec;
1081 + }
1082 +
1083 + /**
1084 + * Gets the channel string based on the provided channel type.
1085 + * @param {string} channelType - The type of the channel (e.g., 'mono', 'stereo').
1086 + * @returns {string} The channel string or an empty string if the channel type is not found.
1087 + */
1088 + getChannelString(channelType) {
1089 + const channelMapping = {
1090 + 'mono': { number: 1, string: '单声道' },
1091 + '单声道': { number: 1, string: '单声道' },
1092 + 'stereo': { number: 2, string: '立体声' },
1093 + '立体声': { number: 2, string: '立体声' },
1094 + '3ch': { number: 3, string: '中置' },
1095 + '3 声道': { number: 3, string: '中置' },
1096 + '4ch': { number: 4, string: '四声道' },
1097 + '4 声道': { number: 4, string: '四声道' },
1098 + '5ch': { number: 5, string: '环绕' },
1099 + '5 声道': { number: 5, string: '环绕' },
1100 + '6ch': { number: 6, string: '环绕' },
1101 + '6 声道': { number: 6, string: '环绕' },
1102 + '7ch': { number: 7, string: '环绕' },
1103 + '7 声道': { number: 7, string: '环绕' },
1104 + '8ch': { number: 8, string: '环绕' },
1105 + '8 声道': { number: 8, string: '环绕' },
1106 + '10ch': { number: 10, string: '环绕' },
1107 + '10 声道':{ number: 10, string: '环绕' },
1108 + '12ch': { number: 12, string: '环绕' },
1109 + '12 声道':{ number: 12, string: '环绕' }
1110 + };
1111 +
1112 + const channel = channelMapping[channelType];
1113 + if (!channel) return '';
1114 +
1115 + if (grSet.showGridChannelLogo_layout === 'textlogo') {
1116 + return channel.string;
1117 + } else if (grSet.showGridChannelLogo_layout === false) {
1118 + return `${channel.number} ${Unicode.MiddleDot} ${channel.string}`;
1119 + } else {
1120 + return '';
1121 + }
1122 + }
1123 +
1124 + /**
1125 + * Gets the grid tooltip string based on the specified type.
1126 + * @param {string} type - The type of metadata ('artist', 'title', 'album').
1127 + * @returns {string} The tooltip string.
1128 + */
1129 + getGridTooltip(type) {
1130 + const tooltipType = {
1131 + artist: grStr.artist,
1132 + title: `${grStr.tracknum} ${grStr.title} ${grStr.composer}`,
1133 + album: `${grStr.album} ${grStr.composer}`
1134 + };
1135 + return tooltipType[type];
1136 + }
1137 +
1138 + /**
1139 + * Handles the grid tooltip. If a tooltip is ready, it displays and then clears it.
1140 + * @param {number} x - The x-coordinate.
1141 + * @param {number} y - The y-coordinate.
1142 + */
1143 + handleGridTooltip(x, y) {
1144 + const artistTooltipRange = this.mouseInMetadataGrid(x, y, 'artist');
1145 + const titleTooltipRange = this.mouseInMetadataGrid(x, y, 'title');
1146 + const albumTooltipRange = this.mouseInMetadataGrid(x, y, 'album');
1147 +
1148 + if (!artistTooltipRange && !titleTooltipRange && !albumTooltipRange) return;
1149 +
1150 + const showArtistToolTip = artistTooltipRange && grSet.showGridArtist_layout && (
1151 + this.gridArtistWidth > this.gridContentWidth * 2
1152 + ||
1153 + this.gridArtistWrapLinesExceed
1154 + );
1155 +
1156 + const showTitleToolTip = titleTooltipRange && grSet.showGridTitle_layout && (
1157 + this.gridTitleWidth > this.gridContentWidth * 2
1158 + ||
1159 + this.gridTitleWrapLinesExceed
1160 + );
1161 +
1162 + const showAlbumToolTip = albumTooltipRange && (
1163 + !grSet.showGridArtist_layout && !grSet.showGridTitle_layout && (this.gridAlbumWidth > this.gridContentWidth * 3)
1164 + ||
1165 + (grSet.showGridArtist_layout || grSet.showGridTitle_layout) && (this.gridAlbumWidth > this.gridContentWidth * 2)
1166 + ||
1167 + this.gridAlbumWrapLinesExceed
1168 + );
1169 +
1170 + const tooltip =
1171 + showArtistToolTip ? this.getGridTooltip('artist') :
1172 + showTitleToolTip ? this.getGridTooltip('title') :
1173 + showAlbumToolTip ? this.getGridTooltip('album') : '';
1174 +
1175 + if (tooltip.length) { // * Display tooltip
1176 + const offset = SCALE(30);
1177 + this.gridTooltipText = tooltip;
1178 + grm.ttip.showDelayed(this.gridTooltipText);
1179 + grm.ui.repaintStyledTooltips(grm.ui.styledToolTipX - offset * 2, grm.ui.styledToolTipY - offset, grm.ui.styledToolTipW + offset * 4, grm.ui.styledToolTipH + offset * 2);
1180 + } else { // * Clear tooltip
1181 + this.gridTooltipText = '';
1182 + grm.ttip.stop();
1183 + window.Repaint();
1184 + }
1185 + }
1186 +
1187 + /**
1188 + * Loads the codec logo of the now playing track, displayed in the metadata grid in Details.
1189 + * @param {FbMetadbHandle} metadb - The metadb of the track.
1190 + */
1191 + loadGridCodecLogo(metadb = grm.ui.initMetadb()) {
1192 + let codec = $('$lower($if2(%codec%,$ext(%path%)))', metadb);
1193 + let format = $('$lower($ext(%path%))', metadb);
1194 +
1195 + // Foobar bug showing wrong metadata when DTS is in wav file format
1196 + if (codec === 'pcm' && (format === 'cue' || format === 'wav')) {
1197 + codec = $('$lower($if2(%codec%,$ext(%path%)))');
1198 + format = $('$lower($ext(%path%))');
1199 + }
1200 +
1201 + const codecFormat = {
1202 + 'aac': 'aac', 'aac acm codec': 'aac', 'mp4': 'aac',
1203 + 'ac3': 'ac3', 'atsc a/52': 'ac3', 'e-ac3': 'ac3', 'atsc a/52a (ac-3)': 'ac3',
1204 + 'aiff': 'pcm-aiff',
1205 + 'alac': 'alac',
1206 + 'alaw': 'alaw', 'ccitt a-law': 'alaw',
1207 + 'amr': 'amr',
1208 + 'ape': 'ape', 'monkey\'s audio': 'ape',
1209 + 'caf': 'caf',
1210 + 'dsd': format === 'iso' ? 'dsd-sacd' : 'dsd',
1211 + 'dst': 'dsd-sacd',
1212 + 'dts': 'dts', 'dca (dts coherent acoustics)': 'dts',
1213 + 'dxd': format === 'iso' ? 'dsd-sacd' : 'dxd',
1214 + 'flac': 'flac',
1215 + 'gsm': 'gsm', 'gsm 6.10': 'gsm',
1216 + 'imaadpcm': 'imaadpcm', 'ima adpcm': 'imaadpcm',
1217 + 'la': 'la',
1218 + 'mid': 'mid',
1219 + 'mlp': 'mlp',
1220 + 'mod': 'mod',
1221 + 'mp2': 'mp2',
1222 + 'mp3': 'mp3', 'mpeg layer-3': 'mp3',
1223 + 'mpc': 'musepack', 'musepack': 'musepack',
1224 + 'msadpcm': 'msadpcm', 'microsoft adpcm': 'msadpcm',
1225 + 'ofr': 'ofr', 'optimfrog': 'ofr',
1226 + 'ogg': 'ogg', 'vorbis': 'ogg',
1227 + 'opus': 'opus',
1228 + 'pcm': format === 'aiff' ? 'pcm-aiff' : ['w64', 'wav'].includes(format) ? 'pcm-wav' : 'pcm',
1229 + 'qoa': 'qoa',
1230 + 'shn': 'shn', 'shorten': 'shn',
1231 + 'spx': 'spx', 'speex': 'spx',
1232 + 'tak': 'tak',
1233 + 'tta': 'tta', 'true audio': 'tta',
1234 + 'ulaw': 'ulaw', 'ccitt u-law': 'ulaw',
1235 + 'usac': 'usac',
1236 + 'wav': 'pcm-wav',
1237 + 'w64': 'pcm-wav',
1238 + 'wma': 'wma',
1239 + 'wv': 'wavpack', 'wavpack': 'wavpack'
1240 + };
1241 +
1242 + let logoName;
1243 + const HDCD = $('%__hdcd%') === 'yes';
1244 + const codecName = codecFormat[codec] || codecFormat[format];
1245 +
1246 + if (codec.startsWith('dsd')) {
1247 + logoName = codecFormat.dsd;
1248 + } else if (codec.startsWith('dxd')) {
1249 + logoName = codecFormat.dxd;
1250 + } else if (codec.startsWith('dst')) {
1251 + logoName = codecFormat.dst;
1252 + } else {
1253 + logoName = HDCD && codecName === 'pcm-wav' ? 'pcm-hdcd' : HDCD ? `${codecName}-hdcd` : codecName;
1254 + }
1255 +
1256 + const bw = grCol.lightBgDetails ? 'black' : 'white';
1257 + const path = `${grPath.images}codec\\${logoName}-${bw}.png`;
1258 +
1259 + this.gridCodecLogo = gdi.Image(path);
1260 + }
1261 +
1262 + /**
1263 + * Loads the channel logo of the now playing track, displayed in the metadata grid in Details.
1264 + * @param {FbMetadbHandle} metadb - The metadb of the track.
1265 + */
1266 + loadGridChannelLogo(metadb = grm.ui.initMetadb()) {
1267 + const codec = $('$lower($if2(%codec%,$ext(%path%)))', metadb);
1268 + const format = $('$lower($ext(%path%))', metadb);
1269 +
1270 + // Foobar bug showing wrong metadata when DTS is in wav file format
1271 + const channels = codec === 'pcm' && (format === 'cue' || format === 'wav') ? $('%channels%') : $('%channels%', metadb);
1272 +
1273 + const type =
1274 + (grSet.layout === 'default' && grSet.showGridChannelLogo_default === 'textlogo' ||
1275 + grSet.layout === 'artwork' && grSet.showGridChannelLogo_artwork === 'textlogo') ? '_text' : '';
1276 +
1277 + const bw = grCol.lightBgDetails ? 'black' : 'white';
1278 +
1279 + const channelFormat = {
1280 + 'mono': '10_mono',
1281 + '单声道': '10_mono',
1282 + 'stereo': '20_stereo',
1283 + '立体声': '20_stereo',
1284 + '3ch': '30_center',
1285 + '3 声道': '30_center',
1286 + '4ch': '40_quad',
1287 + '4 声道': '40_quad',
1288 + '5ch': '50_surround',
1289 + '5 声道': '50_surround',
1290 + '6ch': '51_surround',
1291 + '6 声道': '51_surround',
1292 + '7ch': '61_surround',
1293 + '7 声道': '61_surround',
1294 + '8ch': '71_surround',
1295 + '8 声道': '71_surround',
1296 + '10ch': '91_surround',
1297 + '10 声道':'91_surround',
1298 + '12ch': '111_surround',
1299 + '12 声道':'111_surround'
1300 + };
1301 +
1302 + const channelName = channelFormat[channels];
1303 + const channelLogoPath = (channelName) => `${grPath.images}channels\\${channelName}${type}-${bw}.png`;
1304 + if (channelName) this.gridChannelLogo = gdi.Image(channelLogoPath(channelName));
1305 + }
1306 +
1307 + /**
1308 + * Loads the release country flags, displayed in the metadata grid in Details.
1309 + * @param {FbMetadbHandle} metadb - The metadb of the track.
1310 + */
1311 + loadGridReleaseCountryFlag(metadb = undefined) {
1312 + if (!grSet.showGridReleaseFlags_layout) return;
1313 + this.gridReleaseFlagImg = grm.ui.loadFlagImage($(grTF.releaseCountry, metadb));
1314 + }
1315 +
1316 + /**
1317 + * Updates the metadata grid in Details, reuses last value for last played unless provided one.
1318 + * @param {string} currentLastPlayed - The current value of the "Last Played" metadata field.
1319 + * @param {string} currentPlayingPlaylist - The current active playlist that is being played from.
1320 + * @param {FbMetadbHandle} metadb - The metadb of the track.
1321 + * @returns {Array|null} The updated metadata grid, which is an array of objects with properties `label`, `val` and `age`.
1322 + */
1323 + updateGrid(currentLastPlayed, currentPlayingPlaylist, metadb = undefined) {
1324 + if (!grCfg.metadataGrid) return null;
1325 +
1326 + currentLastPlayed = (grStr && grStr.grid ? grStr.grid.find(value => value.label === '最近播放') || {} : {}).val;
1327 + grStr.grid = [];
1328 +
1329 + for (const key of grCfg.metadataGrid) {
1330 + let val = $(key.val, metadb);
1331 + if (val && key.label) {
1332 + if (key.age) {
1333 + val = $(`$date(${val})`, metadb); // Never show time
1334 + const age = CalcAgeDateString(val);
1335 + if (age) val += ` (${age})`;
1336 + }
1337 + grStr.grid.push({
1338 + age: key.age,
1339 + label: key.label,
1340 + val
1341 + });
1342 + }
1343 + }
1344 + if (typeof currentLastPlayed !== 'undefined') {
1345 + const lp = grStr.grid.find(value => value.label === '最近播放');
1346 + if (lp) {
1347 + lp.val = $Date(currentLastPlayed);
1348 + if (CalcAgeDateString(lp.val)) {
1349 + lp.val += ` (${CalcAgeDateString(lp.val)})`;
1350 + }
1351 + }
1352 + }
1353 + if (typeof currentPlayingPlaylist !== 'undefined') {
1354 + const pl = grStr.grid.find(value => value.label === '播放列表');
1355 + if (pl) {
1356 + pl.val = currentPlayingPlaylist;
1357 + }
1358 + }
1359 +
1360 + return grStr.grid;
1361 + }
1362 +
1363 + /**
1364 + * Updates the metadata grid codec and channel logo in Details.
1365 + * This method is primarily used to refresh the colors of the logos.
1366 + */
1367 + updateGridLogos() {
1368 + this.clearCache('codecLogo');
1369 + this.clearCache('channelLogo');
1370 + }
1371 +
1372 + /**
1373 + * Updates the metadata grid positions in Details.
1374 + * This method is primarily used to refresh the coordinates for mouseInMetadataGrid.
1375 + */
1376 + updateGridPos() {
1377 + this.gridTop = 0;
1378 + this.gridArtistTop = 0;
1379 + this.gridArtistBottom = 0;
1380 + this.gridTitleTop = 0;
1381 + this.gridTitleBottom = 0;
1382 + this.gridAlbumTop = 0;
1383 + this.gridAlbumBottom = 0;
1384 + }
1385 + // #endregion
1386 +
1387 + // * PUBLIC METHODS - METADATA GRID TIMELINE * //
1388 + // #region PUBLIC METHODS - METADATA GRID TIMELINE
1389 + /**
1390 + * Draws the timeline above the metadata grid in Details.
1391 + * @param {GdiGraphics} gr - The GDI graphics object.
1392 + */
1393 + drawGridTimeline(gr) {
1394 + gr.SetSmoothingMode(SmoothingMode.None); // Disable smoothing
1395 + gr.FillSolidRect(this.gridMarginLeft, this.timelineY, this.timelineDrawWidth + this.timelineExtraLeftSpace + this.timelineLineWidth, this.timelineH, grCol.timelineAdded);
1396 +
1397 + if (grSet.theme.startsWith('custom')) {
1398 + gr.DrawRect(this.timelineX - 2, this.timelineY - 2, this.timelineW + 3, this.timelineH + 3, 1, grCol.timelineFrame);
1399 + }
1400 +
1401 + if (this.timelineFirstPlayedPercent >= 0 && this.timelineLastPlayedPercent >= 0) {
1402 + const x1 = Math.floor(this.timelineDrawWidth * this.timelineFirstPlayedPercent) + this.timelineExtraLeftSpace;
1403 + const x2 = Math.floor(this.timelineDrawWidth * this.timelineLastPlayedPercent) + this.timelineExtraLeftSpace;
1404 + gr.FillSolidRect(x1 + this.gridMarginLeft, this.timelineY, this.timelineDrawWidth - x1 + this.timelineExtraLeftSpace, this.timelineH, grCol.timelinePlayed);
1405 + gr.FillSolidRect(x2 + this.gridMarginLeft, this.timelineY, this.timelineDrawWidth - x2 + this.timelineExtraLeftSpace + this.timelineLineWidth, this.timelineH, grCol.timelineUnplayed);
1406 + }
1407 +
1408 + for (let i = 0; i < this.timelinePlayedTimesPercents.length; i++) {
1409 + const x = Math.floor(this.timelineDrawWidth * this.timelinePlayedTimesPercents[i]) + this.gridMarginLeft + this.timelineExtraLeftSpace;
1410 + if (!Number.isNaN(x) && x <= this.timelineW + this.gridMarginLeft * 2) {
1411 + const linePos = Math.max(this.gridMarginLeft, Math.min(x, x));
1412 + gr.DrawLine(linePos, this.timelineY, linePos, this.timelineY + this.timelineH, this.timelineLineWidth, this.timelinePlayCol);
1413 + } else {
1414 + // console.log('Played Times Error! ratio: ' + this.playedTimesPercents[i], 'x: ' + x);
1415 + }
1416 + }
1417 +
1418 + gr.SetSmoothingMode(SmoothingMode.AntiAlias);
1419 + }
1420 +
1421 + /**
1422 + * Handles the grid timeline tooltip. If a tooltip is ready, it displays and then clears it.
1423 + * @param {number} x - The x-coordinate.
1424 + * @param {number} y - The y-coordinate.
1425 + */
1426 + handleGridTimelineTooltip(x, y) {
1427 + if (!this.mouseInMetadataGrid(x, y, 'timeline') || !grSet.showGridTimeline_layout || this.timelinePlayedTimesPercents.length === 0) {
1428 + return;
1429 + }
1430 +
1431 + let tooltip = '';
1432 + const percent = ToFixed((x + this.timelineX - this.gridMarginLeft * 2 - this.timelineExtraLeftSpace) / this.timelineDrawWidth, 3);
1433 + const timezoneOffset = UpdateTimezoneOffset();
1434 +
1435 + for (let i = 0; i < this.timelinePlayedTimesPercents.length; i++) {
1436 + if (Math.abs(percent - this.timelinePlayedTimesPercents[i]) <= this.timelineLeeway) {
1437 + const date = new Date(this.timelinePlayedTimes[i]);
1438 + tooltip += tooltip.length ? '\n' : '';
1439 + tooltip += date.toLocaleString();
1440 + }
1441 + else if (percent < this.timelinePlayedTimesPercents[i]) {
1442 + if (!tooltip.length) {
1443 + const added = i === 0 ? DateDiff($Date('[%added%]'), this.timelinePlayedTimes[0], timezoneOffset) : DateDiff(new Date(this.timelinePlayedTimes[i - 1]).toISOString(), this.timelinePlayedTimes[i], timezoneOffset);
1444 + tooltip = added ? (i === 0 ? `添加 ${added} 后首次播放` : `已 ${added} 无播放`) : '';
1445 + }
1446 + break;
1447 + }
1448 + }
1449 +
1450 + if (tooltip.length) {
1451 + this.gridTimelineTooltipText = tooltip;
1452 + grm.ttip.showImmediate(tooltip);
1453 + window.RepaintRect(this.timelineX, this.timelineY, this.timelineW, this.timelineH);
1454 + } else {
1455 + this.gridTimelineTooltipText = '';
1456 + grm.ttip.stop();
1457 + window.Repaint();
1458 + }
1459 + }
1460 +
1461 + /**
1462 + * Sets the width and position of the timeline.
1463 + * @param {number} x - The x-coordinate.
1464 + * @param {number} y - The y-coordinate.
1465 + * @param {number} width - The width of the timeline.
1466 + * @param {number} height - The height of the timeline.
1467 + */
1468 + setGridTimelineSize(x, y, width, height) {
1469 + if (this.timelineX === x && this.timelineY === y && this.timelineW === width) {
1470 + return;
1471 + }
1472 +
1473 + this.timelineX = x;
1474 + this.timelineY = y;
1475 + this.timelineW = width;
1476 + this.timelineH = height;
1477 +
1478 + this.timelineLineWidth = HD_4K(2, 3);
1479 + this.timelineExtraLeftSpace = SCALE(3); // Add a little space to the left so songs that were played a long time ago show more in the "added" stage
1480 + this.timelineDrawWidth = Math.floor(this.timelineW - this.timelineExtraLeftSpace - 1 - this.timelineLineWidth / 2);
1481 + this.timelineLeeway = (1 / this.timelineDrawWidth) * (this.timelineLineWidth + SCALE(2)) / 2;
1482 + }
1483 +
1484 + /**
1485 + * Sets the first and last played percentages, as well as the played time ratios and values.
1486 + * @param {number} firstPlayed - The percentage of the total play time that represents the first time the item was played.
1487 + * @param {number} lastPlayed - The percentage of the total play time that represents the last time the item was played.
1488 + * @param {number} playedTimeRatios - The percentage of time played for each playedTimesValues.
1489 + * @param {number} playedTimesValues - Contains the actual played times for each interval.
1490 + * For example, if the intervals are divided into 5 parts, playedTimesValues would be an
1491 + * array of 5 numbers representing the played times for each interval.
1492 + */
1493 + setGridTimelinePlayTimes(firstPlayed, lastPlayed, playedTimeRatios, playedTimesValues) {
1494 + this.timelineFirstPlayedPercent = firstPlayed;
1495 + this.timelineLastPlayedPercent = lastPlayed;
1496 + this.timelinePlayedTimesPercents = playedTimeRatios;
1497 + this.timelinePlayedTimes = playedTimesValues;
1498 + }
1499 +
1500 + /**
1501 + * Sets date ratios based on various time-related properties of a music track.
1502 + * @param {boolean} dontUpdateLastPlayed - Whether the last played date should be updated or not.
1503 + * @param {string} currentLastPlayed - The current value of the last played time.
1504 + * @param {FbMetadbHandle} metadb - The metadb of the track.
1505 + */
1506 + setGridTimelineDateRatios(dontUpdateLastPlayed = false, currentLastPlayed, metadb = undefined) {
1507 + const newDate = new Date();
1508 + const timezoneOffset = UpdateTimezoneOffset();
1509 +
1510 + let ratio;
1511 + let lfmPlayedTimesJsonLast = '';
1512 + let playedTimesJsonLast = '';
1513 + let playedTimesRatios = [];
1514 + let lfmPlayedTimes = [];
1515 + let playedTimes = [];
1516 +
1517 + let added = ToTime($('$if2(%added_enhanced%,%added%)', metadb), timezoneOffset);
1518 + let lastPlayed = ToTime($('$if2(%last_played_enhanced%,%last_played%)', metadb), timezoneOffset);
1519 + const firstPlayed = ToTime($('$if2(%first_played_enhanced%,%first_played%)', metadb), timezoneOffset);
1520 + const today = DateToYMD(newDate);
1521 +
1522 + if (dontUpdateLastPlayed && $Date(lastPlayed) === today) {
1523 + lastPlayed = ToTime(currentLastPlayed, timezoneOffset);
1524 + }
1525 +
1526 + if (Component.EnhancedPlaycount) {
1527 + const playedTimesJson = $('[%played_times_js%]', metadb);
1528 + const lastfmJson = $('[%lastfm_played_times_js%]', metadb);
1529 + const log = ''; // ! Don't need this crap to flood the console // playedTimesJson === playedTimesJsonLast && lastfmJson === lfmPlayedTimesJsonLast ? false : grCfg.settings.showDebugLog;
1530 + lfmPlayedTimesJsonLast = lastfmJson;
1531 + playedTimesJsonLast = playedTimesJson;
1532 + lfmPlayedTimes = ParseJson(lastfmJson, 'lastfm: ', log);
1533 + playedTimes = ParseJson(playedTimesJson, 'foobar: ', log);
1534 + }
1535 + else {
1536 + playedTimes.push(firstPlayed);
1537 + playedTimes.push(lastPlayed);
1538 + }
1539 +
1540 + if (firstPlayed) {
1541 + if (!added) {
1542 + added = firstPlayed;
1543 + }
1544 + const age = CalcAge(added);
1545 +
1546 + this.timelineFirstPlayedRatio = CalcAgeRatio(firstPlayed, age);
1547 + this.timelineLastPlayedRatio = CalcAgeRatio(lastPlayed, age);
1548 + if (this.timelineLastPlayedRatio < this.timelineFirstPlayedRatio) {
1549 + // Due to daylight savings time, if there's a single play before the time changed lastPlayed could be < firstPlayed
1550 + this.timelineLastPlayedRatio = this.timelineFirstPlayedRatio;
1551 + }
1552 +
1553 + if (playedTimes.length) {
1554 + for (let i = 0; i < playedTimes.length; i++) {
1555 + ratio = CalcAgeRatio(playedTimes[i], age);
1556 + playedTimesRatios.push(ratio);
1557 + }
1558 + } else {
1559 + playedTimesRatios = [this.timelineFirstPlayedRatio, this.timelineLastPlayedRatio];
1560 + playedTimes = [firstPlayed, lastPlayed];
1561 + }
1562 +
1563 + let j = 0;
1564 + const tempPlayedTimesRatios = playedTimesRatios.slice();
1565 + tempPlayedTimesRatios.push(1.0001); // Pick up every last.fm time after lastPlayed fb knows about
1566 + for (let i = 0; i < tempPlayedTimesRatios.length; i++) {
1567 + while (j < lfmPlayedTimes.length && (ratio = CalcAgeRatio(lfmPlayedTimes[j], age)) < tempPlayedTimesRatios[i]) {
1568 + playedTimesRatios.push(ratio);
1569 + playedTimes.push(lfmPlayedTimes[j]);
1570 + j++;
1571 + }
1572 + if (ratio === tempPlayedTimesRatios[i]) { // Skip one instance
1573 + // console.log('skipped -->', ratio);
1574 + j++;
1575 + }
1576 + }
1577 + playedTimesRatios.sort((a, b) => a - b);
1578 + playedTimes.sort((a, b) => a - b);
1579 +
1580 + this.timelineFirstPlayedRatio = playedTimesRatios[0];
1581 + this.timelineLastPlayedRatio = playedTimesRatios[Math.max(0, playedTimesRatios.length - (dontUpdateLastPlayed ? 2 : 1))];
1582 + }
1583 + else {
1584 + this.timelineFirstPlayedRatio = 0.33;
1585 + this.timelineLastPlayedRatio = 0.66;
1586 + }
1587 + this.setGridTimelinePlayTimes(this.timelineFirstPlayedRatio, this.timelineLastPlayedRatio, playedTimesRatios, playedTimes);
1588 + }
1589 +
1590 + /**
1591 + * Updates the timeline by setting the sizes, colors, and last played dates.
1592 + * @param {boolean} updateLastPlayed - Whether to update the last played date.
1593 + * @param {FbMetadbHandle} metadb - The metadb of the track.
1594 + */
1595 + updateGridTimeline(updateLastPlayed, metadb) {
1596 + this.setGridTimelineSize(this.gridMarginLeft, this.gridTop + Math.floor(this.gridLineSpacing * 0.33), grm.ui.albumArtSize.x - this.gridMarginLeft * 2, this.timelineH);
1597 +
1598 + if (!updateLastPlayed) return;
1599 +
1600 + const lastPlayed = $(grTF.last_played, metadb);
1601 + this.setGridTimelineDateRatios($Date(grm.ui.currentLastPlayed) !== $Date(lastPlayed), grm.ui.currentLastPlayed, metadb);
1602 +
1603 + if (lastPlayed.length) {
1604 + const today = DateToYMD(new Date());
1605 + if (!grm.ui.currentLastPlayed.length || $Date(lastPlayed) !== today) {
1606 + grm.ui.currentLastPlayed = lastPlayed;
1607 + }
1608 + }
1609 + }
1610 + // #endregion
1611 +
1612 + // * PUBLIC METHODS - DISC ART * //
1613 + // #region PUBLIC METHODS - DISC ART
1614 + /**
1615 + * Creates and masks an image to the disc art.
1616 + * @param {GdiGraphics} gr - The GDI graphics object.
1617 + * @param {number} x - The X-coordinate of the disc area.
1618 + * @param {number} y - The Y-coordinate of the disc area.
1619 + * @param {number} w - The width of the mask.
1620 + * @param {number} h - The height of the mask.
1621 + * @param {number} srcX - The X-coordinate of the source image.
1622 + * @param {number} srcY - The Y-coordinate of the source image.
1623 + * @param {number} srcW - The width of the source image.
1624 + * @param {number} srcH - The height of the source image.
1625 + * @param {number} [angle] - The angle of the mask in degrees. Default 0.
1626 + * @param {number} [alpha] - The alpha of the mask. Values 0-255.
1627 + * @returns {GdiGraphics} The rounded masked image.
1628 + */
1629 + createDiscArtAlbumArtMask(gr, x, y, w, h, srcX, srcY, srcW, srcH, angle, alpha) {
1630 + if (w < 1 || h < 1) return null;
1631 +
1632 + // * First draw album art in the background
1633 + gr.DrawImage(grm.ui.albumArtScaled, x, y, w, h, 0, 0, w, h, 0, alpha);
1634 +
1635 + // * Mask
1636 + const maskImg = gdi.CreateImage(w, h);
1637 + let g = maskImg.GetGraphics();
1638 + g.FillEllipse(this.discArtSize.x - grm.ui.albumArtSize.x + this.discArtShadow - SCALE(4), this.discArtSize.y - grm.ui.albumArtSize.y + SCALE(2),
1639 + this.discArtSize.w - this.discArtShadow + SCALE(4), this.discArtSize.h - this.discArtShadow + SCALE(2), 0xffffffff);
1640 + maskImg.ReleaseGraphics(g);
1641 +
1642 + // * Album art
1643 + const albumArtImg = gdi.CreateImage(w, h);
1644 + g = albumArtImg.GetGraphics();
1645 + g.DrawImage(grm.ui.albumArtScaled, 0, 0, w, h, 0, 0, grm.ui.albumArtScaled.Width, grm.ui.albumArtScaled.Height);
1646 + albumArtImg.ReleaseGraphics(g);
1647 +
1648 + const mask = maskImg.Resize(w, h);
1649 + albumArtImg.ApplyMask(mask);
1650 +
1651 + return gr.DrawImage(albumArtImg, x, y, w, h, 0, 0, w, h, 0, 255);
1652 + }
1653 +
1654 + /**
1655 + * Creates the album cover mask for the disc art stub.
1656 + * @param {GdiBitmap} img - The image to apply the mask to.
1657 + * @param {number} w - The width of the mask.
1658 + * @param {number} h - The height of the mask.
1659 + */
1660 + createDiscArtCoverMask(img, w, h) {
1661 + const { w: discArtW, h: discArtH } = this.discArtSize;
1662 + const lineW = SCALE(25);
1663 +
1664 + const outerRingX = lineW * 0.5;
1665 + const outerRingY = lineW * 0.5;
1666 + const outerRingW = discArtW - lineW;
1667 + const outerRingH = discArtH - lineW;
1668 +
1669 + const innerRingSize = discArtH * 0.666 + lineW * 0.5;
1670 + const innerCenterX = discArtW * 0.5;
1671 + const innerCenterY = discArtH * 0.5;
1672 + const innerRadiusX = discArtW * 0.5 - innerRingSize * 0.5;
1673 + const innerRadiusY = discArtH * 0.5 - innerRingSize * 0.5;
1674 +
1675 + const innerRingX = innerCenterX - innerRadiusX;
1676 + const innerRingY = innerCenterY - innerRadiusY;
1677 + const innerRingW = innerRadiusX * 2;
1678 + const innerRingH = innerRadiusY * 2;
1679 +
1680 + const mask = GDI(discArtW, discArtH, true, g => {
1681 + g.SetSmoothingMode(SmoothingMode.AntiAlias);
1682 + g.FillSolidRect(0, 0, discArtW, discArtH, RGB(255, 255, 255));
1683 + g.FillEllipse(outerRingX, outerRingY, outerRingW, outerRingH, RGB(0, 0, 0)); // Outer ring
1684 + g.FillEllipse(innerRingX, innerRingY, innerRingW, innerRingH, RGB(255, 255, 255)); // Inner ring
1685 + });
1686 +
1687 + img.ApplyMask(mask.Resize(w, h));
1688 + }
1689 +
1690 + /**
1691 + * Combines disc art with album cover art if conditions are met.
1692 + * @param {boolean} applyMask - Whether to apply the disc art cover mask or not.
1693 + * @returns {GdiBitmap} The combined image.
1694 + */
1695 + combineDiscArtWithCover(applyMask) {
1696 + if (['cdAlbumCover', 'vinylAlbumCover'].includes(grSet.discArtStub) &&
1697 + (!this.discArtFound && (!grSet.noDiscArtStub || grSet.showDiscArtStub)) &&
1698 + this.discArtCover && this.discArtCover.Width > 0 && this.discArtCover.Height > 0) {
1699 + if (applyMask) {
1700 + this.createDiscArtCoverMask(this.discArtCover, this.discArtCover.Width, this.discArtCover.Height);
1701 + }
1702 + return CombineImages(this.discArt, this.discArtCover, this.discArtSize.w, this.discArtSize.h);
1703 + }
1704 + return this.discArt;
1705 + }
1706 +
1707 + /**
1708 + * Disposes the disc art image when changing or deactivating disc art.
1709 + * @param {GdiBitmap} discArtImg - The loaded disc art image.
1710 + */
1711 + disposeDiscArt(discArtImg) {
1712 + this.discArtSize = new ImageSize(0, 0, 0, 0);
1713 + discArtImg = null;
1714 + }
1715 +
1716 + /**
1717 + * Fetches new disc art when a new album is being played.
1718 + */
1719 + fetchDiscArt() {
1720 + if (!grm.ui.displayDetails) {
1721 + this.clearCache('discArt');
1722 + return;
1723 + }
1724 +
1725 + grm.debug.setDebugProfile(grm.debug.showDebugTiming || grCfg.settings.showDebugPerformanceOverlay, 'create', '获取碟片');
1726 +
1727 + if (grSet.displayDiscArt && !grm.ui.isStreaming) {
1728 + this.loadDiscArt(this.findDiscArtPath());
1729 + }
1730 +
1731 + grm.debug.setDebugProfile(false, 'print', '获取碟片');
1732 + }
1733 +
1734 + /**
1735 + * Finds the path to the disc art or disc art stub.
1736 + * @returns {string} The path to the disc art or disc art stub.
1737 + */
1738 + findDiscArtPath() {
1739 + const discArtImagePaths = grPath.discArtImagePaths();
1740 + const discArtStubPaths = grPath.discArtStubPaths();
1741 +
1742 + if (grSet.noDiscArtStub || grSet.showDiscArtStub) {
1743 + for (const path of discArtImagePaths) {
1744 + if (IsFile(path)) {
1745 + this.discArtFound = true;
1746 + return path;
1747 + }
1748 + }
1749 + }
1750 +
1751 + this.discArtFound = false;
1752 +
1753 + return grSet.noDiscArtStub ? '' : discArtStubPaths[grSet.discArtStub] || grPath.discArtCustomStub;
1754 + }
1755 +
1756 + /**
1757 + * Initializes the disc art when the Details panel is opened or closed.
1758 + */
1759 + initDiscArt() {
1760 + if (!grm.ui.displayDetails) {
1761 + this.clearCache('discArt');
1762 + this.clearTimer('discArt');
1763 + return;
1764 + }
1765 +
1766 + if (!this.discArtCover && grm.ui.albumArtList.length) {
1767 + const artIndex = grm.ui.albumArtList[grm.ui.albumArtIndex];
1768 + if (artIndex && grm.artCache) {
1769 + this.discArtCover = grm.artCache.getImage(artIndex, 2) ||
1770 + (grm.ui.albumArt && grm.artCache.encache(grm.ui.albumArt, artIndex, 2));
1771 + }
1772 + }
1773 +
1774 + if (grSet.displayDiscArt && !grm.ui.isStreaming) {
1775 + if (this.discArt) {
1776 + this.updateDiscArt();
1777 + } else {
1778 + this.fetchDiscArt();
1779 + }
1780 + }
1781 + }
1782 +
1783 + initDiscArtStub() {
1784 + if (!grSet.displayDiscArt || grSet.noDiscArtStub) return;
1785 +
1786 + const stubPath = grPath.discArtStubPaths()[grSet.discArtStub] || grPath.discArtCustomStub;
1787 + if (!stubPath || grm.artCache.getImage(stubPath)) return; // already cached
1788 +
1789 + gdi.LoadImageAsyncV2(window.ID, stubPath).then(img => {
1790 + if (img) grm.artCache.encache(img, stubPath);
1791 + });
1792 + }
1793 +
1794 + /**
1795 + * Loads the disc art from the given path.
1796 + * @param {string} discArtPath - The path to the disc art.
1797 + */
1798 + loadDiscArt(discArtPath) {
1799 + const tempDiscArt = grm.ui.albumArtFromCache ? grm.artCache.getImage(discArtPath) : null;
1800 +
1801 + if (tempDiscArt) {
1802 + this.disposeDiscArt(this.discArt);
1803 + this.discArt = tempDiscArt;
1804 + if (grm.ui.displayDetails) this.updateDiscArt();
1805 + return;
1806 + }
1807 +
1808 + gdi.LoadImageAsyncV2(window.ID, discArtPath).then(discArtImg => {
1809 + this.disposeDiscArt(this.discArt); // Delay disposal so we don't get flashing
1810 + this.discArt = grm.artCache.encache(discArtImg, discArtPath);
1811 +
1812 + if (!this.discArt && !grSet.noDiscArtStub) {
1813 + grm.ui.handleArtworkError('discArt');
1814 + } else {
1815 + this.updateDiscArt();
1816 + }
1817 +
1818 + this.clearCache('metrics', 'cachedLabelLastLeftEdge'); // Recalc label location
1819 + grm.debug.repaintWindow();
1820 + });
1821 + }
1822 +
1823 + /**
1824 + * Resizes and resets the size and position of the disc art.
1825 + * @param {boolean} resetDiscArtPosition - Whether the position of the disc art should be reset.
1826 + */
1827 + resizeDiscArt(resetDiscArtPosition) {
1828 + if (!this.discArt) {
1829 + this.discArtSize = new ImageSize(0, 0, 0, 0);
1830 + return;
1831 + }
1832 +
1833 + this.setDiscArtScaleFactor();
1834 + this.setDiscArtSize(resetDiscArtPosition);
1835 + this.setDiscArtPosition(resetDiscArtPosition);
1836 + this.setDiscArtShadow();
1837 + }
1838 +
1839 + /**
1840 + * Repaints the disc art area to only cover the necessary region based on album art opacity settings and disc art layering.
1841 + */
1842 + repaintDiscArt() {
1843 + const discArtLeftEdge = (
1844 + grSet.detailsAlbumArtOpacity !== 255 || grSet.detailsAlbumArtDiscAreaOpacity !== 255 || grSet.discArtOnTop
1845 + ) ? this.discArtSize.x : grm.ui.albumArtSize.x + grm.ui.albumArtSize.w - 1;
1846 +
1847 + window.RepaintRect(
1848 + discArtLeftEdge, this.discArtSize.y,
1849 + this.discArtSize.w - (discArtLeftEdge - this.discArtSize.x), this.discArtSize.h,
1850 + !grSet.discArtOnTop && !grm.ui.displayLyrics
1851 + );
1852 + }
1853 +
1854 + /**
1855 + * Repaints the metadata grid area to only cover the necessary region.
1856 + */
1857 + repaintMetadataGrid() {
1858 + if (!grm.ui.displayDetails) return;
1859 + window.RepaintRect(0, grm.ui.topMenuHeight, Math.max(grm.ui.albumArtSize.x, SCALE(40)), grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight);
1860 + }
1861 +
1862 + /**
1863 + * Set the scale factor for the disc art based on the window size and layout.
1864 + */
1865 + setDiscArtScaleFactor() {
1866 + const discArtMaxHeight = grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight;
1867 + const scaleFactor = grm.ui.displayPlaylist || grm.ui.displayLibrary ? 0.5 : 0.75;
1868 + const discScale = Math.min(grm.ui.ww * scaleFactor / this.discArt.Width, (discArtMaxHeight - SCALE(16)) / this.discArt.Height);
1869 + this.discArtScaleFactor = discScale;
1870 + }
1871 +
1872 + /**
1873 + * Set the size of the disc art based on its scale, window state, and layout settings.
1874 + * @param {boolean} resetDiscArtPosition - Whether the position of the disc art should be reset.
1875 + */
1876 + setDiscArtSize(resetDiscArtPosition) {
1877 + const discArtSizeCorr = SCALE(4);
1878 +
1879 + const discArtSize =
1880 + grm.ui.hasArtwork ? grm.ui.albumArtSize.h - discArtSizeCorr :
1881 + Math.floor(this.discArt.Width * this.discArtScaleFactor) - discArtSizeCorr;
1882 +
1883 + if (resetDiscArtPosition) {
1884 + this.discArtSize = { w: discArtSize, h: discArtSize };
1885 + } else {
1886 + this.discArtSize.w = Math.max(this.discArtSize.w, discArtSize);
1887 + this.discArtSize.h = this.discArtSize.w;
1888 + }
1889 + }
1890 +
1891 + /**
1892 + * Set the position of the disc art based on the window size and layout settings.
1893 + * @param {boolean} resetDiscArtPosition - Whether the position of the disc art should be reset.
1894 + */
1895 + setDiscArtPosition(resetDiscArtPosition) {
1896 + const discArtSizeCorr = SCALE(4);
1897 + const discArtMargin = SCALE(2);
1898 + const discArtMarginRight = SCALE(36);
1899 + const discArtMaxHeight = grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight;
1900 +
1901 + if (grm.ui.hasArtwork) {
1902 + const baseX = grm.ui.ww - grm.ui.albumArtSize.h - discArtMarginRight;
1903 +
1904 + const adjustedX = grm.ui.albumArtSize.x + grm.ui.albumArtSize.w -
1905 + (grm.ui.albumArtSize.h - discArtSizeCorr) * (1 - grSet.discArtDisplayAmount) -
1906 + (grSet.discArtDisplayAmount === 1 || grSet.discArtDisplayAmount === 0.5 ? 0 : discArtMarginRight);
1907 +
1908 + const discArtX = Math.floor(
1909 + grSet.discArtDisplayAmount === 1 ? baseX :
1910 + grSet.discArtDisplayAmount === 0.5 ? Math.min(baseX, adjustedX) :
1911 + adjustedX
1912 + );
1913 +
1914 + this.discArtSize.x = resetDiscArtPosition ? discArtX : Math.max(this.discArtSize.x, discArtX);
1915 + this.discArtSize.y = resetDiscArtPosition ? (grm.ui.albumArtSize.y + discArtMargin) :
1916 + Math.min(this.discArtSize.y > 0 ? this.discArtSize.y :
1917 + (grm.ui.albumArtSize.y + discArtMargin), grm.ui.albumArtSize.y + discArtMargin);
1918 +
1919 + if (this.discArtSize.x + this.discArtSize.w > grm.ui.ww) {
1920 + this.discArtSize.x = grm.ui.ww - this.discArtSize.w - discArtMarginRight;
1921 + }
1922 +
1923 + return;
1924 + }
1925 +
1926 + // * Set no disc art x-coordinate
1927 + const discArtOffCenter = this.discArtScaleFactor === (grm.ui.ww * 0.75 / this.discArt.Width);
1928 +
1929 + const discArtCenterX =
1930 + discArtOffCenter ? Math.round(grm.ui.ww * 0.66 - grm.ui.edgeMargin) :
1931 + (grm.ui.displayPlaylist || grm.ui.displayLibrary) ? grm.ui.ww * 0.25 :
1932 + grm.ui.ww * 0.5;
1933 +
1934 + this.discArtSize.x = Math.floor(discArtCenterX - this.discArtSize.w * 0.5);
1935 +
1936 + // * Set no disc art y-coordinate
1937 + const restrictedWidth = this.discArtScaleFactor !== (discArtMaxHeight - SCALE(16)) / this.discArt.Height;
1938 + const discArtCenterY = grm.ui.topMenuHeight + Math.floor(((discArtMaxHeight - SCALE(16)) / 2) - this.discArtSize.h / 2);
1939 + this.discArtSize.y = restrictedWidth ? Math.min(discArtCenterY, 160) : grm.ui.topMenuHeight + discArtMargin;
1940 +
1941 + grm.ui.hasArtwork = true;
1942 + }
1943 +
1944 + /**
1945 + * Sets up async precomputation of disc art frames, prioritized from current index.
1946 + * @param {GdiBitmap} combinedImg - The base image to rotate.
1947 + * @param {string} currentAlbumId - Unique ID to detect album changes.
1948 + * @param {number} rotationDegreeIncrement - Degrees per frame.
1949 + */
1950 + setDiscArtPrecomputation(combinedImg, currentAlbumId, rotationDegreeIncrement) {
1951 + let batchCount = 0;
1952 + let frameTimeAvg = 0;
1953 + let precomputeIndex = (this.discArtRotationIndex + 1) % grSet.spinDiscArtImageCount;
1954 + let precomputeTimer = null;
1955 + let performanceTierCurrent = 'medium';
1956 +
1957 + const performanceTiers = {
1958 + low: { batchSize: 1, batchDelay: 75 }, // 50-100ms
1959 + medium: { batchSize: 2, batchDelay: 25 }, // 20-40ms
1960 + high: { batchSize: 4, batchDelay: 10 } // 10-20ms
1961 + };
1962 +
1963 + const updatePerformanceTier = (frameTime) => {
1964 + frameTimeAvg = (frameTimeAvg + frameTime) / 2;
1965 + const performanceTierNew = frameTimeAvg > 50 ? 'low' : frameTimeAvg < 10 ? 'high' : 'medium';
1966 + if (performanceTierNew === performanceTierCurrent) return;
1967 + performanceTierCurrent = performanceTierNew;
1968 + const tier = performanceTiers[performanceTierCurrent];
1969 + grm.debug.debugLog(`碟片 => 适应 ${performanceTierCurrent} 性能: 批量大小=${tier.batchSize}, 批量延迟=${tier.batchDelay} 毫秒 (avgFrameTime=${Math.round(frameTimeAvg)} 毫秒)`);
1970 + };
1971 +
1972 + const precomputeBatch = () => {
1973 + if (this.discArt.Path !== currentAlbumId) return;
1974 +
1975 + const tier = performanceTiers[performanceTierCurrent];
1976 + let computedInBatch = 0;
1977 +
1978 + while (computedInBatch < tier.batchSize && !this.discArtArray[precomputeIndex]) {
1979 + const rotationDegrees = rotationDegreeIncrement * precomputeIndex;
1980 + const frameStart = Date.now();
1981 + this.discArtArray[precomputeIndex] = RotateImage(combinedImg, this.discArtSize.w, this.discArtSize.h, rotationDegrees, grm.artCache.discArtImgMaxRes);
1982 + const frameTime = Date.now() - frameStart;
1983 + updatePerformanceTier(frameTime); // Update per-frame for quicker response
1984 + grm.debug.debugLog(`碟片 => 预计算碟片图像: ${precomputeIndex} (${this.discArtSize.w}x${this.discArtSize.h}) 旋转: ${rotationDegrees} 度`);
1985 + computedInBatch++;
1986 + precomputeIndex = (precomputeIndex + 1) % grSet.spinDiscArtImageCount;
1987 + }
1988 +
1989 + batchCount++;
1990 +
1991 + if (this.discArtArray.every(frame => !!frame)) {
1992 + grm.debug.debugLog('碟片 => 所有帧均已预先计算');
1993 + return;
1994 + }
1995 +
1996 + if (computedInBatch > 0) {
1997 + precomputeTimer = setTimeout(precomputeBatch, tier.batchDelay);
1998 + }
1999 + };
2000 +
2001 + // Start immediately but async
2002 + setTimeout(precomputeBatch, 0);
2003 +
2004 + // Cleanup
2005 + this.clearTimer = (type) => {
2006 + if (type === 'discArt' && precomputeTimer) {
2007 + clearTimeout(precomputeTimer);
2008 + grm.debug.debugLog('碟片 => 清除预计算计时器');
2009 + }
2010 + Details.prototype.clearTimer.call(this, type);
2011 + delete this.clearTimer; // Restore to prototype chain
2012 + };
2013 + }
2014 +
2015 + /**
2016 + * Sets and creates the disc art rotation animation with RotateImg().
2017 + * @returns {GdiBitmap} The rotated disc art image.
2018 + */
2019 + setDiscArtRotation() {
2020 + if (!grSet.displayDiscArt || grm.ui.albumArtCorrupt || !grm.ui.albumArt || !this.discArt || this.discArtSize.w < 1) {
2021 + return null;
2022 + }
2023 +
2024 + // Drawing discArt rotated is slow, so first draw it rotated into the discArtRotation image, and then draw discArtRotation image unrotated in on_paint.
2025 + const vinylAdjustedTrackNumFormat = `$num($if(${grTF.vinyl_tracknum},$sub($mul(${grTF.vinyl_tracknum},2),1),$if2(%tracknumber%,1)),1)`;
2026 + let tracknum = parseInt($(vinylAdjustedTrackNumFormat)) - 1;
2027 + if (!grSet.rotateDiscArt || Number.isNaN(tracknum)) tracknum = 0;
2028 +
2029 + const tracknumRotation = tracknum * grSet.rotationAmt % 360;
2030 + const combinedImg = this.combineDiscArtWithCover(true);
2031 +
2032 + this.discArtRotation = RotateImage(combinedImg, this.discArtSize.w, this.discArtSize.h, tracknumRotation, grm.artCache.discArtImgMaxRes);
2033 + this.discArtRotationIndex = Math.round(tracknumRotation / (360 / grSet.spinDiscArtImageCount)) % grSet.spinDiscArtImageCount;
2034 +
2035 + return this.discArtRotation;
2036 + }
2037 +
2038 + /**
2039 + * Sets the disc art timer with different set interval values for rotating the disc art.
2040 + */
2041 + setDiscArtRotationTimer() {
2042 + this.clearTimer('discArt');
2043 +
2044 + if (grSet.layout !== 'default' || !grm.ui.displayDetails ||
2045 + grm.ui.albumArtCorrupt || !grm.ui.albumArt || !this.discArt || !this.discArtSize.w ||
2046 + !fb.IsPlaying || fb.IsPaused || !grSet.displayDiscArt || !grSet.spinDiscArt) {
2047 + return;
2048 + }
2049 +
2050 + grm.debug.debugLog(`碟片 => 使用异步预计算启动延迟旋转: ${grSet.spinDiscArtImageCount} 帧, 每 ${grSet.spinDiscArtRedrawInterval} 毫秒 1 次`);
2051 +
2052 + const rotationDegreeIncrement = 360 / grSet.spinDiscArtImageCount;
2053 + const combinedImg = this.combineDiscArtWithCover(false);
2054 + const currentAlbumId = this.discArt.Path;
2055 +
2056 + // Main animation timer
2057 + this.discArtRotationTimer = setInterval(() => {
2058 + const intendedIndex = (this.discArtRotationIndex + 1) % grSet.spinDiscArtImageCount;
2059 +
2060 + let displayIndex = intendedIndex;
2061 + if (!this.discArtArray[intendedIndex]) {
2062 + // Nearest available: prioritize smallest angular distance, alternating fwd/bwd
2063 + const count = grSet.spinDiscArtImageCount;
2064 + let nearestFound = false;
2065 + for (let dist = 0; dist < count; dist++) {
2066 + const fwd = (intendedIndex + dist) % count;
2067 + if (this.discArtArray[fwd]) {
2068 + displayIndex = fwd;
2069 + nearestFound = true;
2070 + break;
2071 + }
2072 +
2073 + const bwd = (intendedIndex - dist + count) % count;
2074 + if (this.discArtArray[bwd]) {
2075 + displayIndex = bwd;
2076 + nearestFound = true;
2077 + break;
2078 + }
2079 + }
2080 + if (!nearestFound) displayIndex = 0; // Ultimate fallback
2081 +
2082 + grm.debug.debugLog(`碟片 => 帧 ${intendedIndex} 未就绪,显示最接近的 ${displayIndex}`);
2083 +
2084 + // Emergency compute intended (sync for immediacy, but only one frame)
2085 + const rotationDegrees = rotationDegreeIncrement * intendedIndex;
2086 + this.discArtArray[intendedIndex] = RotateImage(combinedImg, this.discArtSize.w, this.discArtSize.h, rotationDegrees, grm.artCache.discArtImgMaxRes);
2087 + grm.debug.debugLog(`碟片 => 紧急计算碟片: ${intendedIndex} (${this.discArtSize.w}x${this.discArtSize.h}) 旋转: ${rotationDegrees} 度`);
2088 + }
2089 +
2090 + this.discArtRotationIndex = intendedIndex; // Advance intended for next tick
2091 + this.repaintDiscArt(); // Repaint with displayIndex (but since we just computed if missing, often same)
2092 + }, grSet.spinDiscArtRedrawInterval);
2093 +
2094 + // Start precomputation
2095 + this.setDiscArtPrecomputation(combinedImg, currentAlbumId, rotationDegreeIncrement);
2096 + }
2097 +
2098 + /**
2099 + * Sets the drop shadow for disc art.
2100 + */
2101 + setDiscArtShadow() {
2102 + const isDisabled = !grm.ui.displayDetails || !grSet.displayDiscArt || grSet.layout === 'compact';
2103 + const isMissing = !this.discArt || !grm.ui.hasArtwork && !grm.ui.noAlbumArtStub;
2104 + const isCached = this.discArtShadowImg && this.discArtShadowImg.image && this.discArtShadowImg.size === this.discArtSize.h;
2105 +
2106 + if (isDisabled || isMissing || isCached) return;
2107 +
2108 + grm.debug.setDebugProfile(grm.debug.showDebugTiming || grCfg.settings.showDebugPerformanceOverlay, 'create', '创建碟片阴影');
2109 +
2110 + const discArtMargin = SCALE(2);
2111 +
2112 + if (grm.ui.albumArtSize.w > 0 || this.discArtSize.w > 0) {
2113 + const width = this.discArt
2114 + ? this.discArtSize.x + this.discArtSize.w + 2 * this.discArtShadow
2115 + : grm.ui.albumArtSize.x + grm.ui.albumArtSize.w + 2 * this.discArtShadow;
2116 +
2117 + const height = this.discArt
2118 + ? this.discArtSize.h + discArtMargin + 2 * this.discArtShadow
2119 + : grm.ui.albumArtSize.h + 2 * this.discArtShadow;
2120 +
2121 + const newShadowImg = gdi.CreateImage(width, height);
2122 +
2123 + if (grSet.layout === 'default' && newShadowImg) {
2124 + const shimg = newShadowImg.GetGraphics();
2125 +
2126 + if (this.discArt) {
2127 + const offset = this.discArtSize.w * 0.40; // Don't change this value
2128 + const xVal = this.discArtSize.x;
2129 + const shadowOffset = this.discArtShadow * 2;
2130 +
2131 + shimg.DrawEllipse(xVal + shadowOffset, shadowOffset + discArtMargin, this.discArtSize.w - shadowOffset, this.discArtSize.w - shadowOffset, shadowOffset, grCol.discArtShadow); // outer shadow
2132 + shimg.DrawEllipse(xVal + this.discArtShadow + offset, offset + this.discArtShadow + discArtMargin, this.discArtSize.w - offset * 2, this.discArtSize.h - offset * 2, 60, grCol.discArtShadow); // inner shadow
2133 + }
2134 +
2135 + newShadowImg.ReleaseGraphics(shimg);
2136 + newShadowImg.StackBlur(this.discArtShadow);
2137 + }
2138 +
2139 + this.discArtShadowImg.image = newShadowImg;
2140 + this.discArtShadowImg.size = this.discArtSize.h;
2141 + }
2142 +
2143 + grm.debug.setDebugProfile(false, 'print', '创建碟片阴影');
2144 + }
2145 +
2146 + /**
2147 + * Updates the disc art by resizing artwork, creating rotation, and setting the rotation timer.
2148 + */
2149 + updateDiscArt() {
2150 + grm.ui.resizeArtwork(true);
2151 + this.setDiscArtRotation();
2152 +
2153 + if (!grSet.spinDiscArt) return;
2154 +
2155 + this.discArtArray = []; // Clear last image
2156 + this.setDiscArtRotationTimer();
2157 + }
2158 + // #endregion
2159 +
2160 + // * PUBLIC METHODS - BAND & LABEL LOGO * //
2161 + // #region PUBLIC METHODS - BAND & LABEL LOGO
2162 + /**
2163 + * Gets the band logo path if it exists at various paths.
2164 + * @param {string} bandStr - The name of the band.
2165 + * @returns {string} The path of the band logo if it exists.
2166 + */
2167 + getBandLogoPath(bandStr) {
2168 + if (!bandStr) return '';
2169 +
2170 + const testBandLogoPath = (imgDir, name) => {
2171 + const logoPath = `${imgDir}${name}.png`;
2172 + if (IsFile(logoPath)) {
2173 + grm.debug.debugLog(`图标 => 找到艺术家标识: ${logoPath}`);
2174 + return logoPath;
2175 + }
2176 + return '';
2177 + };
2178 +
2179 + const bandLogoPath =
2180 + testBandLogoPath(grPath.artistlogos, bandStr) || // Try 800x310 white
2181 + testBandLogoPath(grPath.artistlogosColor, bandStr); // Try 800x310 color
2182 +
2183 + return bandLogoPath || '';
2184 + }
2185 +
2186 + /**
2187 + * Gets the band logo and its inverted version based on the current playing album artist in Details.
2188 + * @param {FbMetadbHandle} metadb - The metadb of the track.
2189 + */
2190 + getBandLogo(metadb = undefined) {
2191 + this.clearCache('bandLogo');
2192 + let path;
2193 +
2194 + const artists = GetMetaValues('%artist%', metadb);
2195 + const trackArtist = ReplaceIllegalChars($('[%track artist%]', metadb));
2196 + const albumArtists = GetMetaValues('%album artist%', metadb);
2197 +
2198 + const artistList = [
2199 + ...artists.flatMap(artist => [
2200 + ReplaceIllegalChars(artist), ReplaceIllegalChars(artist).replace(Regex.TextPrefixThe, '')
2201 + ]),
2202 + trackArtist,
2203 + ...albumArtists.flatMap(artist => [
2204 + ReplaceIllegalChars(artist), ReplaceIllegalChars(artist).replace(Regex.TextPrefixThe, '')
2205 + ])
2206 + ];
2207 + const uniqueArtistList = [...new Set(artistList)];
2208 +
2209 + for (const artist of uniqueArtistList) {
2210 + path = this.getBandLogoPath(artist);
2211 + if (path) break;
2212 + }
2213 +
2214 + if (!path) return;
2215 +
2216 + this.bandLogo = grm.artCache.getImage(path);
2217 + if (!this.bandLogo) {
2218 + const logo = gdi.Image(path);
2219 + if (logo) {
2220 + this.bandLogo = grm.artCache.encache(logo, path);
2221 + this.bandLogoInverted = grm.artCache.encache(logo.InvertColours(), `${path}-inv`);
2222 + }
2223 + }
2224 +
2225 + this.bandLogoInverted = grm.artCache.getImage(`${path}-inv`);
2226 + if (!this.bandLogoInverted && this.bandLogo) {
2227 + this.bandLogoInverted = grm.artCache.encache(this.bandLogo.InvertColours(), `${path}-inv`);
2228 + }
2229 + }
2230 +
2231 + /**
2232 + * Gets label logos based on current playing album artist in Details.
2233 + * @param {FbMetadbHandle} metadb - The metadb of the track.
2234 + */
2235 + getLabelLogo(metadb) {
2236 + this.clearCache('labelLogo');
2237 + const labelFields = ['label', 'publisher', 'discogs_label'];
2238 + const labels = new Set(labelFields.flatMap(label => GetMetaValues(label, metadb)));
2239 +
2240 + for (const label of labels) {
2241 + const addLabel = this.loadLabelLogo(label);
2242 + if (addLabel != null) {
2243 + this.labelLogo.push(addLabel);
2244 + try {
2245 + this.labelLogoInverted.push(addLabel.InvertColours());
2246 + } catch (e) {}
2247 + }
2248 + }
2249 + }
2250 +
2251 + /**
2252 + * Loads the label logo image for a given record label in Details.
2253 + * @param {string} publisherString - The name of a record label or publisher.
2254 + * @returns {GdiBitmap|null} The record label logo as a gdi image object or null if not found.
2255 + */
2256 + loadLabelLogo(publisherString) {
2257 + const date = new Date();
2258 + const lastSearchYear = date.getFullYear();
2259 + let dir = grPath.labelsBase;
2260 + let labelStr = ReplaceIllegalChars(publisherString);
2261 + let recordLabel = null;
2262 +
2263 + if (!labelStr) return recordLabel;
2264 +
2265 + // * Clean up the label string
2266 + const cleanLabelString = (str) => str
2267 + .replace(Regex.ArtImageLabelSuffix, '')
2268 + .replace(Regex.EdgeDotSpaceTrailing, '')
2269 + .replace(Regex.TextDash, '-');
2270 +
2271 + // * Check for label folders by year
2272 + const checkLabelFolders = (label) => {
2273 + const startYear = parseInt($('$year(%date%)'));
2274 + const baseDir = `${dir}${label}\\`;
2275 +
2276 + for (let year = startYear; year <= lastSearchYear; year++) {
2277 + const yearFolder = `${baseDir}${year}`;
2278 + if (IsFolder(yearFolder)) {
2279 + grm.debug.debugLog(`图标 => 找到 ${label} 年份 ${year}文件夹.`);
2280 + return `${yearFolder}\\`;
2281 + }
2282 + }
2283 +
2284 + grm.debug.debugLog(`图标 => 找到 ${label} 文件夹并使用最新图标.`);
2285 + return baseDir;
2286 + };
2287 +
2288 + // * Check if a folder exists for the initial label string
2289 + const folderExists = (label) => IsFolder(`${dir}${label}`);
2290 + if (folderExists(labelStr)) {
2291 + dir = checkLabelFolders(labelStr);
2292 + } else {
2293 + labelStr = cleanLabelString(labelStr);
2294 + if (folderExists(labelStr)) {
2295 + dir = checkLabelFolders(labelStr);
2296 + }
2297 + }
2298 +
2299 + // * Reinitialize to original string for file search
2300 + labelStr = ReplaceIllegalChars(publisherString);
2301 +
2302 + // * Get the file path for the initial label string
2303 + const searchFile = (label) => `${dir}${label}.png`;
2304 + let label = searchFile(labelStr);
2305 +
2306 + // * Load the record label image
2307 + if (IsFile(label)) {
2308 + recordLabel = gdi.Image(label);
2309 + grm.debug.debugLog('图标 => 找到唱片商标:', label, !recordLabel ? '<无法加载>' : '');
2310 + } else {
2311 + labelStr = cleanLabelString(labelStr);
2312 + label = searchFile(labelStr);
2313 + if (IsFile(label)) {
2314 + recordLabel = gdi.Image(label);
2315 + } else {
2316 + label = searchFile(`${labelStr} Records`);
2317 + if (IsFile(label)) {
2318 + recordLabel = gdi.Image(label);
2319 + }
2320 + }
2321 + }
2322 +
2323 + return recordLabel;
2324 + }
2325 + // #endregion
2326 +
2327 + // * PUBLIC METHODS - CALLBACKS * //
2328 + // #region PUBLIC METHODS - CALLBACKS
2329 + /**
2330 + * Checks if the mouse is within the boundaries of the metadata grid in Details.
2331 + * @global
2332 + * @param {number} x - The x-coordinate.
2333 + * @param {number} y - The y-coordinate.
2334 + * @param {string} boundary - The boundary to check ('artist', 'title', 'album', 'tagKey', 'tagValue', 'timeline', 'grid').
2335 + * @returns {boolean} True or false.
2336 + */
2337 + mouseInMetadataGrid(x, y, boundary) {
2338 + return this.gridSectionBounds[boundary] ? this.gridSectionBounds[boundary](x, y) : false;
2339 + }
2340 +
2341 + /**
2342 + * Handles the tooltip when the mouse is in the metadata grid tooltip area.
2343 + * @param {number} x - The x-coordinate.
2344 + * @param {number} y - The y-coordinate.
2345 + * @param {number} m - The mouse mask.
2346 + */
2347 + on_mouse_move(x, y, m) {
2348 + if (grSet.showTooltipMain || grSet.showTooltipTruncated) {
2349 + this.handleGridTooltip(x, y);
2350 + }
2351 + if (grSet.showTooltipTimeline) {
2352 + this.handleGridTimelineTooltip(x, y);
2353 + }
2354 + }
2355 + // #endregion
2356 + }

gr-main-components.js(dosya oluşturuldu)

@@ -0,0 +1,4459 @@
1 + /////////////////////////////////////////////////////////////////////////////////
2 + // * Georgia-ReBORN: A Clean - Full Dynamic Color Reborn - Foobar2000 Player * //
3 + // * Description: Georgia-ReBORN Main Components * //
4 + // * Author: TT * //
5 + // * Website: https://github.com/TT-ReBORN/Georgia-ReBORN * //
6 + // * Version: 3.0-x64-DEV * //
7 + // * Dev. started: 22-12-2017 * //
8 + // * Last change: 17-05-2026 * //
9 + /////////////////////////////////////////////////////////////////////////////////
10 +
11 +
12 + 'use strict';
13 +
14 +
15 + ///////////////////////
16 + // * IMAGE CACHING * //
17 + ///////////////////////
18 + /**
19 + * A class that creates album art and playlist thumbnails cache.
20 + */
21 + class ArtCache {
22 + /**
23 + * Creates the `ArtCache` instance.
24 + * The ArtCache is a Least-Recently Used cache meaning that each cache hit will bump
25 + * that image to be the last image to be removed from the cache (if maxCacheSize is exceeded).
26 + * @param {number} maxCacheSize - The maximum number of images to keep in the cache.
27 + */
28 + constructor(maxCacheSize = 15) {
29 + /**
30 + * @typedef {object} ArtCacheObj
31 + * @property {GdiBitmap} image - The GDI+ bitmap image object cached.
32 + * @property {number} filesize - The size of the image file in bytes.
33 + */
34 +
35 + /** @private @type {object.<string, ArtCacheObj>} The primary cache storing image objects. */
36 + this.cache = {};
37 + /** @private @type {object.<string, ArtCacheObj>} The secondary cache used mainly for disc art covers to prevent overwriting album art with masked images. */
38 + this.cache2 = {};
39 + /** @private @type {string[]} The array of cache keys in the order of their usage. */
40 + this.cacheIndexes = [];
41 + /** @private @type {string[]} The array of secondary cache keys in the order of their usage. */
42 + this.cacheIndexes2 = [];
43 + /** @private @type {number} The maximum number of images that can be stored in the primary cache. */
44 + this.cacheMaxSize = maxCacheSize;
45 + /** @private @type {number} The maximum number of images that can be stored in the secondary cache. */
46 + this.cacheMaxSize2 = maxCacheSize;
47 +
48 + /** @private @type {number} The maximum width an image can be displayed. */
49 + this.imgMaxWidth = SCALE(1440);
50 + /** @private @type {number} The maximum height an image can be displayed. */
51 + this.imgMaxHeight = SCALE(872);
52 +
53 + /**
54 + * Because foobar x86 can allocate only 4 gigs memory, we must limit disc art res for 4K when using
55 + * high grSet.spinDiscArtImageCount, i.e 90 (4 degrees), 120 (3 degrees), 180 (2 degrees) to prevent crash.
56 + * When SMP has x64 support, we could try to increase this limit w (1836px max possible res for 4K).
57 + * @public @type {number}
58 + */
59 + this.discArtImgMaxRes = this.setDiscArtMaxResolution(grSet.spinDiscArtImageCount);
60 + }
61 +
62 + // * PUBLIC METHODS * //
63 + // #region PUBLIC METHODS
64 + /**
65 + * Gets cached image if it exists under the location string. If image is found, move it's index to the end of the cacheIndexes.
66 + * @param {string} location - The string value to check if image is cached under.
67 + * @param {number} cacheIndex - The first or second index of the cache to check.
68 + * @returns {GdiBitmap|null} The cached image, or null if not found or the file does not exist.
69 + */
70 + getImage(location, cacheIndex = 1) {
71 + const cache = cacheIndex === 1 ? this.cache : this.cache2;
72 + const cacheIndexes = cacheIndex === 1 ? this.cacheIndexes : this.cacheIndexes2;
73 +
74 + if (!cache[location] || !fso.FileExists(location)) {
75 + // If image is not in cache or location does not exist, return to prevent crash.
76 + return null;
77 + }
78 +
79 + const file = fso.GetFile(location);
80 + const pathIndex = cacheIndexes.indexOf(location);
81 + cacheIndexes.splice(pathIndex, 1);
82 +
83 + if (file && file.Size === cache[location].filesize) {
84 + cacheIndexes.push(location);
85 + grm.debug.debugLog('Art cache => Cache hit:', location);
86 + return cache[location].image;
87 + }
88 +
89 + // Size of file on disk has changed
90 + grm.debug.debugLog(`Art cache => Cache entry was stale: ${location} [old size: ${cache[location].filesize}, new size: ${file.Size}]`);
91 + delete cache[location]; // Was removed from cacheIndexes already
92 +
93 + return null;
94 + }
95 +
96 + /**
97 + * Gets and optionally logs the size of an image or all images in both caches if cacheIndex is 0.
98 + * @param {string|null} location - The location string of the image to check. If null, process all images in the specified cache or both if cacheIndex is 0.
99 + * @param {number} cacheIndex - The index of the cache to check. 0 for both, 1 for the first, 2 for the second. Defaults to 1.
100 + * @param {boolean} logSize - Whether to log the size(s) to the console. Defaults to false.
101 + * @returns {number|null|object} The size of the image in bytes, all images sizes if location is null and cacheIndex is specified, or null if the image is not found.
102 + * @example
103 + * - Get size of a specific image in cache 1: getImageSize('path/to/img.jpg', 1);
104 + * - Get sizes of all images in cache 1: getImageSize(null, 1, true);
105 + * - Get sizes of all images in both caches: getImageSize(null, 0, true);
106 + */
107 + getImageSize(location, cacheIndex = 1, logSize = false) {
108 + const processCache = (cache, prefix = '') => {
109 + const sizes = {};
110 + for (const [loc, cacheObj] of Object.entries(cache)) {
111 + const formattedSize = FormatSize(cacheObj.filesize);
112 + sizes[loc] = formattedSize;
113 + if (logSize) {
114 + console.log(`Art cache => ${prefix}Image at '${loc}' size: ${formattedSize}`);
115 + }
116 + }
117 + return sizes;
118 + };
119 +
120 + if (cacheIndex === 0) { // If location is 0, process both caches
121 + const sizes1 = processCache(this.cache, 'Cache 1 ');
122 + const sizes2 = processCache(this.cache2, 'Cache 2 ');
123 + return { ...sizes1, ...sizes2 }; // Merge results from both caches
124 + }
125 +
126 + const cache = cacheIndex === 1 ? this.cache : this.cache2;
127 +
128 + if (location === null) { // If location is null, process all images in the specified cache.
129 + return processCache(cache);
130 + }
131 + else if (cache[location]) { // Process a specific image in the specified cache.
132 + const formattedSize = FormatSize(cache[location].filesize);
133 + if (logSize) {
134 + console.log(`Art cache => Image at '${location}' size: ${formattedSize}`);
135 + }
136 + return formattedSize;
137 + }
138 +
139 + return null;
140 + }
141 +
142 + /**
143 + * Gets and optionally logs the total size of the cached images.
144 + * If cacheIndex is 0, calculates for both caches combined.
145 + * @param {number} cacheIndex - The index of the cache to calculate size for. If 0, calculates for both caches.
146 + * @param {boolean} logSizes - Whether to log individual image sizes to the console.
147 + * @returns {number} The total size of the cache or caches in bytes.
148 + * @example
149 + * - Get total size of cache 1: getTotalCacheSize(1, true);
150 + * - Get total size of both caches combined: getTotalCacheSize(0, true);
151 + */
152 + getTotalCacheSize(cacheIndex, logSizes = false) {
153 + let totalSize = 0;
154 +
155 + const calculateAndLogSize = (cache, cacheName = '') => {
156 + for (const [location, cacheObj] of Object.entries(cache)) {
157 + totalSize += cacheObj.filesize;
158 + if (logSizes) {
159 + const formattedSize = FormatSize(cacheObj.filesize);
160 + console.log(`Art cache => ${cacheName} Image at '${location}' size: ${formattedSize}`);
161 + }
162 + }
163 + };
164 +
165 + if (cacheIndex === 0) { // If cacheIndex is 0, process both caches
166 + calculateAndLogSize(this.cache, 'Cache 1');
167 + calculateAndLogSize(this.cache2, 'Cache 2');
168 + } else {
169 + const cache = cacheIndex === 1 ? this.cache : this.cache2;
170 + calculateAndLogSize(cache, `Cache ${cacheIndex}`);
171 + }
172 +
173 + const cacheLabel = cacheIndex === 0 ? 'Total size for both caches' : `Total size for Cache ${cacheIndex}`;
174 + const totalFormattedSize = FormatSize(totalSize);
175 + if (logSizes) console.log(`Art cache => ${cacheLabel}: ${totalFormattedSize}`);
176 +
177 + return totalFormattedSize;
178 + }
179 +
180 + /**
181 + * Sets the maximum resolution for disc art based on the spinDiscArtImageCount.
182 + * @param {number} spinDiscArtImageCount - The count for spinning disc art images.
183 + * @returns {number} The maximum resolution for the disc art image.
184 + */
185 + setDiscArtMaxResolution(spinDiscArtImageCount = 72) {
186 + const maxResByImgCount = {
187 + 36: 1500,
188 + 45: 1500,
189 + 60: 1400,
190 + 72: 1400,
191 + 90: 1300,
192 + 120: 1200,
193 + 180: 1000
194 + };
195 +
196 + return maxResByImgCount[spinDiscArtImageCount];
197 + }
198 +
199 + /**
200 + * Adds a rescaled image to the cache under string `location` and returns the cached image.
201 + * @param {GdiBitmap} img - The image object to cache.
202 + * @param {string} location - The string value to cache image under. Does not need to be a path.
203 + * @param {number} cacheIndex - The first or second index of the cache to check.
204 + * @returns {GdiBitmap} The image stored in the cache at the specified location.
205 + * If there is no image in the cache at that location, it returns the original image passed as a parameter.
206 + */
207 + encache(img, location, cacheIndex = 1) {
208 + const cache = cacheIndex === 1 ? this.cache : this.cache2;
209 + const cacheIndexes = cacheIndex === 1 ? this.cacheIndexes : this.cacheIndexes2;
210 + const cacheMaxSize = cacheIndex === 1 ? this.cacheMaxSize : this.cacheMaxSize2;
211 +
212 + try {
213 + let { Width: w, Height: h } = img;
214 +
215 + // Scale image
216 + if (w > this.imgMaxWidth || h > this.imgMaxHeight) {
217 + const scaleFactor = Math.max(w / this.imgMaxWidth, h / this.imgMaxHeight);
218 + w /= scaleFactor;
219 + h /= scaleFactor;
220 + }
221 +
222 + const file = fso.GetFile(location);
223 + cache[location] = { image: img.Resize(w, h), filesize: file.Size };
224 + img = null;
225 +
226 + // Update cache order
227 + const pathIndex = cacheIndexes.indexOf(location);
228 + if (pathIndex !== -1) {
229 + cacheIndexes.splice(pathIndex, 1); // Remove from middle of cache and put on end
230 + }
231 + cacheIndexes.push(location);
232 +
233 + // Maintain cache size
234 + if (cacheIndexes.length > cacheMaxSize) {
235 + const remove = cacheIndexes.shift();
236 + grm.debug.debugLog('Art cache => Removing img from cache:', remove);
237 + delete cache[remove];
238 + }
239 + } catch (e) {
240 + // Do not console.log inverted band logo and label images in the process of being created
241 + grm.ui.bandLogoInverted && console.log(`\nArt cache => <Error: Image could not be properly parsed: ${location}>\n`);
242 + }
243 +
244 + return cache[location] ? cache[location].image : img;
245 + }
246 +
247 + /**
248 + * Completely clears all cached entries and releases memory held by scaled bitmaps.
249 + */
250 + clear() {
251 + if (grCfg.settings.showDebugLog) {
252 + grm.debug.debugLog(`Art cache => Total cache size for Cache 1: ${this.getTotalCacheSize(1, false)}`);
253 + grm.debug.debugLog(`Art cache => Total cache size for Cache 2: ${this.getTotalCacheSize(2, false)}`);
254 + grm.debug.debugLog(`Art cache => Total cache size cleared: ${this.getTotalCacheSize(0, false)}`);
255 + }
256 +
257 + const clearCache = (cacheIndexes, cache) => {
258 + for (const index of cacheIndexes) {
259 + delete cache[index];
260 + }
261 + cacheIndexes.length = 0;
262 + };
263 +
264 + clearCache(this.cacheIndexes, this.cache);
265 + clearCache(this.cacheIndexes2, this.cache2);
266 + }
267 + // #endregion
268 + }
269 +
270 +
271 + /**
272 + * A class that creates background images for the Playlist or Library.
273 + */
274 + class BackgroundImage {
275 + /**
276 + * Creates the `BackgroundImage` instance.
277 + */
278 + constructor() {
279 + // * BACKGROUND PANEL IMAGES * //
280 + // #region BACKGROUND PANEL IMAGES
281 + /** @public @type {GdiBitmap|null} The background image used for the Playlist. */
282 + this.playlistBgImg = null;
283 + /** @public @type {GdiBitmap|null} The background image used for the Library. */
284 + this.libraryBgImg = null;
285 + /** @public @type {GdiBitmap|null} The background image used for the Lyrics. */
286 + this.lyricsBgImg = null;
287 + // #endregion
288 +
289 + // * ARTIST IMAGES * //
290 + // #region ARTIST IMAGES
291 + /** @public @type {GdiBitmap|null} The artist background image of the biography. */
292 + this.artistBgImg = null;
293 + /** @public @type {GdiBitmap[]} The artist list of background images. */
294 + this.artistImgList = [];
295 + /** @public @type {number} The artist index of the currently displayed background image for the Playlist. */
296 + this.artistIdxPlaylist = 0;
297 + /** @public @type {number} The artist index of the currently displayed background image for the Library. */
298 + this.artistIdxLibrary = 0;
299 + /** @public @type {number} The artist index of the currently displayed background image for the Lyrics. */
300 + this.artistIdxLyrics = 0;
301 + /** @public @type {number} The artist index of the cached biography artist image for the Playlist. */
302 + this.artistIdxCachedPlaylist = -1;
303 + /** @public @type {number} The artist index of the cached biography artist image for the Library. */
304 + this.artistIdxCachedLibrary = -1;
305 + /** @public @type {number} The artist index of the cached biography artist image for the Lyrics. */
306 + this.artistIdxCachedLyrics = -1;
307 + // #endregion
308 +
309 + // * ALBUM IMAGES * //
310 + // #region ALBUM IMAGES
311 + /** @public @type {GdiBitmap|null} The album background image. */
312 + this.albumBgImg = null;
313 + /** @public @type {GdiBitmap[]} The album list of background images. */
314 + this.albumImgList = [];
315 + /** @public @type {number[]} The album art image index: 0 for Front, 1 for Back, and 4 for Artist. */
316 + this.albumArtIdx = [0, 1, 4];
317 + /** @public @type {number} The album index of the currently displayed background image for the Playlist. */
318 + this.albumIdxPlaylist = 0;
319 + /** @public @type {number} The album index of the currently displayed background image for the Library. */
320 + this.albumIdxLibrary = 0;
321 + /** @public @type {number} The album index of the currently displayed background image for the Lyrics. */
322 + this.albumIdxLyrics = 0;
323 + /** @public @type {number} The album index of the cached album background image for the Playlist. */
324 + this.albumIdxCachedPlaylist = -1;
325 + /** @public @type {number} The album index of the cached album background image for the Library. */
326 + this.albumIdxCachedLibrary = -1;
327 + /** @public @type {number} The album index of the cached album background image for the Lyrics. */
328 + this.albumIdxCachedLyrics = -1;
329 + // #endregion
330 +
331 + // * CUSTOM IMAGES * //
332 + // #region CUSTOM IMAGES
333 + /** @public @type {GdiBitmap|null} The custom background image. */
334 + this.customBgImg = null;
335 + /** @public @type {GdiBitmap[]} The custom list of custom background images. */
336 + this.customImgList = [];
337 + /** @public @type {number} The custom index of the currently displayed custom background image for the Playlist. */
338 + this.customIdxPlaylist = 0;
339 + /** @public @type {number} The custom index of the currently displayed custom background image for the Library. */
340 + this.customIdxLibrary = 0;
341 + /** @public @type {number} The custom index of the currently displayed custom background image for the Lyrics. */
342 + this.customIdxLyrics = 0;
343 + /** @public @type {number} The custom index of the cached custom background image for the Playlist. */
344 + this.customIdxCachedPlaylist = -1;
345 + /** @public @type {number} The custom index of the cached custom background image for the Library. */
346 + this.customIdxCachedLibrary = -1;
347 + /** @public @type {number} The custom index of the cached custom background image for the Lyrics. */
348 + this.customIdxCachedLyrics = -1;
349 + // #endregion
350 +
351 + // * STATE * //
352 + // #region STATE
353 + /** @public @type {Object} The background image fetching state for the Playlist, Library, and Lyrics. */
354 + this.imgFetching = {};
355 + /** @public @type {Object} The background image cycle intervals for the Playlist, Library, and Lyrics. */
356 + this.imgCycleIntervals = {};
357 + // #endregion
358 +
359 + // * INITIALIZATION * //
360 + // #region INITIALIZATION
361 + this.initBgImageCycle();
362 + // #endregion
363 + }
364 +
365 + // * PUBLIC METHODS * //
366 + // #region PUBLIC METHODS
367 + /**
368 + * Draws an artist, album or custom image on the Playlist or Library's background.
369 + * @param {GdiGraphics} gr - The GDI graphics object.
370 + * @param {object} img - The image object containing the image and other properties.
371 + * @param {string} scale - The scale mode ("default", "filled" or "stretched") to apply to the image.
372 + * @param {number} x - The x-coordinate where the image should be drawn.
373 + * @param {number} y - The y-coordinate where the image should be drawn.
374 + * @param {number} w - The width of the area to draw the image.
375 + * @param {number} h - The height of the area to draw the image.
376 + * @param {number} opacity - The opacity level to apply to the image.
377 + * @param {boolean} mask - Whether to apply a mask to the image.
378 + * @param {number} maskOffsetY - The y-offset for the mask.
379 + * @param {number} maskHeight - The height of the mask.
380 + */
381 + drawBgImage(gr, img, scale, x, y, w, h, opacity, mask, maskOffsetY, maskHeight) {
382 + if (!img || !img.image) return;
383 +
384 + if (!img.scaled || img.changed) {
385 + img.scaled = ScaleImage(img.image, scale, x, y, w, h, 0, 0, img.image.Width, img.image.Height);
386 + }
387 +
388 + if (mask && (!img.masked || img.changed)) {
389 + img.masked = MaskImage(img.scaled, 0, maskOffsetY, img.scaled.Width, img.scaled.Height - maskHeight);
390 + }
391 +
392 + const finalImage = mask ? img.masked : img.scaled;
393 + gr.DrawImage(finalImage, x, y, w, h, 0, 0, finalImage.Width, finalImage.Height, 0, opacity);
394 +
395 + img.changed = false;
396 + }
397 +
398 + /**
399 + * Initializes the current background image by clearing the relevant caches and fetching a new image based on the source setting.
400 + * @param {string} panel - The panel ('playlist', 'library', 'lyrics', or false for all) in which the image is being requested.
401 + * @param {boolean} [clearCache] - Whether to clear the background image cache.
402 + *
403 + * The background image cache should be cleared when:
404 + * - The background image source is updated, clearing the previous source.
405 + * - The playback starts a new album, clearing the previous images.
406 + * - The player size changes, requiring a new scaling for the images.
407 + */
408 + initBgImage(panel, clearCache) {
409 + if (!grSet.playlistBgImg && !grSet.libraryBgImg && !grSet.lyricsBgImg) return;
410 +
411 + if (clearCache) this.clearBgImageCache();
412 +
413 + this.handleBgImageIndex(panel, 'getIndexes');
414 +
415 + const displayPanel = {
416 + playlist: grm.ui.displayPlaylist,
417 + library: grm.ui.displayLibrary,
418 + lyrics: grm.ui.displayLyrics
419 + };
420 +
421 + const panelSize = {
422 + playlist: [pl.playlist.x - SCALE(1), pl.playlist.y - pl.plman.h, pl.playlist.w + SCALE(2), pl.playlist.h + pl.plman.h * 2],
423 + library: [lib.ui.x, lib.ui.y, lib.ui.w, lib.ui.h],
424 + lyrics: [0, grm.ui.topMenuHeight, grm.ui.ww, grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight]
425 + };
426 +
427 + const panelToProcess = panel ? [panel] : ['playlist', 'library', 'lyrics'].filter(p => grSet[`${p}BgImg`]);
428 +
429 + for (const p of panelToProcess) {
430 + this.getBgImage(p).then(img => {
431 + this[`${p}BgImg`] = img;
432 + this.handleBgImageIndex(p, 'setIndexes');
433 + if (displayPanel[p]) window.RepaintRect(...panelSize[p]);
434 + });
435 + }
436 + }
437 +
438 + /**
439 + * Initializes or clears the cycling of background images.
440 + */
441 + initBgImageCycle() {
442 + for (const panel of ['playlist', 'library', 'lyrics']) {
443 + clearInterval(this.imgCycleIntervals[panel]);
444 + this.imgCycleIntervals[panel] = null;
445 +
446 + const enabled = grSet[`${panel}BgImg`];
447 + const cycle = grSet[`${panel}BgImgCycle`];
448 + const cycleTime = grSet[`${panel}BgImgCycleTime`];
449 +
450 + if (!enabled || !cycle) continue;
451 +
452 + grm.debug.debugLog(`\n>>> initImage => initImgCycle => Panel: ${CapitalizeString(panel)} - Cycle time: ${cycleTime} seconds <<<\n`);
453 + this.imgCycleIntervals[panel] = setInterval(() => {
454 + this.cycleBgImage(panel, 1);
455 + }, cycleTime * 1000);
456 + }
457 + }
458 +
459 + /**
460 + * Cycles the background image for the specified panel.
461 + * @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image should be cycled.
462 + * @param {number} direction - The direction to cycle the images (1 for next, -1 for previous).
463 + */
464 + cycleBgImage(panel, direction) {
465 + const imgKey = this.getBgImageSourceKeys(panel);
466 + const imgList = Array.isArray(imgKey.imgList) ? imgKey.imgList : this[imgKey.imgList];
467 +
468 + if (!imgList.length) {
469 + this[imgKey.imgIdx] = (this[imgKey.imgIdx] + direction + this.albumArtIdx.length) % this.albumArtIdx.length;
470 + const imgIdx = this.albumArtIdx[this[imgKey.imgIdx]];
471 + this.handleBgImageIndex(panel, 'setIndexes');
472 +
473 + this.fetchBgImageEmbedded(panel, imgIdx, imgKey.bgImg, imgKey.bgImgIdx).then(() => {
474 + this.initBgImage(panel);
475 + });
476 +
477 + return;
478 + }
479 +
480 + if (imgList.length <= 1) return;
481 +
482 + this[imgKey.imgIdx] = (this[imgKey.imgIdx] + direction + imgList.length) % imgList.length;
483 + this.handleBgImageIndex(panel, 'setIndexes');
484 +
485 + this.fetchBgImage(panel, imgList, imgKey.imgIdx, imgKey.bgImg, imgKey.bgImgIdx).then(() => {
486 + this.initBgImage(panel);
487 + });
488 + }
489 +
490 + /**
491 + * Checks if the background image is cached, and updates the relevant properties if it is.
492 + * @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested.
493 + * @param {string|string[]} imgList - The name of the property in `this` that contains the image list, or the image list array itself.
494 + * @param {string} imgIdx - The name of the property in `this` that contains the current image index.
495 + * @param {string} bgImg - The name of the property in `this` that contains the cached image.
496 + * @param {string} bgImgIdx - The name of the property in `this` that contains the cached image index.
497 + * @returns {{image: GdiBitmap|null, changed: boolean}} The background image and a flag indicating if it has changed.
498 + */
499 + checkBgImageCache(panel, imgList, imgIdx, bgImg, bgImgIdx) {
500 + const imgArray = Array.isArray(imgList) ? imgList : this[imgList];
501 + const imgPath = imgArray[this[imgIdx]];
502 + const imgCached = grm.artCache.getImage(imgPath);
503 +
504 + if (imgCached) {
505 + this[bgImg] = imgCached;
506 + this[bgImgIdx] = this[imgIdx];
507 + return { image: imgCached, changed: false };
508 + }
509 + return { image: null, changed: true };
510 + }
511 +
512 + /**
513 + * Clears the background image cache.
514 + */
515 + clearBgImageCache() {
516 + this.playlistBgImg = null;
517 + this.libraryBgImg = null;
518 + this.lyricsBgImg = null;
519 + this.artistBgImg = null;
520 + this.artistImgList = [];
521 + this.albumBgImg = null;
522 + this.albumImgList = [];
523 + this.customBgImg = null;
524 + this.customImgList = [];
525 + grm.debug.debugLog('Main cache => Background image cache cleared');
526 + }
527 +
528 + /**
529 + * Fetches a background image, either from cache or asynchronously, and updates the relevant properties.
530 + * @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested.
531 + * @param {string|string[]} imgList - The name of the property in `this` that contains the image list, or the image list array itself.
532 + * @param {string} imgIdx - The name of the property in `this` that contains the current image index.
533 + * @param {string} bgImg - The name of the property in `this` that contains the cached image.
534 + * @param {string} bgImgIdx - The name of the property in `this` that contains the cached image index.
535 + * @returns {Promise<{image: GdiBitmap|null, changed: boolean}>} The background image and a flag indicating if it has changed.
536 + */
537 + fetchBgImage(panel, imgList, imgIdx, bgImg, bgImgIdx) {
538 + if (this.imgFetching[panel]) {
539 + return Promise.resolve({ image: null, changed: false });
540 + }
541 +
542 + this.imgFetching[panel] = true;
543 +
544 + const imgArray = Array.isArray(imgList) ? imgList : this[imgList];
545 + const imgIdxLocal = (this[imgIdx] >= imgArray.length) ? 0 : this[imgIdx];
546 + const imgPathIdx = imgArray[imgIdxLocal];
547 +
548 + return gdi.LoadImageAsyncV2(window.ID, imgPathIdx)
549 + .then(img => {
550 + this[bgImg] = grm.artCache.encache(img, imgPathIdx);
551 + this[bgImgIdx] = imgIdxLocal;
552 + return { image: img, changed: true };
553 + })
554 + .catch(error => {
555 + console.log(`\n>>> Background Image => fetchBgImage => <Error: Image could not be properly parsed: ${panel}:>\n`, error);
556 + return { image: null, changed: false };
557 + })
558 + .finally(() => {
559 + this.imgFetching[panel] = false;
560 + });
561 + }
562 +
563 + /**
564 + * Fetches and caches embedded album art if available.
565 + * @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested.
566 + * @param {string} imgIdx - The name of the property in `this` that contains the current image index.
567 + * @param {string} bgImg - The name of the property in `this` that contains the cached image.
568 + * @param {string} bgImgIdx - The name of the property in `this` that contains the cached image index.
569 + * @returns {Promise<{image: GdiBitmap|null, changed: boolean}>} The embedded album art image and a flag indicating if it has changed.
570 + */
571 + fetchBgImageEmbedded(panel, imgIdx, bgImg, bgImgIdx) {
572 + if (this.imgFetching[panel]) {
573 + return Promise.resolve({ image: null, changed: false });
574 + }
575 +
576 + this.imgFetching[panel] = true;
577 +
578 + try {
579 + const metadb = grm.ui.initMetadb();
580 +
581 + if (!metadb) {
582 + return Promise.resolve({ image: null, changed: false });
583 + }
584 +
585 + const imgIdxLocal = this.albumArtIdx.includes(imgIdx) ? imgIdx : 0;
586 + const albumArt = utils.GetAlbumArtV2(metadb, imgIdxLocal);
587 +
588 + if (albumArt) {
589 + this[bgImg] = grm.artCache.encache(albumArt, imgIdxLocal);
590 + this[bgImgIdx] = imgIdxLocal;
591 + return Promise.resolve({ image: albumArt, changed: true });
592 + }
593 +
594 + return Promise.resolve({ image: null, changed: false });
595 + }
596 + catch (error) {
597 + console.log(`\n>>> Background Image => fetchBgImageEmbedded => <Error: Image could not be properly parsed: ${panel}:>\n`, error);
598 + return Promise.resolve({ image: null, changed: false });
599 + }
600 + finally {
601 + this.imgFetching[panel] = false;
602 + }
603 + }
604 +
605 + /**
606 + * Gets the background image based on the source setting.
607 + * @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested.
608 + * @returns {{image: GdiBitmap|null, changed: boolean}} The background image and a flag indicating if it has changed.
609 + */
610 + getBgImage(panel) {
611 + const { imgType, imgList, imgIdx, bgImg, bgImgIdx } = this.getBgImageSourceKeys(panel);
612 + const bgImageFormats = ParseStringToRegExp(grCfg.artworkImageFormats.bgImage);
613 + const bgImagePattern = this.getBgImagePatterns(panel);
614 +
615 + this[imgList] = grm.ui.getImagePathList(imgType, grm.ui.initMetadb(), bgImagePattern).filter(path => bgImageFormats.test(path));
616 +
617 + if (!this[imgList].length) {
618 + const embeddedIdx = this.albumArtIdx[this[imgIdx]];
619 + return this.fetchBgImageEmbedded(panel, embeddedIdx, bgImg, bgImgIdx);
620 + }
621 +
622 + const { image, changed } = this.checkBgImageCache(panel, imgList, imgIdx, bgImg, bgImgIdx);
623 + if (image) {
624 + return Promise.resolve({ image, changed });
625 + }
626 +
627 + return this.fetchBgImage(panel, imgList, imgIdx, bgImg, bgImgIdx);
628 + }
629 +
630 + /**
631 + * Gets the background image pattern for a given panel type.
632 + * @param {string} panel - The panel type, which can be 'playlist', 'library', or 'lyrics'.
633 + * @returns {RegExp} The pattern for the specified panel type.
634 + */
635 + getBgImagePatterns(panel) {
636 + const bgImagePatterns = {
637 + playlist: grSet.playlistBgImgAlbumArtFilter && ParseStringToRegExp(grCfg.artworkPatterns.playlistBgAlbumArt),
638 + library: grSet.libraryBgImgAlbumArtFilter && ParseStringToRegExp(grCfg.artworkPatterns.libraryBgAlbumArt),
639 + lyrics: grSet.lyricsBgImgAlbumArtFilter && ParseStringToRegExp(grCfg.artworkPatterns.lyricsBgAlbumArt)
640 + };
641 +
642 + return bgImagePatterns[panel];
643 + }
644 +
645 + /**
646 + * Retrieves the background image source keys based on the specified panel.
647 + * @param {string} panel - The panel ('playlist', 'library', 'lyrics') whose background image source keys are to be retrieved.
648 + * @returns {object} An object containing the image type, list, index, and background image keys.
649 + */
650 + getBgImageSourceKeys(panel) {
651 + const Panel = CapitalizeString(panel);
652 +
653 + const imgSrcKeys = {
654 + artist: { imgType: 'artistArt', imgList: 'artistImgList', imgIdx: `artistIdx${Panel}`, bgImg: 'artistBgImg', bgImgIdx: `artistIdxCached${Panel}` },
655 + album: { imgType: 'albumArt', imgList: 'albumImgList', imgIdx: `albumIdx${Panel}`, bgImg: 'albumBgImg', bgImgIdx: `albumIdxCached${Panel}` },
656 + custom: { imgType: 'customArt', imgList: 'customImgList', imgIdx: `customIdx${Panel}`, bgImg: 'customBgImg', bgImgIdx: `customIdxCached${Panel}` }
657 + };
658 +
659 + return imgSrcKeys[grSet[`${panel}BgImgSource`]];
660 + }
661 +
662 + /**
663 + * Handles background image indexes for the specified panel.
664 + * @param {string} panel - The panel ('playlist', 'library', 'lyrics', or false for all) whose background image indexes are to be retrieved.
665 + * @param {string} action - The action to perform: 'getIndexes', 'setIndexes', or 'clearIndexes'.
666 + */
667 + handleBgImageIndex(panel, action) {
668 + const panels = panel ? [panel] : ['playlist', 'library', 'lyrics'];
669 +
670 + const indexFields = {
671 + playlist: ['bgImgArtistIdxPlaylist', 'bgImgAlbumIdxPlaylist', 'bgImgCustomIdxPlaylist'],
672 + library: ['bgImgArtistIdxLibrary', 'bgImgAlbumIdxLibrary', 'bgImgCustomIdxLibrary'],
673 + lyrics: ['bgImgArtistIdxLyrics', 'bgImgAlbumIdxLyrics', 'bgImgCustomIdxLyrics']
674 + };
675 +
676 + const actions = {
677 + getIndexes: (Panel, artistIdx, albumIdx, customIdx) => {
678 + this[`artistIdx${Panel}`] = grSet[artistIdx];
679 + this[`albumIdx${Panel}`] = grSet[albumIdx];
680 + this[`customIdx${Panel}`] = grSet[customIdx];
681 + },
682 + setIndexes: (Panel, artistIdx, albumIdx, customIdx) => {
683 + grSet[artistIdx] = this[`artistIdx${Panel}`];
684 + grSet[albumIdx] = this[`albumIdx${Panel}`];
685 + grSet[customIdx] = this[`customIdx${Panel}`];
686 + },
687 + clearIndexes: (Panel) => {
688 + this[`artistIdx${Panel}`] = 0;
689 + this[`artistIdxCached${Panel}`] = -1;
690 + this[`albumIdx${Panel}`] = 0;
691 + this[`albumIdxCached${Panel}`] = -1;
692 + this[`customIdx${Panel}`] = 0;
693 + this[`customIdxCached${Panel}`] = -1;
694 + }
695 + };
696 +
697 + for (const panel of panels) {
698 + if (actions[action]) {
699 + actions[action](CapitalizeString(panel), ...indexFields[panel]);
700 + }
701 + }
702 + }
703 + // #endregion
704 + }
705 +
706 +
707 + ///////////////////////////
708 + // * CPU USAGE TRACKER * //
709 + ///////////////////////////
710 + /**
711 + * A class that tracks and monitors CPU usage.
712 + */
713 + class CPUTracker {
714 + /**
715 + * Create the CPUTracker instance.
716 + * @param {Function} onChangeCallback - A callback function to call when CPU usage changes.
717 + */
718 + constructor(onChangeCallback) {
719 + /** @private @type {number} */
720 + this.cpuUsage = 0;
721 + /** @private @type {number} */
722 + this.guiCpuUsage = 0;
723 + /** @private @type {?number} */
724 + this.cpuTrackerTimer = null;
725 + /** @private @type {Function} */
726 + this.onChangeCallback = onChangeCallback;
727 + /** @private @type {{[key: string]: {sampleCount: number, currentSampleCount: number, resetSampleCount: number, acumUsage: number, averageUsage: number}}} */
728 + this.usage = {
729 + idle: {
730 + sampleCount: 30,
731 + currentSampleCount: 0,
732 + resetSampleCount: 0,
733 + acumUsage: 0,
734 + averageUsage: 0
735 + },
736 + playing: {
737 + sampleCount: 30,
738 + currentSampleCount: 0,
739 + resetSampleCount: 0,
740 + acumUsage: 0,
741 + averageUsage: 0
742 + }
743 + };
744 + }
745 +
746 + /**
747 + * Gets the current CPU usage.
748 + * @returns {number} The current CPU usage.
749 + */
750 + getCpuUsage() {
751 + return this.cpuUsage;
752 + }
753 +
754 + /**
755 + * Gets the current GUI CPU usage.
756 + * @returns {number} The current GUI CPU usage.
757 + */
758 + getGuiCpuUsage() {
759 + return this.guiCpuUsage;
760 + }
761 +
762 + /**
763 + * Starts the CPU usage monitoring process.
764 + */
765 + start() {
766 + if (this.cpuTrackerTimer) return;
767 +
768 + this.cpuTrackerTimer = setInterval(() => {
769 + const floatUsage = Math.random() * 100; // Simulated CPU usage
770 + const isPlaying = Math.random() > 0.5; // Simulated playback status
771 + const isPaused = Math.random() > 0.8;
772 + const usageType = isPlaying && !isPaused ? 'playing' : 'idle';
773 +
774 + this.updateUsage(usageType, floatUsage);
775 +
776 + const baseLine = this.usage[usageType].averageUsage;
777 + this.cpuUsage = floatUsage.toFixed(1);
778 + let usageDiff = Math.max((floatUsage - baseLine), 0);
779 + usageDiff = (usageDiff <= 0.5 ? 0 : usageDiff); // Suppress low spikes
780 + this.guiCpuUsage = usageDiff.toFixed(1);
781 +
782 + if (this.onChangeCallback) {
783 + this.onChangeCallback();
784 + }
785 + }, 1000);
786 + }
787 +
788 + /**
789 + * Stops the CPU usage monitoring and resets usage statistics.
790 + */
791 + stop() {
792 + if (this.cpuTrackerTimer) {
793 + clearInterval(this.cpuTrackerTimer);
794 + this.cpuTrackerTimer = undefined;
795 + }
796 +
797 + this.resetUsage('idle');
798 + this.resetUsage('playing');
799 + }
800 +
801 + /**
802 + * Recalculates the average CPU usage based on a new sample.
803 + * @param {string} type - The type of CPU usage to recalculate ('idle' or 'playing').
804 + * @param {number} currentUsage - The new CPU usage sample.
805 + */
806 + recalcAvg(type, currentUsage) {
807 + const usageState = this.usage[type];
808 +
809 + if (usageState.currentSampleCount < usageState.sampleCount) {
810 + usageState.acumUsage += currentUsage;
811 + usageState.currentSampleCount++;
812 + usageState.averageUsage = usageState.acumUsage / usageState.currentSampleCount;
813 + return;
814 + }
815 +
816 + usageState.averageUsage -= usageState.averageUsage / usageState.sampleCount;
817 + usageState.averageUsage += currentUsage / usageState.sampleCount;
818 + }
819 +
820 + /**
821 + * Resets the CPU usage data for a specified type.
822 + * @param {string} type - The type of CPU usage to reset ('idle' or 'playing').
823 + */
824 + resetUsage(type) {
825 + const usageState = this.usage[type];
826 + usageState.currentSampleCount = 0;
827 + usageState.resetSampleCount = 0;
828 + usageState.acumUsage = 0;
829 + usageState.averageUsage = 0;
830 + }
831 +
832 + /**
833 + * Updates the CPU usage data based on new sample.
834 + * @param {string} type - The type of CPU usage to update ('idle' or 'playing').
835 + * @param {number} currentUsage - The current CPU usage to update.
836 + */
837 + updateUsage(type, currentUsage) {
838 + const usageState = this.usage[type];
839 +
840 + if (usageState.currentSampleCount) {
841 + if (usageState.averageUsage - currentUsage > 2) {
842 + if (usageState.resetSampleCount < 3) {
843 + usageState.resetSampleCount++;
844 + } else {
845 + this.resetUsage(type);
846 + }
847 + } else if (Math.abs(currentUsage - usageState.averageUsage) < 2) {
848 + this.recalcAvg(type, currentUsage);
849 + }
850 + } else {
851 + this.recalcAvg(type, currentUsage);
852 + }
853 + }
854 + }
855 +
856 +
857 + /////////////////
858 + // * TOOLTIP * //
859 + /////////////////
860 + /**
861 + * A class that creates or stops the tooltip timer.
862 + */
863 + class TooltipTimer {
864 + /**
865 + * Creates the `TooltipTimer` instance.
866 + */
867 + constructor() {
868 + /** @private @type {number|undefined} The timer ID for the tooltip display timeout. */
869 + this.tooltipTimer = undefined;
870 + /** @private @type {number|undefined} The identifier of the current tooltip caller. */
871 + this.tooltipCaller = undefined;
872 + }
873 +
874 + // * PUBLIC METHODS * //
875 + // #region PUBLIC METHODS
876 + /**
877 + * Displays the tooltip.
878 + * @param {string} text - The text to show in the tooltip.
879 + * @param {boolean} [force] - Activates the tooltip whether or not text has changed.
880 + */
881 + displayTooltip(text, force) {
882 + if (grm.ui.ttip && (grm.ui.ttip.Text !== text.toString() || force)) {
883 + grm.ui.ttip.Text = text;
884 + grm.ui.ttip.Activate();
885 + }
886 + }
887 +
888 + /**
889 + * Starts a tooltip.
890 + * @param {number} id - The id of the caller.
891 + * @param {string} text - The text to show in the tooltip.
892 + */
893 + start(id, text) {
894 + const oldCaller = this.tooltipCaller;
895 + this.tooltipCaller = id;
896 +
897 + if (!this.tooltipTimer && grm.ui.ttip.Text) {
898 + this.displayTooltip(text, oldCaller !== this.tooltipCaller);
899 + }
900 + else { // * There can be only one tooltip present at all times, so we can kill the timer w/o any worries
901 + if (this.tooltipTimer) {
902 + this.forceStop();
903 + }
904 +
905 + if (!this.tooltipTimer) {
906 + this.tooltipTimer = setTimeout(() => {
907 + this.displayTooltip(text);
908 + this.tooltipTimer = null;
909 + }, 300);
910 + }
911 + }
912 + }
913 +
914 + /**
915 + * Stops a tooltip.
916 + * @param {number} id - The id of the caller.
917 + */
918 + stop(id) {
919 + if (this.tooltipCaller === id) { // Do not stop other callers
920 + this.forceStop();
921 + }
922 + }
923 +
924 + /**
925 + * Forces the tooltip to stop.
926 + */
927 + forceStop() {
928 + this.displayTooltip('');
929 + if (!this.tooltipTimer) return;
930 + clearTimeout(this.tooltipTimer);
931 + this.tooltipTimer = null;
932 + this.tooltipCaller = null;
933 + }
934 + // #endregion
935 + }
936 +
937 +
938 + /**
939 + * A class that creates or clears the tooltip text for normal and styled tooltips.
940 + */
941 + class TooltipHandler {
942 + /**
943 + * Creates the `TooltipHandler` instance.
944 + * Constructs a unique ID and a reference to the TooltipTimer instance.
945 + */
946 + constructor() {
947 + /** @private @type {number} The unique identifier for this TooltipHandler instance. */
948 + this.id = Math.ceil(Math.random() * 10000);
949 + /** @private @type {TooltipTimer} A reference to the TooltipTimer instance used to manage tooltip timing. */
950 + this.timer = new TooltipTimer();
951 + }
952 +
953 + // * PUBLIC METHODS * //
954 + // #region PUBLIC METHODS
955 + /**
956 + * Shows tooltip after delay (300ms).
957 + * @param {string} text - The text to show in the tooltip.
958 + */
959 + showDelayed(text) {
960 + grm.ui.styledTooltipText = text;
961 + if (!grSet.showStyledTooltips) {
962 + this.timer.start(this.id, text);
963 + }
964 + }
965 +
966 + /**
967 + * Shows the tooltip immediately.
968 + * @param {string} text - The text to show in the tooltip.
969 + */
970 + showImmediate(text) {
971 + grm.ui.styledTooltipText = text;
972 + if (!grSet.showStyledTooltips) {
973 + this.timer.stop(this.id);
974 + this.timer.displayTooltip(text);
975 + }
976 + }
977 +
978 + /**
979 + * Clears this tooltip if this handler created it.
980 + */
981 + clear() {
982 + this.timer.stop(this.id);
983 + }
984 +
985 + /**
986 + * Clears the tooltip regardless of which handler created it.
987 + */
988 + stop() {
989 + this.timer.forceStop();
990 + }
991 + // #endregion
992 + }
993 +
994 +
995 + //////////////////////////////
996 + // * INTERFACE HYPERLINKS * //
997 + //////////////////////////////
998 + /**
999 + * A class that creates clickable hyperlinks in the Playlist header and in the lower bar.
1000 + */
1001 + class Hyperlink {
1002 + /**
1003 + * Creates the `Hyperlink` instance.
1004 + * Initializes properties for the text element in the playlist.
1005 + * @param {string} text - The text that will be displayed in the hyperlink.
1006 + * @param {GdiFont} font - The font to use.
1007 + * @param {string} type - The field name which will be searched when clicking on the hyperlink.
1008 + * @param {number} xOffset - The x-offset of the hyperlink. Negative values will be subtracted from the containerWidth to right justify.
1009 + * @param {number} yOffset - The y-offset of the hyperlink.
1010 + * @param {number} containerWidth - The width of the container the hyperlink will be in. Used for right justification purposes.
1011 + * @param {boolean} [inPlaylist] - If the hyperlink is drawing in a scrolling container like a playlist, then it is drawn differently.
1012 + */
1013 + constructor(text, font, type, xOffset, yOffset, containerWidth, inPlaylist = false) {
1014 + /** @private @type {string} */
1015 + this.text = text;
1016 + /** @private @type {string} */
1017 + this.type = type;
1018 + /** @private @type {number} */
1019 + this.x_offset = xOffset;
1020 + /** @private @type {number} */
1021 + this.x = xOffset < 0 ? containerWidth + xOffset : xOffset;
1022 + /** @private @type {number} */
1023 + this.y_offset = yOffset;
1024 + /** @private @type {number} */
1025 + this.y = yOffset;
1026 + /** @private @type {number} */
1027 + this.container_w = containerWidth;
1028 + /** @private @type {boolean} */
1029 + this.state = HyperlinkStates.Normal;
1030 + /** @private @type {boolean} */
1031 + this.inPlaylist = inPlaylist;
1032 +
1033 + this.setFont(font);
1034 + }
1035 +
1036 + // * PUBLIC METHODS * //
1037 + // #region PUBLIC METHODS
1038 + /**
1039 + * Draws the hyperlink. When drawing in a playlist, we draw from the y-offset instead of y, because the playlist scrolls.
1040 + * @param {GdiGraphics} gr - The GDI graphics object.
1041 + * @param {number} color - The color of the hyperlink.
1042 + */
1043 + draw(gr, color) {
1044 + const font = this.state === HyperlinkStates.Hovered ? this.hoverFont : this.font;
1045 + DrawString(gr, this.text, font, color, this.x, this.inPlaylist ? this.y_offset : this.y, this.w + SCALE(1), this.h, Stringformat.Trim_Ellipsis_Char);
1046 + }
1047 +
1048 + /**
1049 + * Sets the xOffset of the hyperlink after it has been created.
1050 + * @param {number} xOffset - The x-offset of the hyperlink. Negative values will be subtracted from the containerWidth to right justify.
1051 + */
1052 + setXOffset(xOffset) {
1053 + this.x = xOffset < 0 ? this.container_w + xOffset : xOffset;
1054 + }
1055 +
1056 + /**
1057 + * Sets the vertical position of the hyperlink.
1058 + * The playlist requires subtracting 2 additional pixels from y for some reason.
1059 + * @param {number} y - The y-coordinate.
1060 + */
1061 + setY(y) {
1062 + this.y = y + this.y_offset + (-2);
1063 + }
1064 +
1065 + /**
1066 + * Sets the font for the hyperlink.
1067 + * @param {GdiFont} font - The font that will be used.
1068 + */
1069 + setFont(font) {
1070 + this.font = font;
1071 + this.hoverFont = gdi.Font(font.Name, font.Size, font.Style | FontStyle.Underline);
1072 + this.link_dimensions = this.updateDimensions();
1073 + }
1074 +
1075 + /**
1076 + * Sets the width of the container the hyperlink will be placed in.
1077 + * If hyperlink width is smaller than the container, it will be truncated.
1078 + * If the the xOffset is negative, the position will be adjusted as the container width changes.
1079 + * @param {number} w - The width.
1080 + */
1081 + setContainerWidth(w) {
1082 + if (this.x_offset < 0) {
1083 + this.x = w + this.x_offset; // Add because offset is negative
1084 + }
1085 + this.container_w = w;
1086 + this.link_dimensions = this.updateDimensions();
1087 + this.w = Math.ceil(Math.min(this.container_w, this.link_dimensions.Width + 1));
1088 + }
1089 +
1090 + /**
1091 + * Gets the width of the hyperlink.
1092 + * @returns {number} The width of the link in pixels.
1093 + */
1094 + getWidth() {
1095 + try {
1096 + return Math.ceil(this.link_dimensions.Width);
1097 + } catch (e) {
1098 + return null;
1099 + }
1100 + }
1101 +
1102 + /**
1103 + * Updates the width and height of the hyperlinks.
1104 + * @returns {number} The dimensions of the text.
1105 + */
1106 + updateDimensions() {
1107 + try {
1108 + const measureStringScratchImg = gdi.CreateImage(1000, 200);
1109 + const gr = measureStringScratchImg.GetGraphics();
1110 + const dimensions = gr.MeasureString(this.text, this.font, 0, 0, 0, 0);
1111 + this.h = Math.ceil(dimensions.Height) + 1;
1112 + this.w = Math.min(Math.ceil(dimensions.Width) + 1, this.container_w);
1113 + measureStringScratchImg.ReleaseGraphics(gr);
1114 + return dimensions;
1115 + } catch (e) {
1116 + return null; // Probably some invalid parameters on init
1117 + }
1118 + }
1119 +
1120 + /**
1121 + * Populates the result of artist, album, date or label in the "Search" playlist when a hyperlink was clicked.
1122 + */
1123 + click() {
1124 + const populatePlaylist = (query) => {
1125 + grm.debug.debugLog(query);
1126 + try {
1127 + const handle_list = fb.GetQueryItems(fb.GetLibraryItems(), query);
1128 + if (handle_list.Count) {
1129 + pl.history.ignorePlaylistMutations = true;
1130 + const plist = plman.FindOrCreatePlaylist('Search', true);
1131 + plman.UndoBackup(plist);
1132 + handle_list.Sort();
1133 + const index = fb.IsPlaying ? handle_list.BSearch(fb.GetNowPlaying()) : -1;
1134 +
1135 + if (plist === plman.PlayingPlaylist && plman.GetPlayingItemLocation().PlaylistIndex === pl && index !== -1) {
1136 + // Remove everything in playlist except currently playing song
1137 + plman.ClearPlaylistSelection(plist);
1138 + plman.SetPlaylistSelection(plist, [plman.GetPlayingItemLocation().PlaylistItemIndex], true);
1139 + plman.RemovePlaylistSelection(plist, true);
1140 + plman.ClearPlaylistSelection(plist);
1141 +
1142 + handle_list.RemoveById(index);
1143 + }
1144 + else {
1145 + // Nothing playing or Search playlist is not active
1146 + plman.ClearPlaylist(plist);
1147 + }
1148 +
1149 + plman.InsertPlaylistItems(plist, 0, handle_list);
1150 + plman.SortByFormat(plist, grCfg.settings.playlistSortDefault);
1151 + plman.ActivePlaylist = plist;
1152 + pl.history.ignorePlaylistMutations = false;
1153 +
1154 + return true;
1155 + }
1156 + return false;
1157 + }
1158 + catch (e) {
1159 + pl.history.ignorePlaylistMutations = false;
1160 + console.log(`Could not successfully execute: ${query}`);
1161 + }
1162 + };
1163 +
1164 + /** @type {string} */
1165 + let query;
1166 + switch (this.type) {
1167 + case 'update': RunCmd('https://github.com/TT-ReBORN/Georgia-ReBORN/releases'); break;
1168 + case 'date': query = grSet.showPlaylistFullDate ? `"${grTF.date}" IS ${this.text}` : `"$year(%date%)" IS ${this.text}`; break;
1169 + case 'artist': query = grSet.headerFlipRows ? `Album HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}"` : `Artist HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}" OR Album Artist HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}" OR ARTISTFILTER HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}"`; break;
1170 + case 'album': query = grSet.headerFlipRows ? `Artist HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}" OR Album Artist HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}" OR ARTISTFILTER HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}"` : `Album HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}"`; break;
1171 + case 'label': query = `Label HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}" OR Publisher HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}"`; break;
1172 + default: query = `${this.type} IS "${this.text}"`; break;
1173 + }
1174 +
1175 + if (!populatePlaylist(query)) {
1176 + const start = this.text.indexOf('[');
1177 + if (start > 0) {
1178 + query = `${this.type} IS ${this.text.slice(0, start - 3)}`; // Remove ' - [...]' from end of string in case we're showing "Album - [Deluxe Edition]", etc.
1179 + populatePlaylist(query);
1180 + }
1181 + }
1182 + }
1183 +
1184 + /**
1185 + * Updates the hyperlink state.
1186 + */
1187 + repaint() {
1188 + try {
1189 + window.RepaintRect(this.x, this.y, this.w, this.h);
1190 + } catch (e) {
1191 + // Probably already redrawing
1192 + }
1193 + }
1194 + // #endregion
1195 +
1196 + // * CALLBACKS * //
1197 + // #region CALLBACKS
1198 + /**
1199 + * Sets mouse hover state for every hyperlink not created in Playlist.
1200 + * @param {object} hyperlink - The hyperlink object.
1201 + * @param {number} x - The x-coordinate.
1202 + * @param {number} y - The y-coordinate.
1203 + * @returns {boolean} True or false.
1204 + */
1205 + on_mouse_move(hyperlink, x, y) {
1206 + if (hyperlink.trace(x, y)) {
1207 + if (hyperlink.state !== HyperlinkStates.Hovered) {
1208 + hyperlink.state = HyperlinkStates.Hovered;
1209 + window.RepaintRect(hyperlink.x, hyperlink.y, hyperlink.w, hyperlink.h);
1210 + }
1211 + return true;
1212 + }
1213 + if (hyperlink.state !== HyperlinkStates.Normal) {
1214 + hyperlink.state = HyperlinkStates.Normal;
1215 + window.RepaintRect(hyperlink.x, hyperlink.y, hyperlink.w, hyperlink.h);
1216 + }
1217 + return false;
1218 + }
1219 +
1220 + /**
1221 + * Checks if the mouse is within the boundaries of a hyperlink.
1222 + * @param {number} x - The x-coordinate.
1223 + * @param {number} y - The y-coordinate.
1224 + * @returns {boolean} True or false.
1225 + */
1226 + trace(x, y) {
1227 + return (this.x <= x) && (x <= this.x + this.w) && (this.y <= y) && (y <= this.y + this.h);
1228 + }
1229 + // #endregion
1230 + }
1231 +
1232 +
1233 + /////////////////////
1234 + // * JUMP SEARCH * //
1235 + /////////////////////
1236 + /**
1237 + * A class that creates the jump search when using keystrokes.
1238 + * Searches in the active Playlist first and when nothing found, it tries in the Library.
1239 + */
1240 + class JumpSearch {
1241 + /**
1242 + * Creates the `JumpSearch` instance.
1243 + */
1244 + constructor() {
1245 + /** @private @type {number} */
1246 + this.arc1 = 5;
1247 + /** @private @type {number} */
1248 + this.arc2 = 4;
1249 + /** @private @type {object} */
1250 + this.j = {
1251 + x: 0,
1252 + y: 0,
1253 + w: grSet.notificationFontSize_layout * 2,
1254 + h: grSet.notificationFontSize_layout * 2
1255 + };
1256 + /** @private @type {string} */
1257 + this.jSearch = '';
1258 + /** @private @type {boolean} */
1259 + this.jump_search = true;
1260 + /** @type {{ [key: string]: number[] }} */
1261 + this.initials = null;
1262 + }
1263 +
1264 + // * PUBLIC METHODS * //
1265 + // #region PUBLIC METHODS
1266 + /**
1267 + * Draws the jump search on the playlist panel.
1268 + * @param {GdiGraphics} gr - The GDI graphics object.
1269 + */
1270 + draw(gr) {
1271 + if (!this.jSearch) return;
1272 + gr.SetSmoothingMode(4);
1273 + this.j.w = gr.CalcTextWidth(this.jSearch, grFont.notification) + 25;
1274 + gr.FillRoundRect(this.j.x - this.j.w / 2, this.j.y, this.j.w, this.j.h, this.arc1, this.arc1, RGBtoRGBA(grCol.popupBg, 220));
1275 + gr.DrawRoundRect(this.j.x - this.j.w / 2, this.j.y, this.j.w, this.j.h, this.arc1, this.arc1, 1, 0x64000000);
1276 + gr.DrawRoundRect(this.j.x - this.j.w / 2 + 1, this.j.y + 1, this.j.w - 2, this.j.h - 2, this.arc2, this.arc2, 1, 0x28ffffff);
1277 + // gr.GdiDrawText(this.jSearch, grFont.notification, RGB(0, 0, 0), this.j.x - this.j.w / 2 + 1, this.j.y + 1, this.j.w, this.j.h, panel.cc); // Drop shadow not needed
1278 + gr.GdiDrawText(this.jSearch, grFont.notification, this.jump_search ? grCol.popupText : 0xffff4646, this.j.x - this.j.w / 2, this.j.y, this.j.w, this.j.h, lib.panel.cc);
1279 + gr.SetSmoothingMode(0);
1280 + }
1281 +
1282 + /**
1283 + * Sets the vertical position of the jump search.
1284 + * @param {number} y - The y-coordinate.
1285 + */
1286 + setY(y) {
1287 + this.y = y;
1288 + }
1289 + // #endregion
1290 +
1291 + // * CALLBACKS * //
1292 + // #region CALLBACKS
1293 + /**
1294 + * Handles key pressed events and activates the jump search.
1295 + * @param {number} code - The character code.
1296 + */
1297 + on_char(code) {
1298 + const text = String.fromCharCode(code);
1299 +
1300 + if (grSet.jumpSearchDisabled || lib.panel.search.active ||
1301 + utils.IsKeyPressed(VKey.CONTROL) || utils.IsKeyPressed(VKey.ESCAPE) ||
1302 + this.jSearch === '' && text === ' ') {
1303 + return;
1304 + }
1305 +
1306 + const playlistItems = plman.GetPlaylistItems(plman.ActivePlaylist);
1307 + const search = fb.TitleFormat(grSet.jumpSearchComposerOnly ? '%composer%' : '$if2(%album artist%, %artist%)').EvalWithMetadbs(playlistItems);
1308 + let focusIndex = plman.GetPlaylistFocusItemIndex(plman.ActivePlaylist);
1309 + let advance = false;
1310 + let foundInPlaylist = false;
1311 + let foundInLibrary = false;
1312 +
1313 + switch (code) {
1314 + case lib.vk.back:
1315 + this.jSearch = this.jSearch.slice(0, -1);
1316 + break;
1317 + case lib.vk.enter:
1318 + this.jSearch = '';
1319 + return;
1320 + default:
1321 + this.jSearch += text;
1322 + break;
1323 + }
1324 +
1325 + // * Playlist advance
1326 + if (focusIndex >= 0 && focusIndex < search.length && (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true))) {
1327 + const char = search[focusIndex].replace(Regex.LibMarkerColor, '').charAt(0).toLowerCase();
1328 + if (char === text && AllEqual(this.jSearch)) {
1329 + this.jSearch = this.jSearch.slice(0, 1);
1330 + advance = true;
1331 + }
1332 + }
1333 + // * Library advance
1334 + else if (lib.panel.pos >= 0 && lib.panel.pos < lib.pop.tree.length && !grm.ui.displayLibrarySplit(true)) {
1335 + const char = lib.pop.tree[lib.panel.pos].name.replace(Regex.LibMarkerColor, '').charAt(0).toLowerCase();
1336 + if (lib.pop.tree[lib.panel.pos].sel && char === text && AllEqual(this.jSearch)) {
1337 + this.jSearch = this.jSearch.slice(0, 1);
1338 + advance = true;
1339 + }
1340 + }
1341 +
1342 + switch (true) {
1343 + case advance: {
1344 + if (utils.IsKeyPressed(0x0A) || utils.IsKeyPressed(VKey.BACK) || utils.IsKeyPressed(VKey.TAB) || utils.IsKeyPressed(VKey.CONTROL) || utils.IsKeyPressed(VKey.ESCAPE) || utils.IsKeyPressed(VKey.MULTIPLY) || utils.IsKeyPressed(VKey.SUBTRACT)) return;
1345 + let init = '';
1346 + let cur = 'currentArr';
1347 + if (!this.initials) { // reset in buildTree
1348 + this.initials = {};
1349 + // * Playlist advance
1350 + if (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true)) {
1351 + for (const [i] of playlistItems.Convert().entries()) {
1352 + const name = search[i].replace(Regex.LibMarkerColor, '');
1353 + init = name.charAt().toLowerCase();
1354 + if (cur !== init && !this.initials[init]) {
1355 + this.initials[init] = [i];
1356 + cur = init;
1357 + } else {
1358 + this.initials[init].push(i);
1359 + }
1360 + }
1361 + }
1362 + // * Library advance
1363 + else {
1364 + for (const [i, v] of lib.pop.tree.entries()) {
1365 + if (!v.root) {
1366 + const nm = v.name.replace(Regex.LibMarkerColor, '');
1367 + init = nm.charAt().toLowerCase();
1368 + if (cur !== init && !this.initials[init]) {
1369 + this.initials[init] = [i];
1370 + cur = init;
1371 + } else {
1372 + this.initials[init].push(i);
1373 + }
1374 + }
1375 + }
1376 + }
1377 + }
1378 +
1379 + this.jump_search = false;
1380 +
1381 + // * Playlist advance
1382 + if (focusIndex >= 0 && focusIndex < search.length && (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true))) {
1383 + this.matches = this.initials[text];
1384 + grm.debug.debugLog('Playlist advance results', this.matches); // Debug
1385 + this.ix = this.matches.indexOf(focusIndex);
1386 + this.ix++;
1387 + if (this.ix >= this.matches.length) this.ix = 0;
1388 + focusIndex = this.matches[this.ix];
1389 + this.jump_search = true;
1390 + }
1391 + // * Library advance
1392 + else if (lib.panel.pos >= 0 && lib.panel.pos < lib.pop.tree.length && !grm.ui.displayLibrarySplit(true)) {
1393 + this.matches = this.initials[text];
1394 + grm.debug.debugLog('Library advance results', this.matches); // Debug, can remove this soon
1395 + this.ix = this.matches.indexOf(lib.panel.pos);
1396 + this.ix++;
1397 + if (this.ix >= this.matches.length) this.ix = 0;
1398 + lib.panel.pos = this.matches[this.ix];
1399 + this.jump_search = true;
1400 + }
1401 +
1402 + // * Playlist advance
1403 + if (this.jump_search && (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true))) {
1404 + plman.ClearPlaylistSelection(plman.ActivePlaylist);
1405 + plman.SetPlaylistFocusItem(plman.ActivePlaylist, focusIndex);
1406 + plman.SetPlaylistSelectionSingle(plman.ActivePlaylist, focusIndex, true);
1407 + window.Repaint();
1408 + }
1409 + // * Library advance
1410 + else if (this.jump_search && !grm.ui.displayLibrarySplit(true)) {
1411 + lib.pop.clearSelected();
1412 + lib.pop.sel_items = [];
1413 + lib.pop.tree[lib.panel.pos].sel = true;
1414 + lib.pop.setPos(lib.panel.pos);
1415 + lib.pop.getTreeSel();
1416 + lib.lib.treeState(false, libSet.rememberTree);
1417 + window.Repaint();
1418 + if (lib.panel.imgView) lib.pop.showItem(lib.panel.pos, 'focus');
1419 + else {
1420 + const row = (lib.panel.pos * lib.ui.row.h - lib.sbar.scroll) / lib.ui.row.h;
1421 + if (lib.sbar.rows_drawn - row < 3 || row < 0) lib.sbar.checkScroll((lib.panel.pos + 3) * lib.ui.row.h - lib.sbar.rows_drawn * lib.ui.row.h);
1422 + }
1423 + if (libSet.libSource) {
1424 + if (lib.pop.autoFill.key) lib.pop.load(lib.pop.sel_items, true, false, false, !libSet.sendToCur, false);
1425 + lib.pop.track(lib.pop.autoFill.key);
1426 + } else if (lib.panel.pos >= 0 && lib.panel.pos < lib.pop.tree.length) lib.pop.setPlaylistSelection(lib.panel.pos, lib.pop.tree[lib.panel.pos]);
1427 + }
1428 + else {
1429 + window.Repaint();
1430 + }
1431 + lib.timer.clear(lib.timer.jsearch2);
1432 + lib.timer.jsearch2.id = setTimeout(() => {
1433 + this.jSearch = '';
1434 + window.Repaint();
1435 + lib.timer.jsearch2.id = null;
1436 + }, 2200);
1437 + }
1438 + break;
1439 +
1440 + case !advance:
1441 + if (utils.IsKeyPressed(VKey.TAB) || utils.IsKeyPressed(VKey.CONTROL) || utils.IsKeyPressed(VKey.ESCAPE) || utils.IsKeyPressed(VKey.MULTIPLY) || utils.IsKeyPressed(VKey.SUBTRACT)) return;
1442 + if (!lib.panel.search.active) {
1443 + let pos = -1;
1444 + lib.pop.clearSelected();
1445 + if (!this.jSearch) return;
1446 + lib.pop.sel_items = [];
1447 + this.jump_search = true;
1448 + window.Repaint();
1449 + lib.timer.clear(lib.timer.jsearch1);
1450 +
1451 + lib.timer.jsearch1.id = setTimeout(() => {
1452 + // * First search in the Playlist
1453 + playlistItems.Convert().some((v, i) => {
1454 + const name = search[i].replace(Regex.LibMarkerColor, '');
1455 + if (name.substring(0, this.jSearch.length).toLowerCase() === this.jSearch.toLowerCase()) {
1456 + foundInPlaylist = true;
1457 + pos = i;
1458 + plman.ClearPlaylistSelection(plman.ActivePlaylist);
1459 + plman.SetPlaylistFocusItem(plman.ActivePlaylist, pos);
1460 + plman.SetPlaylistSelectionSingle(plman.ActivePlaylist, pos, true);
1461 + grm.debug.debugLog(`Jumpsearch: "${name}" found in Playlist`); // Debug, can remove this soon
1462 + return true;
1463 + }
1464 + return false;
1465 + });
1466 + // * If no Playlist results found, try search query in the Library
1467 + if (!foundInPlaylist && grSet.jumpSearchIncludeLibrary && grSet.layout !== 'compact') {
1468 + lib.pop.tree.some((v, i) => {
1469 + const name = v.name.replace(Regex.LibMarkerColor, '');
1470 + if (name !== lib.panel.rootName && name.substring(0, this.jSearch.length).toLowerCase() === this.jSearch.toLowerCase()) {
1471 + foundInPlaylist = false;
1472 + foundInLibrary = true;
1473 + pos = i;
1474 + v.sel = true;
1475 + lib.pop.setPos(pos);
1476 + if (lib.pop.autoFill.key) lib.pop.getTreeSel();
1477 + lib.lib.treeState(false, libSet.rememberTree);
1478 + grm.debug.debugLog(`Jumpsearch: "${name}" found in Library`); // Debug, can remove this soon
1479 + return true;
1480 + }
1481 + return false;
1482 + });
1483 + }
1484 +
1485 + if (!foundInPlaylist && !foundInLibrary) {
1486 + this.jump_search = false;
1487 + grm.debug.debugLog('Jumpsearch: No results were found'); // Debug, can remove this soon
1488 + }
1489 +
1490 + window.Repaint();
1491 +
1492 + if (foundInPlaylist) {
1493 + grm.ui.displayPlaylist = true;
1494 + grm.ui.displayLibrary = grSet.libraryLayout === 'split' && grm.ui.displayPlaylist;
1495 + grm.ui.displayBiography = false;
1496 + grm.ui.displayLyrics = false;
1497 + grm.button.initButtonState();
1498 + }
1499 + else if (foundInLibrary && grSet.jumpSearchIncludeLibrary) {
1500 + grm.ui.displayPlaylist = grSet.libraryLayout === 'split' && grm.ui.displayPlaylist;
1501 + grm.ui.displayLibrary = true;
1502 + grm.ui.displayBiography = false;
1503 + grm.ui.displayLyrics = false;
1504 + lib.pop.showItem(pos, 'focus');
1505 + this.jSearch = ''; // Reset to avoid conflict with other query
1506 + grm.button.initButtonState();
1507 + }
1508 +
1509 + lib.timer.jsearch1.id = null;
1510 + }, 500);
1511 +
1512 + lib.timer.clear(lib.timer.jsearch2);
1513 +
1514 + lib.timer.jsearch2.id = setTimeout(() => {
1515 + this.jSearch = '';
1516 + window.Repaint();
1517 + lib.timer.jsearch2.id = null;
1518 + }, 1200);
1519 + }
1520 + }
1521 + }
1522 +
1523 + /**
1524 + * Sets the size and position of the jump search and updates them on window resizing.
1525 + */
1526 + on_size() {
1527 + this.j.h = grSet.notificationFontSize_layout * 2;
1528 + this.j.x = Math.round(grSet.playlistLayout === 'full' || grSet.layout !== 'default' ? grm.ui.ww * 0.5 : grm.ui.ww * 0.5 + grm.ui.ww * 0.25);
1529 + this.j.y = Math.round((grm.ui.wh + grm.ui.topMenuHeight - grm.ui.lowerBarHeight - this.j.h) / 2);
1530 + this.arc1 = Math.min(5, this.j.h / 2);
1531 + this.arc2 = Math.min(4, (this.j.h - 2) / 2);
1532 + }
1533 + // #endregion
1534 + }
1535 +
1536 +
1537 + //////////////////////
1538 + // * PROGRESS BAR * //
1539 + //////////////////////
1540 + /**
1541 + * A class that creates the progress bar in the lower bar when enabled.
1542 + * Quick access via right click context menu on lower bar.
1543 + */
1544 + class ProgressBar {
1545 + /**
1546 + * Creates the `ProgressBar` instance.
1547 + */
1548 + constructor() {
1549 + /** @public @type {number} The x-coordinate of the progress bar. */
1550 + this.x = grm.ui.edgeMargin;
1551 + /** @public @type {number} The y-coordinate of the progress bar. */
1552 + this.y = 0;
1553 + /** @public @type {number} The width of the progress bar. */
1554 + this.w = grm.ui.ww - grm.ui.edgeMarginBoth;
1555 + /** @public @type {number} The height of the progress bar. */
1556 + this.h = grm.ui.seekbarHeight;
1557 + /** @public @type {number} The arc radius for rounded corners of the progress bar. */
1558 + this.arc = Math.min(this.w, this.h) / 2;
1559 + /** @public @type {number} The length of the progress bar fill. */
1560 + this.progressLength = 0;
1561 + /** @private @type {boolean} The state that indicates if the progress bar is being dragged. */
1562 + this.drag = false;
1563 + }
1564 +
1565 + // * PUBLIC METHODS * //
1566 + // #region PUBLIC METHODS
1567 + /**
1568 + * Draws the progress bar with various progress bar styles.
1569 + * @param {GdiGraphics} gr - The GDI graphics object.
1570 + */
1571 + draw(gr) {
1572 + if (grm.debug.showDrawExtendedTiming) grm.ui.seekbarProfiler.Reset();
1573 +
1574 + const styleRounded = grSet.styleProgressBarDesign === 'rounded';
1575 + if (styleRounded) this.arc = Math.min(this.w, this.h) / 2;
1576 +
1577 + gr.SetSmoothingMode(styleRounded ? SmoothingMode.AntiAlias : SmoothingMode.None);
1578 +
1579 + this.drawProgressBarBg(gr);
1580 + this.drawProgressBarFill(gr);
1581 + }
1582 +
1583 + /**
1584 + * Draws the progress bar background.
1585 + * @param {GdiGraphics} gr - The GDI graphics object.
1586 + */
1587 + drawProgressBarBg(gr) {
1588 + const barDesignNoDotsThin = !['dots', 'thin'].includes(grSet.styleProgressBarDesign);
1589 + const styleRounded = grSet.styleProgressBarDesign === 'rounded';
1590 + const styleDefault = grSet.styleDefault && (['blue', 'darkblue', 'red', 'cream'].includes(grSet.theme) || grSet.theme.startsWith('custom'));
1591 + const styleCream = grSet.theme === 'cream' && (grSet.styleAlternative || grSet.styleAlternative2) && (!grSet.styleBevel && !grSet.styleBlend && !grSet.styleBlend2 && grSet.styleProgressBarDesign !== 'rounded') && !grSet.systemFirstLaunch;
1592 + const styleBevelOrInner = barDesignNoDotsThin && ['bevel', 'inner'].includes(grSet.styleProgressBar);
1593 + const progressBarColor = grm.ui.isStreaming && fb.IsPlaying ? grCol.progressBarStreaming : grCol.progressBar;
1594 +
1595 + if (styleRounded) {
1596 + FillRoundRect(gr, this.x, this.y, this.w, this.h, this.arc, this.arc, progressBarColor);
1597 + } else if (barDesignNoDotsThin) {
1598 + gr.FillSolidRect(this.x, this.y, this.w, this.h, progressBarColor);
1599 + }
1600 +
1601 + if (styleDefault || styleCream) {
1602 + gr.DrawRect(this.x - 2, this.y - 2, this.w + 3, this.h + 3, 1, grCol.progressBarFrame);
1603 + }
1604 +
1605 + if (styleBevelOrInner) {
1606 + const styleBlackReborn = grSet.styleBlackReborn && fb.IsPlaying;
1607 + const angle = grSet.styleProgressBar === 'inner' ? (styleBlackReborn ? 90 : -90) : (styleBlackReborn ? -90 : 90);
1608 +
1609 + if (styleRounded) {
1610 + FillGradRoundRect(gr, this.x, this.y, this.w + SCALE(2), this.h + SCALE(2), this.arc, this.arc, angle, 0, grCol.styleProgressBar, 1);
1611 +
1612 + const xLeft = this.x + SCALE(3);
1613 + const xRight = this.w + this.x - SCALE(12);
1614 + const yTop = this.y - 0.5;
1615 + const yBottom = this.y + this.h - 0.5;
1616 + FillGradRect(gr, xLeft, yTop, SCALE(9), 1, 179, grCol.styleProgressBarLineTop, 0); // Top left
1617 + FillGradRect(gr, xLeft, yBottom, SCALE(9), 1, 179, grCol.styleProgressBarLineBottom, 0); // Bottom left
1618 + FillGradRect(gr, xRight, yTop, SCALE(9), 1, 179, 0, grCol.styleProgressBarLineTop); // Top right
1619 + FillGradRect(gr, xRight, yBottom, SCALE(9), 1, 179, 0, grCol.styleProgressBarLineBottom); // Bottom right
1620 + }
1621 + else {
1622 + FillGradRect(gr, this.x, this.y, this.w, this.h, angle, 0, grCol.styleProgressBar);
1623 + }
1624 +
1625 + const lineX1 = this.x + (styleRounded ? SCALE(12) : 0);
1626 + const lineX2 = this.x + this.w - (styleRounded ? SCALE(12) : 1);
1627 + gr.DrawLine(lineX1, this.y, lineX2, this.y, 1, grCol.styleProgressBarLineTop);
1628 + gr.DrawLine(lineX1, this.y + this.h, lineX2, this.y + this.h, 1, grCol.styleProgressBarLineBottom);
1629 + }
1630 + }
1631 +
1632 + /**
1633 + * Draws the progress bar fill.
1634 + * @param {GdiGraphics} gr - The GDI graphics object.
1635 + */
1636 + drawProgressBarFill(gr) {
1637 + if (!fb.IsPlaying || fb.PlaybackLength <= 0) return;
1638 +
1639 + const playbackRatio = fb.PlaybackTime / fb.PlaybackLength;
1640 + this.progressLength = Math.floor(this.w * playbackRatio);
1641 +
1642 + const drawBarDesign = {
1643 + default: () => gr.FillSolidRect(this.x, this.y, this.progressLength, this.h, grCol.progressBarFill),
1644 + rounded: () => FillRoundRect(gr, this.x, this.y, this.progressLength, this.h, this.arc, this.arc, grCol.progressBarFill),
1645 + lines: () => this.drawBarDesignLines(gr),
1646 + blocks: () => this.drawBarDesignBlocks(gr),
1647 + dots: () => this.drawBarDesignDots(gr),
1648 + thin: () => this.drawBarDesignThin(gr)
1649 + };
1650 +
1651 + drawBarDesign[grSet.styleProgressBarDesign]();
1652 +
1653 + if (!['dots', 'thin'].includes(grSet.styleProgressBarDesign) && ['bevel', 'inner'].includes(grSet.styleProgressBarFill)) {
1654 + if (grSet.styleProgressBarDesign === 'rounded') {
1655 + FillGradRoundRect(gr, this.x, this.y, this.progressLength + SCALE(2), this.h + SCALE(2), this.arc, this.arc, grSet.styleProgressBarFill === 'inner' ? -88 : 88, 0, grCol.styleProgressBarFill);
1656 + } else {
1657 + FillGradRect(gr, this.x, this.y, this.progressLength, this.h, grSet.styleProgressBarFill === 'inner' ? -90 : 89, 0, grCol.styleProgressBarFill);
1658 + }
1659 + }
1660 + else if (grSet.styleProgressBarFill === 'blend' && grm.ui.albumArt && grCol.imgBlended) {
1661 + if (grSet.styleProgressBarDesign === 'rounded') {
1662 + FillBlendedRoundRect(gr, this.x, this.y, this.progressLength + SCALE(2), this.h + SCALE(2), this.arc, this.arc, 88, grCol.imgBlended, 0);
1663 + } else {
1664 + gr.DrawImage(grCol.imgBlended, this.x, this.y, this.progressLength, this.h, 0, this.h, grCol.imgBlended.Width, grCol.imgBlended.Height);
1665 + }
1666 + }
1667 + }
1668 +
1669 + /**
1670 + * Draws the progress bar fill in lines design.
1671 + * @param {GdiGraphics} gr - The GDI graphics object.
1672 + */
1673 + drawBarDesignLines(gr) {
1674 + gr.FillSolidRect(this.x + this.progressLength, this.y, SCALE(2), grm.ui.seekbarHeight, grCol.progressBarFill);
1675 +
1676 + for (let progressLine = 0; progressLine < this.progressLength; progressLine += SCALE(4)) {
1677 + gr.DrawLine(this.x + progressLine + SCALE(2), this.y, this.x + progressLine + SCALE(2), this.y + this.h, SCALE(2), grCol.progressBarFill);
1678 + }
1679 + }
1680 +
1681 + /**
1682 + * Draws the progress bar fill in blocks design.
1683 + * @param {GdiGraphics} gr - The GDI graphics object.
1684 + */
1685 + drawBarDesignBlocks(gr) {
1686 + for (let progressLine = 0; progressLine < this.progressLength; progressLine += grm.ui.seekbarHeight + SCALE(2)) {
1687 + gr.FillSolidRect(this.x + progressLine, this.y + SCALE(2), grm.ui.seekbarHeight, grm.ui.seekbarHeight - SCALE(4), grCol.progressBarFill);
1688 + }
1689 +
1690 + gr.FillSolidRect(this.x + this.progressLength, this.y + 1, grm.ui.seekbarHeight, grm.ui.seekbarHeight - 1, grCol.progressBar);
1691 + FillGradRect(gr, this.x + this.progressLength, this.y + 1, grm.ui.seekbarHeight, grm.ui.seekbarHeight - 1, grSet.styleProgressBar === 'inner' ? grSet.styleBlackReborn && fb.IsPlaying ? 88 : -88 : grSet.styleBlackReborn && fb.IsPlaying ? -88 : 88, 0, grCol.styleProgressBar);
1692 + }
1693 +
1694 + /**
1695 + * Draws the progress bar fill in dots design.
1696 + * @param {GdiGraphics} gr - The GDI graphics object.
1697 + */
1698 + drawBarDesignDots(gr) {
1699 + for (let progressLine = 0; progressLine < this.progressLength; progressLine += SCALE(8)) {
1700 + gr.DrawLine(this.x + this.progressLength + SCALE(10), this.y + this.h * 0.5, this.x + this.w, this.y + this.h * 0.5, SCALE(1), grCol.progressBar);
1701 + gr.SetSmoothingMode(SmoothingMode.AntiAlias);
1702 + gr.DrawEllipse(this.x + progressLine, this.y + this.h * 0.5 - SCALE(1), SCALE(2), SCALE(2), SCALE(2), grCol.progressBarFill);
1703 + }
1704 +
1705 + const posFix = HD_4K(3, grSet.layout !== 'default' ? 6 : 7);
1706 + gr.DrawEllipse(this.x + this.progressLength, this.y + this.h * 0.5 - grm.ui.seekbarHeight * 0.5 + SCALE(2), grm.ui.seekbarHeight - SCALE(4), grm.ui.seekbarHeight - SCALE(4), SCALE(2), grCol.progressBarFill); // Knob outline
1707 + gr.DrawEllipse(this.x + this.progressLength + posFix, this.y + this.h * 0.5 - SCALE(1), SCALE(2), SCALE(2), SCALE(2), grCol.transportIconHovered); // Knob inner
1708 + }
1709 +
1710 + /**
1711 + * Draws the progress bar fill in thin design.
1712 + * @param {GdiGraphics} gr - The GDI graphics object.
1713 + */
1714 + drawBarDesignThin(gr) {
1715 + gr.DrawLine(this.x, this.y + this.h * 0.5, this.x + this.w, this.y + this.h * 0.5, SCALE(1), grCol.progressBar);
1716 + gr.SetSmoothingMode(SmoothingMode.AntiAlias);
1717 + gr.FillSolidRect(this.x, this.y + this.h * 0.5 - SCALE(2), this.progressLength, SCALE(4), grCol.progressBarFill);
1718 + gr.FillSolidRect(this.x + this.progressLength, this.y + this.h * 0.5 - SCALE(3), SCALE(6), SCALE(6), grCol.progressBarFill);
1719 + }
1720 +
1721 + /**
1722 + * Sets the vertical progress bar position.
1723 + * @param {number} y - The y-coordinate.
1724 + */
1725 + setY(y) {
1726 + this.y = y;
1727 + }
1728 +
1729 + /**
1730 + * Sets the playback time of the progress bar.
1731 + * @param {number} x - The x-coordinate.
1732 + * @private
1733 + */
1734 + setPlaybackTime(x) {
1735 + const clampedPosition = Clamp((x - this.x) / this.w, 0, 1);
1736 + const newPlaybackTime = clampedPosition * fb.PlaybackLength;
1737 + if (fb.PlaybackTime !== newPlaybackTime) {
1738 + fb.PlaybackTime = newPlaybackTime;
1739 + }
1740 + }
1741 +
1742 + /**
1743 + * Sets the refresh rate for the progress bar.
1744 + */
1745 + setRefreshRate() {
1746 + if (grm.ui.isStreaming) {
1747 + grm.ui.seekbarTimerInterval = FPS._1;
1748 + }
1749 + else if (grSet.progressBarRefreshRate !== 'variable') {
1750 + grm.ui.seekbarTimerInterval = grSet.progressBarRefreshRate;
1751 + }
1752 + else {
1753 + const pixelsPerMillisecond = (grm.ui.ww - grm.ui.edgeMarginBoth) / fb.PlaybackLength;
1754 + const FPS_VARIABLE = Math.ceil(1000 / pixelsPerMillisecond);
1755 + grm.ui.seekbarTimerInterval = Clamp(FPS_VARIABLE, FPS._15, FPS._2);
1756 + }
1757 + }
1758 +
1759 + /**
1760 + * Updates the progress bar state.
1761 + */
1762 + repaint() {
1763 + window.RepaintRect(this.x, this.y, this.w, this.h);
1764 + }
1765 + // #endregion
1766 +
1767 + // * CALLBACKS * //
1768 + // #region CALLBACKS
1769 + /**
1770 + * Checks if the mouse is within the boundaries of the progress bar.
1771 + * @param {number} x - The x-coordinate.
1772 + * @param {number} y - The y-coordinate.
1773 + * @returns {boolean} True or false.
1774 + */
1775 + mouseInThis(x, y) {
1776 + return (x >= this.x && y >= this.y && x < this.x + this.w && y <= this.y + this.h);
1777 + }
1778 +
1779 + /**
1780 + * Handles left mouse button down click events and enables dragging.
1781 + * @param {number} x - The x-coordinate.
1782 + * @param {number} y - The y-coordinate.
1783 + */
1784 + on_mouse_lbtn_down(x, y) {
1785 + this.drag = true;
1786 + }
1787 +
1788 + /**
1789 + * Handles left mouse button up click events and disables dragging and updates the playback time.
1790 + * @param {number} x - The x-coordinate.
1791 + * @param {number} y - The y-coordinate.
1792 + */
1793 + on_mouse_lbtn_up(x, y) {
1794 + this.drag = false;
1795 + if (this.mouseInThis(x, y)) {
1796 + this.setPlaybackTime(x);
1797 + }
1798 + }
1799 +
1800 + /**
1801 + * Handles mouse movement events and updates the playback time based on the mouse movement if a drag event is occurring.
1802 + * @param {number} x - The x-coordinate.
1803 + * @param {number} y - The y-coordinate.
1804 + */
1805 + on_mouse_move(x, y) {
1806 + if (this.drag) {
1807 + this.setPlaybackTime(x);
1808 + }
1809 + }
1810 +
1811 + /**
1812 + * Updates progress bar length when playing a new track.
1813 + * @param {FbMetadbHandle} metadb - The metadb of the track.
1814 + */
1815 + on_playback_new_track(metadb) {
1816 + if (!metadb) return;
1817 + this.progressLength = 0;
1818 + }
1819 +
1820 + /**
1821 + * Sets the size and position of the progress bar and updates them on window resizing.
1822 + * @param {number} w - The width of the window or element.
1823 + * @param {number} h - The height of the window or element.
1824 + */
1825 + on_size(w, h) {
1826 + this.x = grm.ui.edgeMargin;
1827 + this.y = 0;
1828 + this.w = w - grm.ui.edgeMarginBoth;
1829 + this.h = grm.ui.seekbarHeight;
1830 + }
1831 + // #endregion
1832 + }
1833 +
1834 +
1835 + ///////////////////////
1836 + // * PEAKMETER BAR * //
1837 + ///////////////////////
1838 + /**
1839 + * A class that creates the peakmeter bar in the lower bar when enabled.
1840 + * Quick access via right click context menu on lower bar.
1841 + */
1842 + class PeakmeterBar {
1843 + /**
1844 + * Creates the `PeakmeterBar` instance.
1845 + */
1846 + constructor() {
1847 + // * GEOMETRY - STYLE HORIZONTAL * //
1848 + // #region GEOMETRY - STYLE HORIZONTAL
1849 + /** @public @type {number} The x-position of the peakmeter bar. */
1850 + this.x = grm.ui.edgeMargin;
1851 + /** @public @type {number} The y-position of the peakmeter bar. */
1852 + this.y = 0;
1853 + /** @public @type {number} The width of the peakmeter bar. */
1854 + this.w = grm.ui.ww - grm.ui.edgeMarginBoth;
1855 + /** @public @type {number} The secondary width of the peakmeter bar. */
1856 + this.w2 = 0;
1857 + /** @public @type {number} The height of the peakmeter bar. */
1858 + this.h = grm.ui.seekbarHeight;
1859 + /** @private @type {number} The height of the bar for the peakmeter. */
1860 + this.bar_h = grSet.layout !== 'default' ? SCALE(2) : SCALE(4);
1861 + /** @private @type {number} The half height of the bar for the peakmeter. */
1862 + this.bar2_h = this.bar_h * 0.5;
1863 + /** @private @type {number} The offset for the peakmeter bar. */
1864 + this.offset = 0;
1865 + /** @private @type {number} The middle offset for the peakmeter bar. */
1866 + this.middleOffset = 0;
1867 + /** @private @type {number} The middle width for the peakmeter bar. */
1868 + this.middle_w = 0;
1869 +
1870 + // * Top
1871 + /** @private @type {number} The width of the outer left bar. */
1872 + this.outerLeft_w = 0;
1873 + /** @private @type {number} The old width of the outer left bar. */
1874 + this.outerLeft_w_old = 0;
1875 + /** @private @type {number} The animated width of the outer left bar. */
1876 + this.outerLeftAnim_w = 0;
1877 + /** @private @type {number} The x-position of the animated outer left bar. */
1878 + this.outerLeftAnim_x = 0;
1879 + /** @private @type {number} The offset for the outer left bar. */
1880 + this.outerLeft_k = 0;
1881 +
1882 + /** @private @type {number} The x-position of the main left bar. */
1883 + this.mainLeft_x = 0;
1884 + /** @private @type {number} The x-position of the animated main left bar. */
1885 + this.mainLeftAnim_x = 0;
1886 + /** @private @type {number} The secondary x-position of the animated main left bar. */
1887 + this.mainLeftAnim2_x = 0;
1888 + /** @private @type {number} The offset for the main left bar. */
1889 + this.mainLeft_k = 0;
1890 + /** @private @type {number} The secondary offset for the main left bar. */
1891 + this.mainLeft2_k = 0;
1892 +
1893 + // * Bottom
1894 + /** @private @type {number} The width of the outer right bar. */
1895 + this.outerRight_w = 0;
1896 + /** @private @type {number} The old width of the outer right bar. */
1897 + this.outerRight_w_old = 0;
1898 + /** @private @type {number} The animated width of the outer right bar. */
1899 + this.outerRightAnim_w = 0;
1900 + /** @private @type {number} The x-position of the animated outer right bar. */
1901 + this.outerRightAnim_x = 0;
1902 + /** @private @type {number} The offset for the outer right bar. */
1903 + this.outerRight_k = 0;
1904 +
1905 + /** @private @type {number} The x-position of the main right bar. */
1906 + this.mainRight_x = 0;
1907 + /** @private @type {number} The x-position of the animated main right bar. */
1908 + this.mainRightAnim_x = 0;
1909 + /** @private @type {number} The secondary x-position of the animated main right bar. */
1910 + this.mainRightAnim2_x = 0;
1911 + /** @private @type {number} The offset for the main right bar. */
1912 + this.mainRight_k = 0;
1913 + /** @private @type {number} The secondary offset for the main right bar. */
1914 + this.mainRight2_k = 0;
1915 + // #endregion
1916 +
1917 + // * GEOMETRY - STYLE VERTICAL * //
1918 + // #region GEOMETRY - STYLE VERTICAL
1919 + /** @private @type {number} The vertical offset for the bar. */
1920 + this.vertBar_offset = 0;
1921 + /** @private @type {number} The vertical width of the bar. */
1922 + this.vertBar_w = 0;
1923 + /** @private @type {number} The vertical height of the bar. */
1924 + this.vertBar_h = 0;
1925 + /** @private @type {number} The x-coordinate for the left vertical bar. */
1926 + this.vertLeft_x = 0;
1927 + /** @private @type {number} The x-coordinate for the right vertical bar. */
1928 + this.vertRight_x = 0;
1929 + // #endregion
1930 +
1931 + // * PROGRESS BAR * //
1932 + // #region PROGRESS BAR
1933 + /** @public @type {number} The length of the progress bar. */
1934 + this.progressLength = 0;
1935 + /** @private @type {boolean} The state indicating whether the progress bar is being dragged. */
1936 + this.drag = false;
1937 + // #endregion
1938 +
1939 + // * MOUSE EVENTS * //
1940 + // #region MOUSE EVENTS
1941 + /** @private @type {number} The x-coordinate position of the mouse. */
1942 + this.pos_x = 0;
1943 + /** @private @type {number} The y-coordinate position of the mouse. */
1944 + this.pos_y = 0;
1945 + /** @private @type {boolean} The state indicating whether the mouse is over the peakmeter bar. */
1946 + this.on_mouse = false;
1947 + /** @private @type {boolean} The state indicating whether the mouse wheel is being used. */
1948 + this.wheel = false;
1949 + // #endregion
1950 +
1951 + // * TEXT * //
1952 + // #region TEXT
1953 + /** @private @type {GdiFont} The font used for text rendering. */
1954 + this.textFont = gdi.Font('Segoe UI', HD_4K(9, 16), 1);
1955 + /** @private @type {number} The width of the text. */
1956 + this.textWidth = 0;
1957 + /** @private @type {number} The height of the text. */
1958 + this.textHeight = 0;
1959 + /** @private @type {string} The text for the tooltip. */
1960 + this.tooltipText = '';
1961 + // #endregion
1962 +
1963 + // * VOLUME * //
1964 + // #region VOLUME
1965 + /** @private @type {number[]} The middle decibel values. */
1966 + this.db_middle = [];
1967 + /** @private @type {number[]} The current decibel values. */
1968 + this.db = [];
1969 + /** @private @type {object[]} The vertical decibel values. */
1970 + this.db_vert = {};
1971 +
1972 + /** @private @type {number} The middle points for the peakmeter. */
1973 + this.points_middle = 0;
1974 + /** @private @type {number} The points for the peakmeter. */
1975 + this.points = 0;
1976 + /** @private @type {number} The vertical points for the peakmeter. */
1977 + this.points_vert = 0;
1978 + /** @private @type {number[]} The left peaks for the peakmeter. */
1979 + this.leftPeaks_s = [];
1980 + /** @private @type {number[]} The right peaks for the peakmeter. */
1981 + this.rightPeaks_s = [];
1982 + // #endregion
1983 +
1984 + // * COLORS * //
1985 + // #region COLORS
1986 + /** @private @type {number} The separator index for the peakmeter. */
1987 + this.separator = 0;
1988 + /** @private @type {number} The first separator value for the peakmeter. */
1989 + this.sep1 = 0;
1990 + /** @private @type {number} The second separator value for the peakmeter. */
1991 + this.sep2 = 0;
1992 + // #endregion
1993 +
1994 + // * INITIALIZATION * //
1995 + // #region INITIALIZATION
1996 + grm.ui.seekbarTimerInterval = grSet.peakmeterBarRefreshRate === 'variable' ? FPS._10 : grSet.peakmeterBarRefreshRate;
1997 +
1998 + this.initDecibel();
1999 + this.initPeaks();
2000 + this.initPoints();
2001 + this.initSeparator();
2002 + this.setColors();
2003 + // #endregion
2004 + }
2005 +
2006 + // * PUBLIC METHODS - DRAW * //
2007 + // #region PUBLIC METHODS - DRAW
2008 + /**
2009 + * Draws the peakmeter bar in various peakmeter bar designs.
2010 + * @param {GdiGraphics} gr - The GDI graphics object.
2011 + */
2012 + draw(gr) {
2013 + if (!fb.IsPlaying || !AudioWizard) {
2014 + gr.FillSolidRect(this.x, this.y, this.w, this.h, grCol.bg);
2015 + gr.FillSolidRect(this.x, grm.ui.seekbarY, this.w, SCALE(grSet.layout !== 'default' ? 10 : 12), grCol.progressBar);
2016 + return;
2017 + }
2018 +
2019 + if (grSet.peakmeterBarRefreshRate === 'variable' || grm.debug.showDrawExtendedTiming) {
2020 + grm.ui.seekbarProfiler.Reset();
2021 + }
2022 +
2023 + this.drawPeakmeterBar(gr);
2024 + this.setAnimation();
2025 + this.setMonitoring();
2026 + this.setRefreshRate();
2027 + }
2028 +
2029 + /**
2030 + * Draws the peakmeter bar design based on design setting.
2031 + * @param {GdiGraphics} gr - The GDI graphics object.
2032 + */
2033 + drawPeakmeterBar(gr) {
2034 + const drawBarDesign = {
2035 + horizontal: () => this.drawBarDesignHorizontal(gr),
2036 + horizontal_center: () => this.drawBarDesignCenter(gr),
2037 + vertical: () => this.drawBarDesignVertical(gr)
2038 + };
2039 +
2040 + drawBarDesign[grSet.peakmeterBarDesign]();
2041 + }
2042 +
2043 + /**
2044 + * Draws the horizontal peakmeter bar design.
2045 + * @param {GdiGraphics} gr - The GDI graphics object.
2046 + */
2047 + drawBarDesignHorizontal(gr) {
2048 + this.drawBarGrid(gr);
2049 +
2050 + for (let i = 0; i <= this.points; i++) {
2051 + const color = this.color[i];
2052 + const db = this.db[i];
2053 + const dbNext = this.db[i + 1];
2054 + const offset = i * this.offset;
2055 +
2056 + this.drawHorizontalMainBars(gr, db, dbNext, offset, this.leftPeak, this.mainLeftAnim_x, this.mainLeftAnim2_x, 'mainLeft_x', this.mainLeft_y, color);
2057 + this.drawHorizontalMainBars(gr, db, dbNext, offset, this.rightPeak, this.mainRightAnim_x, this.mainRightAnim2_x, 'mainRight_x', this.mainRight_y, color);
2058 +
2059 + this.drawHorizontalOuterBars(gr, db, dbNext, offset, this.leftLevel, this.outerLeftAnim_x, this.outerLeftAnim_w, this.outerLeft_y, 'outerLeft_w');
2060 + this.drawHorizontalOuterBars(gr, db, dbNext, offset, this.rightLevel, this.outerRightAnim_x, this.outerRightAnim_w, this.outerRight_y, 'outerRight_w');
2061 +
2062 + this.drawOverBars(gr);
2063 + }
2064 +
2065 + this.drawMiddleBars(gr);
2066 + this.drawProgressBar(gr);
2067 + this.drawBarInfo(gr);
2068 + }
2069 +
2070 + /**
2071 + * Draws the main bars in the horizontal peakmeter bar design.
2072 + * @param {GdiGraphics} gr - The GDI graphics object.
2073 + * @param {number} db - The current decibel level.
2074 + * @param {number} dbNext - The next decibel level.
2075 + * @param {number} offset - The offset for drawing.
2076 + * @param {number} peak - The peak value.
2077 + * @param {number} anim_x - The animation x-coordinate.
2078 + * @param {number} anim2_x - The second animation x-coordinate.
2079 + * @param {string} main_x - The main x-coordinate property key name.
2080 + * @param {number} main_y - The main y-coordinate.
2081 + * @param {number} color - The color of the bar.
2082 + * @private
2083 + */
2084 + drawHorizontalMainBars(gr, db, dbNext, offset, peak, anim_x, anim2_x, main_x, main_y, color) {
2085 + if (peak <= db) return;
2086 +
2087 + if (peak < dbNext) this[main_x] = offset;
2088 +
2089 + if (grSet.peakmeterBarMainBars) {
2090 + gr.FillSolidRect(this.x + offset, main_y, this.w2, this.bar_h, color);
2091 + }
2092 +
2093 + if (grSet.peakmeterBarMainPeaks) {
2094 + const color = this.color[Math.round(this.mainLeftAnim_x / this.offset)];
2095 + gr.FillSolidRect(this.x + anim_x + this.offset, main_y, this.w2 * 0.66, this.bar_h, color);
2096 +
2097 + const x = Clamp(this.x + anim2_x + this.offset + this.w2 * 0.66, this.x, this.x + this.w - this.w2 * 0.33);
2098 + const w = x > this.w + this.w2 * 0.5 ? 0 : this.w2 * 0.33;
2099 + gr.FillSolidRect(x, main_y, w, this.bar_h, color);
2100 + }
2101 + }
2102 +
2103 + /**
2104 + * Draws the outer bars in the horizontal peakmeter bar design.
2105 + * @param {GdiGraphics} gr - The GDI graphics object.
2106 + * @param {number} db - The current decibel level.
2107 + * @param {number} dbNext - The next decibel level.
2108 + * @param {number} offset - The offset for drawing.
2109 + * @param {number} level - The level value.
2110 + * @param {number} anim_x - The animation x-coordinate.
2111 + * @param {number} anim_w - The animation width.
2112 + * @param {number} outer_y - The outer y-coordinate.
2113 + * @param {string} outer_w - The outer width property key name.
2114 + * @private
2115 + */
2116 + drawHorizontalOuterBars(gr, db, dbNext, offset, level, anim_x, anim_w, outer_y, outer_w) {
2117 + if (level <= db) return;
2118 +
2119 + if (level < dbNext) {
2120 + this[outer_w] = offset + this.offset / Math.abs(dbNext - db) * Math.abs(level - db) - this.x;
2121 + }
2122 +
2123 + if (grSet.peakmeterBarOuterBars) {
2124 + gr.FillSolidRect(this.x, outer_y, this[outer_w], this.bar_h, this.color[1]);
2125 + }
2126 +
2127 + if (grSet.peakmeterBarOuterPeaks) {
2128 + const x = Clamp(this.x + anim_x, this.x, this.x + this.w - anim_w);
2129 + gr.FillSolidRect(x, outer_y, anim_w <= 0 ? 2 : anim_w, this.bar_h, this.color[1]);
2130 + }
2131 + }
2132 +
2133 + /**
2134 + * Draws the horizontal center peakmeter bar design.
2135 + * @param {GdiGraphics} gr - The GDI graphics object.
2136 + */
2137 + drawBarDesignCenter(gr) {
2138 + this.drawBarGrid(gr);
2139 +
2140 + for (let i = 0; i <= this.points; i++) {
2141 + const color = this.color[i];
2142 + const db = this.db[i];
2143 + const dbNext = this.db[i + 1];
2144 + const offset = i * this.offset;
2145 + const mainLeft_x = this.x * 0.5 + this.w * 0.5 - i * this.offset + this.w2;
2146 + const mainRight_x = this.x + this.w * 0.5 + i * this.offset - this.w2;
2147 +
2148 + this.drawCenterMainBars(gr, db, dbNext, offset, this.leftPeak, this.mainLeftAnim_x, this.mainLeftAnim2_x, mainLeft_x, mainRight_x, 'mainLeft_x', this.mainLeft_y, color);
2149 + this.drawCenterMainBars(gr, db, dbNext, offset, this.rightPeak, this.mainRightAnim_x, this.mainRightAnim2_x, mainLeft_x, mainRight_x, 'mainRight_x', this.mainRight_y, color);
2150 +
2151 + this.drawCenterOuterBars(gr, db, dbNext, offset, this.leftLevel, this.outerLeftAnim_x, this.outerLeftAnim_w, this.outerLeft_y, 'outerLeft_w');
2152 + this.drawCenterOuterBars(gr, db, dbNext, offset, this.rightLevel, this.outerRightAnim_x, this.outerRightAnim_w, this.outerRight_y, 'outerRight_w');
2153 +
2154 + this.drawOverBars(gr);
2155 + }
2156 +
2157 + this.drawMiddleBars(gr);
2158 + this.drawProgressBar(gr);
2159 + this.drawBarInfo(gr);
2160 + }
2161 +
2162 + /**
2163 + * Draws the main bars in the horizontal center peakmeter bar design.
2164 + * @param {GdiGraphics} gr - The GDI graphics object.
2165 + * @param {number} db - The current decibel level.
2166 + * @param {number} dbNext - The next decibel level.
2167 + * @param {number} offset - The offset for drawing.
2168 + * @param {number} peak - The peak value.
2169 + * @param {number} anim_x - The animation x-coordinate.
2170 + * @param {number} anim2_x - The second animation x-coordinate.
2171 + * @param {number} mainLeft_x - The main left x-coordinate.
2172 + * @param {number} mainRight_x - The main right x-coordinate.
2173 + * @param {string} main_x - The main x-coordinate property key name.
2174 + * @param {number} main_y - The main y-coordinate.
2175 + * @param {string} color - The color of the bar.
2176 + * @private
2177 + */
2178 + drawCenterMainBars(gr, db, dbNext, offset, peak, anim_x, anim2_x, mainLeft_x, mainRight_x, main_x, main_y, color) {
2179 + if (peak <= db) return;
2180 +
2181 + if (peak < dbNext) this[main_x] = offset;
2182 +
2183 + if (grSet.peakmeterBarMainBars) {
2184 + gr.FillSolidRect(mainLeft_x, main_y, this.w2, this.bar_h, color);
2185 + gr.FillSolidRect(mainRight_x, main_y, this.w2, this.bar_h, color);
2186 + }
2187 +
2188 + if (grSet.peakmeterBarMainPeaks) {
2189 + const color = this.color[Math.round(anim_x / this.offset)];
2190 + const xLeft = this.x * 0.5 + this.w * 0.5 - (anim_x + this.offset) + this.w2 * 0.33;
2191 + const xRight = this.x + anim_x + this.offset + this.w * 0.5;
2192 + gr.FillSolidRect(xLeft, main_y, this.w2 * 0.66, this.bar_h, color);
2193 + gr.FillSolidRect(xRight, main_y, this.w2 * 0.66, this.bar_h, color);
2194 +
2195 + const xLeftPeaks = this.x + this.w * 0.5 - anim2_x - this.offset - this.w2 * 0.66;
2196 + const wLeftPeaks = xLeftPeaks < this.x + this.w2 * 0.5 ? 0 : this.w2 * 0.33;
2197 + const xRightPeaks = this.x + this.w * 0.5 + anim2_x + this.offset + this.w2 * 0.66;
2198 + const wRightPeaks = xRightPeaks > this.w + this.w2 * 0.5 ? 0 : this.w2 * 0.33;
2199 + gr.FillSolidRect(xLeftPeaks, main_y, wLeftPeaks, this.bar_h, color);
2200 + gr.FillSolidRect(xRightPeaks, main_y, wRightPeaks, this.bar_h, color);
2201 + }
2202 + }
2203 +
2204 + /**
2205 + * Draws the outer bars in the horizontal center peakmeter bar design.
2206 + * @param {GdiGraphics} gr - The GDI graphics object.
2207 + * @param {number} db - The current decibel level.
2208 + * @param {number} dbNext - The next decibel level.
2209 + * @param {number} offset - The offset for drawing.
2210 + * @param {number} level - The level value.
2211 + * @param {number} anim_x - The animation x-coordinate.
2212 + * @param {number} anim_w - The animation width.
2213 + * @param {number} outer_y - The outer y-coordinate.
2214 + * @param {string} outer_w - The outer width property name.
2215 + * @private
2216 + */
2217 + drawCenterOuterBars(gr, db, dbNext, offset, level, anim_x, anim_w, outer_y, outer_w) {
2218 + if (level <= db) return;
2219 +
2220 + if (level < dbNext) {
2221 + this[outer_w] = offset + this.offset / Math.abs(dbNext - db) * Math.abs(level - db) - this.x;
2222 + }
2223 +
2224 + if (grSet.peakmeterBarOuterBars) {
2225 + const xLeft = Clamp(this.x + this.w * 0.5 - this[outer_w], this.x, this.w * 0.5);
2226 + const xRight = this.x + this.w * 0.5;
2227 + const w = Clamp(this[outer_w], 0, this.w * 0.5);
2228 + gr.FillSolidRect(xLeft, outer_y, w, this.bar_h, this.color[1]);
2229 + gr.FillSolidRect(xRight, outer_y, w, this.bar_h, this.color[1]);
2230 + }
2231 +
2232 + if (grSet.peakmeterBarOuterPeaks) {
2233 + const x = Clamp(this.x + anim_x, this.x, this.x + this.w * 0.5 - anim_w);
2234 + const w = anim_w <= 0 ? 2 : anim_w;
2235 + const xLeftPeaks = this.w * 0.5 + this.x * 2 - x - w;
2236 + const xRightPeaks = this.w * 0.5 + x;
2237 + gr.FillSolidRect(xLeftPeaks, outer_y, w, this.bar_h, this.color[1]);
2238 + gr.FillSolidRect(xRightPeaks, outer_y, w, this.bar_h, this.color[1]);
2239 + }
2240 + }
2241 +
2242 + /**
2243 + * Draws the vertical peakmeter bar design.
2244 + * @param {GdiGraphics} gr - The GDI graphics object.
2245 + */
2246 + drawBarDesignVertical(gr) {
2247 + const peakL = Math.round(this.leftPeak);
2248 + const peakR = Math.round(this.rightPeak);
2249 + const vertBarH = this.vertBar_h * 1.5;
2250 +
2251 + const toleranceBase = 0.05;
2252 + const toleranceMin = 0.1;
2253 + const toleranceMax = 1.0;
2254 + const toleranceL = Clamp(toleranceBase * Math.abs(peakL), toleranceMin, toleranceMax);
2255 + const toleranceR = Clamp(toleranceBase * Math.abs(peakR), toleranceMin, toleranceMax);
2256 +
2257 + for (let i = 0; i < this.points_vert; i++) {
2258 + const dbL = this.db_vert[i];
2259 + const dbR = this.db_vert[this.points_vert - 1 - i];
2260 + const offset = this.vertBar_offset * i;
2261 +
2262 + if (Math.abs(peakL - dbL) <= toleranceL) this.leftPeaks_s[i] = vertBarH;
2263 + if (Math.abs(peakR - dbR) <= toleranceR) this.rightPeaks_s[i] = vertBarH;
2264 +
2265 + this.drawVerticalPeaks(gr, this.vertLeft_x, offset, this.leftPeaks_s[i]);
2266 + this.drawVerticalPeaks(gr, this.vertRight_x, offset, this.rightPeaks_s[i]);
2267 + }
2268 +
2269 + if (grSet.peakmeterBarVertBaseline) {
2270 + gr.FillSolidRect(this.x, this.y + this.h - this.vertBar_h, this.w, this.vertBar_h, grCol.peakmeterBarProg);
2271 + }
2272 +
2273 + this.drawProgressBar(gr);
2274 + this.drawBarInfo(gr);
2275 + }
2276 +
2277 + /**
2278 + * Draws the peaks in the vertical peakmeter bar design.
2279 + * @param {GdiGraphics} gr - The GDI graphics object.
2280 + * @param {number} xBase - The base x-coordinate.
2281 + * @param {number} offset - The offset for drawing.
2282 + * @param {number} peak_s - The peak value.
2283 + * @private
2284 + */
2285 + drawVerticalPeaks(gr, xBase, offset, peak_s) {
2286 + const x = xBase + offset;
2287 + const y = this.y + peak_s - this.vertBar_h;
2288 +
2289 + if (peak_s <= this.h) {
2290 + const h = Math.min(this.h - peak_s, this.h);
2291 + gr.FillSolidRect(x, y, this.vertBar_w, h, grCol.peakmeterBarVertFill);
2292 + }
2293 +
2294 + if (grSet.peakmeterBarVertPeaks && peak_s >= 0) {
2295 + gr.FillSolidRect(x, y, this.vertBar_w, this.vertBar_h, grCol.peakmeterBarVertFillPeaks);
2296 + }
2297 + }
2298 +
2299 + /**
2300 + * Draws the over bars in the peakmeter bar designs.
2301 + * @param {GdiGraphics} gr - The GDI graphics object.
2302 + * @private
2303 + */
2304 + drawOverBars(gr) {
2305 + if (!grSet.peakmeterBarOverBars) return;
2306 +
2307 + const widthSize = grSet.peakmeterBarDesign === 'horizontal' ? 1 : 0.5;
2308 + const overLeft = this.outerLeftAnim_x + this.outerLeftAnim_w - (this.w * widthSize);
2309 + const overRight = this.outerRightAnim_x + this.outerRightAnim_w - (this.w * widthSize);
2310 +
2311 + const outerAnim = this.outerLeftAnim_x - this.outerLeftAnim_w;
2312 + const outerAnimHalf = outerAnim * 0.5;
2313 +
2314 + const xLeft = this.w - overLeft - this.x;
2315 + const xRight = this.w - overRight - this.x;
2316 + const xLeft2 = Clamp(this.w * 0.5 - overLeft - outerAnim, this.x, this.w * 0.5);
2317 + const xRight2 = Clamp(this.w * 0.5 - overRight - outerAnim, this.x, this.w * 0.5);
2318 +
2319 + const wLeft = this.w - xLeft + this.x;
2320 + const wRight = this.w - xRight + this.x;
2321 + const wLeft2 = this.w - xLeft + outerAnimHalf;
2322 + const wRight2 = this.w - xRight + outerAnimHalf;
2323 +
2324 + if (overLeft > 0) { // Top
2325 + gr.FillSolidRect(xLeft, this.overLeft_y, wLeft, this.bar2_h, this.color[10]);
2326 + grSet.peakmeterBarDesign === 'horizontal_center' && gr.FillSolidRect(xLeft2, this.overLeft_y, wLeft2, this.bar2_h, this.color[10]);
2327 + }
2328 + if (overRight > 0) { // Bottom
2329 + gr.FillSolidRect(xRight, this.overRight_y, wRight, this.bar2_h, this.color[10]);
2330 + grSet.peakmeterBarDesign === 'horizontal_center' && gr.FillSolidRect(xRight2, this.overRight_y, wRight2, this.bar2_h, this.color[10]);
2331 + }
2332 + }
2333 +
2334 + /**
2335 + * Draws the middle bars in the peakmeter bar designs.
2336 + * @param {GdiGraphics} gr - The GDI graphics object.
2337 + * @private
2338 + */
2339 + drawMiddleBars(gr) {
2340 + if (!grSet.peakmeterBarMiddleBars) return;
2341 +
2342 + if (grSet.peakmeterBarDesign === 'horizontal') {
2343 + for (let i = 0; i <= this.points_middle; i++) {
2344 + const dbMiddle = this.db_middle[i];
2345 + const x = this.x + i * this.middleOffset;
2346 +
2347 + if (this.leftPeak > dbMiddle) {
2348 + gr.FillSolidRect(x, this.middleLeft_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
2349 + }
2350 + if (this.rightPeak > dbMiddle) {
2351 + gr.FillSolidRect(x, this.middleRight_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
2352 + }
2353 + }
2354 + }
2355 + else if (grSet.peakmeterBarDesign === 'horizontal_center') {
2356 + for (let i = 0; i <= this.points_middle; i++) {
2357 + const dbMiddle = this.db_middle[i];
2358 + const x1 = this.x * 0.5 + this.w * 0.5 - i * this.middleOffset + 1;
2359 + const x2 = this.x + this.w * 0.5 + i * this.middleOffset - 1;
2360 +
2361 + if (this.leftPeak > dbMiddle) {
2362 + gr.FillSolidRect(x1, this.middleLeft_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
2363 + gr.FillSolidRect(x2, this.middleLeft_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
2364 + }
2365 + if (this.rightPeak > dbMiddle) {
2366 + gr.FillSolidRect(x1, this.middleRight_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
2367 + gr.FillSolidRect(x2, this.middleRight_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
2368 + }
2369 + }
2370 + }
2371 + }
2372 +
2373 + /**
2374 + * Draws the progress bar in the peakmeter bar designs.
2375 + * @param {GdiGraphics} gr - The GDI graphics object.
2376 + * @private
2377 + */
2378 + drawProgressBar(gr) {
2379 + if (!fb.IsPlaying || fb.PlaybackLength <= 0 || !grSet.peakmeterBarProgBar) return;
2380 +
2381 + const playbackRatio = fb.PlaybackTime / fb.PlaybackLength;
2382 + this.progressLength = Math.floor(this.w * (grSet.peakmeterBarDesign === 'horizontal_center' ? 0.5 : 1) * playbackRatio);
2383 +
2384 + if (grSet.peakmeterBarDesign === 'horizontal') {
2385 + gr.FillSolidRect(this.x, this.middleLeft_y, this.w, this.bar_h, grCol.peakmeterBarProg);
2386 + gr.FillSolidRect(this.x, this.middleLeft_y, this.progressLength, this.bar_h, grCol.peakmeterBarProgFill);
2387 + }
2388 + else if (grSet.peakmeterBarDesign === 'horizontal_center') {
2389 + gr.FillSolidRect(this.x, this.middleLeft_y, this.w, this.bar_h, grCol.peakmeterBarProg);
2390 + gr.FillSolidRect(this.x + this.w * 0.5 - this.progressLength, this.middleLeft_y, this.progressLength, this.bar_h, grCol.peakmeterBarProgFill);
2391 + gr.FillSolidRect(this.x + this.w * 0.5, this.middleLeft_y, this.progressLength, this.bar_h, grCol.peakmeterBarProgFill);
2392 + }
2393 + else if (grSet.peakmeterBarDesign === 'vertical') {
2394 + gr.FillSolidRect(this.x, this.y + this.h - this.vertBar_h, this.w, this.bar_h, grCol.peakmeterBarProg);
2395 + gr.FillSolidRect(this.x, this.y + this.h - this.vertBar_h, this.progressLength, this.bar_h, grCol.peakmeterBarVertProgFill);
2396 + }
2397 + }
2398 +
2399 + /**
2400 + * Draws the grid in the peakmeter bar designs.
2401 + * @param {GdiGraphics} gr - The GDI graphics object.
2402 + */
2403 + drawBarGrid(gr) {
2404 + if (!grSet.peakmeterBarGrid) return;
2405 + gr.FillSolidRect(this.x, this.outerLeft_y, this.w, this.bar_h, grCol.peakmeterBarProg);
2406 + gr.FillSolidRect(this.x, this.outerRight_y, this.w, this.bar_h, grCol.peakmeterBarProg);
2407 + }
2408 +
2409 + /**
2410 + * Draws the bar info in the peakmeter bar designs.
2411 + * @param {GdiGraphics} gr - The GDI graphics object.
2412 + */
2413 + drawBarInfo(gr) {
2414 + if (!grSet.peakmeterBarInfo) return;
2415 +
2416 + const infoTextColor = grCol.lowerBarArtist;
2417 +
2418 + if (grSet.peakmeterBarDesign === 'horizontal') {
2419 + const text_db_w = gr.CalcTextWidth('db', this.textFont);
2420 +
2421 + for (let i = 4; i <= this.points; i += 2) {
2422 + const text_w = gr.CalcTextWidth(this.db[i], this.textFont);
2423 + gr.GdiDrawText(this.db[i], this.textFont, infoTextColor, this.x + this.offset * i - text_w * 0.5, this.text_y, this.w, this.h);
2424 + }
2425 +
2426 + gr.GdiDrawText('db', this.textFont, infoTextColor, this.x + this.offset * 2 - text_db_w, this.text_y, this.w, this.h);
2427 + }
2428 + else if (grSet.peakmeterBarDesign === 'horizontal_center') {
2429 + const text_db_w = gr.CalcTextWidth('db', this.textFont);
2430 +
2431 + for (let i = 4; i <= this.points; i += 2) {
2432 + const textRight_w = gr.CalcTextWidth(this.db[i], this.textFont);
2433 + const textLeft_w2 = gr.CalcTextWidth(`${this.db[this.points + 3 - i]}-`, this.textFont);
2434 +
2435 + gr.GdiDrawText(this.db[i], this.textFont, infoTextColor, this.w * 0.5 + this.offset * i - textRight_w * 0.5, this.text_y, this.w, this.h);
2436 + gr.GdiDrawText(this.db[this.points + 3 - i], this.textFont, infoTextColor, this.x + this.offset * i - textLeft_w2 * 1.5, this.text_y, this.w, this.h);
2437 + }
2438 +
2439 + gr.GdiDrawText('db', this.textFont, infoTextColor, this.w * 0.5 + this.offset * 2 - text_db_w * 0.5, this.text_y, this.w, this.h);
2440 + }
2441 + else if (grSet.peakmeterBarDesign === 'vertical') {
2442 + for (let i = 0; i <= this.points_vert; i++) {
2443 + const dbLeft = this.db_vert[i];
2444 + const dbRight = this.db_vert[this.points_vert - 1 - i];
2445 +
2446 + const textLeft_w = gr.CalcTextWidth(`${dbLeft}--`, this.textFont);
2447 + const textRight_w = gr.CalcTextWidth(`${dbRight}--`, this.textFont);
2448 + const textLeft_x = this.vertLeft_x + this.vertBar_offset * i - textLeft_w / 2 + (this.vertBar_offset - this.vertBar_w);
2449 + const textRight_x = this.vertRight_x + this.vertBar_offset * i - textRight_w / 2 + (this.vertBar_offset - this.vertBar_w);
2450 +
2451 + gr.GdiDrawText(dbLeft % 2 === 0 ? dbLeft : '', this.textFont, infoTextColor, textLeft_x, this.y, grm.ui.ww, grm.ui.wh);
2452 + gr.GdiDrawText(dbRight % 2 === 0 ? dbRight : '', this.textFont, infoTextColor, textRight_x, this.y, grm.ui.ww, grm.ui.wh);
2453 + }
2454 + }
2455 + }
2456 + // #endregion
2457 +
2458 + // * PUBLIC METHODS - INITIALIZATION * //
2459 + // #region PUBLIC METHODS - INITIALIZATION
2460 + /**
2461 + * Initializes the decibel arrays for different configurations.
2462 + */
2463 + initDecibel() {
2464 + this.db = [
2465 + -20, -19.5, -19, -18.5, -18, -17.5, -17, -16.5, -16, -15.5, -15, -14.5, -14, -13.5, -13, -12.5, -12, -11.5, -11, -10.5,
2466 + -10, -9.5, -9, -8.5, -8, -7.5, -7, -6.5, -6, -5.5, -5, -4.5, -4, -3.5, -3, -2.5, -2, -1.5, -1, -0.5, 0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5
2467 + ];
2468 + this.db_middle = [
2469 + -70, -65, -62.5, -60, -57.5, -55, -52.5, -50, -47.5, -45, -42.5, -40, -37.5, -35, -32.5, -30, -27.5, -25, -22.5
2470 + ];
2471 + this.db_vert = {
2472 + 220: [-20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2],
2473 + 215: [-15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2],
2474 + 210: [-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2],
2475 + 320: [-20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3],
2476 + 315: [-15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3],
2477 + 310: [-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3],
2478 + 520: [-20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5],
2479 + 515: [-15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5],
2480 + 510: [-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5]
2481 + }[grSet.peakmeterBarVertDbRange];
2482 + }
2483 +
2484 + /**
2485 + * Initializes the points for different decibel arrays.
2486 + */
2487 + initPoints() {
2488 + this.points_middle = this.db_middle.length;
2489 + this.points = this.db.length;
2490 + this.points_vert = this.db_vert.length;
2491 +
2492 + for (let i = 0; i <= this.points_vert; i++) {
2493 + this.leftPeaks_s[i] = 0;
2494 + this.rightPeaks_s[i] = 0;
2495 + }
2496 + }
2497 +
2498 + /**
2499 + * Initializes the peaks arrays for left and right channels.
2500 + */
2501 + initPeaks() {
2502 + this.leftPeaks_s = new Array(this.points_vert + 1).fill(0);
2503 + this.rightPeaks_s = new Array(this.points_vert + 1).fill(0);
2504 + }
2505 +
2506 + /**
2507 + * Initializes the separator index based on the decibel array.
2508 + */
2509 + initSeparator() {
2510 + this.separator = this.db.indexOf(0);
2511 + this.sep1 = this.separator;
2512 + this.sep2 = this.points - this.sep1;
2513 + }
2514 +
2515 + /**
2516 + * Initializes bar geometry properties.
2517 + */
2518 + initGeometry() {
2519 + this.bar_h = grSet.layout !== 'default' ? SCALE(2) : SCALE(4);
2520 +
2521 + this.offset = (grSet.peakmeterBarDesign === 'horizontal_center' ? this.w * 0.5 : this.w) / this.points;
2522 + this.middleOffset = (grSet.peakmeterBarDesign === 'horizontal_center' ? this.w * 0.5 : this.w) / this.points_middle;
2523 + this.middle_w = this.middleOffset - (grSet.peakmeterBarGaps ? 1 : 0);
2524 + this.w2 = this.offset - (grSet.peakmeterBarGaps ? 1 : 0);
2525 +
2526 + this.vertBar_offset = ((this.w / this.points_vert) + ((grSet.peakmeterBarVertSize === 'min' ? 2 : grSet.peakmeterBarVertSize) / this.points_vert * 0.5)) * 0.5;
2527 + this.vertBar_w = grSet.peakmeterBarVertSize === 'min' ? Math.ceil(this.vertBar_offset * 0.1 * 0.5) : this.vertBar_offset - grSet.peakmeterBarVertSize * 0.5;
2528 + this.vertBar_h = 2;
2529 + this.vertLeft_x = this.x;
2530 + this.vertRight_x = this.vertLeft_x + this.vertBar_offset * this.points_vert;
2531 + }
2532 + // #endregion
2533 +
2534 + // * PUBLIC METHODS - COMMON * //
2535 + // #region PUBLIC METHODS - COMMON
2536 + /**
2537 + * Resets the state of the peakmeter bar.
2538 + */
2539 + reset() {
2540 + this.leftLevel = 0;
2541 + this.leftPeak = 0;
2542 + this.rightLevel = 0;
2543 + this.rightPeak = 0;
2544 + this.leftPeaks_s = [];
2545 + this.rightPeaks_s = [];
2546 + this.progressLength = 0;
2547 + this.tooltipText = '';
2548 + }
2549 +
2550 + /**
2551 + * Sets all vertical peakmeter bar positions.
2552 + * Bars are ordered from top to bottom.
2553 + * @param {number} y - The y-coordinate.
2554 + */
2555 + setY(y = grm.ui.seekbarY) {
2556 + this.y = y;
2557 + this.overLeft_y = this.y;
2558 + this.outerLeft_y = this.overLeft_y + this.bar2_h;
2559 + this.mainLeft_y = this.outerLeft_y + this.bar_h;
2560 + this.middleLeft_y = this.mainLeft_y + this.bar_h + SCALE(1);
2561 + this.middleRight_y = this.middleLeft_y + this.bar2_h;
2562 + this.mainRight_y = this.middleRight_y + this.bar2_h + SCALE(1);
2563 + this.outerRight_y = this.mainRight_y + this.bar_h;
2564 + this.overRight_y = this.outerRight_y + this.bar_h;
2565 + this.text_y = this.outerRight_y + this.bar_h * 2;
2566 + }
2567 +
2568 + /**
2569 + * Monitors volume levels and peaks and sets horizontal or vertical animations based on peakmeterBarDesign.
2570 + */
2571 + setAnimation() {
2572 + // * Set horizontal animation
2573 + if (['horizontal', 'horizontal_center'].includes(grSet.peakmeterBarDesign)) {
2574 + const increment1 = 0.09; // 0.3 ** 2
2575 + const increment2 = 1.21; // 1.1 ** 2
2576 +
2577 + // * Main left middle peaks
2578 + if (this.mainLeftAnim_x <= this.mainLeft_x) {
2579 + this.mainLeftAnim_x = this.mainLeft_x;
2580 + this.mainLeftAnim2_x = this.mainLeft_x;
2581 + this.mainLeft_k = 0;
2582 + this.mainLeft2_k = 0;
2583 + }
2584 + this.mainLeft_k += increment1;
2585 + this.mainLeftAnim_x -= this.mainLeft_k;
2586 + this.mainLeft2_k += increment2;
2587 + this.mainLeftAnim2_x += this.mainLeft2_k;
2588 +
2589 + // * Main right middle peaks
2590 + if (this.mainRightAnim_x <= this.mainRight_x) {
2591 + this.mainRightAnim_x = this.mainRight_x;
2592 + this.mainRightAnim2_x = this.mainRight_x;
2593 + this.mainRight_k = 0;
2594 + this.mainRight2_k = 0;
2595 + }
2596 + this.mainRight_k += increment1;
2597 + this.mainRightAnim_x -= this.mainRight_k;
2598 + this.mainRight2_k += increment2;
2599 + this.mainRightAnim2_x += this.mainRight2_k;
2600 +
2601 + // * Outer left peaks
2602 + if (this.outerLeftAnim_x <= this.outerLeft_w) {
2603 + this.outerLeftAnim_x = this.outerLeft_w;
2604 + this.outerLeft_k = 0;
2605 + this.outerLeftAnim_w = this.outerLeft_w - this.outerLeft_w_old < 1 ? this.outerLeftAnim_w : this.outerLeft_w - this.outerLeft_w_old + 10;
2606 + } else {
2607 + this.outerLeft_w_old = this.outerLeft_w;
2608 + }
2609 + this.outerLeft_k += increment1;
2610 + this.outerLeftAnim_x -= this.outerLeft_k;
2611 + this.outerLeftAnim_w -= this.outerLeft_k * 2;
2612 +
2613 + // * Outer right peaks
2614 + if (this.outerRightAnim_x <= this.outerRight_w) {
2615 + this.outerRightAnim_x = this.outerRight_w;
2616 + this.outerRight_k = 0;
2617 + this.outerRightAnim_w = this.outerRight_w - this.outerRight_w_old < 1 ? this.outerRightAnim_w : this.outerRight_w - this.outerRight_w_old + 10;
2618 + } else {
2619 + this.outerRight_w_old = this.outerRight_w;
2620 + }
2621 + this.outerRight_k += increment1;
2622 + this.outerRightAnim_x -= this.outerRight_k;
2623 + this.outerRightAnim_w -= this.outerRight_k * 2;
2624 + }
2625 + // * Set vertical animation
2626 + else if (grSet.peakmeterBarDesign === 'vertical') {
2627 + for (let j = 0; j < this.leftPeaks_s.length; j++) {
2628 + this.leftPeaks_s[j] = this.leftPeaks_s[j] < this.h ? this.leftPeaks_s[j] + 2 : this.h;
2629 + }
2630 + for (let j = 0; j < this.rightPeaks_s.length; j++) {
2631 + this.rightPeaks_s[j] = this.rightPeaks_s[j] < this.h ? this.rightPeaks_s[j] + 2 : this.h;
2632 + }
2633 + }
2634 + }
2635 +
2636 + /**
2637 + * Sets the peakmeter bar colors.
2638 + * @param {FbMetadbHandle} metadb - The metadb of the track.
2639 + */
2640 + setColors(metadb = fb.GetNowPlaying()) {
2641 + if (grSet.seekbar !== 'peakmeterbar') return;
2642 +
2643 + let img = gdi.CreateImage(1, 1);
2644 + const g = img.GetGraphics();
2645 + img.ReleaseGraphics(g);
2646 +
2647 + if (metadb) img = utils.GetAlbumArtV2(metadb, 0);
2648 +
2649 + if (img) {
2650 + try {
2651 + grm.ui.albumArtCorrupt = false;
2652 + // this.colors = JSON.parse(img.GetColourSchemeJSON(4));
2653 + this.c1 = grCol.peakmeterBarFillMiddle; // this.colors[1].col;
2654 + this.c2 = grCol.peakmeterBarFillTop; // this.colors[2].col;
2655 + this.c3 = grCol.peakmeterBarFillBack; // this.colors[3].col;
2656 + } catch (e) {
2657 + grm.ui.noArtwork = true;
2658 + grm.ui.noAlbumArtStub = true;
2659 + grm.ui.albumArtCorrupt = true;
2660 + this.setDefaultColors();
2661 + }
2662 + } else {
2663 + this.setDefaultColors();
2664 + }
2665 +
2666 + this.color = [];
2667 + this.color1 = [this.c2, this.c3];
2668 + this.color2 = [this.c3, this.c1];
2669 +
2670 + for (let j = 0; j < this.sep1; j++) {
2671 + this.color.push(BlendColors(this.color1[0], this.color1[1], j / this.sep1));
2672 + }
2673 + for (let j = 0; j < this.sep2; j++) {
2674 + this.color.push(BlendColors(this.color2[0], this.color2[1], j / this.sep2));
2675 + }
2676 + }
2677 +
2678 + /**
2679 + * Sets the default peakmeter bar colors.
2680 + */
2681 + setDefaultColors() {
2682 + this.c1 = grCol.peakmeterBarFillMiddle; // RGB(0, 200, 255);
2683 + this.c2 = grCol.peakmeterBarFillTop; // RGB(255, 255, 0);
2684 + this.c3 = grCol.peakmeterBarFillBack; // RGB(230, 230, 30);
2685 + this.color1 = [this.c3, this.c1];
2686 + this.color2 = [this.c2, this.c3];
2687 + }
2688 +
2689 + /**
2690 + * Sets monitoring of audio levels and peaks based on playback state.
2691 + * Converts and stores volume levels and peaks for both left and right channels in decibels.
2692 + */
2693 + setMonitoring() {
2694 + if (!AudioWizard) return;
2695 +
2696 + this.leftLevel = AudioWizard.PeakmeterAdjustedLeftRMS;
2697 + this.rightLevel = AudioWizard.PeakmeterAdjustedRightRMS;
2698 + this.leftPeak = AudioWizard.PeakmeterAdjustedLeftSamplePeak;
2699 + this.rightPeak = AudioWizard.PeakmeterAdjustedRightSamplePeak;
2700 + }
2701 +
2702 + /**
2703 + * Sets the playback time of the progress bar.
2704 + * @param {number} x - The x-coordinate.
2705 + * @private
2706 + */
2707 + setPlaybackTime(x) {
2708 + const clampedPosition = Clamp((x - this.x) / this.w, 0, 1);
2709 + const newPlaybackTime = clampedPosition * fb.PlaybackLength;
2710 + if (fb.PlaybackTime !== newPlaybackTime) {
2711 + fb.PlaybackTime = newPlaybackTime;
2712 + }
2713 + }
2714 +
2715 + /**
2716 + * Sets the refresh rate for the peakmeter bar.
2717 + */
2718 + setRefreshRate() {
2719 + if (grm.ui.isStreaming) { // Radio streaming refresh rate
2720 + grm.ui.seekbarTimerInterval = FPS._1;
2721 + }
2722 + else if (grSet.peakmeterBarRefreshRate !== 'variable') { // Fixed refresh rate
2723 + grm.ui.seekbarTimerInterval = grSet.peakmeterBarRefreshRate;
2724 + }
2725 + else { // Variable refresh rate calculation
2726 + const now = Date.now();
2727 + if (this.updateTimeLast && (now - this.updateTimeLast) < 250) return; // Check every 250 ms
2728 + this.updateTimeLast = now;
2729 +
2730 + if (this.profilerPaintTimeLast === undefined) {
2731 + this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time;
2732 + }
2733 +
2734 + const timeDifference = grm.ui.seekbarProfiler.Time - this.profilerPaintTimeLast;
2735 + grm.ui.seekbarTimerInterval = Clamp(grm.ui.seekbarTimerInterval + (timeDifference > 0 ? 8 : -5), FPS._20, FPS._10);
2736 + this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time;
2737 +
2738 + grm.ui.clearTimer('seekbar', true);
2739 + grm.ui.seekbarTimer = !fb.IsPaused ? setInterval(() => grm.ui.refreshSeekbar(), grm.ui.seekbarTimerInterval) : null;
2740 + }
2741 + }
2742 +
2743 + /**
2744 + * Starts Audio Wizard's peakmeter real-time monitoring.
2745 + */
2746 + startPeakmeter() {
2747 + const refreshRate = grSet.peakmeterBarRefreshRate === 'variable' ? 17 : grSet.peakmeterBarRefreshRate;
2748 + AudioWizard && AudioWizard.StartPeakmeterMonitoring(refreshRate, 50);
2749 + }
2750 +
2751 + /**
2752 + * Stops Audio Wizard's peakmeter real-time monitoring.
2753 + */
2754 + stopPeakmeter() {
2755 + AudioWizard && AudioWizard.StopPeakmeterMonitoring();
2756 + }
2757 + // #endregion
2758 +
2759 + // * CALLBACKS * //
2760 + // #region CALLBACKS
2761 + /**
2762 + * Checks if the mouse is within the boundaries of the peakmeter bar.
2763 + * @param {number} x - The x-coordinate.
2764 + * @param {number} y - The y-coordinate.
2765 + * @returns {boolean} True or false.
2766 + */
2767 + mouseInThis(x, y) {
2768 + return (x >= this.x && y >= this.y && x < this.x + this.w && y <= this.y + this.h);
2769 + }
2770 +
2771 + /**
2772 + * Handles left mouse button down click events and enables dragging.
2773 + * @param {number} x - The x-coordinate.
2774 + * @param {number} y - The y-coordinate.
2775 + */
2776 + on_mouse_lbtn_down(x, y) {
2777 + this.drag = true;
2778 + }
2779 +
2780 + /**
2781 + * Handles left mouse button up click events and disables dragging and updates the playback time.
2782 + * @param {number} x - The x-coordinate.
2783 + * @param {number} y - The y-coordinate.
2784 + */
2785 + on_mouse_lbtn_up(x, y) {
2786 + this.drag = false;
2787 + if (this.on_mouse && this.mouseInThis(x, y)) {
2788 + this.setPlaybackTime(x);
2789 + }
2790 + }
2791 +
2792 + /**
2793 + * Handle mouse leave events.
2794 + */
2795 + on_mouse_leave() {
2796 + this.drag = false;
2797 + this.on_mouse = false;
2798 + }
2799 +
2800 + /**
2801 + * Handles mouse movement events and updates the playback time based on the mouse movement if a drag event is occurring.
2802 + * @param {number} x - The x-coordinate.
2803 + * @param {number} y - The y-coordinate.
2804 + */
2805 + on_mouse_move(x, y) {
2806 + this.on_mouse = true;
2807 + this.pos_x = x <= this.textWidth ? this.x + this.textWidth : this.x + x;
2808 + this.pos_y = y <= this.textHeight ? this.textHeight : y;
2809 +
2810 + if (this.drag) {
2811 + this.setPlaybackTime(x);
2812 + }
2813 +
2814 + if (this.tooltipText) {
2815 + this.wheel = false;
2816 + this.tooltipTimer = false;
2817 + this.tooltipText = '';
2818 + grm.ttip.stop();
2819 + window.Repaint();
2820 + }
2821 + }
2822 +
2823 + /**
2824 + * Handles mouse wheel events and controls the volume offset.
2825 + * @param {number} step - The wheel scroll direction.
2826 + */
2827 + on_mouse_wheel(step) {
2828 + this.wheel = true;
2829 +
2830 + if (!AudioWizard) return;
2831 +
2832 + AudioWizard.PeakmeterOffset = AudioWizard.PeakmeterOffset + step;
2833 + this.tooltipText = `${Math.round(AudioWizard.PeakmeterOffset)} db`;
2834 + grm.ttip.showImmediate(this.tooltipText);
2835 + }
2836 +
2837 + /**
2838 + * Updates peakmeter bar colors when playing a new track.
2839 + * @param {FbMetadbHandle} metadb - The metadb of the track.
2840 + */
2841 + on_playback_new_track(metadb) {
2842 + if (!metadb) return;
2843 + this.startPeakmeter();
2844 + this.progressLength = 0;
2845 + this.setColors(metadb);
2846 + }
2847 +
2848 + /**
2849 + * Resets the peakmeter bar on playback stop.
2850 + * @param {number} reason - The type of playback stop.
2851 + */
2852 + on_playback_stop(reason = -1) {
2853 + if (['progressbar', 'waveformbar'].includes(grSet.seekbar)) {
2854 + return;
2855 + }
2856 +
2857 + if (reason !== 2) {
2858 + this.stopPeakmeter();
2859 + this.reset();
2860 + window.Repaint();
2861 + }
2862 + }
2863 +
2864 + /**
2865 + * Sets the size and position of the peakmeter bar and updates them on window resizing.
2866 + */
2867 + on_size() {
2868 + this.x = grm.ui.edgeMargin;
2869 + this.y = grm.ui.seekbarY;
2870 + this.w = grm.ui.ww - grm.ui.edgeMarginBoth;
2871 + this.h = grm.ui.seekbarHeight;
2872 +
2873 + this.initGeometry();
2874 + this.setY();
2875 +
2876 + this.textFont = gdi.Font('Segoe UI', HD_4K(9, 16), 1);
2877 + }
2878 + // #endregion
2879 + }
2880 +
2881 +
2882 + //////////////////////
2883 + // * WAVEFORM BAR * //
2884 + //////////////////////
2885 + /**
2886 + * A class that creates the waveform bar in the lower bar when enabled.
2887 + * Quick access via right click context menu on lower bar.
2888 + */
2889 + class WaveformBar {
2890 + /**
2891 + * Creates the `WaveformBar` instance.
2892 + */
2893 + constructor() {
2894 + // * Dependencies
2895 + include(`${fb.ProfilePath}georgia-reborn\\externals\\Codepages.js`);
2896 + include(`${fb.ProfilePath}georgia-reborn\\externals\\lz-utf8\\lzutf8.js`);
2897 + include(`${fb.ProfilePath}georgia-reborn\\externals\\lz-string\\lz-string.min.js`);
2898 +
2899 + /** @private @type {string} The match pattern used to create folder path. */
2900 + this.matchPattern = '$replace($ascii([$replace($if2($meta(ALBUMARTIST,0),$meta(ARTIST,0)),\\,)]\\[$replace([$if3(%original release date%,%originaldate%,%date%,%fy_upload_date%,) - ]%ALBUM%,\\,)]\\%TRACKNUMBER% - $replace(%TITLE%,\\,)), ?,,= ,,?,)';
2901 + /** @private @type {boolean} The debug flag for logging debug information. */
2902 + this.debug = false;
2903 + /** @private @type {boolean} The profile flag for logging performance information. */
2904 + this.profile = false;
2905 + /** @private @type {FbProfiler} The profiler for logging performance information. */
2906 + this.profiler = null;
2907 +
2908 + // * Easy access
2909 + /** @public @type {number} The x-coordinate of the waveform bar. */
2910 + this.x = grm.ui.edgeMargin;
2911 + /** @public @type {number} The y-coordinate of the waveform bar. */
2912 + this.y = 0;
2913 + /** @public @type {number} The width of the waveform bar. */
2914 + this.w = grm.ui.ww - grm.ui.edgeMarginBoth;
2915 + /** @public @type {number} The height of the waveform bar. */
2916 + this.h = grm.ui.seekbarHeight;
2917 +
2918 + // * Internals
2919 + /** @private @type {boolean} The active state of the waveform bar. */
2920 + this.active = true;
2921 + /** @private @type {string} The title format used for the waveform bar. */
2922 + this.Tf = fb.TitleFormat(this.matchPattern);
2923 + /** @private @type {number} The maximum step for the title format. */
2924 + this.TfMaxStep = fb.TitleFormat('[%BPM%]');
2925 + /** @private @type {string[]} The cache storage for the waveform data. */
2926 + this.cache = null;
2927 + /** @private @type {string} The directory for the waveform cache. */
2928 + this.cacheDir = grSet.customWaveformBarDir ? $(`${grCfg.customWaveformBarDir}`, undefined, true) : `${fb.ProfilePath}cache\\waveform\\`;
2929 + /** @private @type {string} The code page for character encoding conversion. */
2930 + this.codePage = convertCharsetToCodepage('UTF-8');
2931 + /** @private @type {string} The code page for UTF-16LE character encoding conversion. */
2932 + this.codePageV2 = convertCharsetToCodepage('UTF-16LE');
2933 + /** @private @type {number} The queue identifier for the waveform bar. */
2934 + this.queueId = null;
2935 + /** @private @type {number} The queue interval in milliseconds. */
2936 + this.queueMs = 1000;
2937 + /** @private @type {string[]} The current waveform data. */
2938 + this.current = [];
2939 + /** @private @type {number[]} The offset values for the waveform data. */
2940 + this.offset = [];
2941 + /** @private @type {number} The current step in the waveform animation. */
2942 + this.step = 0; // 0 - maxStep
2943 + /** @private @type {number} The maximum step for the waveform animation. */
2944 + this.maxStep = 4;
2945 + /** @private @type {number} The current playback time for the waveform bar. */
2946 + this.time = 0;
2947 + /** @private @type {boolean} The state indicating if the mouse is down. */
2948 + this.mouseDown = false;
2949 + /** @private @type {boolean} The state indicating if the file is allowed. Set at checkAllowedFile(). */
2950 + this.isAllowedFile = true;
2951 + /** @private @type {boolean} The state indicating if the file is a zipped file. Set at checkAllowedFile(). */
2952 + this.isZippedFile = false;
2953 + /** @private @type {boolean} The state indicating if there was an error. Set at verifyData() after retrying analysis. */
2954 + this.isError = false;
2955 + /** @private @type {boolean} The state indicating if fallback mode is active. For visualizerFallback, set at checkAllowedFile(). */
2956 + this.isFallback = false;
2957 + /** @private @type {number} The number of audio channels in the current waveform data. */
2958 + this.currentChannels = 1;
2959 +
2960 + /**
2961 + * The waveform bar analysis settings.
2962 + * @typedef {object} waveformBarAnalysis
2963 + * @property {string} binaryMode - The analysis mode: 'audioWizard' | 'visualizer'.
2964 + * @property {number} resolution - The temporal resolution in points per second from 1-1000, recommended preset ranges are:
2965 + * - 100 points/sec: Very High Details (10ms, for transient-heavy audio like EDM).
2966 + * - 50 points/sec: High Details (20ms, for mastering and detailed visualization).
2967 + * - 20 points/sec: Standard Details (50ms, matches FFmpeg/astats, ideal for broadcast).
2968 + * - 15 points/sec: Balanced Details (~67ms, for smooth audio like pop or jazz).
2969 + * - 10 points/sec: Low Details (100ms, for basic visualization).
2970 + * - 5 points/sec: Very Low Details (200ms, for low-performance devices, very smooth audio).
2971 + * - 1 points/sec: Minimum Details (1000ms, for ultra-minimal previews on very slow devices).
2972 + * @property {number} timeout - The maximum duration for waveform analysis in milliseconds.
2973 + * @property {string} compressionMode - The compression mode: 'none' | 'utf-8' | 'utf-16' | '7zip'.
2974 + * @property {string} saveMode - The save behavior: 'always' | 'library' | 'never'.
2975 + * @property {boolean} autoAnalysis - Whether to automatically analyze files.
2976 + * @property {boolean} autoDelete - Whether to auto-delete analysis files when unloading the script.
2977 + * @property {boolean} visualizerFallbackAnalysis - Whether to use visualizer mode when analyzing the file.
2978 + * @property {boolean} visualizerFallback - Whether to use visualizer mode for incompatible file formats.
2979 + * @public
2980 + */
2981 + /** @public @type {waveformBarAnalysis} */
2982 + this.analysis = {
2983 + binaryMode: grSet.waveformBarMode,
2984 + resolution: grSet.waveformBarResolution,
2985 + timeout: 60000,
2986 + compressionMode: 'utf-16',
2987 + saveMode: grSet.waveformBarSaveMode,
2988 + autoAnalysis: true,
2989 + autoDelete: grSet.waveformBarAutoDelete,
2990 + visualizerFallbackAnalysis: grSet.waveformBarFallbackAnalysis,
2991 + visualizerFallback: grSet.waveformBarFallback
2992 + };
2993 +
2994 + /**
2995 + * The waveform bar binary settings.
2996 + * @typedef {object} waveformBarBinaries
2997 + * @property {string} visualizer - The visualizer binary to use.
2998 + * @public
2999 + */
3000 + /** @public @type {waveformBarBinaries} */
3001 + this.binaries = {
3002 + audioWizard: Component.AudioWizard,
3003 + visualizer: `${fb.ProfilePath}running`
3004 + };
3005 +
3006 + /**
3007 + * The waveform bar compatible file settings.
3008 + * @typedef {object} waveformBarCompatibility
3009 + * @property {RegExp} audioWizard - The regular expression to test for file types compatible with audioWizard.
3010 + * @public
3011 + */
3012 + /** @private @type {waveformBarCompatibility} */
3013 + this.compatibleFiles = {
3014 + audioWizardList: ['2sf', 'aa', 'aac', 'ac3', 'ac4', 'aiff', 'ape', 'cue', 'dff', 'dsf', 'dts', 'eac3', 'flac', 'hmi', 'iso', 'la', 'lpcm', 'm4a', 'minincsf', 'mp2', 'mp3', 'mp4', 'mpc', 'ogg', 'ogx', 'opus', 'ra', 'snd', 'shn', 'spc', 'tak', 'tta', 'vgm', 'wav', 'wma', 'wv'],
3015 + audioWizard: null
3016 + };
3017 + for (const key of ['audioWizard']) {
3018 + this.compatibleFiles[key] = new RegExp(`\\.(${this.compatibleFiles[`${key}List`].join('|')})$`, 'i');
3019 + }
3020 +
3021 + /**
3022 + * The waveform bar fallback mode settings for visualizerFallbackAnalysis.
3023 + * @typedef {object} waveformBarFallbackMode
3024 + * @property {boolean} paint - The state that indicates whether to use the paint fallback mode.
3025 + * @property {boolean} analysis - The state that indicates whether to use the analysis fallback mode.
3026 + * @public
3027 + */
3028 + /** @private @type {waveformBarFallbackMode} */
3029 + this.fallbackMode = {
3030 + paint: false,
3031 + analysis: false
3032 + };
3033 +
3034 + /**
3035 + * The waveform bar metrics configuration.
3036 + * @typedef {object} waveformBarMetricsConfig
3037 + * @property {number} count - The number of metrics per frame.
3038 + * @property {object} index - The metric names to their frame indexes (e.g., rms: 0).
3039 + * @property {object} range - The valid ranges for each metric (e.g., [-100, 0] for dB metrics).
3040 + * @property {object} mode - The analysisMode values to metric names (e.g., rms: 'rms').
3041 + * @public
3042 + */
3043 + /** @private @type {waveformBarMetricsConfig} */
3044 + this.metrics = {
3045 + count: 5,
3046 + index: {
3047 + rms: 0,
3048 + rms_peak: 1,
3049 + sample_peak: 2,
3050 + min: 3,
3051 + max: 4
3052 + },
3053 + mode: {
3054 + rms: 'rms',
3055 + rms_peak: 'rms_peak',
3056 + sample_peak: 'sample_peak',
3057 + waveform: 'waveform'
3058 + },
3059 + range: {
3060 + rms: [-100, 0],
3061 + rms_peak: [-100, 0],
3062 + sample_peak: [-100, 0],
3063 + min: [-1, 1],
3064 + max: [-1, 1]
3065 + }
3066 + };
3067 +
3068 + /**
3069 + * The waveform bar preset settings.
3070 + * @typedef {object} waveformBarPreset
3071 + * @property {string} analysisMode - The waveform bar analysis mode `rms`, `rms_peak`, `sample_peak`, `waveform`.
3072 + * @property {string} barDesign - The waveform bar design `waveform`, `bars`, `dots`, `halfbars`.
3073 + * @property {string} paintMode - The waveform bar paint mode `full`, `partial`.
3074 + * @property {boolean} animate - The flag to display animation.
3075 + * @property {boolean} useBPM - The flag to use synced BPM.
3076 + * @property {boolean} indicator - The flag to show waveform bar progress indicator.
3077 + * @property {boolean} prepaint - The flag to prepaint waveform bar progress.
3078 + * @property {number} prepaintFront - The prepaint waveform bar progress length.
3079 + * @property {boolean} invertHalfbars - The flag to invert waveform bar halfbars.
3080 + * @public
3081 + */
3082 + /** @public @type {waveformBarPreset} */
3083 + this.preset = {
3084 + analysisMode: grSet.waveformBarAnalysis,
3085 + barDesign: grSet.waveformBarDesign,
3086 + paintMode: grSet.waveformBarPaint,
3087 + animate: grSet.waveformBarAnimate,
3088 + useBPM: grSet.waveformBarBPM,
3089 + indicator: grSet.waveformBarIndicator,
3090 + prepaint: grSet.waveformBarPrepaint,
3091 + prepaintFront: grSet.waveformBarPrepaintFront,
3092 + invertHalfbars: grSet.waveformBarInvertHalfbars
3093 + };
3094 +
3095 + /**
3096 + * The waveform bar ui settings.
3097 + * @typedef {object} waveformBarUI
3098 + * @property {number} sizeWave - The width size of drawn waveform.
3099 + * @property {number} sizeBars - The width size of drawn bars.
3100 + * @property {number} sizeDots - The width size of drawn dots.
3101 + * @property {number} sizeHalf - The width size of drawn halfbars.
3102 + * @property {number} sizeNormalizeWidth - The visualizer binary to use.
3103 + * @property {number} refreshRate - The refresh rate in ms when using animations for any type. 100 is smooth enough but the performance hit is high.
3104 + * @public
3105 + */
3106 + /** @public @type {waveformBarUI} */
3107 + this.ui = {
3108 + sizeWave: grSet.waveformBarSizeWave,
3109 + sizeBars: grSet.waveformBarSizeBars,
3110 + sizeDots: grSet.waveformBarSizeDots,
3111 + sizeHalf: grSet.waveformBarSizeHalf,
3112 + sizeNormalizeWidth: grSet.waveformBarSizeNormalize,
3113 + refreshRate: grSet.waveformBarRefreshRate === 'variable' ? FPS._5 : grSet.waveformBarRefreshRate
3114 + };
3115 +
3116 + /**
3117 + * The waveform bar wheel settings.
3118 + * @typedef {object} waveformBarWheel
3119 + * @property {number} seekSpeed - The mouse wheel seek type, 'seconds' or 'percentage.
3120 + * @property {string} seekType - The mouse wheel seek speed.
3121 + * @public
3122 + */
3123 + /** @public @type {waveformBarWheel} */
3124 + this.wheel = {
3125 + seekSpeed: grSet.waveformBarWheelSeekSpeed,
3126 + seekType: grSet.waveformBarWheelSeekType
3127 + };
3128 +
3129 + // * Initialization
3130 + this.checkConfig();
3131 + this.defaultSteps();
3132 + this.setThrottlePaint();
3133 + if (!IsFolder(this.cacheDir)) { CreateFolder(this.cacheDir); }
3134 + }
3135 +
3136 + // * PUBLIC METHODS - DRAW * //
3137 + // #region PUBLIC METHODS - DRAW
3138 + /**
3139 + * Draws the waveform bar with various designs based on the current settings.
3140 + * @param {GdiGraphics} gr - The GDI graphics object.
3141 + */
3142 + draw(gr) {
3143 + if (!fb.IsPlaying) {
3144 + gr.FillSolidRect(this.x, grm.ui.seekbarY, this.w, SCALE(grSet.layout !== 'default' ? 10 : 12), grCol.progressBar);
3145 + this.reset();
3146 + return;
3147 + }
3148 +
3149 + if (this.current.length === 0) {
3150 + this.drawBarInfo(gr);
3151 + return;
3152 + }
3153 +
3154 + if (grSet.waveformBarRefreshRate === 'variable' || grm.debug.showDrawExtendedTiming) {
3155 + grm.ui.seekbarProfiler.Reset();
3156 + }
3157 +
3158 + // * Set shared properties
3159 + /** @private @type {number} The time constant for the waveform bar calculation. */
3160 + this.timeConstant = fb.PlaybackLength / this.current.length;
3161 + /** @private @type {number} The current X position based on playback time. */
3162 + this.currX = this.x + this.w * ((fb.PlaybackTime / fb.PlaybackLength) || 0);
3163 + /** @private @type {number} The width of each bar in the waveform. */
3164 + this.barW = this.w / this.current.length;
3165 + /** @private @type {boolean} The state whether prepaint mode is active. */
3166 + this.prepaint = this.preset.paintMode === 'partial' && this.preset.prepaint;
3167 + /** @private @type {boolean} The state whether visualizer mode is active. */
3168 + this.visualizer = this.analysis.binaryMode === 'visualizer' || this.isFallback || this.fallbackMode.paint;
3169 +
3170 + const minPointDiff = 1; // in px
3171 + const past = [{ x: 0, y: 1 }, { x: 0, y: -1 }];
3172 + let pastIndex = 0;
3173 +
3174 + gr.SetSmoothingMode(SmoothingMode.AntiAlias);
3175 +
3176 + for (let n = 0; n < this.current.length; n++) {
3177 + const frame = this.current[n];
3178 + const current = this.timeConstant * n;
3179 + const isPrepaintAllowed = (current - this.time) < this.preset.prepaintFront;
3180 +
3181 + /** @private @type {boolean} The state whether the current frame is in prepaint mode. */
3182 + this.isPrepaint = current > this.time;
3183 + /** @private @type {number} The scaled size of the current frame. */
3184 + this.scaledSize = this.h / 2 * frame;
3185 + /** @private @type {number} The x-position of the current frame. */
3186 + this.frameX = this.x + this.barW * n;
3187 +
3188 + // * Exit loop if prepaint mode conditions are met
3189 + if (this.preset.paintMode === 'partial' && !this.prepaint && this.isPrepaint) break;
3190 + if (this.prepaint && this.isPrepaint && !isPrepaintAllowed) break;
3191 + if (!this.offset[n]) this.offset[n] = 0;
3192 +
3193 + // * Calculate offsets for prepainting and visualizer animation
3194 + /** @private @type {number} The offset value for prepainting and visualizer animation. */
3195 + this.offset[n] += (this.prepaint && this.isPrepaint && this.preset.animate || this.visualizer ? // Add movement when pre-painting
3196 + this.preset.barDesign === 'dots' ? Math.random() * Math.abs(this.step / this.maxStep) :
3197 + -Math.sign(frame) * Math.random() * this.scaledSize / 10 * this.step / this.maxStep : 0);
3198 +
3199 + /** @private @type {number} The random offset value for the current frame. */
3200 + this.offsetRandom = this.preset.barDesign === 'dots' ? this.offset[n] : Math.sign(frame) * this.offset[n];
3201 +
3202 + // * Draw the waveform bar
3203 + if (past.every((p) =>
3204 + (p.y !== Math.sign(frame) && this.preset.barDesign !== 'halfbars') ||
3205 + (p.y === Math.sign(frame) || this.preset.barDesign === 'halfbars') && (this.frameX - p.x) >= minPointDiff)) {
3206 + this.drawWaveformBar(gr);
3207 +
3208 + past[pastIndex] = { x: this.frameX, y: Math.sign(frame) };
3209 + pastIndex = (pastIndex + 1) % past.length;
3210 + }
3211 + }
3212 +
3213 + this.drawBarProgressLine(gr);
3214 + this.drawBarAnimation();
3215 + }
3216 +
3217 + /**
3218 + * Draws the waveform bar based on the preset design.
3219 + * @param {GdiGraphics} gr - The GDI graphics object.
3220 + */
3221 + drawWaveformBar(gr) {
3222 + const drawBarDesign = {
3223 + waveform: () => this.drawBarDesignWaveform(gr),
3224 + bars: () => this.drawBarDesignBars(gr),
3225 + halfbars: () => this.drawBarDesignHalfbars(gr),
3226 + dots: () => this.drawBarDesignDots(gr)
3227 + };
3228 +
3229 + drawBarDesign[this.preset.barDesign]();
3230 + }
3231 +
3232 + /**
3233 + * Draws the waveform bar in "waveform" design.
3234 + * @param {GdiGraphics} gr - The GDI graphics object.
3235 + */
3236 + drawBarDesignWaveform(gr) {
3237 + const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize + this.offsetRandom, 1) : Math.min(this.scaledSize + this.offsetRandom, -1);
3238 + const zTop = this.visualizer ? Math.abs(yOffset) : yOffset;
3239 + const zBottom = this.visualizer ? -Math.abs(yOffset) : yOffset;
3240 + const { sizeWave } = this.ui;
3241 + const { colorBack, colorFront, colorsDiffer } = this.getColors();
3242 +
3243 + if (zTop > 0) {
3244 + if (colorsDiffer) {
3245 + gr.FillSolidRect(this.frameX, this.y - zTop, sizeWave, zTop / 2, colorBack);
3246 + gr.FillSolidRect(this.frameX, this.y - zTop / 2, sizeWave, zTop / 2, colorFront);
3247 + } else {
3248 + gr.FillSolidRect(this.frameX, this.y - zTop, sizeWave, zTop, colorBack);
3249 + }
3250 + }
3251 +
3252 + if (zBottom < 0) {
3253 + if (colorsDiffer) {
3254 + gr.FillSolidRect(this.frameX, this.y - zBottom / 2, sizeWave, -zBottom / 2, colorBack);
3255 + gr.FillSolidRect(this.frameX, this.y, sizeWave, -zBottom / 2, colorFront);
3256 + } else {
3257 + gr.FillSolidRect(this.frameX, this.y, sizeWave, -zBottom, colorBack);
3258 + }
3259 + }
3260 + }
3261 +
3262 + /**
3263 + * Draws the waveform bar in "bars" design.
3264 + * @param {GdiGraphics} gr - The GDI graphics object.
3265 + */
3266 + drawBarDesignBars(gr) {
3267 + const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize + this.offsetRandom, 1) : Math.min(this.scaledSize + this.offsetRandom, -1);
3268 + const zTop = this.visualizer ? Math.abs(yOffset) : yOffset;
3269 + const zBottom = this.visualizer ? -Math.abs(yOffset) : yOffset;
3270 + const sizeBars = this.barW * this.ui.sizeBars;
3271 + const { colorBack, colorFront, colorsDiffer } = this.getColors(true, true);
3272 +
3273 + if (zTop > 0) {
3274 + if (colorsDiffer) {
3275 + gr.DrawRect(this.frameX, this.y - zTop, sizeBars, zTop / 2, 1, colorBack);
3276 + gr.DrawRect(this.frameX, this.y - zTop / 2, sizeBars, zTop / 2, 1, colorFront);
3277 + } else {
3278 + gr.DrawRect(this.frameX, this.y - zTop, sizeBars, zTop, 1, colorBack);
3279 + }
3280 + }
3281 +
3282 + if (zBottom < 0) {
3283 + if (colorsDiffer) {
3284 + gr.DrawRect(this.frameX, this.y - zBottom / 2, sizeBars, -zBottom / 2, 1, colorBack);
3285 + gr.DrawRect(this.frameX, this.y, sizeBars, -zBottom / 2, 1, colorFront);
3286 + } else {
3287 + gr.DrawRect(this.frameX, this.y, sizeBars, -zBottom, 1, colorBack);
3288 + }
3289 + }
3290 + }
3291 +
3292 + /**
3293 + * Draws the waveform bar in "halfbars" design.
3294 + * @param {GdiGraphics} gr - The GDI graphics object.
3295 + */
3296 + drawBarDesignHalfbars(gr) {
3297 + const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize + this.offsetRandom, 1) : Math.min(this.scaledSize + this.offsetRandom, -1);
3298 + const y = this.preset.invertHalfbars ? Math.abs(yOffset) : yOffset;
3299 + const sizeHalf = this.visualizer ? this.barW * this.ui.sizeHalf * (this.visualizer ? 0.2 : 0.5) : this.ui.sizeHalf;
3300 + const { colorBack, colorFront, colorsDiffer } = this.getColors(false, true);
3301 +
3302 + if (y > 0) {
3303 + if (colorsDiffer) {
3304 + gr.FillSolidRect(this.frameX, this.y - 2 * y + this.h * 0.5, sizeHalf, y, colorBack);
3305 + gr.FillSolidRect(this.frameX, this.y - y + this.h * 0.5, sizeHalf, y, colorFront);
3306 + } else {
3307 + gr.FillSolidRect(this.frameX, this.y - 2 * y + this.h * 0.5, sizeHalf, 2 * y, colorBack);
3308 + }
3309 + }
3310 + }
3311 +
3312 + /**
3313 + * Draws the waveform bar in "dots" design.
3314 + * @param {GdiGraphics} gr - The GDI graphics object.
3315 + */
3316 + drawBarDesignDots(gr) {
3317 + const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize, 1) : Math.min(this.scaledSize, -1);
3318 + const dotStep = Math.max(this.h / 80, 5) + (this.offsetRandom || 1);
3319 + const dotSize = Math.max(dotStep / 25, 1) * this.ui.sizeDots;
3320 + const { colorBack, colorFront } = this.getColors();
3321 +
3322 + const drawDots = (direction, startY, yOffset, color1, color2) => {
3323 + const sign = this.visualizer ? direction : Math.sign(yOffset);
3324 + const step = direction * yOffset / 2;
3325 + let currentY = startY;
3326 +
3327 + for (const endY = startY - step; sign * (currentY - endY) > 0; currentY -= sign * dotStep) {
3328 + gr.DrawEllipse(this.frameX, currentY, dotSize, dotSize, 1, color1);
3329 + }
3330 +
3331 + for (const endY = startY - 2 * step; sign * (currentY - endY) > 0; currentY -= sign * dotStep) {
3332 + gr.DrawEllipse(this.frameX, currentY, dotSize, dotSize, 1, color2);
3333 + }
3334 + };
3335 +
3336 + drawDots(1, this.y, yOffset, colorFront, colorBack);
3337 + if (!this.visualizer) return;
3338 + drawDots(-1, this.y, yOffset, colorFront, colorBack);
3339 + }
3340 +
3341 + /**
3342 + * Draws the progress line on the waveform bar.
3343 + * @param {GdiGraphics} gr - The GDI graphics object.
3344 + */
3345 + drawBarProgressLine(gr) {
3346 + if (!this.preset.indicator && !this.mouseDown) return;
3347 +
3348 + gr.SetSmoothingMode(0);
3349 +
3350 + if (this.analysis.binaryMode === 'audioWizard' || ['waveform', 'dots'].includes(this.preset.barDesign)) {
3351 + const minBarW = Math.round(Math.max(this.barW, SCALE(1)));
3352 + gr.DrawLine(this.currX, this.y - this.h * 0.5, this.currX, this.y + this.h * 0.5, minBarW, grCol.waveformBarIndicator);
3353 + }
3354 + }
3355 +
3356 + /**
3357 + * Draws information text when waveform data is loading or when it is not available.
3358 + * @param {GdiGraphics} gr - The GDI graphics object.
3359 + */
3360 + drawBarInfo(gr) {
3361 + if (pl.col.row_nowplaying_bg === null) return; // * Wait until nowplaying bg has a new color to prevent flashing
3362 +
3363 + const DT_CENTER = DrawText.VCenter | DrawText.Center | DrawText.EndEllipsis | DrawText.CalcRect | DrawText.NoPrefix;
3364 + const bgColor = grSet.theme === 'reborn' ? pl.col.row_nowplaying_bg : grCol.transportEllipseBg;
3365 + const message =
3366 + !this.isAllowedFile && !this.isFallback && this.analysis.binaryMode !== 'visualizer' ? 'Incompatible file format' :
3367 + !this.analysis.autoAnalysis ? 'Waveform bar file not found' :
3368 + this.isError ? 'Waveform bar file can not be analyzed' :
3369 + this.active ? 'Loading' : '';
3370 +
3371 + gr.FillSolidRect(this.x, this.y - this.h * 0.5, this.w, this.h, bgColor);
3372 + gr.GdiDrawText(message, grFont.lowerBarWave, pl.col.header_artist_normal, this.x, this.y - this.h * 0.5, this.w, this.h, DT_CENTER);
3373 + }
3374 +
3375 + /**
3376 + * Draw the waveform bar animation.
3377 + */
3378 + drawBarAnimation() {
3379 + if (this.prepaint && this.preset.animate || this.visualizer) {
3380 + if (this.step >= this.maxStep) {
3381 + this.step = -this.step;
3382 + } else {
3383 + if (this.step === 0) { this.offset = []; }
3384 + this.step++;
3385 + }
3386 + }
3387 +
3388 + if (fb.IsPlaying && !fb.IsPaused) {
3389 + this.setRefreshRate();
3390 +
3391 + if (this.visualizer) {
3392 + this.throttlePaint();
3393 + }
3394 + else if (this.current.length && (this.prepaint || this.preset.paintMode === 'partial' || this.preset.indicator)) {
3395 + const paintRect = this.setPaintRect(this.time);
3396 + this.throttlePaintRect(paintRect.x, paintRect.y, paintRect.width, paintRect.height);
3397 + }
3398 + }
3399 + }
3400 + // #endregion
3401 +
3402 + // * PUBLIC METHODS - INITALIZATION * //
3403 + // #region PUBLIC METHODS - INITALIZATION
3404 + /**
3405 + * Checks if the current file is allowed to be played, i.e not corrupted.
3406 + * @param {object} handle - The current file handle.
3407 + */
3408 + checkAllowedFile(handle = fb.GetNowPlaying()) {
3409 + if (!handle) return;
3410 +
3411 + const noVisual = this.analysis.binaryMode !== 'visualizer';
3412 + const validExt = this.checkCompatibleFileExtension(handle);
3413 +
3414 + this.isZippedFile = handle.RawPath.includes('unpack://');
3415 + this.isAllowedFile = noVisual && validExt && !this.isZippedFile;
3416 + this.isFallback = !this.isAllowedFile && this.analysis.visualizerFallback;
3417 + }
3418 +
3419 + /**
3420 + * Checks if the file extension of the current file handle is compatible.
3421 + * @param {object} handle - The current file handle.
3422 + * @param {string} mode - The analysis binary mode.
3423 + * @returns {boolean} True if the file extension is compatible, otherwise false.
3424 + */
3425 + checkCompatibleFileExtension(handle = fb.GetNowPlaying(), mode = this.analysis.binaryMode) {
3426 + return (mode === 'visualizer') || (handle && this.compatibleFiles[mode].test(handle.Path));
3427 + }
3428 +
3429 + /**
3430 + * Checks the report list of compatible file extensions for the given mode.
3431 + * @param {string} mode - The analysis binary mode.
3432 + * @returns {Array<string>} An array of compatible file extensions.
3433 + */
3434 + checkCompatibleFileExtensionReport(mode = this.analysis.binaryMode) {
3435 + return [...this.compatibleFiles[`${mode}List`]];
3436 + }
3437 +
3438 + /**
3439 + * Checks the configuration for validity, called from the constructor.
3440 + */
3441 + checkConfig() {
3442 + if (!Object.prototype.hasOwnProperty.call(this.binaries, this.analysis.binaryMode)) {
3443 + this.analysis.binaryMode = 'visualizer';
3444 + }
3445 + if (!this.binaries[this.analysis.binaryMode]) {
3446 + fb.ShowPopupMessage(`Waveform bar => required dependency not found: ${this.analysis.binaryMode}\n\n${JSON.stringify(this.binaries[this.analysis.binaryMode])}`, window.Name);
3447 + }
3448 +
3449 + if (this.preset.prepaintFront <= 0 || this.preset.prepaintFront === null) {
3450 + this.preset.prepaintFront = Infinity;
3451 + }
3452 +
3453 + if (this.wheel.seekSpeed < 0) {
3454 + this.wheel.seekSpeed = 1;
3455 + } else if (this.wheel.seekSpeed > 100 && this.wheel.seekType === 'percentage') {
3456 + this.wheel.seekSpeed = 100;
3457 + }
3458 + }
3459 +
3460 + /**
3461 + * Updates the config and ensures the UI is being updated properly after changing settings.
3462 + * @param {object} newConfig - The new configuration object with settings to be applied.
3463 + */
3464 + updateConfig(newConfig) {
3465 + if (newConfig) {
3466 + DeepAssign()(this, newConfig);
3467 + }
3468 +
3469 + this.checkConfig();
3470 + let recalculate = false;
3471 +
3472 + if (newConfig.preset) {
3473 + if (this.preset.paintMode === 'partial' && this.preset.prepaint || this.analysis.binaryMode === 'visualizer') {
3474 + this.offset = [];
3475 + this.step = 0;
3476 + }
3477 + if (Object.prototype.hasOwnProperty.call(newConfig.preset, 'animate') ||
3478 + Object.prototype.hasOwnProperty.call(newConfig.preset, 'useBPM')) {
3479 + if (this.preset.animate && this.preset.useBPM) {
3480 + this.bpmSteps();
3481 + } else {
3482 + this.defaultSteps();
3483 + }
3484 + }
3485 + }
3486 +
3487 + if (newConfig.ui) {
3488 + if (Object.prototype.hasOwnProperty.call(newConfig.ui, 'refreshRate')) {
3489 + this.setThrottlePaint();
3490 + }
3491 + if (Object.prototype.hasOwnProperty.call(newConfig.ui, 'sizeNormalizeWidth') ||
3492 + Object.prototype.hasOwnProperty.call(newConfig.ui, 'normalizeWidth')) {
3493 + recalculate = true;
3494 + }
3495 + }
3496 +
3497 + if (newConfig.analysis) {
3498 + recalculate = true;
3499 + }
3500 +
3501 + if (recalculate) {
3502 + this.on_playback_new_track();
3503 + } else {
3504 + this.throttlePaint();
3505 + }
3506 + }
3507 + // #endregion
3508 +
3509 + // * PUBLIC METHODS - DATA * //
3510 + // #region PUBLIC METHODS - DATA
3511 + /**
3512 + * Starts the analysis process of the waveform data and updates the current state.
3513 + * @param {FbMetadbHandle} handle - The handle of the current track.
3514 + * @param {boolean} isRetry - The flag indicating whether the method call is a retry attempt.
3515 + * @returns {Promise<boolean>} The promise that resolves to `true` if analysis is successful, `false` otherwise.
3516 + */
3517 + async analyzeDataStart(handle, isRetry) {
3518 + if (this.analysis.binaryMode === 'visualizer' || this.analysis.visualizerFallbackAnalysis) {
3519 + this.current = this.visualizerData(handle);
3520 +
3521 + if (this.analysis.binaryMode === 'visualizer') {
3522 + this.normalizePoints();
3523 + return;
3524 + }
3525 + }
3526 +
3527 + const { waveformBarFolder, waveformBarFile, sourceFile } = this.getPaths(handle);
3528 + const files = this.getFileConfigs();
3529 + let analysisComplete = false;
3530 +
3531 + for (const file of files) {
3532 + const fileWithExt = `${waveformBarFile}${file.ext}`;
3533 + if (IsFile(fileWithExt)) {
3534 + const str = Open(fileWithExt, file.codePage) || '';
3535 + const parsed = file.decompress(str) || {};
3536 +
3537 + if (parsed.data && Array.isArray(parsed.data)) {
3538 + this.current = parsed.data;
3539 + this.currentChannels = parsed.channels || 1;
3540 + } else {
3541 + this.current = Array.isArray(parsed) ? parsed : [];
3542 + this.currentChannels = 1;
3543 + }
3544 +
3545 + if (this.verifyData(handle, fileWithExt, isRetry)) {
3546 + analysisComplete = true;
3547 + break;
3548 + }
3549 + }
3550 + }
3551 +
3552 + if (!analysisComplete && this.analysis.autoAnalysis && IsFile(sourceFile)) {
3553 + if (this.analysis.visualizerFallbackAnalysis && this.isAllowedFile) {
3554 + this.fallbackMode.analysis = this.fallbackMode.paint = true;
3555 + this.normalizePoints();
3556 + if (this.preset.animate && this.preset.useBPM) this.bpmSteps(handle);
3557 + if (fb.IsPlaying) this.time = fb.PlaybackTime;
3558 + }
3559 +
3560 + this.throttlePaint(true);
3561 + if (this.analysis.visualizerFallbackAnalysis) {
3562 + this.fallbackMode.analysis = false;
3563 + }
3564 +
3565 + await this.analyzeData(handle, waveformBarFolder, waveformBarFile, sourceFile);
3566 + this.fallbackMode.analysis = this.fallbackMode.paint = false;
3567 + analysisComplete = this.verifyData(handle, undefined, isRetry);
3568 + }
3569 +
3570 + this.isFallback = !analysisComplete;
3571 + this.normalizePoints(this.analysis.binaryMode !== 'visualizer' && this.ui.sizeNormalizeWidth);
3572 + }
3573 +
3574 + /**
3575 + * Analyzes data of the given handle(s) and saves the results in the waveform bar cache directory.
3576 + * @param {FbMetadbHandle|FbMetadbHandleList} handle - The handle(s) to analyze.
3577 + * @param {string} waveformBarFolder - The folder where the waveform bar data should be saved.
3578 + * @param {string} waveformBarFile - The name of the waveform bar file.
3579 + * @param {string} [sourceFile] - The path of the source file.
3580 + * @returns {Promise<void>} The promise that resolves when the analysis has finished.
3581 + */
3582 + async analyzeData(handle, waveformBarFolder, waveformBarFile, sourceFile = handle.Path || handle[0].Path) {
3583 + if (!this.isAllowedFile || !AudioWizard || AudioWizard.FullTrackProcessing) return;
3584 +
3585 + if (!IsFolder(waveformBarFolder)) {
3586 + CreateFolder(waveformBarFolder);
3587 + }
3588 +
3589 + try {
3590 + const handleList = (handle instanceof FbMetadbHandleList) ? handle :
3591 + new FbMetadbHandleList(Array.isArray(handle) ? handle : [handle]
3592 + );
3593 + const startTime = Date.now();
3594 +
3595 + grm.debug.debugLog(`Audio Wizard => Starting waveform analysis: mode=${this.preset.analysisMode}, resolution=${this.analysis.resolution}`);
3596 +
3597 + const success = await new Promise((resolve) => {
3598 + const { metadata } = GetMetadata(handleList);
3599 + AudioWizard.SetFullTrackWaveformCallback((res) => resolve(res));
3600 + AudioWizard.StartWaveformAnalysis(metadata, this.analysis.resolution);
3601 + });
3602 +
3603 + if (!success) {
3604 + console.log('Audio Wizard => Waveform analysis failed - API returned error');
3605 + return;
3606 + }
3607 +
3608 + const metricsPerChannel = 5;
3609 + const trackCount = AudioWizard.GetWaveformTrackCount();
3610 +
3611 + for (let i = 0; i < trackCount; i++) {
3612 + const trackHandle = handleList[i];
3613 + const data = [];
3614 + const rawData = AudioWizard.GetWaveformData(i);
3615 + const channels = AudioWizard.GetWaveformTrackChannels(i);
3616 + const stepSize = metricsPerChannel * channels;
3617 +
3618 + // Restructure flat array into array of arrays (one array per time step)
3619 + for (let j = 0; j < rawData.length; j += stepSize) {
3620 + const pointSlice = rawData.slice(j, j + stepSize);
3621 + const roundedPoint = pointSlice.map(v => Math.round(v * 1000) / 1000);
3622 + data.push(roundedPoint);
3623 + }
3624 +
3625 + if (this.saveDataAllowed(trackHandle)) {
3626 + this.analyzeDataSave(waveformBarFile, JSON.stringify({ channels, data }));
3627 + }
3628 +
3629 + if (handleList.Count === 1) {
3630 + this.current = data;
3631 + this.currentChannels = channels; // Store for normalizePoints
3632 + }
3633 + }
3634 +
3635 + grm.debug.debugLog(`Audio Wizard => Analysis completed in ${(Date.now() - startTime) / 1000} seconds`);
3636 + this.throttlePaint();
3637 + }
3638 + catch (e) {
3639 + console.log(`Audio Wizard => Analysis error: ${e.message}`);
3640 + AudioWizard.StopWaveformAnalysis();
3641 + }
3642 + }
3643 +
3644 + /**
3645 + * Saves the compressed data to a file.
3646 + * @param {string} waveformBarFile - The name of the waveform bar file.
3647 + * @param {string} dataStr - The data to be saved.
3648 + */
3649 + analyzeDataSave(waveformBarFile, dataStr) {
3650 + if (this.analysis.binaryMode === 'visualizer') return;
3651 +
3652 + const compression = {
3653 + 'utf-16': () => SaveFSO(`${waveformBarFile}.awz.lz16`, LZString.compressToUTF16(dataStr), true),
3654 + 'utf-8': () => Save(`${waveformBarFile}.awz.lz`, LZUTF8.compress(dataStr, { outputEncoding: 'Base64' })),
3655 + 'none': () => Save(`${waveformBarFile}.awz.json`, dataStr)
3656 + };
3657 +
3658 + (compression[this.analysis.compressionMode] || compression.none)();
3659 + }
3660 +
3661 + /**
3662 + * Generates data for the visualizer.
3663 + * @param {FbMetadbHandle} handle - The handle to analyze.
3664 + * @param {string} preset - The preset to use for the visualizer.
3665 + * @param {boolean} variableLen - The flag whether the length of the data should be variable.
3666 + * @returns {Array} The data for the visualizer bar.
3667 + */
3668 + visualizerData(handle, preset = 'classic spectrum analyzer', variableLen = false) {
3669 + const barW = this.getBarWidth();
3670 + const samplesMax = Math.floor(this.w / barW);
3671 + const samplesTotal = Math.floor(handle.Length * this.analysis.resolution);
3672 + const samples = variableLen ? samplesTotal : Math.min(samplesMax, samplesTotal);
3673 + const data = new Array(samples);
3674 +
3675 + if (preset === 'classic spectrum analyzer') {
3676 + const third = Math.round(samples / 3);
3677 + const half = Math.round(samples / 2);
3678 +
3679 + // * Filling first half
3680 + for (let i = 0; i < third; i++) {
3681 + const val = (Math.random() * i) / third;
3682 + data[i] = val;
3683 + }
3684 + for (let i = third; i < half; i++) {
3685 + const val = (Math.random() * i) / third;
3686 + data[i] = val;
3687 + }
3688 + // * Filling second half with reversed first half
3689 + for (let i = half, j = 0; i < samples; i++, j++) {
3690 + data[i] = data[half - 1 - j];
3691 + }
3692 + }
3693 +
3694 + return data;
3695 + }
3696 +
3697 + /**
3698 + * Checks if the processed waveform data is valid for audioWizard mode.
3699 + * @returns {boolean} True if the data is valid.
3700 + */
3701 + validData() {
3702 + if (!Array.isArray(this.current) || !this.current.length) {
3703 + return false;
3704 + }
3705 +
3706 + const channels = this.currentChannels || 1;
3707 + const expectedLength = this.metrics.count * channels;
3708 +
3709 + return this.current.every(frame => {
3710 + if (!Array.isArray(frame) || frame.length < expectedLength) {
3711 + return false;
3712 + }
3713 +
3714 + // Validate metrics for each channel
3715 + for (let ch = 0; ch < channels; ch++) {
3716 + const offset = ch * this.metrics.count;
3717 +
3718 + for (const [metric, index] of Object.entries(this.metrics.index)) {
3719 + const value = frame[offset + index];
3720 + if (typeof value !== 'number' || !isFinite(value)) {
3721 + return false;
3722 + }
3723 + const [min, max] = this.metrics.range[metric];
3724 + if (value < min || value > max) {
3725 + return false;
3726 + }
3727 + }
3728 + }
3729 +
3730 + return true;
3731 + });
3732 + }
3733 +
3734 + /**
3735 + * Verifies if the processed data is valid.
3736 + * @param {FbMetadbHandle} handle - The handle to analyze.
3737 + * @param {string} file - The file to analyze.
3738 + * @param {boolean} isRetry - The flag whether the data should be retried.
3739 + * @returns {boolean} True if the data is valid.
3740 + */
3741 + verifyData(handle, file, isRetry = false) {
3742 + if (this.validData()) return true;
3743 +
3744 + if (file) DeleteFile(file);
3745 +
3746 + if (isRetry) {
3747 + console.log('File was not successfully analyzed after retrying.');
3748 + this.isAllowedFile = false;
3749 + this.isFallback = this.analysis.visualizerFallback;
3750 + this.isError = true;
3751 + this.current = [];
3752 + } else {
3753 + console.log(`Waveform bar file not valid. Creating new one${file ? `: ${file}` : '.'}`);
3754 + this.on_playback_new_track(handle, true);
3755 + }
3756 +
3757 + return false;
3758 + }
3759 +
3760 + /**
3761 + * Deletes the waveform file(s) associated with the given track handle.
3762 + * @param {FbMetadbHandle} handle - The handle of the track.
3763 + */
3764 + deleteWaveformFile(handle) {
3765 + if (!handle) return;
3766 +
3767 + const { waveformBarFile } = this.getPaths(handle);
3768 + const fileConfigs = this.getFileConfigs();
3769 +
3770 + for (const config of fileConfigs) {
3771 + const filePath = `${waveformBarFile}${config.ext}`;
3772 + if (IsFile(filePath)) {
3773 + try {
3774 + DeleteFile(filePath);
3775 + } catch (e) {
3776 + console.log(`Error deleting waveform file: ${filePath}`, e);
3777 + }
3778 + }
3779 + }
3780 + }
3781 +
3782 + /**
3783 + * Deletes the waveform bar cache directory with its processed data.
3784 + */
3785 + removeData() {
3786 + DeleteFolder(this.cacheDir);
3787 + }
3788 +
3789 + /**
3790 + * Determines whether data should be saved based on the current analysis save mode and the handle.
3791 + * @param {FbMetadbHandle} handle - The handle to check against the save mode and media library.
3792 + * @returns {boolean} - Returns `true` if the data should be saved, `false` otherwise.
3793 + */
3794 + saveDataAllowed(handle) {
3795 + return this.analysis.saveMode === 'always' || (this.analysis.saveMode === 'library' && handle && fb.IsMetadbInMediaLibrary(handle));
3796 + }
3797 + // #endregion
3798 +
3799 + // * PUBLIC METHODS - COMMON * //
3800 + // #region PUBLIC METHODS - COMMON
3801 + /**
3802 + * Sets the max step based on the BPM of the track.
3803 + * @param {object} handle - The handle of the track.
3804 + * @returns {number} The max steps.
3805 + */
3806 + bpmSteps(handle = fb.GetNowPlaying()) {
3807 + if (!handle) return this.defaultSteps();
3808 +
3809 + // Don't allow anything faster than 2 steps or slower than 10 (scaled to 200 ms refresh rate) and consider setting tracks having 100 BPM as default.
3810 + const BPM = Number(this.TfMaxStep.EvalWithMetadb(handle));
3811 + this.maxStep = Math.round(Math.min(Math.max(200 / (BPM || 100) * 2, 2), 10) * (200 / this.ui.refreshRate) ** (1 / 2));
3812 +
3813 + return this.maxStep;
3814 + }
3815 +
3816 + /**
3817 + * Sets the max step to a default value.
3818 + * @returns {number} The max steps.
3819 + */
3820 + defaultSteps() {
3821 + this.maxStep = Math.round(4 * (200 / this.ui.refreshRate) ** (1 / 2));
3822 + return this.maxStep;
3823 + }
3824 +
3825 + /**
3826 + * Gets the bar width based on the bar design preset.
3827 + * @returns {number} The width of the bar corresponding to the design preset.
3828 + */
3829 + getBarWidth() {
3830 + const barWidth = {
3831 + waveform: this.ui.sizeWave,
3832 + bars: this.ui.sizeBars,
3833 + dots: this.ui.sizeDots,
3834 + halfbars: this.ui.sizeHalf
3835 + };
3836 +
3837 + return barWidth[this.preset.barDesign] || 1;
3838 + }
3839 +
3840 + /**
3841 + * Gets the colors for the waveform bars.
3842 + * @param {boolean} useShadeColor - The flag indicating whether to use the ShadeColor for adjustments.
3843 + * @param {boolean} highlightCurrentPosition - The flag indicating whether to highlight the current position indicator.
3844 + * @returns {object} The object containing colorBack, colorFront and colorsDiffer.
3845 + */
3846 + getColors(useShadeColor = true, highlightCurrentPosition = false) {
3847 + if (highlightCurrentPosition && (this.preset.indicator || this.mouseDown) && this.analysis.binaryMode === 'audioWizard' &&
3848 + (this.frameX <= this.currX && this.frameX >= this.currX - 2 * this.barW)) {
3849 + return { colorBack: grCol.waveformBarIndicator, colorFront: grCol.waveformBarIndicator, colorsDiffer: false };
3850 + }
3851 +
3852 + const colorBack = this.prepaint && this.isPrepaint ?
3853 + useShadeColor ? ShadeColor(grCol.waveformBarFillBack, 40) : grCol.waveformBarFillPreBack :
3854 + grCol.waveformBarFillBack;
3855 +
3856 + const colorFront = this.prepaint && this.isPrepaint ?
3857 + useShadeColor ? ShadeColor(grCol.waveformBarFillFront, 20) : grCol.waveformBarFillPreFront :
3858 + grCol.waveformBarFillFront;
3859 +
3860 + return { colorBack, colorFront, colorsDiffer: colorFront !== colorBack };
3861 + }
3862 +
3863 + /**
3864 + * Gets the configuration for the different file types to be analyzed.
3865 + * @returns {Array<object>} An array of file configuration objects. Each object contains:
3866 + * - {string} ext - The file extension.
3867 + * - {Function} decompress - The function to decompress and parse the file content.
3868 + * - {string} codePage - The code page to be used when reading the file.
3869 + */
3870 + getFileConfigs() {
3871 + return [
3872 + { ext: '.awz.json', decompress: JSON.parse, codePage: this.codePage },
3873 + { ext: '.awz.lz', decompress: str => JSON.parse(LZUTF8.decompress(str, { inputEncoding: 'Base64' })), codePage: this.codePage },
3874 + { ext: '.awz.lz16', decompress: str => JSON.parse(LZString.decompressFromUTF16(str)), codePage: this.codePageV2 }
3875 + ];
3876 + }
3877 +
3878 + /**
3879 + * Gets the paths to the waveform bar cache folder and file.
3880 + * @param {object} handle - The handle of the track.
3881 + * @returns {object} The paths to the waveform bar cache folder and file.
3882 + */
3883 + getPaths(handle) {
3884 + const id = CleanFilePath(this.Tf.EvalWithMetadb(handle)); // Ensures paths are valid!
3885 + const fileName = id.split('\\').pop();
3886 + const waveformBarFolder = this.cacheDir + (this.saveDataAllowed(handle) ? id.replace(fileName, '') : '');
3887 + const waveformBarFile = this.cacheDir + id;
3888 + const sourceFile = this.isZippedFile ? handle.Path.split('|')[0] : handle.Path;
3889 +
3890 + return { waveformBarFolder, waveformBarFile, sourceFile };
3891 + }
3892 +
3893 + /**
3894 + * Gets the maximum and minimum values from the frames.
3895 + * @param {number[]} frames - The array of frame values.
3896 + * @returns {object} The object containing the `upper` and `lower` values.
3897 + */
3898 + getMaxValue(frames) {
3899 + let upper = 0;
3900 + let lower = 0;
3901 +
3902 + for (let i = 0; i < frames.length; i++) {
3903 + const frame = frames[i];
3904 + upper = Math.max(upper, frame);
3905 + lower = Math.min(lower, frame);
3906 + }
3907 +
3908 + return { upper, lower };
3909 + }
3910 +
3911 + /**
3912 + * Gets the minimum value at a specific position in the frames.
3913 + * @param {Array} frames - The array of frame data.
3914 + * @param {number} pos - The position index in the frame data.
3915 + * @returns {number} The minimum value at the specified position.
3916 + */
3917 + getMinValuePos(frames, pos) {
3918 + let minVal = Infinity;
3919 +
3920 + for (let i = 0; i < frames.length; i++) {
3921 + const frame = frames[i];
3922 + if (frame[pos] === null) frame[pos] = -Infinity;
3923 + const val = frame[pos];
3924 + if (isFinite(val)) {
3925 + minVal = Math.min(minVal, val);
3926 + }
3927 + }
3928 +
3929 + return minVal === Infinity ? 0 : minVal;
3930 + }
3931 +
3932 + /**
3933 + * Gets the Normalized frame values by subtracting the maximum value from each frame.
3934 + * @param {Array} frames - The array of frame data.
3935 + * @param {number} maxVal - The maximum value to be subtracted from each frame.
3936 + * @returns {Array} The normalized frame data.
3937 + */
3938 + getNormalizedFrameValues(frames, maxVal) {
3939 + const normalizedFrames = new Array(frames.length);
3940 + const scaledIndex = this.metrics.count; // Scaled value stored at metric.count
3941 +
3942 + for (let i = 0; i < frames.length; i++) {
3943 + const frame = frames[i];
3944 + const newFrame = frame.slice();
3945 +
3946 + if (newFrame[scaledIndex] !== 1) newFrame[scaledIndex] -= maxVal;
3947 + if (!isFinite(newFrame[scaledIndex])) newFrame[scaledIndex] = 0;
3948 +
3949 + normalizedFrames[i] = newFrame;
3950 + }
3951 +
3952 + return normalizedFrames;
3953 + }
3954 +
3955 + /**
3956 + * Gets the scaled frames based on the given position, maximum value, and level type.
3957 + * @param {Array} frames - The array of frame data.
3958 + * @param {number} pos - The position index in the frame data to be scaled.
3959 + * @param {number} max - The maximum value for scaling.
3960 + * @param {boolean} isRmsLevel - Whether if RMS level scaling should be applied.
3961 + * @returns {Array} The scaled frame data.
3962 + */
3963 + getScaledFrames(frames, pos, max, isRmsLevel) {
3964 + const scaledFrames = new Array(frames.length);
3965 + const logMax = Math.log(Math.abs(max));
3966 +
3967 + for (let i = 0; i < frames.length; i++) {
3968 + const frame = frames[i];
3969 + const value = isFinite(frame[pos]) ? frame[pos] : -Infinity;
3970 +
3971 + let scaledVal =
3972 + !isFinite(value) ? 1 :
3973 + isRmsLevel ? 1 - Math.abs((value - max) / max) :
3974 + Math.abs(1 - (logMax + Math.log(Math.abs(value))) / logMax);
3975 +
3976 + if (!isFinite(scaledVal)) scaledVal = 0;
3977 +
3978 + const newFrame = frame.slice(0, this.metrics.count);
3979 + newFrame.push(scaledVal);
3980 + scaledFrames[i] = newFrame;
3981 + }
3982 +
3983 + return scaledFrames;
3984 + }
3985 +
3986 + /**
3987 + * Gets the resized frames based on the given scale and new frame count.
3988 + * @param {number} scale - The scale factor for resizing.
3989 + * @param {number} frames - The current number of frames.
3990 + * @param {number} newFrames - The desired number of frames after resizing.
3991 + * @returns {Array} The resized frame data.
3992 + */
3993 + getResizedFrames(scale, frames, newFrames) {
3994 + const data = Array(newFrames).fill(null).map(() => ({ maxAbs: 0, maxSigned: 0, val: 0, count: 0 }));
3995 + const scaleFactor = newFrames < frames ? frames / newFrames : newFrames / frames;
3996 + const isWaveform = this.preset.analysisMode === 'waveform';
3997 +
3998 + if (frames === 0 || newFrames === 0) return [];
3999 +
4000 + for (let i = 0, j = 0, h = 0; i < frames; i++) {
4001 + const frame = this.current[i];
4002 +
4003 + if (newFrames < frames) {
4004 + if (isWaveform) { // Track max absolute and signed values for waveform
4005 + data[j].maxAbs = Math.max(data[j].maxAbs, Math.abs(frame));
4006 + data[j].maxSigned = Math.abs(frame) > Math.abs(data[j].maxSigned) ? frame : data[j].maxSigned;
4007 + data[j].count++;
4008 + h += 1;
4009 + if (h >= scaleFactor) {
4010 + j++;
4011 + h -= scaleFactor;
4012 + if (j >= newFrames) break;
4013 + }
4014 + }
4015 + else { // Averaging logic for other modes
4016 + while (h >= scaleFactor) {
4017 + const w = h - scaleFactor;
4018 + if (j + 1 < newFrames) {
4019 + data[j + 1].val += frame * w;
4020 + data[j + 1].count += w;
4021 + }
4022 + j += 2;
4023 + h = 0;
4024 + if (j >= newFrames) break;
4025 + data[j].val += frame * (1 - w);
4026 + data[j].count += (1 - w);
4027 + }
4028 + if (i % 2 === 0 && j + 1 < newFrames) {
4029 + data[j + 1].val += frame;
4030 + data[j + 1].count++;
4031 + } else {
4032 + data[j].val += frame;
4033 + data[j].count++;
4034 + h++;
4035 + }
4036 + }
4037 + }
4038 + else { // Upsampling: repeat or interpolate frames
4039 + while (h < scaleFactor && j < newFrames) {
4040 + if (isWaveform) {
4041 + data[j].maxAbs = Math.max(data[j].maxAbs, Math.abs(frame));
4042 + data[j].maxSigned = Math.abs(frame) > Math.abs(data[j].maxSigned) ? frame : data[j].maxSigned;
4043 + } else {
4044 + data[j].val += frame;
4045 + }
4046 + data[j].count++;
4047 + j++;
4048 + h++;
4049 + }
4050 + h -= scaleFactor;
4051 + }
4052 + }
4053 +
4054 + return data.filter(el => el.count > 0).map(el => isWaveform ? el.maxSigned : el.val / el.count);
4055 + }
4056 +
4057 + /**
4058 + * Normalizes points to ensure all points are on the same scale to prevent distortion of the waveform.
4059 + * @param {boolean} normalizeWidth - If `true`, adjusts the number of frames to match the window size.
4060 + */
4061 + normalizePoints(normalizeWidth = false) {
4062 + if (!this.current.length) return;
4063 +
4064 + // Safety filter for any unexpected invalid frames
4065 + if (this.analysis.binaryMode === 'audioWizard' && !this.isFallback && !this.fallbackMode.paint) {
4066 + this.current = this.current.filter(frame => frame != null && Array.isArray(frame) && frame.length >= this.metrics.count);
4067 + }
4068 +
4069 + if (this.analysis.binaryMode === 'audioWizard' && !this.isFallback && !this.fallbackMode.paint && this.preset.analysisMode === 'waveform') {
4070 + const channels = this.currentChannels || 1;
4071 + const minIdx = this.metrics.index.min;
4072 + const maxIdx = this.metrics.index.max;
4073 + const metricsPerChannel = this.metrics.count;
4074 +
4075 + this.current = this.current.map(frame => {
4076 + let globalMin = Infinity;
4077 + let globalMax = -Infinity;
4078 +
4079 + // Process ALL channels
4080 + for (let ch = 0; ch < channels; ch++) {
4081 + const offset = ch * metricsPerChannel;
4082 + const chMin = frame[offset + minIdx];
4083 + const chMax = frame[offset + maxIdx];
4084 +
4085 + globalMin = Math.min(globalMin, chMin);
4086 + globalMax = Math.max(globalMax, chMax);
4087 + }
4088 +
4089 + // Return the value with larger magnitude
4090 + return Math.abs(globalMax) > Math.abs(globalMin) ? globalMax : globalMin;
4091 + });
4092 + }
4093 +
4094 + let { upper, lower } = this.getMaxValue(this.current);
4095 +
4096 + if (this.analysis.binaryMode === 'audioWizard' && !this.isFallback && !this.fallbackMode.paint && this.preset.analysisMode !== 'waveform') {
4097 + const metric = this.metrics.mode[this.preset.analysisMode];
4098 + const pos = this.metrics.index[metric];
4099 + const minVal = this.getMinValuePos(this.current, pos);
4100 +
4101 + this.current = this.getScaledFrames(this.current, pos, minVal, this.preset.analysisMode === 'rms');
4102 + this.current = this.getNormalizedFrameValues(this.current, Math.min(...this.current.map(frame => frame[this.metrics.count])));
4103 + this.current = this.current.map((x, i) => Math.sign((0.5 - i % 2)) * (1 - x[this.metrics.count]));
4104 + }
4105 + else if (this.analysis.binaryMode === 'visualizer' || this.isFallback || this.fallbackMode.paint) {
4106 + const maxVal = Math.max(Math.abs(upper), Math.abs(lower));
4107 + this.current = this.current.map(frame => frame / maxVal);
4108 + }
4109 +
4110 + if (normalizeWidth) {
4111 + const barW = this.getBarWidth();
4112 + const frames = this.current.length;
4113 + const newFrames = Math.floor(this.w / barW);
4114 +
4115 + if (newFrames === frames) return;
4116 +
4117 + this.current = this.getResizedFrames(frames / newFrames, frames, newFrames);
4118 +
4119 + const bias = Math.abs(upper / lower);
4120 + upper = lower = 0;
4121 + ({ upper, lower } = this.getMaxValue(this.current));
4122 + const newBias = Math.abs(upper / lower);
4123 + const biasDiff = bias - newBias;
4124 +
4125 + if (biasDiff > 0.1) {
4126 + const distort = bias / newBias;
4127 + const sign = Math.sign(biasDiff);
4128 + this.current = this.current.map(frame => (sign === 1 && frame > 0) || (sign !== 1 && frame < 0) ? frame * distort : frame);
4129 + }
4130 + }
4131 +
4132 + // Clamp frame values to [-1, 1] to prevent overflow/distortion from imbalanced data or edge cases
4133 + this.current = this.current.map(frame => Math.max(-1, Math.min(1, frame)));
4134 + }
4135 +
4136 + /**
4137 + * Resets the state of the waveform bar.
4138 + */
4139 + reset() {
4140 + this.current = [];
4141 + this.cache = null;
4142 + this.time = 0;
4143 + this.step = 0;
4144 + this.maxStep = 6;
4145 + this.offset = [];
4146 + this.isAllowedFile = true;
4147 + this.isZippedFile = false;
4148 + this.isError = false;
4149 + this.isFallback = false;
4150 + this.fallbackMode.paint = this.fallbackMode.analysis = false;
4151 + this.resetAnimation();
4152 + clearTimeout(this.queueId);
4153 + }
4154 +
4155 + /**
4156 + * Resets the state of the waveform bar animation.
4157 + */
4158 + resetAnimation() {
4159 + this.step = 0;
4160 + this.offset = [];
4161 + this.defaultSteps();
4162 + }
4163 +
4164 + /**
4165 + * Sets the refresh rate for the waveform bar.
4166 + */
4167 + setRefreshRate() {
4168 + if (grm.ui.isStreaming) { // Radio streaming refresh rate
4169 + this.ui.refreshRate = grm.ui.seekbarTimerInterval = FPS._1;
4170 + }
4171 + else if (grSet.waveformBarRefreshRate !== 'variable') { // Fixed refresh rate
4172 + this.ui.refreshRate = grm.ui.seekbarTimerInterval = grSet.waveformBarRefreshRate;
4173 + }
4174 + else { // Variable refresh rate calculation
4175 + const now = Date.now();
4176 + if (this.updateTimeLast && (now - this.updateTimeLast) < 250) return; // Check every 250 ms
4177 + this.updateTimeLast = now;
4178 +
4179 + if (this.profilerPaintTimeLast === undefined) {
4180 + this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time;
4181 + }
4182 +
4183 + const timeDifference = grm.ui.seekbarProfiler.Time - this.profilerPaintTimeLast;
4184 + this.ui.refreshRate = grm.ui.seekbarTimerInterval = Clamp(grm.ui.seekbarTimerInterval + (timeDifference > 0 ? 12 : -7), FPS._10, FPS._5);
4185 + this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time;
4186 +
4187 + grm.ui.clearTimer('seekbar', true);
4188 + grm.ui.seekbarTimer = !fb.IsPaused ? setInterval(() => grm.ui.refreshSeekbar(), grm.ui.seekbarTimerInterval) : null;
4189 + }
4190 + }
4191 +
4192 + /**
4193 + * Sets the rectangular area to be painted.
4194 + * @param {number} time - The current playback time.
4195 + * @returns {{ x: number, y: number, width: number, height: number }} The object containing the dimensions of the rectangle to be painted.
4196 + */
4197 + setPaintRect(time) {
4198 + const widerModesScale = ['bars', 'halfbars'].includes(this.preset.barDesign) ? 2 : 1;
4199 + const barW = Math.ceil(Math.max(this.w / this.current.length, SCALE(2))) * widerModesScale;
4200 + const currX = this.x + (this.w * time / fb.PlaybackLength);
4201 +
4202 + const prePaintW = Math.min(
4203 + this.prepaint && this.preset.prepaintFront !== Infinity || this.preset.animate
4204 + ? this.preset.prepaintFront === Infinity && this.preset.animate
4205 + ? Infinity
4206 + : (this.preset.prepaintFront / this.timeConstant * barW) + barW
4207 + : 2.5 * barW,
4208 + this.w - currX + barW
4209 + );
4210 +
4211 + return {
4212 + x: currX - barW - grm.ui.edgeMargin,
4213 + y: this.y,
4214 + width: prePaintW + grm.ui.edgeMarginBoth,
4215 + height: this.h
4216 + };
4217 + }
4218 +
4219 + /**
4220 + * Sets the throttle paint methods based on the current UI refresh rate.
4221 + */
4222 + setThrottlePaint() {
4223 + /**
4224 + * Throttles the window repaint to improve performance by limiting the rate of repaint operations.
4225 + * This function is specifically tailored to repaint a defined rectangular area of the window.
4226 + * The repaint is controlled by the UI refresh rate.
4227 + * @param {boolean} [force] - If set to true, the repaint will be forced even if the window is not dirty.
4228 + * @private
4229 + */
4230 + this.throttlePaint = Throttle((force = false) =>
4231 + window.RepaintRect(this.x - SCALE(2), this.y - this.h * 0.5 - SCALE(4), this.w + SCALE(4), this.h + SCALE(8), force), this.ui.refreshRate);
4232 +
4233 + /**
4234 + * Throttles the window repaint to improve performance by limiting the rate of repaint operations.
4235 + * This function allows for the specification of the rectangular area to be repainted.
4236 + * The repaint is controlled by the UI refresh rate.
4237 + * @param {number} x - The x-coordinate of the upper-left corner of the rectangle to repaint.
4238 + * @param {number} y - The y-coordinate of the upper-left corner of the rectangle to repaint.
4239 + * @param {number} w - The width of the rectangle to repaint.
4240 + * @param {number} h - The height of the rectangle to repaint.
4241 + * @param {boolean} [force] - If set to true, the repaint will be forced even if the window is not dirty.
4242 + * @private
4243 + */
4244 + this.throttlePaintRect = Throttle((x, y, w, h, force = false) =>
4245 + window.RepaintRect(x - SCALE(2), y - h * 0.5 - SCALE(4), w + SCALE(4), h + SCALE(8), force), this.ui.refreshRate);
4246 + }
4247 +
4248 + /**
4249 + * Sets the vertical waveform bar position.
4250 + * @param {number} y - The y-coordinate.
4251 + */
4252 + setY(y) {
4253 + this.y = y + SCALE(10);
4254 + }
4255 +
4256 + /**
4257 + * This method is currently not used.
4258 + * @param {boolean} [enable] - If true, activates the component; if false, deactivates it.
4259 + */
4260 + switch(enable = !this.active) {
4261 + if (!fb.IsPlaying) return;
4262 +
4263 + const wasActive = this.active;
4264 + this.active = enable;
4265 +
4266 + if (!wasActive && this.active) {
4267 + window.Repaint();
4268 + setTimeout(() => {
4269 + this.on_playback_new_track(fb.GetNowPlaying());
4270 + this.on_playback_time(fb.PlaybackTime);
4271 + }, 0);
4272 + }
4273 + else if (wasActive && !this.active) {
4274 + this.on_playback_stop(-1);
4275 + }
4276 + }
4277 +
4278 + /**
4279 + * Checks if the mouse is within the boundaries of the waveform bar.
4280 + * @param {number} x - The x-coordinate.
4281 + * @param {number} y - The y-coordinate.
4282 + * @returns {boolean} True or false.
4283 + */
4284 + trace(x, y) {
4285 + return (x >= this.x && y >= this.y && x <= this.x + this.w && y <= this.y + this.h);
4286 + }
4287 +
4288 + /**
4289 + * Updates the waveform bar with the current track information, playback time and size.
4290 + * @param {boolean} current - Whether the current track has changed or not.
4291 + */
4292 + updateBar(current) {
4293 + if (!current) this.on_playback_new_track(fb.GetNowPlaying());
4294 + this.on_playback_time(fb.PlaybackTime);
4295 + this.on_size(grm.ui.ww, grm.ui.wh);
4296 + }
4297 + // #endregion
4298 +
4299 + // * CALLBACKS * //
4300 + // #region CALLBACKS
4301 + /**
4302 + * Handles left mouse button up click events and disables dragging and updates the playback time.
4303 + * @param {number} x - The x-coordinate.
4304 + * @param {number} y - The y-coordinate.
4305 + * @param {number} mask - The mouse mask.
4306 + * @returns {boolean} True or false.
4307 + */
4308 + on_mouse_lbtn_up(x, y, mask) {
4309 + if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) ||
4310 + !this.active || !this.trace(x, y) || !fb.IsPlaying || this.current.length === 0) {
4311 + this.mouseDown = false;
4312 + return false;
4313 + }
4314 +
4315 + this.mouseDown = false;
4316 +
4317 + if (!fb.GetSelection()) return;
4318 +
4319 + const barW = this.w / this.current.length;
4320 + const time = Math.round(fb.PlaybackLength / this.current.length * (x - this.x) / barW);
4321 + fb.PlaybackTime = Clamp(time, 0, fb.PlaybackLength);
4322 + this.throttlePaint(true);
4323 +
4324 + return true;
4325 + }
4326 +
4327 + /**
4328 + * Handles mouse movement events on the waveform bar.
4329 + * @param {number} x - The x-coordinate.
4330 + * @param {number} y - The y-coordinate.
4331 + * @param {number} mask - The mouse mask.
4332 + */
4333 + on_mouse_move(x, y, mask) {
4334 + if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar)) {
4335 + return;
4336 + }
4337 +
4338 + this.mouseDown = (mask === MouseKey.LButton && this.on_mouse_lbtn_up(x, y, mask));
4339 + }
4340 +
4341 + /**
4342 + * Handles the mouse wheel event to seek through the playback.
4343 + * @param {number} step - The wheel scroll direction.
4344 + * @returns {boolean} True or false.
4345 + */
4346 + on_mouse_wheel(step) {
4347 + if (!this.active || !fb.GetSelection() || !fb.IsPlaying || this.current.length === 0) {
4348 + return false;
4349 + }
4350 +
4351 + const seekType = {
4352 + seconds: (scroll) => scroll * this.wheel.seekSpeed,
4353 + percentage: (scroll) => (scroll * this.wheel.seekSpeed) / 100 * fb.PlaybackLength
4354 + };
4355 +
4356 + const seekTypeFunc = seekType[this.wheel.seekType] || seekType.seconds;
4357 + const newTime = fb.PlaybackTime + seekTypeFunc(step);
4358 + fb.PlaybackTime = Clamp(newTime, 0, fb.PlaybackLength);
4359 + this.throttlePaint(true);
4360 +
4361 + return true;
4362 + }
4363 +
4364 + /**
4365 + * Resets the current waveform and processes new data for the new current playing track.
4366 + * @param {FbMetadbHandle} handle - The handle of the new track.
4367 + * @param {boolean} [isRetry] - The flag indicating whether the method call is a retry attempt.
4368 + * @returns {Promise<void>} The promise that resolves when the processing has finished.
4369 + */
4370 + async on_playback_new_track(handle = fb.GetNowPlaying(), isRetry = false) {
4371 + if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) || !this.active || !handle) {
4372 + return;
4373 + }
4374 +
4375 + this.reset();
4376 + this.checkAllowedFile(handle);
4377 + await this.analyzeDataStart(handle, isRetry);
4378 + this.resetAnimation();
4379 +
4380 + if (this.preset.animate && this.preset.useBPM) this.bpmSteps(handle);
4381 + if (fb.IsPlaying) this.time = fb.PlaybackTime;
4382 +
4383 + this.throttlePaint();
4384 + }
4385 +
4386 + /**
4387 + * Schedules the `on_playback_new_track` event to be triggered after a specified delay.
4388 + * This is useful for debouncing the event, ensuring it is fired only once after a series of track changes.
4389 + */
4390 + on_playback_new_track_queue() {
4391 + clearTimeout(this.queueId);
4392 +
4393 + this.queueId = setTimeout(() => {
4394 + this.on_playback_new_track(...arguments);
4395 + }, this.queueMs);
4396 + }
4397 +
4398 + /**
4399 + * Resets the waveform bar on playback stop.
4400 + * @param {number} reason - The type of playback stop.
4401 + */
4402 + on_playback_stop(reason = -1) {
4403 + if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) || reason !== -1 && !this.active) {
4404 + return;
4405 + }
4406 +
4407 + this.reset();
4408 + if (reason !== 2) this.throttlePaint();
4409 + }
4410 +
4411 + /**
4412 + * Updates the waveform bar with throttled repaints.
4413 + * @param {number} time - The current playback time.
4414 + */
4415 + on_playback_time(time) {
4416 + if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) || !this.active) {
4417 + return;
4418 + }
4419 +
4420 + this.time = time;
4421 +
4422 + if ((this.preset.paintMode === 'full' || this.preset.indicator || this.analysis.binaryMode === 'visualizer') &&
4423 + this.cache === this.current) {
4424 + return;
4425 + }
4426 +
4427 + this.cache = this.current;
4428 +
4429 + if (this.analysis.binaryMode === 'visualizer' || !this.current.length) {
4430 + this.throttlePaint();
4431 + }
4432 + else if (this.preset.paintMode === 'partial' || this.preset.indicator) {
4433 + const paintRect = this.setPaintRect(this.time);
4434 + this.throttlePaintRect(paintRect.x, paintRect.y, paintRect.width, paintRect.height);
4435 + }
4436 + }
4437 +
4438 + /**
4439 + * Handles the waveform bar state when reloading the theme.
4440 + */
4441 + on_script_unload() {
4442 + if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar)) return;
4443 + if (this.analysis.autoDelete) this.removeData();
4444 + }
4445 +
4446 + /**
4447 + * Sets the size and position of the waveform bar and updates them on window resizing.
4448 + * @param {number} w - The width of the waveform bar.
4449 + * @param {number} h - The height of the waveform bar.
4450 + */
4451 + on_size(w, h) {
4452 + if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar)) return;
4453 + this.x = grm.ui.edgeMargin;
4454 + this.y = 0;
4455 + this.w = w - grm.ui.edgeMarginBoth;
4456 + this.h = grm.ui.seekbarHeight;
4457 + }
4458 + // #endregion
4459 + }
Daha yeni Daha eski