Последняя активность 22 hours ago

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

Версия c46a3d502c7ab86d771c0c7886df0a4b862e4a7f

gr-details.js Исходник
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 */
21class 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}
2357
gr-main-components.js Исходник
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 */
21class 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 */
274class 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 */
713class 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 */
863class 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 */
941class 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 */
1001class 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 */
1240class 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 */
1544class 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 */
1842class 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 */
2889class 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}
4460