gr-details.js
· 96 KiB · JavaScript
原始文件
/////////////////////////////////////////////////////////////////////////////////
// * Georgia-ReBORN: A Clean - Full Dynamic Color Reborn - Foobar2000 Player * //
// * Description: Georgia-ReBORN Details * //
// * Author: TT * //
// * Website: https://github.com/TT-ReBORN/Georgia-ReBORN * //
// * Version: 3.0-x64-DEV * //
// * Dev. started: 22-12-2017 * //
// * Last change: 02-05-2026 * //
/////////////////////////////////////////////////////////////////////////////////
'use strict';
////////////////////////////////
// * DETAILS USER INTERFACE * //
////////////////////////////////
/**
* A class that is responsible for the Details panel.
*/
class Details {
/**
* Creates the `Details` instance.
*/
constructor() {
// * GEOMETRY * //
// #region GEOMETRY
/** @public @type {number} The size of the disc art shadow. */
this.discArtShadow = SCALE(6);
/** @public @type {number} The margin width from the edge of the player to start of the metadata grid strings. */
this.gridMarginLeft = grm.ui.edgeMargin;
/** @public @type {number} The margin width from the edge of the metadata grid to the end of the metadata grid strings. */
this.gridMarginRight = SCALE(20);
/** @public @type {number} The spacing between grid lines in the metadata grid. */
this.gridLineSpacing = SCALE(30);
/** @public @type {number} The horizontal spacing between the track number and the artist in the metadata grid. */
this.gridTrackNumSpacing = SCALE(8);
/** @public @type {number} The height of the metadata grid tooltip area. */
this.gridTooltipHeight = SCALE(100);
/** @public @type {number} The top starting fixed position of the metadata grid. */
this.gridTopStart = 0;
/** @public @type {number} The top dynamic position of the metadata grid. */
this.gridTop = 0;
/** @public @type {number} The width of the metadata grid content. */
this.gridContentWidth = 0;
/** @public @type {number} The width of the country flag size in the metadata grid. */
this.gridFlagSizeW = 0;
/** @public @type {number} The white space size for the country flag in the metadata grid. */
this.gridFlagSizeWhiteSpace = 0;
/** @public @type {number} The text rectangle for string calculation in the metadata grid. */
this.gridTxtRec = 0;
/** @public @type {number} The top position of the artist in the metadata grid. */
this.gridArtistTop = 0;
/** @public @type {number} The bottom position of the artist in the metadata grid. */
this.gridArtistBottom = 0;
/** @public @type {object} The calculated artist wrap info for the metadata grid. */
this.gridArtistWrapInfo = {};
/** @public @type {boolean} The state when the artist string exceeds the available lines in the metadata grid. */
this.gridArtistWrapLinesExceed = false;
/** @public @type {number} The width of the wrap space within the artist string in the metadata grid. */
this.gridArtistWrapWidth = 0;
/** @public @type {number} The width of the artist in the metadata grid. */
this.gridArtistWidth = 0;
/** @public @type {number} The height of the artist in the metadata grid. */
this.gridArtistHeight = 0;
/** @public @type {number} The text rectangle for artist string calculation in the metadata grid. */
this.gridArtistTxtRec = 0;
/** @public @type {number} The number of lines for the artist text in the metadata grid. */
this.gridArtistNumLines = 0;
/** @public @type {number} The height of the artist number of lines in the metadata grid. */
this.gridArtistNumLinesHeight = 0;
/** @public @type {number} The top position of the track title in the metadata grid. */
this.gridTitleTop = 0;
/** @public @type {number} The bottom position of the track title in the metadata grid. */
this.gridTitleBottom = 0;
/** @public @type {number} The width of the track number in the metadata grid. */
this.gridTrackNumWidth = 0;
/** @public @type {object} The calculated track title wrap info for the metadata grid. */
this.gridTitleWrapInfo = {};
/** @public @type {boolean} The state when the track title string exceeds the available lines in the metadata grid. */
this.gridTitleWrapLinesExceed = false;
/** @public @type {number} The width of the wrap space within the track title string in the metadata grid. */
this.gridTitleWrapWidth = 0;
/** @public @type {number} The width of the track title in the metadata grid. */
this.gridTitleWidth = 0;
/** @public @type {number} The height of the track title in the metadata grid. */
this.gridTitleHeight = 0;
/** @public @type {number} The text rectangle for track title string calculation in the metadata grid. */
this.gridTitleTxtRec = 0;
/** @public @type {number} The number of lines for the track title text in the metadata grid. */
this.gridTitleNumLines = 0;
/** @public @type {number} The height of the track title number of lines in the metadata grid. */
this.gridTitleNumLinesHeight = 0;
/** @public @type {number} The top position of the album in the metadata grid. */
this.gridAlbumTop = 0;
/** @public @type {number} The bottom position of the album in the metadata grid. */
this.gridAlbumBottom = 0;
/** @public @type {object} The calculated album wrap info for the metadata grid. */
this.gridAlbumWrapInfo = {};
/** @public @type {boolean} The state when the album string exceeds the available lines in the metadata grid. */
this.gridAlbumWrapLinesExceed = false;
/** @public @type {number} The width of the wrap space within the album string in the metadata grid. */
this.gridAlbumWrapWidth = 0;
/** @public @type {number} The width of the album in the metadata grid. */
this.gridAlbumWidth = 0;
/** @public @type {number} The height of the album in the metadata grid. */
this.gridAlbumHeight = 0;
/** @public @type {number} The text rectangle for album string calculation in the metadata grid. */
this.gridAlbumTxtRec = 0;
/** @public @type {number} The number of lines for the album text in the metadata grid. */
this.gridAlbumNumLines = 0;
/** @public @type {number} The height of the album number of lines in the metadata grid. */
this.gridAlbumNumLinesHeight = 0;
/** @public @type {number} The margin between grid columns in the metadata grid. */
this.gridColumnMargin = SCALE(10);
/** @public @type {number} The top position of the grid columns in the metadata grid. */
this.gridColumnTop = 0;
/** @public @type {number} The height of the grid column cell in the metadata grid. */
this.gridColumnCellHeight = 0;
/** @public @type {number} The width of the key strings column in the metadata grid. */
this.gridColumnKeyWidth = 0;
/** @public @type {number} The height of the key strings in the metadata grid. */
this.gridColumnKeyHeight = 0;
/** @public @type {number} The bottom position of the key strings in the metadata grid. */
this.gridColumnKeyBottom = 0;
/** @public @type {number} The width of the value strings column in the metadata grid. */
this.gridColumnValueWidth = 0;
/** @public @type {number} The height of the value strings in the metadata grid. */
this.gridColumnValueHeight = 0;
/** @public @type {number} The left position of the value strings column in the metadata grid. */
this.gridColumnValueLeft = 0;
/** @public @type {number} The bottom position of the value strings in the metadata grid. */
this.gridColumnValueBottom = 0;
// * TIMELINE * //
// #region TIMELINE
/** @public @type {number} The x-coordinate of the timeline. */
this.timelineX = 0;
/** @public @type {number} The y-coordinate of the timeline. */
this.timelineY = 0;
/** @public @type {number} The width of the timeline. */
this.timelineW = 0;
/** @public @type {number} The height of the timeline. */
this.timelineH = SCALE(8);
/** @public @type {number} The color of the played portion of the timeline. */
this.timelinePlayCol = RGBA(255, 255, 255, 150);
/** @public @type {number} The ratio of the first played segment in the timeline. */
this.timelineFirstPlayedRatio = 0;
/** @public @type {number} The ratio of the last played segment in the timeline. */
this.timelineLastPlayedRatio = 0;
/** @public @type {number} The percentage of the first played segment in the timeline. */
this.timelineFirstPlayedPercent = 0.33;
/** @public @type {number} The percentage of the last played segment in the timeline. */
this.timelineLastPlayedPercent = 0.66;
/** @public @type {number[]} The percentages of the played times on the timeline. */
this.timelinePlayedTimesPercents = [];
/** @public @type {number[]} The actual played times on the timeline. */
this.timelinePlayedTimes = [];
/** @public @type {number} The width of the timeline line. */
this.timelineLineWidth = HD_4K(2, 3);
/** @public @type {number} The extra left space on the timeline. */
this.timelineExtraLeftSpace = SCALE(3);
/** @public @type {number} The draw width of the timeline. */
this.timelineDrawWidth = 0;
/** @public @type {number} The leeway of the timeline. */
this.timelineLeeway = 0;
// #endregion
// * CACHE * //
// #region CACHE
/** @public @type {object} The caching object of the calculated text wrap space for the metadata grid. */
this.cachedGridWrapSpace = {};
/** @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. */
this.cachedGridMetrics = false;
/** @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. */
this.cachedLabelLastLeftEdge = 0;
/** @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. */
this.cachedLabelLastHeight = 0;
// #endregion
// * IMAGES * //
// #region IMAGES
/** @public @type {GdiBitmap} The disc art image used in Details. */
this.discArt = null;
/** @public @type {GdiBitmap} The disc art album cover image used in Details. */
this.discArtCover = null;
/** @public @type {GdiBitmap[]} The array of disc art images used in Details. */
this.discArtArray = [];
/** @public @type {number} The scale factor of the disc art used in Details. */
this.discArtScaleFactor = 0;
/** @private @type {{image: GdiBitmap|null, size: number}} The shadow behind the disc art used in Details. */
this.discArtShadowImg = { image: null, size: 0 }
/** @public @type {object} The disc art position used in Details (offset from albumArtSize). */
this.discArtSize = new ImageSize(0, 0, 0, 0);
/** @public @type {GdiBitmap} The rotated disc art from the RotateImg helper used in Details. */
this.discArtRotation = null;
/** @public @type {number} The global index of current discArtArray img to draw used in Details. */
this.discArtRotationIndex = 0;
/** @private @type {GdiBitmap} The release country flag image shown in the metadata grid in Details. */
this.gridReleaseFlagImg = null;
/** @private @type {GdiBitmap} The codec logo image shown in the metadata grid in Details. */
this.gridCodecLogo = null;
/** @private @type {GdiBitmap} The channel logo image shown in the metadata grid in Details. */
this.gridChannelLogo = null;
/** @public @type {GdiBitmap} The band logo image used in Details. */
this.bandLogo = null;
/** @public @type {GdiBitmap} The inverted band logo image shown in Details. */
this.bandLogoInverted = null;
/** @public @type {GdiBitmap[]} The array of record label images used in Details. */
this.labelLogo = [];
/** @public @type {GdiBitmap[]} The array of inverted record label images used in Details. */
this.labelLogoInverted = [];
/** @private @type {GdiBitmap} The shadow behind labels used in Details. */
this.labelShadowImg = null;
// #endregion
// * STATE * //
// #region STATE
/** @private @type {boolean} The state when disc art was found on hard drive used in Details. */
this.discArtFound = false;
/** @public @type {boolean} The last.fm logo image displayed when we %lastfm_play_count% > 0, shown in the metadata grid in Details. */
this.playCountVerifiedByLastFm = false;
/** @public @type {object} The boundary section object contains check functions for different sections of the metadata grid. */
this.gridSectionBounds = {
artist: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridArtistTop && y <= this.gridArtistBottom,
title: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridTitleTop && y <= this.gridTitleBottom,
album: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridAlbumTop && y <= this.gridAlbumBottom,
tagKey: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridColumnKeyWidth) && y >= this.gridAlbumBottom && y <= this.gridColumnKeyBottom,
tagValue: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridColumnKeyWidth + this.gridColumnValueWidth) && y >= this.gridAlbumBottom && y <= this.gridColumnValueBottom,
timeline: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.timelineY - SCALE(10) && y < this.timelineY + this.timelineH + SCALE(10),
grid: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridArtistTop && y <= this.gridColumnValueBottom
};
/** @private @type {string} The text content of the grid tooltip. */
this.gridTooltipText = '';
/** @private @type {string} The text content of the grid timeline tooltip. */
this.gridTimelineTooltipText = '';
// #endregion
// * TIMERS * //
// #region TIMERS
/** @public @type {number} The disc art rotation timer when disc art spins while song is playing. */
this.discArtRotationTimer = null;
// #endregion
}
// * PLUBLIC METHODS - DRAW * //
// #region PUBLIC METHODS - DRAW
/**
* Draws the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawDetails(gr) {
this.drawBackground(gr);
this.drawDiscArt(gr);
this.drawGrid(gr);
this.drawBandLogo(gr);
this.drawLabelLogo(gr);
}
/**
* Draws the Details background.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBackground(gr) {
if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.displayDetails) {
return;
}
gr.SetTextRenderingHint(TextRenderingHint.AntiAliasGridFit);
gr.SetSmoothingMode(SmoothingMode.None);
if (grm.ui.isStreaming && grm.ui.noArtwork || !grm.ui.albumArt && grm.ui.noArtwork) {
gr.FillSolidRect(0, grm.ui.topMenuHeight, grm.ui.ww, grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight, grCol.detailsBg);
} else {
gr.FillSolidRect(0, grm.ui.albumArtSize.y, grSet.noDiscArtBg && !this.discArt ? grm.ui.ww : grm.ui.albumArtSize.x, grm.ui.albumArtSize.h, grCol.detailsBg);
}
if (grm.ui.albumArt && grSet.styleBlend && grCol.imgBlended) {
gr.DrawImage(grCol.imgBlended, 0, 0, grm.ui.ww, grm.ui.wh, 0, 0, grCol.imgBlended.Width, grCol.imgBlended.Height);
}
gr.SetSmoothingMode(SmoothingMode.HighQuality);
}
/**
* Draws the disc art in Details.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawDiscArt(gr) {
if (grSet.layout !== 'default' || !grSet.displayDiscArt || !grm.ui.displayDetails || grm.ui.noAlbumArtStub ||
this.discArtSize.y < grm.ui.albumArtSize.y || this.discArtSize.h > grm.ui.albumArtSize.h) {
return;
}
grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 碟片');
if (!this.discArtRotation) {
this.setDiscArtRotation();
}
const applyOpacity = !grm.ui.displayLyrics && grm.ui.albumArtSize.w < grm.ui.ww * 0.66;
const albumArtOpacity = applyOpacity ? grSet.detailsAlbumArtOpacity : 255;
if (!grSet.discArtOnTop || grm.ui.displayLyrics) {
this.drawDiscArtImage(gr);
if (this.discArtRotation && grSet.detailsAlbumArtDiscAreaOpacity !== 255) {
const discArtOpacity = applyOpacity ? grSet.detailsAlbumArtDiscAreaOpacity : 255;
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);
} else {
grm.ui.drawAlbumArt(gr, albumArtOpacity);
}
} else { // * Draw discArt on top of front cover
grm.ui.drawAlbumArt(gr, albumArtOpacity);
this.drawDiscArtImage(gr);
}
grm.debug.setDebugProfile(false, 'print', '绘图 -> 碟片');
}
/**
* Draws the disc art image and its shadow (if applicable).
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawDiscArtImage(gr) {
const discArtImg = this.discArtArray[this.discArtRotationIndex] || this.discArtRotation;
if (!grSet.filterAlbumArt && grm.ui.discArtImageDisplayed || !discArtImg) {
return;
}
if (this.discArtShadowImg.image) {
const shadowImg = this.discArtShadowImg.image;
gr.DrawImage(shadowImg, -this.discArtShadow, grm.ui.albumArtSize.y - this.discArtShadow, shadowImg.Width, shadowImg.Height, 0, 0, shadowImg.Width, shadowImg.Height);
}
gr.DrawImage(discArtImg, this.discArtSize.x, this.discArtSize.y, this.discArtSize.w, this.discArtSize.h, 0, 0, discArtImg.Width, discArtImg.Height, 0);
}
/**
* Draws the metadata grid on the left side in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawGrid(gr) {
if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.displayDetails) return;
grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 元数据表格');
gr.SetSmoothingMode(SmoothingMode.HighQuality);
gr.SetInterpolationMode(InterpolationMode.HighQualityBicubic);
this.setGridMetrics(gr);
this.gridTop = this.gridTopStart;
if (this.gridContentWidth > 150) {
const spacing = Math.floor(this.gridLineSpacing * 0.33);
const spacing2 = Math.floor(this.gridLineSpacing * 0.5);
// * Artist
if (grSet.showGridArtist_layout) {
this.gridTop += this.drawGridArtist(gr) + spacing;
}
// * Title
if (grSet.showGridTitle_layout) {
this.gridTop += this.drawGridTitle(gr) + spacing;
} else if (!grSet.showGridArtist_layout) {
this.gridTop += this.drawGridAlbum(gr) + spacing;
}
// * Timeline
if (grSet.showGridTimeline_layout) {
this.setGridTimelineSize(this.gridMarginLeft, this.gridTop + spacing, grm.ui.albumArtSize.x - this.gridMarginLeft * 2, this.timelineH);
this.drawGridTimeline(gr);
this.gridTop += this.timelineH + this.gridLineSpacing;
}
// * Album
if (grSet.showGridArtist_layout || grSet.showGridTitle_layout) {
this.gridTop += this.drawGridAlbum(gr) + spacing2;
}
// * Columns key and value
this.drawGridColumns(gr);
}
gr.SetInterpolationMode(InterpolationMode.Default);
grm.debug.setDebugProfile(false, 'print', '绘图 -> 元数据表格');
}
/**
* Draws the custom metadata grid menu.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawGridMenu(gr) {
if (!grm.ui.displayMetadataGridMenu || grSet.layout !== 'default') return;
const x = grm.ui.albumArtSize.x - 1;
const y = grm.ui.topMenuHeight;
const width = grm.ui.ww;
const height = grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight;
gr.FillSolidRect(x, y, width, height, pl.col.bg);
for (const c of CustomMenu.controlList) c.draw(gr);
if (CustomMenu.activeControl && CustomMenu.activeControl instanceof CustomMenuDropDown && CustomMenu.activeControl.isSelectUp) {
CustomMenu.activeControl.draw(gr);
}
}
/**
* Draws the artist on the metadata grid in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
* @returns {number} The height of the artist.
*/
drawGridArtist(gr) {
if (!grStr.artist) return 0;
// * Apply better anti-aliasing on smaller font sizes in HD res
gr.SetTextRenderingHint(!RES._4K && (grSet.gridArtistFontSize_layout < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit);
const artistColor = ['white', 'black', 'reborn', 'random'].includes(grSet.theme) ? grCol.detailsText : grSet.theme === 'cream' ? pl.col.header_artist_normal : pl.col.header_artist_playing;
DrawString(gr, grm.ui.getFormattedString('gridArtist'), grFont.gridArtist, artistColor, this.gridMarginLeft, Math.round(this.gridTop), this.gridContentWidth, this.gridArtistNumLinesHeight, Stringformat.Trim_Ellipsis_Char);
// * Artist country flags
if (grStr.artist && grSet.showGridArtistFlags_layout) {
grm.ui.drawArtistCountryFlag(gr, 'metadataGrid');
}
this.gridArtistTop = this.gridTop;
this.gridArtistBottom = this.gridTop + this.gridArtistNumLinesHeight;
return this.gridArtistNumLinesHeight;
}
/**
* Draws the track title on the metadata grid in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
* @returns {number} The height of the title.
*/
drawGridTitle(gr) {
if (!grStr.title) return 0;
// * Apply better anti-aliasing on smaller font sizes in HD res
gr.SetTextRenderingHint(!RES._4K && (grSet.gridTitleFontSize_layout < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit);
DrawString(gr, grm.ui.getFormattedString('gridTitle'), grFont.gridTitle, grCol.detailsText, this.gridMarginLeft, Math.round(this.gridTop), this.gridContentWidth, this.gridTitleNumLinesHeight, Stringformat.Trim_Ellipsis_Char);
this.gridTitleTop = this.gridTop;
this.gridTitleBottom = this.gridTop + this.gridTitleNumLinesHeight;
return this.gridTitleNumLinesHeight;
}
/**
* Draws the album on the metadata grid in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
* @returns {number} The height of the album.
*/
drawGridAlbum(gr) {
if (!grStr.album) return 0;
// * Apply better anti-aliasing on smaller font sizes in HD res
gr.SetTextRenderingHint(!RES._4K && (grSet.gridAlbumFontSize_layout < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit);
DrawString(gr, grStr.album, grFont.gridAlbum, grCol.detailsText, this.gridMarginLeft, Math.round(this.gridTop), this.gridContentWidth, this.gridAlbumNumLinesHeight, Stringformat.Trim_Ellipsis_Char);
this.gridAlbumTop = this.gridTop;
this.gridAlbumBottom = this.gridTop + this.gridAlbumNumLinesHeight;
return this.gridAlbumNumLinesHeight;
}
/**
* Draws the column key and column value on the metadata grid in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawGridColumns(gr) {
for (let k = 0; k < grStr.grid.length; k++) {
this.gridColumnKey = grStr.grid[k].label;
this.gridColumnValue = grStr.grid[k].val;
this.gridTxtRec = gr.MeasureString(this.gridColumnValue, grFont.gridVal, 0, 0, this.gridColumnValueWidth, grm.ui.wh);
this.gridColumnCellHeight = this.gridTxtRec.Height + 5;
this.gridColumnTop = this.gridTop;
let gridShowLastFmImage = false;
let gridShowReleaseFlagImage = false;
let gridShowCodecLogoImage = false;
let gridShowChannelLogoImage = false;
let gridDropShadow = false;
let gridValueColor = grCol.detailsText;
if (this.gridColumnValue.length) {
const columnKey = {
'目录': () => {
gridShowReleaseFlagImage = grSet.showGridReleaseFlags_layout;
if (grSet.showGridReleaseFlags_layout === 'logo') {
this.gridColumnValue = this.gridColumnValue.replace($('%releasecountry%'), '');
}
},
'发行地区': () => {
gridShowReleaseFlagImage = grSet.showGridReleaseFlags_layout;
if (grSet.showGridReleaseFlags_layout === 'logo') this.gridColumnValue = '';
},
'编解码': () => {
gridShowCodecLogoImage = grSet.showGridCodecLogo_layout;
this.gridColumnValue = grSet.showGridCodecLogo_layout === 'logo' ? '' : this.getCodecString();
this.gridColumnCellHeight = this.gridColumnValueHeight + 5;
},
'声道': () => {
gridShowChannelLogoImage = grSet.showGridChannelLogo_layout;
this.gridColumnValue = grSet.showGridChannelLogo_layout === 'logo' ? '' : this.getChannelString($('%channels%'));
this.gridColumnCellHeight = this.gridColumnValueHeight + 5;
},
'热门': () => {
gridValueColor = grCol.detailsHotness;
gridDropShadow = true;
},
'播放次数': () => {
gridShowLastFmImage = true;
},
'评级': () => {
gridValueColor = grCol.detailsRating;
gridDropShadow = true;
},
'default': () => {
let matchCount = 0;
// * On small player sizes, there is no space for all metadata entries.
// * Hide them and only display entries from basicMeta.
if (this.basicMetadataDisplay(this.gridColumnKey)) {
this.gridColumnValue = '';
this.gridColumnKey = '';
matchCount++;
}
this.gridTop -= this.gridColumnCellHeight * matchCount;
}
};
(columnKey[this.gridColumnKey] || columnKey.default)();
if (this.gridTop + this.gridTxtRec.Height < grm.ui.albumArtSize.y + grm.ui.albumArtSize.h) {
// * Apply better anti-aliasing on smaller font sizes in HD res
gr.SetTextRenderingHint(!RES._4K && (grSet.gridKeyFontSize_layout < 17 || grSet.gridValueFontSize_layout + SCALE(1) < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit);
if (gridDropShadow) {
const gridBorderWidth = SCALE(0.5);
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));
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));
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));
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));
}
gr.DrawString(this.gridColumnKey, grFont.gridKey, grCol.detailsText, this.gridMarginLeft, Math.round(this.gridTop), this.gridColumnKeyWidth, this.gridColumnCellHeight, Stringformat.Trim_Ellipsis_Char);
gr.DrawString(this.gridColumnValue, grFont.gridVal, gridValueColor, this.gridColumnValueLeft, Math.round(this.gridTop), this.gridColumnValueWidth, this.gridColumnCellHeight, StringFormat(0, 0, 4));
// * Release flag
if (gridShowReleaseFlagImage) {
this.drawGridReleaseFlag(gr);
}
// * Codec logo
if (gridShowCodecLogoImage) {
this.drawGridCodecLogo(gr);
}
// * Channel logo
if (gridShowChannelLogoImage) {
this.drawGridChannelLogo(gr);
}
// * Last.fm logo
if (gridShowLastFmImage) {
this.drawGridLastfmLogo(gr);
}
this.gridTop += this.gridColumnCellHeight + 5;
}
}
}
}
/**
* Draws an image on the metadata grid in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
* @param {GdiBitmap} image - The image to draw.
* @param {boolean} showLogoOnly - Whether to show only the logo.
* @param {number} xOffset - The offset added to x position.
* @param {number} yOffset - The offset added to y position.
* @param {number} cellHeightAdjustment - The adjustment applied to cell height.
*/
drawGridImage(gr, image, showLogoOnly, xOffset = 0, yOffset = 0, cellHeightAdjustment = 0) {
if (image == null) return;
// Calculate metrics and ratios
const gridColumnValueMetrics = gr.MeasureString(showLogoOnly ? '' : this.gridColumnValue, grFont.gridVal, 0, 0, this.gridColumnValueWidth, grm.ui.wh);
const heightRatio = (gr.CalcTextHeight(showLogoOnly ? 'Ag' : this.gridColumnValue, grFont.gridVal) - cellHeightAdjustment) / image.Height;
const logoHeight = Math.round(image.Height * heightRatio);
const logoWidth = Math.round(image.Width * heightRatio);
// Get the width of the last line
const newLineWidth = gr.EstimateLineWrap(this.gridColumnValue, grFont.gridVal, this.gridTxtRec.Lines === 1 ? this.gridColumnValueWidth : this.gridTxtRec.Width);
const lastLineIndex = newLineWidth.length - 1;
const lastLineWidth = newLineWidth[lastLineIndex] || gridColumnValueMetrics.Width;
// Initial positions
const stringWidth = lastLineWidth + xOffset;
let xPos = this.gridColumnValueLeft + stringWidth;
let yPos = this.gridTop + yOffset;
// Adjust positions if the logo width exceeds the grid column width and move logo to the next line
if (xPos + logoWidth > this.gridColumnValueLeft + this.gridColumnValueWidth) {
const textHeight = gr.CalcTextHeight('Ag', grFont.gridVal);
xPos = this.gridColumnValueLeft;
yPos += textHeight;
this.gridTxtRec = { ...this.gridTxtRec, Lines: this.gridTxtRec.Lines + 1, Height: this.gridTxtRec.Height + textHeight };
this.gridColumnCellHeight = this.gridTxtRec.Height + 5;
}
gr.DrawImage(image, xPos, yPos, logoWidth, logoHeight, 0, 0, image.Width, image.Height);
}
/**
* Draws the release flag on the metadata grid in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawGridReleaseFlag(gr) {
if (this.gridReleaseFlagImg == null) return;
const logoOnly = grSet.showGridReleaseFlags_layout === 'logo' && this.gridColumnKey === '发行地区';
const lineHeight = this.gridTxtRec.Height / this.gridTxtRec.Lines;
const yCorr = (this.gridTxtRec.Lines - 1) * lineHeight;
this.drawGridImage(gr, this.gridReleaseFlagImg, logoOnly, SCALE(logoOnly ? 0 : 8), logoOnly ? 0 : yCorr);
}
/**
* Draws the codec logo on the metadata grid in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawGridCodecLogo(gr) {
if (this.gridCodecLogo == null) {
this.loadGridCodecLogo();
if (this.gridCodecLogo == null) return;
}
const logoOnly = grSet.showGridCodecLogo_layout === 'logo';
this.drawGridImage(gr, this.gridCodecLogo, logoOnly, SCALE(logoOnly ? 0 : 8), logoOnly ? -1 : 2);
}
/**
* Draws the channel logo on the metadata grid in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawGridChannelLogo(gr) {
if (this.gridChannelLogo == null) {
this.loadGridChannelLogo();
if (this.gridChannelLogo == null) return;
}
const logoOnly = grSet.showGridChannelLogo_layout === 'logo';
this.drawGridImage(gr, this.gridChannelLogo, logoOnly, SCALE(logoOnly ? 0 : 8), logoOnly ? -1 : 2);
}
/**
* Draws the last.fm logo on the metadata grid in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawGridLastfmLogo(gr) {
if (!this.playCountVerifiedByLastFm) return;
const lastFmImg = gdi.Image(grPath.lastFmImageRed);
const lastFmWhiteImg = gdi.Image(grPath.lastFmImageWhite);
const lastFmLogo = grCol.lightBgDetails ? lastFmImg : lastFmWhiteImg;
const lineHeight = this.gridTxtRec.Height / this.gridTxtRec.Lines;
const yCorr = (this.gridTxtRec.Lines - 1) * lineHeight;
this.drawGridImage(gr, lastFmLogo, false, SCALE(8), yCorr, 6);
}
/**
* Draws the band logo on the bottom left side in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBandLogo(gr) {
if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.albumArt || grSet.layout !== 'default' || !grm.ui.displayDetails ||
grSet.lyricsLayout === 'full' && grm.ui.displayLyrics) {
return;
}
grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 艺术家标识');
const availableSpace = grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - this.gridTop;
const logo = grCol.lightBgDetails || grm.ui.noAlbumArtStub ? (this.bandLogoInverted || this.bandLogo) : this.bandLogo;
if (logo && availableSpace > 75) {
let logoWidth = Math.min(HD_4K(logo.Width / 2, logo.Width), grm.ui.albumArtSize.x - grm.ui.ww * 0.05);
const heightScale = Math.min(logoWidth / logo.Width, availableSpace / logo.Height);
logoWidth = logo.Width * heightScale; // Adjust logoWidth after heightScale is potentially updated
const logoX = Math.round(grm.ui.isStreaming ? SCALE(40) : grm.ui.albumArtSize.x / 2 - logoWidth / 2);
const logoY = Math.round(grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - (logo.Height * heightScale)) - HD_4K(4, 24);
const logoW = Math.round(logoWidth);
const logoH = Math.round(logo.Height * heightScale);
gr.DrawImage(logo, logoX, logoY, logoW, logoH, 0, 0, logo.Width, logo.Height, 0);
}
grm.debug.setDebugProfile(false, 'print', '绘图 -> 艺术家标识');
}
/**
* Draws the label logo on the bottom right side in the Details panel.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawLabelLogo(gr) {
if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.albumArt || grSet.layout !== 'default' || !grm.ui.displayDetails ||
grSet.lyricsLayout === 'full' && grm.ui.displayLyrics) {
return;
}
grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 唱片公司标识');
if (this.labelLogo.length > 0) {
const lightBg = grSet.labelArtOnBg ? grCol.lightBgMain : grCol.lightBgDetails;
const labels = lightBg || grm.ui.noAlbumArtStub ? (this.labelLogoInverted.length ? this.labelLogoInverted : this.labelLogo) : this.labelLogo;
const rightSideGap = 20; // How close last label is to right edge
const leftEdgeGap = (grm.ui.albumArtOffCenter ? 20 : 40) * HD_4K(1, 1.8); // Space between art and label
const leftEdgeWidth = HD_4K(30, 45); // How far label background extends on left
const maxLabelWidth = SCALE(200);
let leftEdge = 0;
let topEdge = 0;
let totalLabelWidth = 0;
let labelAreaWidth = 0;
let labelSpacing = 0;
let labelWidth;
let labelHeight;
for (const label of labels) {
if (label.Width > maxLabelWidth) {
totalLabelWidth += maxLabelWidth;
} else {
totalLabelWidth += RES._4K && label.Width < 200 ? label.Width * 2 : label.Width;
}
}
if (!this.cachedLabelLastLeftEdge) { // We don't want to recalculate this every screen refresh
grm.debug.debugLog('图标 => 重新计算最后左边缘');
this.shadowImgLabel = null;
labelWidth = Math.round(totalLabelWidth / labels.length);
labelHeight = Math.round(labels[0].Height * labelWidth / labels[0].Width); // Might be recalc'd below
if (grm.ui.albumArt) {
if (this.discArt && grSet.displayDiscArt) {
leftEdge = Math.round(Math.max(grm.ui.albumArtSize.x + grm.ui.albumArtScaled.Width + 5, grm.ui.ww * 0.975 - totalLabelWidth + 1));
const discCenter = {};
discCenter.x = Math.round(this.discArtSize.x + this.discArtSize.w / 2);
discCenter.y = Math.round(this.discArtSize.y + this.discArtSize.h / 2);
const radius = discCenter.y - this.discArtSize.y;
const radiusSquared = radius * radius;
let posValid = false;
while (!posValid) {
const allLabelsWidth = Math.max(Math.min(Math.round((grm.ui.ww - leftEdge - rightSideGap) / labels.length), maxLabelWidth), 50);
//console.log("leftEdge = " + leftEdge + ", grm.ui.ww-leftEdge-10 = " + (grm.ui.ww-leftEdge-10) + ", allLabelsWidth=" + allLabelsWidth);
const maxWidth = RES._4K && labels[0].Width < 200 ? labels[0].Width * 2 : labels[0].Width;
labelWidth = (allLabelsWidth > maxWidth) ? maxWidth : allLabelsWidth;
labelHeight = Math.round(labels[0].Height * labelWidth / labels[0].Width); // Width is based on height scale
topEdge = Math.round(grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - labelHeight);
const a = topEdge - discCenter.y + 1; // Adding 1 to a and b so that the border just touches the edge of the discArt
const b = leftEdge - discCenter.x + 1;
if ((a * a + b * b) > radiusSquared) {
posValid = true;
} else {
leftEdge += 4;
}
}
} else {
leftEdge = Math.round(Math.max(grm.ui.albumArtSize.x + grm.ui.albumArtSize.w + leftEdgeWidth + leftEdgeGap, grm.ui.ww * 0.975 - totalLabelWidth + 1));
}
} else {
leftEdge = Math.round(grm.ui.ww * 0.975 - totalLabelWidth);
}
labelAreaWidth = grm.ui.ww - leftEdge - rightSideGap;
this.cachedLabelLastLeftEdge = leftEdge;
this.cachedLabelLastHeight = labelHeight;
}
else { // Already calculated
leftEdge = this.cachedLabelLastLeftEdge;
labelHeight = this.cachedLabelLastHeight;
labelAreaWidth = grm.ui.ww - leftEdge - rightSideGap;
}
if (labelAreaWidth >= SCALE(50)) {
if (labels.length > 1) {
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
}
// console.log('labelAreaWidth = ' + labelAreaWidth + ", labelSpacing = " + labelSpacing);
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
const origLabelHeight = labelHeight;
let labelX = leftEdge;
topEdge = grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - labelHeight - 20;
if (!grSet.labelArtOnBg && !grSet.noDiscArtBg || grSet.noDiscArtBg && grSet.displayDiscArt && this.discArt) {
if (!['black', 'nblue', 'ngreen', 'nred', 'ngold'].includes(grSet.theme)) {
if (!this.shadowImgLabel) {
this.shadowImgLabel = ShadowRect(this.discArtShadow, this.discArtShadow, grm.ui.ww - labelX + leftEdgeWidth, labelHeight + 40, this.discArtShadow, grCol.shadow);
}
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,
0, 0, this.shadowImgLabel.Width, this.shadowImgLabel.Height);
}
gr.SetSmoothingMode(SmoothingMode.None); // Disable smoothing
gr.FillSolidRect(labelX - leftEdgeWidth, topEdge - 20, grm.ui.ww - labelX + leftEdgeWidth, labelHeight + 40, grCol.detailsBg);
gr.DrawRect(labelX - leftEdgeWidth, topEdge - 20, grm.ui.ww - labelX + leftEdgeWidth, labelHeight + 40 - 1, 1, grCol.shadow);
gr.SetSmoothingMode(SmoothingMode.HighQuality);
}
for (let i = 0; i < labels.length; i++) {
// allLabelsWidth can never be greater than 200, so if a label image is 161 pixels wide, never draw it wider than 161
const maxWidth = RES._4K && labels[i].Width < 200 ? labels[i].Width * 2 : labels[i].Width;
labelWidth = (allLabelsWidth > maxWidth) ? maxWidth : allLabelsWidth;
labelHeight = Math.round(labels[i].Height * labelWidth / labels[i].Width); // Width is based on height scale
gr.DrawImage(labels[i], labelX, Math.round(topEdge + origLabelHeight / 2 - labelHeight / 2), labelWidth, labelHeight, 0, 0, this.labelLogo[i].Width, this.labelLogo[i].Height);
labelX += labelWidth + labelSpacing;
}
}
}
grm.debug.setDebugProfile(false, 'print', '绘图 -> 唱片公司标识');
}
// #endregion
// * PLUBLIC METHODS - METRICS * //
// #region PUBLIC METHODS - METRICS
/**
* Sets the metadata grid metrics and caches all calculated values.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
setGridMetrics(gr) {
if (this.cachedGridMetrics) return;
const metricsPromises = [
new Promise((resolve) => this.setGridMainMetrics(gr, resolve)),
new Promise((resolve) => this.setGridTextMetrics(gr, resolve))
];
Promise.all(metricsPromises).then(() => {
this.cachedGridMetrics = this.gridColumnValueBottom > this.gridColumnTop && !grm.display.hasPlayerSizeChanged();
});
}
/**
* Sets the metadata grid main sizes.
* This includes calculating margins, content width, and column dimensions.
* @param {GdiGraphics} gr - The GDI graphics object.
* @param {Function} metricsCalculated - The callback function to be executed after calculations are finished.
*/
setGridMainMetrics(gr, metricsCalculated) {
this.discArtShadow = SCALE(6);
this.gridTooltipHeight = SCALE(100);
this.timelineH = SCALE(8);
this.gridMarginLeft = grm.ui.edgeMargin;
this.gridTopStart = grm.ui.albumArtSize.y ? grm.ui.albumArtSize.y + grm.ui.edgeMargin : grm.ui.topMenuHeight + grm.ui.edgeMargin;
this.gridTop = this.gridTopStart;
this.gridContentWidth = Math.floor((!grm.ui.albumArt && this.discArt ? this.discArtSize.x : grm.ui.albumArtSize.x) - grm.ui.edgeMargin * 1.5);
this.gridColumnKeyWidth = CalcGridMaxTextWidth(gr, grStr.grid, grFont.gridKey);
this.gridColumnKeyHeight = gr.MeasureString('Ag', grFont.gridKey, 0, 0, this.gridContentWidth, grm.ui.wh).Height;
this.gridColumnKeyBottom = this.gridColumnTop + this.gridColumnKeyHeight;
this.gridColumnValueWidth = this.gridContentWidth - this.gridColumnMargin - this.gridColumnKeyWidth + SCALE(5);
this.gridColumnValueHeight = gr.MeasureString('Ag', grFont.gridVal, 0, 0, this.gridContentWidth, grm.ui.wh).Height;
this.gridColumnValueLeft = this.gridMarginLeft + this.gridColumnKeyWidth + this.gridColumnMargin;
this.gridColumnValueBottom = this.gridColumnTop + this.gridColumnValueHeight;
metricsCalculated();
}
/**
* Sets the metadata grid text sizes.
* This includes calculating wrap information and dimensions for artist, title, album, and other text elements based on the grid configuration.
* @param {GdiGraphics} gr - The GDI graphics object.
* @param {Function} metricsCalculated - The callback function to be executed after calculations are finished.
*/
setGridTextMetrics(gr, metricsCalculated) {
if (grSet.showGridArtist_layout) {
this.gridFlagSizeW = grm.ui.getFlagSizeWidth('metadataGrid');
this.gridFlagSizeWhiteSpace = grm.ui.getFlagSizeWhiteSpace('metadataGrid');
this.gridArtistWrapInfo = CalcWrapSpace(gr, grStr.artist, grFont.gridArtist, this.gridContentWidth, this.cachedGridWrapSpace);
this.gridArtistWrapLinesExceed = this.gridArtistWrapInfo.lineCount > 2;
this.gridArtistWrapWidth = this.gridArtistWrapInfo.totalWrapSpace - this.gridFlagSizeW;
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);
this.gridArtistHeight = gr.MeasureString(grStr.artist, grFont.gridArtist, 0, 0, 0, 0).Height;
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);
this.gridArtistNumLines = Math.min(2, this.gridArtistTxtRec.Lines);
this.gridArtistNumLinesHeight = gr.CalcTextHeight(grStr.artist, grFont.gridArtist) * this.gridArtistNumLines;
}
if (grSet.showGridTitle_layout) {
this.gridTrackNumWidth = Math.ceil(gr.MeasureString(grStr.tracknum, grFont.gridTrackNumber, 0, 0, 0, 0).Width);
this.gridTitleWrapInfo = CalcWrapSpace(gr, grm.ui.getFormattedString('gridTitle'), grFont.gridTitle, this.gridContentWidth, this.cachedGridWrapSpace);
this.gridTitleWrapLinesExceed = this.gridTitleWrapInfo.lineCount > 2;
this.gridTitleWrapWidth = this.gridTitleWrapInfo.totalWrapSpace;
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);
this.gridTitleHeight = gr.MeasureString(grStr.title, grFont.gridTitle, 0, 0, 0, 0).Height;
this.gridTitleTxtRec = gr.MeasureString(grm.ui.getFormattedString('gridTitle'), grFont.gridTitle, 0, 0, this.gridContentWidth, grm.ui.wh);
this.gridTitleNumLines = Math.min(2, this.gridTitleTxtRec.Lines);
this.gridTitleNumLinesHeight = gr.CalcTextHeight(grStr.title, grFont.gridTitle) * this.gridTitleNumLines;
}
this.gridAlbumWrapInfo = CalcWrapSpace(gr, grStr.album, grFont.gridAlbum, this.gridContentWidth, this.cachedGridWrapSpace);
this.gridAlbumWrapLinesExceed = this.gridAlbumWrapInfo.lineCount > (grSet.showGridArtist_layout || grSet.showGridTitle_layout ? 2 : 3);
this.gridAlbumWrapWidth = this.gridAlbumWrapInfo.totalWrapSpace;
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;
this.gridAlbumHeight = gr.MeasureString(grStr.album, grFont.gridAlbum, 0, 0, 0, 0).Height;
this.gridAlbumTxtRec = gr.MeasureString(grStr.album, grFont.gridAlbum, 0, 0, this.gridContentWidth, grm.ui.wh);
this.gridAlbumNumLines = Math.min(grSet.showGridArtist_layout || grSet.showGridTitle_layout ? 2 : 3, this.gridAlbumTxtRec.Lines);
this.gridAlbumNumLinesHeight = gr.CalcTextHeight(grStr.album, grFont.gridAlbum) * this.gridAlbumNumLines;
metricsCalculated();
}
// #endregion
// * PUBLIC METHODS - COMMON * //
// #region PUBLIC METHODS - COMMON
/**
* Clears individual cache properties, the specified cache type, or all caches.
* @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.
* @param {string} [property] - The specific property to clear within the cache type. Applicable only if `type` is provided.
* @param {boolean} [clearArtCache] - Whether to clear everything in the artCache object.
* @param {boolean} [keepDiscArt] - Whether to keep the disc art. This is considered only when `type` is 'discArt' or not provided (clearing all caches).
* @example
* // Clear an individual property within a specific cache type
* clearCache('metrics', 'cachedGridMetrics');
* @example
* // Clear a specific cache type
* clearCache('metrics');
* @example
* // Clear all caches
* clearCache();
* @example
* // Clear all caches and the artCache
* clearCache(undefined, undefined, true);
*/
clearCache(type, property, clearArtCache, keepDiscArt) {
const cacheActions = {
metrics: () => {
this.cachedGridWrapSpace = {};
this.cachedGridMetrics = false;
this.cachedLabelLastLeftEdge = 0;
this.cachedLabelLastHeight = 0;
},
discArt: () => {
this.discArt = keepDiscArt ? this.discArt : null;
this.discArtCover = null;
this.discArtArray = [];
this.discArtRotation = null;
},
codecLogo: () => {
this.gridCodecLogo = null;
},
channelLogo: () => {
this.gridChannelLogo = null;
},
bandLogo: () => {
this.bandLogo = null;
this.bandLogoInverted = null;
},
labelLogo: () => {
this.labelLogo = [];
this.labelLogoInverted = [];
}
};
if (clearArtCache) {
grm.artCache && grm.artCache.clear();
grm.debug.debugLog('详情缓存 => 已清除图片缓存');
}
if (type) {
// * Clear individual cache property
if (property && Object.hasOwnProperty.call(this, property)) {
this[property] = null;
grm.debug.debugLog(`详情缓存 => 已清除缓存类型 "${type}" 中的属性 "${property}"`);
}
// * Clear specific cache type
else if (cacheActions[type]) {
cacheActions[type]();
grm.debug.debugLog(`详情缓存 => 已清除缓存类型 "${type}"`);
}
return;
}
// * Clear all caches
for (const action in cacheActions) {
cacheActions[action]();
}
grm.debug.debugLog('详情缓存 => 已清除全部缓存');
}
/**
* Clears timers based on the timer type.
* @param {string} [type] - The type of timer to clear. If not provided, all timers will be cleared.
* - 'discArt'.
*/
clearTimer(type) {
const timers = {
discArt: {
timer: this.discArtRotationTimer,
clear: clearInterval,
log: '计时器 => 碟片旋转计时器已清除'
}
};
const clearTimerByType = (type) => {
const { timer, clear, log } = timers[type];
if (timer) {
clear(timer);
timers[type].timer = null;
}
grm.debug.debugLog(log);
};
if (type && timers[type]) {
clearTimerByType(type);
} else {
for (const key in timers) {
clearTimerByType(key);
}
}
}
// #endregion
// * PUBLIC METHODS - METADATA GRID * //
// #region PUBLIC METHODS - METADATA GRID
/**
* Initializes the metadata grid menu and toggles its open/close state.
*/
initGridMenuState() {
if (grSet.layout !== 'default') {
const msg = grm.msg.getMessage('main', 'metadataGridLiveEdit');
fb.ShowPopupMessage(msg, '元数据表格实时编辑');
return;
}
grm.ui.displayMetadataGridMenu = !grm.ui.displayMetadataGridMenu;
grm.ui.displayCustomThemeMenu = false;
if (grm.ui.displayMetadataGridMenu) {
if (!grm.ui.displayDetails) {
grm.ui.displayDetails = true;
grm.ui.displayPlaylist = false;
grm.ui.displayLibrary = false;
grm.ui.displayBiography = false;
grm.ui.resizeArtwork(true);
}
grm.gridMenu.initMetadataGridMenu(1);
}
grm.button.initButtonState();
window.Repaint();
}
/**
* Determines whether basic metadata should be displayed based on the grid column width.
* @param {string} gridColumnKey - The grid column key.
* @returns {boolean} True if basic metadata should be displayed, otherwise false.
*/
basicMetadataDisplay(gridColumnKey) {
const resolutions = [
{ displayRes: 'HD', maxW: 1250, maxH: 800 },
{ displayRes: 'QHD', maxW: 1350, maxH: 900 },
{ displayRes: '4K', maxW: 2350, maxH: 1550 }
];
const basicMeta = ['年份', '唱片公司', '流派', '编解码', '声道', '来源', '数据', '播放次数', '评级'];
const smallRes = resolutions.some(res => grSet.displayRes === res.displayRes && (grm.ui.ww < res.maxW || grm.ui.wh < res.maxH));
return grSet.autoHideGridMetadata && grSet.layout === 'default' && smallRes && !basicMeta.includes(gridColumnKey);
}
/**
* 获取编解码器字符串,如果编解码器是DTS,则返回'DCA'.
* @returns {string} 编解码器字符串或'DCA' 如果编解码器是 DTS.
*/
getCodecString() {
const codec = $('$lower($if2(%codec%,$ext(%path%)))');
if (['dts', 'dca (dts coherent acoustics)'].includes(codec)) {
return 'DCA'; // 如果编解码是DTS,则仅显示DCA缩写
}
return codec;
}
/**
* Gets the channel string based on the provided channel type.
* @param {string} channelType - The type of the channel (e.g., 'mono', 'stereo').
* @returns {string} The channel string or an empty string if the channel type is not found.
*/
getChannelString(channelType) {
const channelMapping = {
'mono': { number: 1, string: '单声道' },
'单声道': { number: 1, string: '单声道' },
'stereo': { number: 2, string: '立体声' },
'立体声': { number: 2, string: '立体声' },
'3ch': { number: 3, string: '中置' },
'3 声道': { number: 3, string: '中置' },
'4ch': { number: 4, string: '四声道' },
'4 声道': { number: 4, string: '四声道' },
'5ch': { number: 5, string: '环绕' },
'5 声道': { number: 5, string: '环绕' },
'6ch': { number: 6, string: '环绕' },
'6 声道': { number: 6, string: '环绕' },
'7ch': { number: 7, string: '环绕' },
'7 声道': { number: 7, string: '环绕' },
'8ch': { number: 8, string: '环绕' },
'8 声道': { number: 8, string: '环绕' },
'10ch': { number: 10, string: '环绕' },
'10 声道':{ number: 10, string: '环绕' },
'12ch': { number: 12, string: '环绕' },
'12 声道':{ number: 12, string: '环绕' }
};
const channel = channelMapping[channelType];
if (!channel) return '';
if (grSet.showGridChannelLogo_layout === 'textlogo') {
return channel.string;
} else if (grSet.showGridChannelLogo_layout === false) {
return `${channel.number} ${Unicode.MiddleDot} ${channel.string}`;
} else {
return '';
}
}
/**
* Gets the grid tooltip string based on the specified type.
* @param {string} type - The type of metadata ('artist', 'title', 'album').
* @returns {string} The tooltip string.
*/
getGridTooltip(type) {
const tooltipType = {
artist: grStr.artist,
title: `${grStr.tracknum} ${grStr.title} ${grStr.composer}`,
album: `${grStr.album} ${grStr.composer}`
};
return tooltipType[type];
}
/**
* Handles the grid tooltip. If a tooltip is ready, it displays and then clears it.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
*/
handleGridTooltip(x, y) {
const artistTooltipRange = this.mouseInMetadataGrid(x, y, 'artist');
const titleTooltipRange = this.mouseInMetadataGrid(x, y, 'title');
const albumTooltipRange = this.mouseInMetadataGrid(x, y, 'album');
if (!artistTooltipRange && !titleTooltipRange && !albumTooltipRange) return;
const showArtistToolTip = artistTooltipRange && grSet.showGridArtist_layout && (
this.gridArtistWidth > this.gridContentWidth * 2
||
this.gridArtistWrapLinesExceed
);
const showTitleToolTip = titleTooltipRange && grSet.showGridTitle_layout && (
this.gridTitleWidth > this.gridContentWidth * 2
||
this.gridTitleWrapLinesExceed
);
const showAlbumToolTip = albumTooltipRange && (
!grSet.showGridArtist_layout && !grSet.showGridTitle_layout && (this.gridAlbumWidth > this.gridContentWidth * 3)
||
(grSet.showGridArtist_layout || grSet.showGridTitle_layout) && (this.gridAlbumWidth > this.gridContentWidth * 2)
||
this.gridAlbumWrapLinesExceed
);
const tooltip =
showArtistToolTip ? this.getGridTooltip('artist') :
showTitleToolTip ? this.getGridTooltip('title') :
showAlbumToolTip ? this.getGridTooltip('album') : '';
if (tooltip.length) { // * Display tooltip
const offset = SCALE(30);
this.gridTooltipText = tooltip;
grm.ttip.showDelayed(this.gridTooltipText);
grm.ui.repaintStyledTooltips(grm.ui.styledToolTipX - offset * 2, grm.ui.styledToolTipY - offset, grm.ui.styledToolTipW + offset * 4, grm.ui.styledToolTipH + offset * 2);
} else { // * Clear tooltip
this.gridTooltipText = '';
grm.ttip.stop();
window.Repaint();
}
}
/**
* Loads the codec logo of the now playing track, displayed in the metadata grid in Details.
* @param {FbMetadbHandle} metadb - The metadb of the track.
*/
loadGridCodecLogo(metadb = grm.ui.initMetadb()) {
let codec = $('$lower($if2(%codec%,$ext(%path%)))', metadb);
let format = $('$lower($ext(%path%))', metadb);
// Foobar bug showing wrong metadata when DTS is in wav file format
if (codec === 'pcm' && (format === 'cue' || format === 'wav')) {
codec = $('$lower($if2(%codec%,$ext(%path%)))');
format = $('$lower($ext(%path%))');
}
const codecFormat = {
'aac': 'aac', 'aac acm codec': 'aac', 'mp4': 'aac',
'ac3': 'ac3', 'atsc a/52': 'ac3', 'e-ac3': 'ac3', 'atsc a/52a (ac-3)': 'ac3',
'aiff': 'pcm-aiff',
'alac': 'alac',
'alaw': 'alaw', 'ccitt a-law': 'alaw',
'amr': 'amr',
'ape': 'ape', 'monkey\'s audio': 'ape',
'caf': 'caf',
'dsd': format === 'iso' ? 'dsd-sacd' : 'dsd',
'dst': 'dsd-sacd',
'dts': 'dts', 'dca (dts coherent acoustics)': 'dts',
'dxd': format === 'iso' ? 'dsd-sacd' : 'dxd',
'flac': 'flac',
'gsm': 'gsm', 'gsm 6.10': 'gsm',
'imaadpcm': 'imaadpcm', 'ima adpcm': 'imaadpcm',
'la': 'la',
'mid': 'mid',
'mlp': 'mlp',
'mod': 'mod',
'mp2': 'mp2',
'mp3': 'mp3', 'mpeg layer-3': 'mp3',
'mpc': 'musepack', 'musepack': 'musepack',
'msadpcm': 'msadpcm', 'microsoft adpcm': 'msadpcm',
'ofr': 'ofr', 'optimfrog': 'ofr',
'ogg': 'ogg', 'vorbis': 'ogg',
'opus': 'opus',
'pcm': format === 'aiff' ? 'pcm-aiff' : ['w64', 'wav'].includes(format) ? 'pcm-wav' : 'pcm',
'qoa': 'qoa',
'shn': 'shn', 'shorten': 'shn',
'spx': 'spx', 'speex': 'spx',
'tak': 'tak',
'tta': 'tta', 'true audio': 'tta',
'ulaw': 'ulaw', 'ccitt u-law': 'ulaw',
'usac': 'usac',
'wav': 'pcm-wav',
'w64': 'pcm-wav',
'wma': 'wma',
'wv': 'wavpack', 'wavpack': 'wavpack'
};
let logoName;
const HDCD = $('%__hdcd%') === 'yes';
const codecName = codecFormat[codec] || codecFormat[format];
if (codec.startsWith('dsd')) {
logoName = codecFormat.dsd;
} else if (codec.startsWith('dxd')) {
logoName = codecFormat.dxd;
} else if (codec.startsWith('dst')) {
logoName = codecFormat.dst;
} else {
logoName = HDCD && codecName === 'pcm-wav' ? 'pcm-hdcd' : HDCD ? `${codecName}-hdcd` : codecName;
}
const bw = grCol.lightBgDetails ? 'black' : 'white';
const path = `${grPath.images}codec\\${logoName}-${bw}.png`;
this.gridCodecLogo = gdi.Image(path);
}
/**
* Loads the channel logo of the now playing track, displayed in the metadata grid in Details.
* @param {FbMetadbHandle} metadb - The metadb of the track.
*/
loadGridChannelLogo(metadb = grm.ui.initMetadb()) {
const codec = $('$lower($if2(%codec%,$ext(%path%)))', metadb);
const format = $('$lower($ext(%path%))', metadb);
// Foobar bug showing wrong metadata when DTS is in wav file format
const channels = codec === 'pcm' && (format === 'cue' || format === 'wav') ? $('%channels%') : $('%channels%', metadb);
const type =
(grSet.layout === 'default' && grSet.showGridChannelLogo_default === 'textlogo' ||
grSet.layout === 'artwork' && grSet.showGridChannelLogo_artwork === 'textlogo') ? '_text' : '';
const bw = grCol.lightBgDetails ? 'black' : 'white';
const channelFormat = {
'mono': '10_mono',
'单声道': '10_mono',
'stereo': '20_stereo',
'立体声': '20_stereo',
'3ch': '30_center',
'3 声道': '30_center',
'4ch': '40_quad',
'4 声道': '40_quad',
'5ch': '50_surround',
'5 声道': '50_surround',
'6ch': '51_surround',
'6 声道': '51_surround',
'7ch': '61_surround',
'7 声道': '61_surround',
'8ch': '71_surround',
'8 声道': '71_surround',
'10ch': '91_surround',
'10 声道':'91_surround',
'12ch': '111_surround',
'12 声道':'111_surround'
};
const channelName = channelFormat[channels];
const channelLogoPath = (channelName) => `${grPath.images}channels\\${channelName}${type}-${bw}.png`;
if (channelName) this.gridChannelLogo = gdi.Image(channelLogoPath(channelName));
}
/**
* Loads the release country flags, displayed in the metadata grid in Details.
* @param {FbMetadbHandle} metadb - The metadb of the track.
*/
loadGridReleaseCountryFlag(metadb = undefined) {
if (!grSet.showGridReleaseFlags_layout) return;
this.gridReleaseFlagImg = grm.ui.loadFlagImage($(grTF.releaseCountry, metadb));
}
/**
* Updates the metadata grid in Details, reuses last value for last played unless provided one.
* @param {string} currentLastPlayed - The current value of the "Last Played" metadata field.
* @param {string} currentPlayingPlaylist - The current active playlist that is being played from.
* @param {FbMetadbHandle} metadb - The metadb of the track.
* @returns {Array|null} The updated metadata grid, which is an array of objects with properties `label`, `val` and `age`.
*/
updateGrid(currentLastPlayed, currentPlayingPlaylist, metadb = undefined) {
if (!grCfg.metadataGrid) return null;
currentLastPlayed = (grStr && grStr.grid ? grStr.grid.find(value => value.label === '最近播放') || {} : {}).val;
grStr.grid = [];
for (const key of grCfg.metadataGrid) {
let val = $(key.val, metadb);
if (val && key.label) {
if (key.age) {
val = $(`$date(${val})`, metadb); // Never show time
const age = CalcAgeDateString(val);
if (age) val += ` (${age})`;
}
grStr.grid.push({
age: key.age,
label: key.label,
val
});
}
}
if (typeof currentLastPlayed !== 'undefined') {
const lp = grStr.grid.find(value => value.label === '最近播放');
if (lp) {
lp.val = $Date(currentLastPlayed);
if (CalcAgeDateString(lp.val)) {
lp.val += ` (${CalcAgeDateString(lp.val)})`;
}
}
}
if (typeof currentPlayingPlaylist !== 'undefined') {
const pl = grStr.grid.find(value => value.label === '播放列表');
if (pl) {
pl.val = currentPlayingPlaylist;
}
}
return grStr.grid;
}
/**
* Updates the metadata grid codec and channel logo in Details.
* This method is primarily used to refresh the colors of the logos.
*/
updateGridLogos() {
this.clearCache('codecLogo');
this.clearCache('channelLogo');
}
/**
* Updates the metadata grid positions in Details.
* This method is primarily used to refresh the coordinates for mouseInMetadataGrid.
*/
updateGridPos() {
this.gridTop = 0;
this.gridArtistTop = 0;
this.gridArtistBottom = 0;
this.gridTitleTop = 0;
this.gridTitleBottom = 0;
this.gridAlbumTop = 0;
this.gridAlbumBottom = 0;
}
// #endregion
// * PUBLIC METHODS - METADATA GRID TIMELINE * //
// #region PUBLIC METHODS - METADATA GRID TIMELINE
/**
* Draws the timeline above the metadata grid in Details.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawGridTimeline(gr) {
gr.SetSmoothingMode(SmoothingMode.None); // Disable smoothing
gr.FillSolidRect(this.gridMarginLeft, this.timelineY, this.timelineDrawWidth + this.timelineExtraLeftSpace + this.timelineLineWidth, this.timelineH, grCol.timelineAdded);
if (grSet.theme.startsWith('custom')) {
gr.DrawRect(this.timelineX - 2, this.timelineY - 2, this.timelineW + 3, this.timelineH + 3, 1, grCol.timelineFrame);
}
if (this.timelineFirstPlayedPercent >= 0 && this.timelineLastPlayedPercent >= 0) {
const x1 = Math.floor(this.timelineDrawWidth * this.timelineFirstPlayedPercent) + this.timelineExtraLeftSpace;
const x2 = Math.floor(this.timelineDrawWidth * this.timelineLastPlayedPercent) + this.timelineExtraLeftSpace;
gr.FillSolidRect(x1 + this.gridMarginLeft, this.timelineY, this.timelineDrawWidth - x1 + this.timelineExtraLeftSpace, this.timelineH, grCol.timelinePlayed);
gr.FillSolidRect(x2 + this.gridMarginLeft, this.timelineY, this.timelineDrawWidth - x2 + this.timelineExtraLeftSpace + this.timelineLineWidth, this.timelineH, grCol.timelineUnplayed);
}
for (let i = 0; i < this.timelinePlayedTimesPercents.length; i++) {
const x = Math.floor(this.timelineDrawWidth * this.timelinePlayedTimesPercents[i]) + this.gridMarginLeft + this.timelineExtraLeftSpace;
if (!Number.isNaN(x) && x <= this.timelineW + this.gridMarginLeft * 2) {
const linePos = Math.max(this.gridMarginLeft, Math.min(x, x));
gr.DrawLine(linePos, this.timelineY, linePos, this.timelineY + this.timelineH, this.timelineLineWidth, this.timelinePlayCol);
} else {
// console.log('Played Times Error! ratio: ' + this.playedTimesPercents[i], 'x: ' + x);
}
}
gr.SetSmoothingMode(SmoothingMode.AntiAlias);
}
/**
* Handles the grid timeline tooltip. If a tooltip is ready, it displays and then clears it.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
*/
handleGridTimelineTooltip(x, y) {
if (!this.mouseInMetadataGrid(x, y, 'timeline') || !grSet.showGridTimeline_layout || this.timelinePlayedTimesPercents.length === 0) {
return;
}
let tooltip = '';
const percent = ToFixed((x + this.timelineX - this.gridMarginLeft * 2 - this.timelineExtraLeftSpace) / this.timelineDrawWidth, 3);
const timezoneOffset = UpdateTimezoneOffset();
for (let i = 0; i < this.timelinePlayedTimesPercents.length; i++) {
if (Math.abs(percent - this.timelinePlayedTimesPercents[i]) <= this.timelineLeeway) {
const date = new Date(this.timelinePlayedTimes[i]);
tooltip += tooltip.length ? '\n' : '';
tooltip += date.toLocaleString();
}
else if (percent < this.timelinePlayedTimesPercents[i]) {
if (!tooltip.length) {
const added = i === 0 ? DateDiff($Date('[%added%]'), this.timelinePlayedTimes[0], timezoneOffset) : DateDiff(new Date(this.timelinePlayedTimes[i - 1]).toISOString(), this.timelinePlayedTimes[i], timezoneOffset);
tooltip = added ? (i === 0 ? `添加 ${added} 后首次播放` : `已 ${added} 无播放`) : '';
}
break;
}
}
if (tooltip.length) {
this.gridTimelineTooltipText = tooltip;
grm.ttip.showImmediate(tooltip);
window.RepaintRect(this.timelineX, this.timelineY, this.timelineW, this.timelineH);
} else {
this.gridTimelineTooltipText = '';
grm.ttip.stop();
window.Repaint();
}
}
/**
* Sets the width and position of the timeline.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @param {number} width - The width of the timeline.
* @param {number} height - The height of the timeline.
*/
setGridTimelineSize(x, y, width, height) {
if (this.timelineX === x && this.timelineY === y && this.timelineW === width) {
return;
}
this.timelineX = x;
this.timelineY = y;
this.timelineW = width;
this.timelineH = height;
this.timelineLineWidth = HD_4K(2, 3);
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
this.timelineDrawWidth = Math.floor(this.timelineW - this.timelineExtraLeftSpace - 1 - this.timelineLineWidth / 2);
this.timelineLeeway = (1 / this.timelineDrawWidth) * (this.timelineLineWidth + SCALE(2)) / 2;
}
/**
* Sets the first and last played percentages, as well as the played time ratios and values.
* @param {number} firstPlayed - The percentage of the total play time that represents the first time the item was played.
* @param {number} lastPlayed - The percentage of the total play time that represents the last time the item was played.
* @param {number} playedTimeRatios - The percentage of time played for each playedTimesValues.
* @param {number} playedTimesValues - Contains the actual played times for each interval.
* For example, if the intervals are divided into 5 parts, playedTimesValues would be an
* array of 5 numbers representing the played times for each interval.
*/
setGridTimelinePlayTimes(firstPlayed, lastPlayed, playedTimeRatios, playedTimesValues) {
this.timelineFirstPlayedPercent = firstPlayed;
this.timelineLastPlayedPercent = lastPlayed;
this.timelinePlayedTimesPercents = playedTimeRatios;
this.timelinePlayedTimes = playedTimesValues;
}
/**
* Sets date ratios based on various time-related properties of a music track.
* @param {boolean} dontUpdateLastPlayed - Whether the last played date should be updated or not.
* @param {string} currentLastPlayed - The current value of the last played time.
* @param {FbMetadbHandle} metadb - The metadb of the track.
*/
setGridTimelineDateRatios(dontUpdateLastPlayed = false, currentLastPlayed, metadb = undefined) {
const newDate = new Date();
const timezoneOffset = UpdateTimezoneOffset();
let ratio;
let lfmPlayedTimesJsonLast = '';
let playedTimesJsonLast = '';
let playedTimesRatios = [];
let lfmPlayedTimes = [];
let playedTimes = [];
let added = ToTime($('$if2(%added_enhanced%,%added%)', metadb), timezoneOffset);
let lastPlayed = ToTime($('$if2(%last_played_enhanced%,%last_played%)', metadb), timezoneOffset);
const firstPlayed = ToTime($('$if2(%first_played_enhanced%,%first_played%)', metadb), timezoneOffset);
const today = DateToYMD(newDate);
if (dontUpdateLastPlayed && $Date(lastPlayed) === today) {
lastPlayed = ToTime(currentLastPlayed, timezoneOffset);
}
if (Component.EnhancedPlaycount) {
const playedTimesJson = $('[%played_times_js%]', metadb);
const lastfmJson = $('[%lastfm_played_times_js%]', metadb);
const log = ''; // ! Don't need this crap to flood the console // playedTimesJson === playedTimesJsonLast && lastfmJson === lfmPlayedTimesJsonLast ? false : grCfg.settings.showDebugLog;
lfmPlayedTimesJsonLast = lastfmJson;
playedTimesJsonLast = playedTimesJson;
lfmPlayedTimes = ParseJson(lastfmJson, 'lastfm: ', log);
playedTimes = ParseJson(playedTimesJson, 'foobar: ', log);
}
else {
playedTimes.push(firstPlayed);
playedTimes.push(lastPlayed);
}
if (firstPlayed) {
if (!added) {
added = firstPlayed;
}
const age = CalcAge(added);
this.timelineFirstPlayedRatio = CalcAgeRatio(firstPlayed, age);
this.timelineLastPlayedRatio = CalcAgeRatio(lastPlayed, age);
if (this.timelineLastPlayedRatio < this.timelineFirstPlayedRatio) {
// Due to daylight savings time, if there's a single play before the time changed lastPlayed could be < firstPlayed
this.timelineLastPlayedRatio = this.timelineFirstPlayedRatio;
}
if (playedTimes.length) {
for (let i = 0; i < playedTimes.length; i++) {
ratio = CalcAgeRatio(playedTimes[i], age);
playedTimesRatios.push(ratio);
}
} else {
playedTimesRatios = [this.timelineFirstPlayedRatio, this.timelineLastPlayedRatio];
playedTimes = [firstPlayed, lastPlayed];
}
let j = 0;
const tempPlayedTimesRatios = playedTimesRatios.slice();
tempPlayedTimesRatios.push(1.0001); // Pick up every last.fm time after lastPlayed fb knows about
for (let i = 0; i < tempPlayedTimesRatios.length; i++) {
while (j < lfmPlayedTimes.length && (ratio = CalcAgeRatio(lfmPlayedTimes[j], age)) < tempPlayedTimesRatios[i]) {
playedTimesRatios.push(ratio);
playedTimes.push(lfmPlayedTimes[j]);
j++;
}
if (ratio === tempPlayedTimesRatios[i]) { // Skip one instance
// console.log('skipped -->', ratio);
j++;
}
}
playedTimesRatios.sort((a, b) => a - b);
playedTimes.sort((a, b) => a - b);
this.timelineFirstPlayedRatio = playedTimesRatios[0];
this.timelineLastPlayedRatio = playedTimesRatios[Math.max(0, playedTimesRatios.length - (dontUpdateLastPlayed ? 2 : 1))];
}
else {
this.timelineFirstPlayedRatio = 0.33;
this.timelineLastPlayedRatio = 0.66;
}
this.setGridTimelinePlayTimes(this.timelineFirstPlayedRatio, this.timelineLastPlayedRatio, playedTimesRatios, playedTimes);
}
/**
* Updates the timeline by setting the sizes, colors, and last played dates.
* @param {boolean} updateLastPlayed - Whether to update the last played date.
* @param {FbMetadbHandle} metadb - The metadb of the track.
*/
updateGridTimeline(updateLastPlayed, metadb) {
this.setGridTimelineSize(this.gridMarginLeft, this.gridTop + Math.floor(this.gridLineSpacing * 0.33), grm.ui.albumArtSize.x - this.gridMarginLeft * 2, this.timelineH);
if (!updateLastPlayed) return;
const lastPlayed = $(grTF.last_played, metadb);
this.setGridTimelineDateRatios($Date(grm.ui.currentLastPlayed) !== $Date(lastPlayed), grm.ui.currentLastPlayed, metadb);
if (lastPlayed.length) {
const today = DateToYMD(new Date());
if (!grm.ui.currentLastPlayed.length || $Date(lastPlayed) !== today) {
grm.ui.currentLastPlayed = lastPlayed;
}
}
}
// #endregion
// * PUBLIC METHODS - DISC ART * //
// #region PUBLIC METHODS - DISC ART
/**
* Creates and masks an image to the disc art.
* @param {GdiGraphics} gr - The GDI graphics object.
* @param {number} x - The X-coordinate of the disc area.
* @param {number} y - The Y-coordinate of the disc area.
* @param {number} w - The width of the mask.
* @param {number} h - The height of the mask.
* @param {number} srcX - The X-coordinate of the source image.
* @param {number} srcY - The Y-coordinate of the source image.
* @param {number} srcW - The width of the source image.
* @param {number} srcH - The height of the source image.
* @param {number} [angle] - The angle of the mask in degrees. Default 0.
* @param {number} [alpha] - The alpha of the mask. Values 0-255.
* @returns {GdiGraphics} The rounded masked image.
*/
createDiscArtAlbumArtMask(gr, x, y, w, h, srcX, srcY, srcW, srcH, angle, alpha) {
if (w < 1 || h < 1) return null;
// * First draw album art in the background
gr.DrawImage(grm.ui.albumArtScaled, x, y, w, h, 0, 0, w, h, 0, alpha);
// * Mask
const maskImg = gdi.CreateImage(w, h);
let g = maskImg.GetGraphics();
g.FillEllipse(this.discArtSize.x - grm.ui.albumArtSize.x + this.discArtShadow - SCALE(4), this.discArtSize.y - grm.ui.albumArtSize.y + SCALE(2),
this.discArtSize.w - this.discArtShadow + SCALE(4), this.discArtSize.h - this.discArtShadow + SCALE(2), 0xffffffff);
maskImg.ReleaseGraphics(g);
// * Album art
const albumArtImg = gdi.CreateImage(w, h);
g = albumArtImg.GetGraphics();
g.DrawImage(grm.ui.albumArtScaled, 0, 0, w, h, 0, 0, grm.ui.albumArtScaled.Width, grm.ui.albumArtScaled.Height);
albumArtImg.ReleaseGraphics(g);
const mask = maskImg.Resize(w, h);
albumArtImg.ApplyMask(mask);
return gr.DrawImage(albumArtImg, x, y, w, h, 0, 0, w, h, 0, 255);
}
/**
* Creates the album cover mask for the disc art stub.
* @param {GdiBitmap} img - The image to apply the mask to.
* @param {number} w - The width of the mask.
* @param {number} h - The height of the mask.
*/
createDiscArtCoverMask(img, w, h) {
const { w: discArtW, h: discArtH } = this.discArtSize;
const lineW = SCALE(25);
const outerRingX = lineW * 0.5;
const outerRingY = lineW * 0.5;
const outerRingW = discArtW - lineW;
const outerRingH = discArtH - lineW;
const innerRingSize = discArtH * 0.666 + lineW * 0.5;
const innerCenterX = discArtW * 0.5;
const innerCenterY = discArtH * 0.5;
const innerRadiusX = discArtW * 0.5 - innerRingSize * 0.5;
const innerRadiusY = discArtH * 0.5 - innerRingSize * 0.5;
const innerRingX = innerCenterX - innerRadiusX;
const innerRingY = innerCenterY - innerRadiusY;
const innerRingW = innerRadiusX * 2;
const innerRingH = innerRadiusY * 2;
const mask = GDI(discArtW, discArtH, true, g => {
g.SetSmoothingMode(SmoothingMode.AntiAlias);
g.FillSolidRect(0, 0, discArtW, discArtH, RGB(255, 255, 255));
g.FillEllipse(outerRingX, outerRingY, outerRingW, outerRingH, RGB(0, 0, 0)); // Outer ring
g.FillEllipse(innerRingX, innerRingY, innerRingW, innerRingH, RGB(255, 255, 255)); // Inner ring
});
img.ApplyMask(mask.Resize(w, h));
}
/**
* Combines disc art with album cover art if conditions are met.
* @param {boolean} applyMask - Whether to apply the disc art cover mask or not.
* @returns {GdiBitmap} The combined image.
*/
combineDiscArtWithCover(applyMask) {
if (['cdAlbumCover', 'vinylAlbumCover'].includes(grSet.discArtStub) &&
(!this.discArtFound && (!grSet.noDiscArtStub || grSet.showDiscArtStub)) &&
this.discArtCover && this.discArtCover.Width > 0 && this.discArtCover.Height > 0) {
if (applyMask) {
this.createDiscArtCoverMask(this.discArtCover, this.discArtCover.Width, this.discArtCover.Height);
}
return CombineImages(this.discArt, this.discArtCover, this.discArtSize.w, this.discArtSize.h);
}
return this.discArt;
}
/**
* Disposes the disc art image when changing or deactivating disc art.
* @param {GdiBitmap} discArtImg - The loaded disc art image.
*/
disposeDiscArt(discArtImg) {
this.discArtSize = new ImageSize(0, 0, 0, 0);
discArtImg = null;
}
/**
* Fetches new disc art when a new album is being played.
*/
fetchDiscArt() {
if (!grm.ui.displayDetails) {
this.clearCache('discArt');
return;
}
grm.debug.setDebugProfile(grm.debug.showDebugTiming || grCfg.settings.showDebugPerformanceOverlay, 'create', '获取碟片');
if (grSet.displayDiscArt && !grm.ui.isStreaming) {
this.loadDiscArt(this.findDiscArtPath());
}
grm.debug.setDebugProfile(false, 'print', '获取碟片');
}
/**
* Finds the path to the disc art or disc art stub.
* @returns {string} The path to the disc art or disc art stub.
*/
findDiscArtPath() {
const discArtImagePaths = grPath.discArtImagePaths();
const discArtStubPaths = grPath.discArtStubPaths();
if (grSet.noDiscArtStub || grSet.showDiscArtStub) {
for (const path of discArtImagePaths) {
if (IsFile(path)) {
this.discArtFound = true;
return path;
}
}
}
this.discArtFound = false;
return grSet.noDiscArtStub ? '' : discArtStubPaths[grSet.discArtStub] || grPath.discArtCustomStub;
}
/**
* Initializes the disc art when the Details panel is opened or closed.
*/
initDiscArt() {
if (!grm.ui.displayDetails) {
this.clearCache('discArt');
this.clearTimer('discArt');
return;
}
if (!this.discArtCover && grm.ui.albumArtList.length) {
const artIndex = grm.ui.albumArtList[grm.ui.albumArtIndex];
if (artIndex && grm.artCache) {
this.discArtCover = grm.artCache.getImage(artIndex, 2) ||
(grm.ui.albumArt && grm.artCache.encache(grm.ui.albumArt, artIndex, 2));
}
}
if (grSet.displayDiscArt && !grm.ui.isStreaming) {
if (this.discArt) {
this.updateDiscArt();
} else {
this.fetchDiscArt();
}
}
}
initDiscArtStub() {
if (!grSet.displayDiscArt || grSet.noDiscArtStub) return;
const stubPath = grPath.discArtStubPaths()[grSet.discArtStub] || grPath.discArtCustomStub;
if (!stubPath || grm.artCache.getImage(stubPath)) return; // already cached
gdi.LoadImageAsyncV2(window.ID, stubPath).then(img => {
if (img) grm.artCache.encache(img, stubPath);
});
}
/**
* Loads the disc art from the given path.
* @param {string} discArtPath - The path to the disc art.
*/
loadDiscArt(discArtPath) {
const tempDiscArt = grm.ui.albumArtFromCache ? grm.artCache.getImage(discArtPath) : null;
if (tempDiscArt) {
this.disposeDiscArt(this.discArt);
this.discArt = tempDiscArt;
if (grm.ui.displayDetails) this.updateDiscArt();
return;
}
gdi.LoadImageAsyncV2(window.ID, discArtPath).then(discArtImg => {
this.disposeDiscArt(this.discArt); // Delay disposal so we don't get flashing
this.discArt = grm.artCache.encache(discArtImg, discArtPath);
if (!this.discArt && !grSet.noDiscArtStub) {
grm.ui.handleArtworkError('discArt');
} else {
this.updateDiscArt();
}
this.clearCache('metrics', 'cachedLabelLastLeftEdge'); // Recalc label location
grm.debug.repaintWindow();
});
}
/**
* Resizes and resets the size and position of the disc art.
* @param {boolean} resetDiscArtPosition - Whether the position of the disc art should be reset.
*/
resizeDiscArt(resetDiscArtPosition) {
if (!this.discArt) {
this.discArtSize = new ImageSize(0, 0, 0, 0);
return;
}
this.setDiscArtScaleFactor();
this.setDiscArtSize(resetDiscArtPosition);
this.setDiscArtPosition(resetDiscArtPosition);
this.setDiscArtShadow();
}
/**
* Repaints the disc art area to only cover the necessary region based on album art opacity settings and disc art layering.
*/
repaintDiscArt() {
const discArtLeftEdge = (
grSet.detailsAlbumArtOpacity !== 255 || grSet.detailsAlbumArtDiscAreaOpacity !== 255 || grSet.discArtOnTop
) ? this.discArtSize.x : grm.ui.albumArtSize.x + grm.ui.albumArtSize.w - 1;
window.RepaintRect(
discArtLeftEdge, this.discArtSize.y,
this.discArtSize.w - (discArtLeftEdge - this.discArtSize.x), this.discArtSize.h,
!grSet.discArtOnTop && !grm.ui.displayLyrics
);
}
/**
* Repaints the metadata grid area to only cover the necessary region.
*/
repaintMetadataGrid() {
if (!grm.ui.displayDetails) return;
window.RepaintRect(0, grm.ui.topMenuHeight, Math.max(grm.ui.albumArtSize.x, SCALE(40)), grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight);
}
/**
* Set the scale factor for the disc art based on the window size and layout.
*/
setDiscArtScaleFactor() {
const discArtMaxHeight = grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight;
const scaleFactor = grm.ui.displayPlaylist || grm.ui.displayLibrary ? 0.5 : 0.75;
const discScale = Math.min(grm.ui.ww * scaleFactor / this.discArt.Width, (discArtMaxHeight - SCALE(16)) / this.discArt.Height);
this.discArtScaleFactor = discScale;
}
/**
* Set the size of the disc art based on its scale, window state, and layout settings.
* @param {boolean} resetDiscArtPosition - Whether the position of the disc art should be reset.
*/
setDiscArtSize(resetDiscArtPosition) {
const discArtSizeCorr = SCALE(4);
const discArtSize =
grm.ui.hasArtwork ? grm.ui.albumArtSize.h - discArtSizeCorr :
Math.floor(this.discArt.Width * this.discArtScaleFactor) - discArtSizeCorr;
if (resetDiscArtPosition) {
this.discArtSize = { w: discArtSize, h: discArtSize };
} else {
this.discArtSize.w = Math.max(this.discArtSize.w, discArtSize);
this.discArtSize.h = this.discArtSize.w;
}
}
/**
* Set the position of the disc art based on the window size and layout settings.
* @param {boolean} resetDiscArtPosition - Whether the position of the disc art should be reset.
*/
setDiscArtPosition(resetDiscArtPosition) {
const discArtSizeCorr = SCALE(4);
const discArtMargin = SCALE(2);
const discArtMarginRight = SCALE(36);
const discArtMaxHeight = grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight;
if (grm.ui.hasArtwork) {
const baseX = grm.ui.ww - grm.ui.albumArtSize.h - discArtMarginRight;
const adjustedX = grm.ui.albumArtSize.x + grm.ui.albumArtSize.w -
(grm.ui.albumArtSize.h - discArtSizeCorr) * (1 - grSet.discArtDisplayAmount) -
(grSet.discArtDisplayAmount === 1 || grSet.discArtDisplayAmount === 0.5 ? 0 : discArtMarginRight);
const discArtX = Math.floor(
grSet.discArtDisplayAmount === 1 ? baseX :
grSet.discArtDisplayAmount === 0.5 ? Math.min(baseX, adjustedX) :
adjustedX
);
this.discArtSize.x = resetDiscArtPosition ? discArtX : Math.max(this.discArtSize.x, discArtX);
this.discArtSize.y = resetDiscArtPosition ? (grm.ui.albumArtSize.y + discArtMargin) :
Math.min(this.discArtSize.y > 0 ? this.discArtSize.y :
(grm.ui.albumArtSize.y + discArtMargin), grm.ui.albumArtSize.y + discArtMargin);
if (this.discArtSize.x + this.discArtSize.w > grm.ui.ww) {
this.discArtSize.x = grm.ui.ww - this.discArtSize.w - discArtMarginRight;
}
return;
}
// * Set no disc art x-coordinate
const discArtOffCenter = this.discArtScaleFactor === (grm.ui.ww * 0.75 / this.discArt.Width);
const discArtCenterX =
discArtOffCenter ? Math.round(grm.ui.ww * 0.66 - grm.ui.edgeMargin) :
(grm.ui.displayPlaylist || grm.ui.displayLibrary) ? grm.ui.ww * 0.25 :
grm.ui.ww * 0.5;
this.discArtSize.x = Math.floor(discArtCenterX - this.discArtSize.w * 0.5);
// * Set no disc art y-coordinate
const restrictedWidth = this.discArtScaleFactor !== (discArtMaxHeight - SCALE(16)) / this.discArt.Height;
const discArtCenterY = grm.ui.topMenuHeight + Math.floor(((discArtMaxHeight - SCALE(16)) / 2) - this.discArtSize.h / 2);
this.discArtSize.y = restrictedWidth ? Math.min(discArtCenterY, 160) : grm.ui.topMenuHeight + discArtMargin;
grm.ui.hasArtwork = true;
}
/**
* Sets up async precomputation of disc art frames, prioritized from current index.
* @param {GdiBitmap} combinedImg - The base image to rotate.
* @param {string} currentAlbumId - Unique ID to detect album changes.
* @param {number} rotationDegreeIncrement - Degrees per frame.
*/
setDiscArtPrecomputation(combinedImg, currentAlbumId, rotationDegreeIncrement) {
let batchCount = 0;
let frameTimeAvg = 0;
let precomputeIndex = (this.discArtRotationIndex + 1) % grSet.spinDiscArtImageCount;
let precomputeTimer = null;
let performanceTierCurrent = 'medium';
const performanceTiers = {
low: { batchSize: 1, batchDelay: 75 }, // 50-100ms
medium: { batchSize: 2, batchDelay: 25 }, // 20-40ms
high: { batchSize: 4, batchDelay: 10 } // 10-20ms
};
const updatePerformanceTier = (frameTime) => {
frameTimeAvg = (frameTimeAvg + frameTime) / 2;
const performanceTierNew = frameTimeAvg > 50 ? 'low' : frameTimeAvg < 10 ? 'high' : 'medium';
if (performanceTierNew === performanceTierCurrent) return;
performanceTierCurrent = performanceTierNew;
const tier = performanceTiers[performanceTierCurrent];
grm.debug.debugLog(`碟片 => 适应 ${performanceTierCurrent} 性能: 批量大小=${tier.batchSize}, 批量延迟=${tier.batchDelay} 毫秒 (avgFrameTime=${Math.round(frameTimeAvg)} 毫秒)`);
};
const precomputeBatch = () => {
if (this.discArt.Path !== currentAlbumId) return;
const tier = performanceTiers[performanceTierCurrent];
let computedInBatch = 0;
while (computedInBatch < tier.batchSize && !this.discArtArray[precomputeIndex]) {
const rotationDegrees = rotationDegreeIncrement * precomputeIndex;
const frameStart = Date.now();
this.discArtArray[precomputeIndex] = RotateImage(combinedImg, this.discArtSize.w, this.discArtSize.h, rotationDegrees, grm.artCache.discArtImgMaxRes);
const frameTime = Date.now() - frameStart;
updatePerformanceTier(frameTime); // Update per-frame for quicker response
grm.debug.debugLog(`碟片 => 预计算碟片图像: ${precomputeIndex} (${this.discArtSize.w}x${this.discArtSize.h}) 旋转: ${rotationDegrees} 度`);
computedInBatch++;
precomputeIndex = (precomputeIndex + 1) % grSet.spinDiscArtImageCount;
}
batchCount++;
if (this.discArtArray.every(frame => !!frame)) {
grm.debug.debugLog('碟片 => 所有帧均已预先计算');
return;
}
if (computedInBatch > 0) {
precomputeTimer = setTimeout(precomputeBatch, tier.batchDelay);
}
};
// Start immediately but async
setTimeout(precomputeBatch, 0);
// Cleanup
this.clearTimer = (type) => {
if (type === 'discArt' && precomputeTimer) {
clearTimeout(precomputeTimer);
grm.debug.debugLog('碟片 => 清除预计算计时器');
}
Details.prototype.clearTimer.call(this, type);
delete this.clearTimer; // Restore to prototype chain
};
}
/**
* Sets and creates the disc art rotation animation with RotateImg().
* @returns {GdiBitmap} The rotated disc art image.
*/
setDiscArtRotation() {
if (!grSet.displayDiscArt || grm.ui.albumArtCorrupt || !grm.ui.albumArt || !this.discArt || this.discArtSize.w < 1) {
return null;
}
// Drawing discArt rotated is slow, so first draw it rotated into the discArtRotation image, and then draw discArtRotation image unrotated in on_paint.
const vinylAdjustedTrackNumFormat = `$num($if(${grTF.vinyl_tracknum},$sub($mul(${grTF.vinyl_tracknum},2),1),$if2(%tracknumber%,1)),1)`;
let tracknum = parseInt($(vinylAdjustedTrackNumFormat)) - 1;
if (!grSet.rotateDiscArt || Number.isNaN(tracknum)) tracknum = 0;
const tracknumRotation = tracknum * grSet.rotationAmt % 360;
const combinedImg = this.combineDiscArtWithCover(true);
this.discArtRotation = RotateImage(combinedImg, this.discArtSize.w, this.discArtSize.h, tracknumRotation, grm.artCache.discArtImgMaxRes);
this.discArtRotationIndex = Math.round(tracknumRotation / (360 / grSet.spinDiscArtImageCount)) % grSet.spinDiscArtImageCount;
return this.discArtRotation;
}
/**
* Sets the disc art timer with different set interval values for rotating the disc art.
*/
setDiscArtRotationTimer() {
this.clearTimer('discArt');
if (grSet.layout !== 'default' || !grm.ui.displayDetails ||
grm.ui.albumArtCorrupt || !grm.ui.albumArt || !this.discArt || !this.discArtSize.w ||
!fb.IsPlaying || fb.IsPaused || !grSet.displayDiscArt || !grSet.spinDiscArt) {
return;
}
grm.debug.debugLog(`碟片 => 使用异步预计算启动延迟旋转: ${grSet.spinDiscArtImageCount} 帧, 每 ${grSet.spinDiscArtRedrawInterval} 毫秒 1 次`);
const rotationDegreeIncrement = 360 / grSet.spinDiscArtImageCount;
const combinedImg = this.combineDiscArtWithCover(false);
const currentAlbumId = this.discArt.Path;
// Main animation timer
this.discArtRotationTimer = setInterval(() => {
const intendedIndex = (this.discArtRotationIndex + 1) % grSet.spinDiscArtImageCount;
let displayIndex = intendedIndex;
if (!this.discArtArray[intendedIndex]) {
// Nearest available: prioritize smallest angular distance, alternating fwd/bwd
const count = grSet.spinDiscArtImageCount;
let nearestFound = false;
for (let dist = 0; dist < count; dist++) {
const fwd = (intendedIndex + dist) % count;
if (this.discArtArray[fwd]) {
displayIndex = fwd;
nearestFound = true;
break;
}
const bwd = (intendedIndex - dist + count) % count;
if (this.discArtArray[bwd]) {
displayIndex = bwd;
nearestFound = true;
break;
}
}
if (!nearestFound) displayIndex = 0; // Ultimate fallback
grm.debug.debugLog(`碟片 => 帧 ${intendedIndex} 未就绪,显示最接近的 ${displayIndex}`);
// Emergency compute intended (sync for immediacy, but only one frame)
const rotationDegrees = rotationDegreeIncrement * intendedIndex;
this.discArtArray[intendedIndex] = RotateImage(combinedImg, this.discArtSize.w, this.discArtSize.h, rotationDegrees, grm.artCache.discArtImgMaxRes);
grm.debug.debugLog(`碟片 => 紧急计算碟片: ${intendedIndex} (${this.discArtSize.w}x${this.discArtSize.h}) 旋转: ${rotationDegrees} 度`);
}
this.discArtRotationIndex = intendedIndex; // Advance intended for next tick
this.repaintDiscArt(); // Repaint with displayIndex (but since we just computed if missing, often same)
}, grSet.spinDiscArtRedrawInterval);
// Start precomputation
this.setDiscArtPrecomputation(combinedImg, currentAlbumId, rotationDegreeIncrement);
}
/**
* Sets the drop shadow for disc art.
*/
setDiscArtShadow() {
const isDisabled = !grm.ui.displayDetails || !grSet.displayDiscArt || grSet.layout === 'compact';
const isMissing = !this.discArt || !grm.ui.hasArtwork && !grm.ui.noAlbumArtStub;
const isCached = this.discArtShadowImg && this.discArtShadowImg.image && this.discArtShadowImg.size === this.discArtSize.h;
if (isDisabled || isMissing || isCached) return;
grm.debug.setDebugProfile(grm.debug.showDebugTiming || grCfg.settings.showDebugPerformanceOverlay, 'create', '创建碟片阴影');
const discArtMargin = SCALE(2);
if (grm.ui.albumArtSize.w > 0 || this.discArtSize.w > 0) {
const width = this.discArt
? this.discArtSize.x + this.discArtSize.w + 2 * this.discArtShadow
: grm.ui.albumArtSize.x + grm.ui.albumArtSize.w + 2 * this.discArtShadow;
const height = this.discArt
? this.discArtSize.h + discArtMargin + 2 * this.discArtShadow
: grm.ui.albumArtSize.h + 2 * this.discArtShadow;
const newShadowImg = gdi.CreateImage(width, height);
if (grSet.layout === 'default' && newShadowImg) {
const shimg = newShadowImg.GetGraphics();
if (this.discArt) {
const offset = this.discArtSize.w * 0.40; // Don't change this value
const xVal = this.discArtSize.x;
const shadowOffset = this.discArtShadow * 2;
shimg.DrawEllipse(xVal + shadowOffset, shadowOffset + discArtMargin, this.discArtSize.w - shadowOffset, this.discArtSize.w - shadowOffset, shadowOffset, grCol.discArtShadow); // outer shadow
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
}
newShadowImg.ReleaseGraphics(shimg);
newShadowImg.StackBlur(this.discArtShadow);
}
this.discArtShadowImg.image = newShadowImg;
this.discArtShadowImg.size = this.discArtSize.h;
}
grm.debug.setDebugProfile(false, 'print', '创建碟片阴影');
}
/**
* Updates the disc art by resizing artwork, creating rotation, and setting the rotation timer.
*/
updateDiscArt() {
grm.ui.resizeArtwork(true);
this.setDiscArtRotation();
if (!grSet.spinDiscArt) return;
this.discArtArray = []; // Clear last image
this.setDiscArtRotationTimer();
}
// #endregion
// * PUBLIC METHODS - BAND & LABEL LOGO * //
// #region PUBLIC METHODS - BAND & LABEL LOGO
/**
* Gets the band logo path if it exists at various paths.
* @param {string} bandStr - The name of the band.
* @returns {string} The path of the band logo if it exists.
*/
getBandLogoPath(bandStr) {
if (!bandStr) return '';
const testBandLogoPath = (imgDir, name) => {
const logoPath = `${imgDir}${name}.png`;
if (IsFile(logoPath)) {
grm.debug.debugLog(`图标 => 找到艺术家标识: ${logoPath}`);
return logoPath;
}
return '';
};
const bandLogoPath =
testBandLogoPath(grPath.artistlogos, bandStr) || // Try 800x310 white
testBandLogoPath(grPath.artistlogosColor, bandStr); // Try 800x310 color
return bandLogoPath || '';
}
/**
* Gets the band logo and its inverted version based on the current playing album artist in Details.
* @param {FbMetadbHandle} metadb - The metadb of the track.
*/
getBandLogo(metadb = undefined) {
this.clearCache('bandLogo');
let path;
const artists = GetMetaValues('%artist%', metadb);
const trackArtist = ReplaceIllegalChars($('[%track artist%]', metadb));
const albumArtists = GetMetaValues('%album artist%', metadb);
const artistList = [
...artists.flatMap(artist => [
ReplaceIllegalChars(artist), ReplaceIllegalChars(artist).replace(Regex.TextPrefixThe, '')
]),
trackArtist,
...albumArtists.flatMap(artist => [
ReplaceIllegalChars(artist), ReplaceIllegalChars(artist).replace(Regex.TextPrefixThe, '')
])
];
const uniqueArtistList = [...new Set(artistList)];
for (const artist of uniqueArtistList) {
path = this.getBandLogoPath(artist);
if (path) break;
}
if (!path) return;
this.bandLogo = grm.artCache.getImage(path);
if (!this.bandLogo) {
const logo = gdi.Image(path);
if (logo) {
this.bandLogo = grm.artCache.encache(logo, path);
this.bandLogoInverted = grm.artCache.encache(logo.InvertColours(), `${path}-inv`);
}
}
this.bandLogoInverted = grm.artCache.getImage(`${path}-inv`);
if (!this.bandLogoInverted && this.bandLogo) {
this.bandLogoInverted = grm.artCache.encache(this.bandLogo.InvertColours(), `${path}-inv`);
}
}
/**
* Gets label logos based on current playing album artist in Details.
* @param {FbMetadbHandle} metadb - The metadb of the track.
*/
getLabelLogo(metadb) {
this.clearCache('labelLogo');
const labelFields = ['label', 'publisher', 'discogs_label'];
const labels = new Set(labelFields.flatMap(label => GetMetaValues(label, metadb)));
for (const label of labels) {
const addLabel = this.loadLabelLogo(label);
if (addLabel != null) {
this.labelLogo.push(addLabel);
try {
this.labelLogoInverted.push(addLabel.InvertColours());
} catch (e) {}
}
}
}
/**
* Loads the label logo image for a given record label in Details.
* @param {string} publisherString - The name of a record label or publisher.
* @returns {GdiBitmap|null} The record label logo as a gdi image object or null if not found.
*/
loadLabelLogo(publisherString) {
const date = new Date();
const lastSearchYear = date.getFullYear();
let dir = grPath.labelsBase;
let labelStr = ReplaceIllegalChars(publisherString);
let recordLabel = null;
if (!labelStr) return recordLabel;
// * Clean up the label string
const cleanLabelString = (str) => str
.replace(Regex.ArtImageLabelSuffix, '')
.replace(Regex.EdgeDotSpaceTrailing, '')
.replace(Regex.TextDash, '-');
// * Check for label folders by year
const checkLabelFolders = (label) => {
const startYear = parseInt($('$year(%date%)'));
const baseDir = `${dir}${label}\\`;
for (let year = startYear; year <= lastSearchYear; year++) {
const yearFolder = `${baseDir}${year}`;
if (IsFolder(yearFolder)) {
grm.debug.debugLog(`图标 => 找到 ${label} 年份 ${year}文件夹.`);
return `${yearFolder}\\`;
}
}
grm.debug.debugLog(`图标 => 找到 ${label} 文件夹并使用最新图标.`);
return baseDir;
};
// * Check if a folder exists for the initial label string
const folderExists = (label) => IsFolder(`${dir}${label}`);
if (folderExists(labelStr)) {
dir = checkLabelFolders(labelStr);
} else {
labelStr = cleanLabelString(labelStr);
if (folderExists(labelStr)) {
dir = checkLabelFolders(labelStr);
}
}
// * Reinitialize to original string for file search
labelStr = ReplaceIllegalChars(publisherString);
// * Get the file path for the initial label string
const searchFile = (label) => `${dir}${label}.png`;
let label = searchFile(labelStr);
// * Load the record label image
if (IsFile(label)) {
recordLabel = gdi.Image(label);
grm.debug.debugLog('图标 => 找到唱片商标:', label, !recordLabel ? '<无法加载>' : '');
} else {
labelStr = cleanLabelString(labelStr);
label = searchFile(labelStr);
if (IsFile(label)) {
recordLabel = gdi.Image(label);
} else {
label = searchFile(`${labelStr} Records`);
if (IsFile(label)) {
recordLabel = gdi.Image(label);
}
}
}
return recordLabel;
}
// #endregion
// * PUBLIC METHODS - CALLBACKS * //
// #region PUBLIC METHODS - CALLBACKS
/**
* Checks if the mouse is within the boundaries of the metadata grid in Details.
* @global
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @param {string} boundary - The boundary to check ('artist', 'title', 'album', 'tagKey', 'tagValue', 'timeline', 'grid').
* @returns {boolean} True or false.
*/
mouseInMetadataGrid(x, y, boundary) {
return this.gridSectionBounds[boundary] ? this.gridSectionBounds[boundary](x, y) : false;
}
/**
* Handles the tooltip when the mouse is in the metadata grid tooltip area.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @param {number} m - The mouse mask.
*/
on_mouse_move(x, y, m) {
if (grSet.showTooltipMain || grSet.showTooltipTruncated) {
this.handleGridTooltip(x, y);
}
if (grSet.showTooltipTimeline) {
this.handleGridTimelineTooltip(x, y);
}
}
// #endregion
}
| 1 | ///////////////////////////////////////////////////////////////////////////////// |
| 2 | // * Georgia-ReBORN: A Clean - Full Dynamic Color Reborn - Foobar2000 Player * // |
| 3 | // * Description: Georgia-ReBORN Details * // |
| 4 | // * Author: TT * // |
| 5 | // * Website: https://github.com/TT-ReBORN/Georgia-ReBORN * // |
| 6 | // * Version: 3.0-x64-DEV * // |
| 7 | // * Dev. started: 22-12-2017 * // |
| 8 | // * Last change: 02-05-2026 * // |
| 9 | ///////////////////////////////////////////////////////////////////////////////// |
| 10 | |
| 11 | |
| 12 | 'use strict'; |
| 13 | |
| 14 | |
| 15 | //////////////////////////////// |
| 16 | // * DETAILS USER INTERFACE * // |
| 17 | //////////////////////////////// |
| 18 | /** |
| 19 | * A class that is responsible for the Details panel. |
| 20 | */ |
| 21 | class Details { |
| 22 | /** |
| 23 | * Creates the `Details` instance. |
| 24 | */ |
| 25 | constructor() { |
| 26 | // * GEOMETRY * // |
| 27 | // #region GEOMETRY |
| 28 | /** @public @type {number} The size of the disc art shadow. */ |
| 29 | this.discArtShadow = SCALE(6); |
| 30 | /** @public @type {number} The margin width from the edge of the player to start of the metadata grid strings. */ |
| 31 | this.gridMarginLeft = grm.ui.edgeMargin; |
| 32 | /** @public @type {number} The margin width from the edge of the metadata grid to the end of the metadata grid strings. */ |
| 33 | this.gridMarginRight = SCALE(20); |
| 34 | /** @public @type {number} The spacing between grid lines in the metadata grid. */ |
| 35 | this.gridLineSpacing = SCALE(30); |
| 36 | /** @public @type {number} The horizontal spacing between the track number and the artist in the metadata grid. */ |
| 37 | this.gridTrackNumSpacing = SCALE(8); |
| 38 | /** @public @type {number} The height of the metadata grid tooltip area. */ |
| 39 | this.gridTooltipHeight = SCALE(100); |
| 40 | /** @public @type {number} The top starting fixed position of the metadata grid. */ |
| 41 | this.gridTopStart = 0; |
| 42 | /** @public @type {number} The top dynamic position of the metadata grid. */ |
| 43 | this.gridTop = 0; |
| 44 | /** @public @type {number} The width of the metadata grid content. */ |
| 45 | this.gridContentWidth = 0; |
| 46 | /** @public @type {number} The width of the country flag size in the metadata grid. */ |
| 47 | this.gridFlagSizeW = 0; |
| 48 | /** @public @type {number} The white space size for the country flag in the metadata grid. */ |
| 49 | this.gridFlagSizeWhiteSpace = 0; |
| 50 | /** @public @type {number} The text rectangle for string calculation in the metadata grid. */ |
| 51 | this.gridTxtRec = 0; |
| 52 | /** @public @type {number} The top position of the artist in the metadata grid. */ |
| 53 | this.gridArtistTop = 0; |
| 54 | /** @public @type {number} The bottom position of the artist in the metadata grid. */ |
| 55 | this.gridArtistBottom = 0; |
| 56 | /** @public @type {object} The calculated artist wrap info for the metadata grid. */ |
| 57 | this.gridArtistWrapInfo = {}; |
| 58 | /** @public @type {boolean} The state when the artist string exceeds the available lines in the metadata grid. */ |
| 59 | this.gridArtistWrapLinesExceed = false; |
| 60 | /** @public @type {number} The width of the wrap space within the artist string in the metadata grid. */ |
| 61 | this.gridArtistWrapWidth = 0; |
| 62 | /** @public @type {number} The width of the artist in the metadata grid. */ |
| 63 | this.gridArtistWidth = 0; |
| 64 | /** @public @type {number} The height of the artist in the metadata grid. */ |
| 65 | this.gridArtistHeight = 0; |
| 66 | /** @public @type {number} The text rectangle for artist string calculation in the metadata grid. */ |
| 67 | this.gridArtistTxtRec = 0; |
| 68 | /** @public @type {number} The number of lines for the artist text in the metadata grid. */ |
| 69 | this.gridArtistNumLines = 0; |
| 70 | /** @public @type {number} The height of the artist number of lines in the metadata grid. */ |
| 71 | this.gridArtistNumLinesHeight = 0; |
| 72 | /** @public @type {number} The top position of the track title in the metadata grid. */ |
| 73 | this.gridTitleTop = 0; |
| 74 | /** @public @type {number} The bottom position of the track title in the metadata grid. */ |
| 75 | this.gridTitleBottom = 0; |
| 76 | /** @public @type {number} The width of the track number in the metadata grid. */ |
| 77 | this.gridTrackNumWidth = 0; |
| 78 | /** @public @type {object} The calculated track title wrap info for the metadata grid. */ |
| 79 | this.gridTitleWrapInfo = {}; |
| 80 | /** @public @type {boolean} The state when the track title string exceeds the available lines in the metadata grid. */ |
| 81 | this.gridTitleWrapLinesExceed = false; |
| 82 | /** @public @type {number} The width of the wrap space within the track title string in the metadata grid. */ |
| 83 | this.gridTitleWrapWidth = 0; |
| 84 | /** @public @type {number} The width of the track title in the metadata grid. */ |
| 85 | this.gridTitleWidth = 0; |
| 86 | /** @public @type {number} The height of the track title in the metadata grid. */ |
| 87 | this.gridTitleHeight = 0; |
| 88 | /** @public @type {number} The text rectangle for track title string calculation in the metadata grid. */ |
| 89 | this.gridTitleTxtRec = 0; |
| 90 | /** @public @type {number} The number of lines for the track title text in the metadata grid. */ |
| 91 | this.gridTitleNumLines = 0; |
| 92 | /** @public @type {number} The height of the track title number of lines in the metadata grid. */ |
| 93 | this.gridTitleNumLinesHeight = 0; |
| 94 | /** @public @type {number} The top position of the album in the metadata grid. */ |
| 95 | this.gridAlbumTop = 0; |
| 96 | /** @public @type {number} The bottom position of the album in the metadata grid. */ |
| 97 | this.gridAlbumBottom = 0; |
| 98 | /** @public @type {object} The calculated album wrap info for the metadata grid. */ |
| 99 | this.gridAlbumWrapInfo = {}; |
| 100 | /** @public @type {boolean} The state when the album string exceeds the available lines in the metadata grid. */ |
| 101 | this.gridAlbumWrapLinesExceed = false; |
| 102 | /** @public @type {number} The width of the wrap space within the album string in the metadata grid. */ |
| 103 | this.gridAlbumWrapWidth = 0; |
| 104 | /** @public @type {number} The width of the album in the metadata grid. */ |
| 105 | this.gridAlbumWidth = 0; |
| 106 | /** @public @type {number} The height of the album in the metadata grid. */ |
| 107 | this.gridAlbumHeight = 0; |
| 108 | /** @public @type {number} The text rectangle for album string calculation in the metadata grid. */ |
| 109 | this.gridAlbumTxtRec = 0; |
| 110 | /** @public @type {number} The number of lines for the album text in the metadata grid. */ |
| 111 | this.gridAlbumNumLines = 0; |
| 112 | /** @public @type {number} The height of the album number of lines in the metadata grid. */ |
| 113 | this.gridAlbumNumLinesHeight = 0; |
| 114 | /** @public @type {number} The margin between grid columns in the metadata grid. */ |
| 115 | this.gridColumnMargin = SCALE(10); |
| 116 | /** @public @type {number} The top position of the grid columns in the metadata grid. */ |
| 117 | this.gridColumnTop = 0; |
| 118 | /** @public @type {number} The height of the grid column cell in the metadata grid. */ |
| 119 | this.gridColumnCellHeight = 0; |
| 120 | /** @public @type {number} The width of the key strings column in the metadata grid. */ |
| 121 | this.gridColumnKeyWidth = 0; |
| 122 | /** @public @type {number} The height of the key strings in the metadata grid. */ |
| 123 | this.gridColumnKeyHeight = 0; |
| 124 | /** @public @type {number} The bottom position of the key strings in the metadata grid. */ |
| 125 | this.gridColumnKeyBottom = 0; |
| 126 | /** @public @type {number} The width of the value strings column in the metadata grid. */ |
| 127 | this.gridColumnValueWidth = 0; |
| 128 | /** @public @type {number} The height of the value strings in the metadata grid. */ |
| 129 | this.gridColumnValueHeight = 0; |
| 130 | /** @public @type {number} The left position of the value strings column in the metadata grid. */ |
| 131 | this.gridColumnValueLeft = 0; |
| 132 | /** @public @type {number} The bottom position of the value strings in the metadata grid. */ |
| 133 | this.gridColumnValueBottom = 0; |
| 134 | |
| 135 | // * TIMELINE * // |
| 136 | // #region TIMELINE |
| 137 | /** @public @type {number} The x-coordinate of the timeline. */ |
| 138 | this.timelineX = 0; |
| 139 | /** @public @type {number} The y-coordinate of the timeline. */ |
| 140 | this.timelineY = 0; |
| 141 | /** @public @type {number} The width of the timeline. */ |
| 142 | this.timelineW = 0; |
| 143 | /** @public @type {number} The height of the timeline. */ |
| 144 | this.timelineH = SCALE(8); |
| 145 | /** @public @type {number} The color of the played portion of the timeline. */ |
| 146 | this.timelinePlayCol = RGBA(255, 255, 255, 150); |
| 147 | /** @public @type {number} The ratio of the first played segment in the timeline. */ |
| 148 | this.timelineFirstPlayedRatio = 0; |
| 149 | /** @public @type {number} The ratio of the last played segment in the timeline. */ |
| 150 | this.timelineLastPlayedRatio = 0; |
| 151 | /** @public @type {number} The percentage of the first played segment in the timeline. */ |
| 152 | this.timelineFirstPlayedPercent = 0.33; |
| 153 | /** @public @type {number} The percentage of the last played segment in the timeline. */ |
| 154 | this.timelineLastPlayedPercent = 0.66; |
| 155 | /** @public @type {number[]} The percentages of the played times on the timeline. */ |
| 156 | this.timelinePlayedTimesPercents = []; |
| 157 | /** @public @type {number[]} The actual played times on the timeline. */ |
| 158 | this.timelinePlayedTimes = []; |
| 159 | /** @public @type {number} The width of the timeline line. */ |
| 160 | this.timelineLineWidth = HD_4K(2, 3); |
| 161 | /** @public @type {number} The extra left space on the timeline. */ |
| 162 | this.timelineExtraLeftSpace = SCALE(3); |
| 163 | /** @public @type {number} The draw width of the timeline. */ |
| 164 | this.timelineDrawWidth = 0; |
| 165 | /** @public @type {number} The leeway of the timeline. */ |
| 166 | this.timelineLeeway = 0; |
| 167 | // #endregion |
| 168 | |
| 169 | // * CACHE * // |
| 170 | // #region CACHE |
| 171 | /** @public @type {object} The caching object of the calculated text wrap space for the metadata grid. */ |
| 172 | this.cachedGridWrapSpace = {}; |
| 173 | /** @public @type {boolean} The calculated metadata grid metrics saved so we don't have to recalculate every on every on_paint unless size or metadata changed. */ |
| 174 | this.cachedGridMetrics = false; |
| 175 | /** @public @type {number} The left edge of the record labels in Details. Saved so we don't have to recalculate every on every on_paint unless size has changed. */ |
| 176 | this.cachedLabelLastLeftEdge = 0; |
| 177 | /** @public @type {number} The last label height of the record labels in Details. Saved so we don't have to recalculate every on every on_paint unless size has changed. */ |
| 178 | this.cachedLabelLastHeight = 0; |
| 179 | // #endregion |
| 180 | |
| 181 | // * IMAGES * // |
| 182 | // #region IMAGES |
| 183 | /** @public @type {GdiBitmap} The disc art image used in Details. */ |
| 184 | this.discArt = null; |
| 185 | /** @public @type {GdiBitmap} The disc art album cover image used in Details. */ |
| 186 | this.discArtCover = null; |
| 187 | /** @public @type {GdiBitmap[]} The array of disc art images used in Details. */ |
| 188 | this.discArtArray = []; |
| 189 | /** @public @type {number} The scale factor of the disc art used in Details. */ |
| 190 | this.discArtScaleFactor = 0; |
| 191 | /** @private @type {{image: GdiBitmap|null, size: number}} The shadow behind the disc art used in Details. */ |
| 192 | this.discArtShadowImg = { image: null, size: 0 } |
| 193 | /** @public @type {object} The disc art position used in Details (offset from albumArtSize). */ |
| 194 | this.discArtSize = new ImageSize(0, 0, 0, 0); |
| 195 | /** @public @type {GdiBitmap} The rotated disc art from the RotateImg helper used in Details. */ |
| 196 | this.discArtRotation = null; |
| 197 | /** @public @type {number} The global index of current discArtArray img to draw used in Details. */ |
| 198 | this.discArtRotationIndex = 0; |
| 199 | /** @private @type {GdiBitmap} The release country flag image shown in the metadata grid in Details. */ |
| 200 | this.gridReleaseFlagImg = null; |
| 201 | /** @private @type {GdiBitmap} The codec logo image shown in the metadata grid in Details. */ |
| 202 | this.gridCodecLogo = null; |
| 203 | /** @private @type {GdiBitmap} The channel logo image shown in the metadata grid in Details. */ |
| 204 | this.gridChannelLogo = null; |
| 205 | /** @public @type {GdiBitmap} The band logo image used in Details. */ |
| 206 | this.bandLogo = null; |
| 207 | /** @public @type {GdiBitmap} The inverted band logo image shown in Details. */ |
| 208 | this.bandLogoInverted = null; |
| 209 | /** @public @type {GdiBitmap[]} The array of record label images used in Details. */ |
| 210 | this.labelLogo = []; |
| 211 | /** @public @type {GdiBitmap[]} The array of inverted record label images used in Details. */ |
| 212 | this.labelLogoInverted = []; |
| 213 | /** @private @type {GdiBitmap} The shadow behind labels used in Details. */ |
| 214 | this.labelShadowImg = null; |
| 215 | // #endregion |
| 216 | |
| 217 | // * STATE * // |
| 218 | // #region STATE |
| 219 | /** @private @type {boolean} The state when disc art was found on hard drive used in Details. */ |
| 220 | this.discArtFound = false; |
| 221 | /** @public @type {boolean} The last.fm logo image displayed when we %lastfm_play_count% > 0, shown in the metadata grid in Details. */ |
| 222 | this.playCountVerifiedByLastFm = false; |
| 223 | /** @public @type {object} The boundary section object contains check functions for different sections of the metadata grid. */ |
| 224 | this.gridSectionBounds = { |
| 225 | artist: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridArtistTop && y <= this.gridArtistBottom, |
| 226 | title: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridTitleTop && y <= this.gridTitleBottom, |
| 227 | album: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridAlbumTop && y <= this.gridAlbumBottom, |
| 228 | tagKey: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridColumnKeyWidth) && y >= this.gridAlbumBottom && y <= this.gridColumnKeyBottom, |
| 229 | tagValue: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridColumnKeyWidth + this.gridColumnValueWidth) && y >= this.gridAlbumBottom && y <= this.gridColumnValueBottom, |
| 230 | timeline: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.timelineY - SCALE(10) && y < this.timelineY + this.timelineH + SCALE(10), |
| 231 | grid: (x, y) => x >= this.gridMarginLeft && x <= (this.gridMarginLeft + this.gridContentWidth) && y >= this.gridArtistTop && y <= this.gridColumnValueBottom |
| 232 | }; |
| 233 | /** @private @type {string} The text content of the grid tooltip. */ |
| 234 | this.gridTooltipText = ''; |
| 235 | /** @private @type {string} The text content of the grid timeline tooltip. */ |
| 236 | this.gridTimelineTooltipText = ''; |
| 237 | // #endregion |
| 238 | |
| 239 | // * TIMERS * // |
| 240 | // #region TIMERS |
| 241 | /** @public @type {number} The disc art rotation timer when disc art spins while song is playing. */ |
| 242 | this.discArtRotationTimer = null; |
| 243 | // #endregion |
| 244 | } |
| 245 | |
| 246 | // * PLUBLIC METHODS - DRAW * // |
| 247 | // #region PUBLIC METHODS - DRAW |
| 248 | /** |
| 249 | * Draws the Details panel. |
| 250 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 251 | */ |
| 252 | drawDetails(gr) { |
| 253 | this.drawBackground(gr); |
| 254 | this.drawDiscArt(gr); |
| 255 | this.drawGrid(gr); |
| 256 | this.drawBandLogo(gr); |
| 257 | this.drawLabelLogo(gr); |
| 258 | } |
| 259 | |
| 260 | /** |
| 261 | * Draws the Details background. |
| 262 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 263 | */ |
| 264 | drawBackground(gr) { |
| 265 | if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.displayDetails) { |
| 266 | return; |
| 267 | } |
| 268 | |
| 269 | gr.SetTextRenderingHint(TextRenderingHint.AntiAliasGridFit); |
| 270 | gr.SetSmoothingMode(SmoothingMode.None); |
| 271 | |
| 272 | if (grm.ui.isStreaming && grm.ui.noArtwork || !grm.ui.albumArt && grm.ui.noArtwork) { |
| 273 | gr.FillSolidRect(0, grm.ui.topMenuHeight, grm.ui.ww, grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight, grCol.detailsBg); |
| 274 | } else { |
| 275 | gr.FillSolidRect(0, grm.ui.albumArtSize.y, grSet.noDiscArtBg && !this.discArt ? grm.ui.ww : grm.ui.albumArtSize.x, grm.ui.albumArtSize.h, grCol.detailsBg); |
| 276 | } |
| 277 | |
| 278 | if (grm.ui.albumArt && grSet.styleBlend && grCol.imgBlended) { |
| 279 | gr.DrawImage(grCol.imgBlended, 0, 0, grm.ui.ww, grm.ui.wh, 0, 0, grCol.imgBlended.Width, grCol.imgBlended.Height); |
| 280 | } |
| 281 | |
| 282 | gr.SetSmoothingMode(SmoothingMode.HighQuality); |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * Draws the disc art in Details. |
| 287 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 288 | */ |
| 289 | drawDiscArt(gr) { |
| 290 | if (grSet.layout !== 'default' || !grSet.displayDiscArt || !grm.ui.displayDetails || grm.ui.noAlbumArtStub || |
| 291 | this.discArtSize.y < grm.ui.albumArtSize.y || this.discArtSize.h > grm.ui.albumArtSize.h) { |
| 292 | return; |
| 293 | } |
| 294 | |
| 295 | grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 碟片'); |
| 296 | |
| 297 | if (!this.discArtRotation) { |
| 298 | this.setDiscArtRotation(); |
| 299 | } |
| 300 | |
| 301 | const applyOpacity = !grm.ui.displayLyrics && grm.ui.albumArtSize.w < grm.ui.ww * 0.66; |
| 302 | const albumArtOpacity = applyOpacity ? grSet.detailsAlbumArtOpacity : 255; |
| 303 | |
| 304 | if (!grSet.discArtOnTop || grm.ui.displayLyrics) { |
| 305 | this.drawDiscArtImage(gr); |
| 306 | if (this.discArtRotation && grSet.detailsAlbumArtDiscAreaOpacity !== 255) { |
| 307 | const discArtOpacity = applyOpacity ? grSet.detailsAlbumArtDiscAreaOpacity : 255; |
| 308 | this.createDiscArtAlbumArtMask(gr, grm.ui.albumArtSize.x, grm.ui.albumArtSize.y, grm.ui.albumArtSize.w, grm.ui.albumArtSize.h, 0, 0, grm.ui.albumArtScaled.Width, grm.ui.albumArtScaled.Height, 0, discArtOpacity); |
| 309 | } else { |
| 310 | grm.ui.drawAlbumArt(gr, albumArtOpacity); |
| 311 | } |
| 312 | } else { // * Draw discArt on top of front cover |
| 313 | grm.ui.drawAlbumArt(gr, albumArtOpacity); |
| 314 | this.drawDiscArtImage(gr); |
| 315 | } |
| 316 | |
| 317 | grm.debug.setDebugProfile(false, 'print', '绘图 -> 碟片'); |
| 318 | } |
| 319 | |
| 320 | /** |
| 321 | * Draws the disc art image and its shadow (if applicable). |
| 322 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 323 | */ |
| 324 | drawDiscArtImage(gr) { |
| 325 | const discArtImg = this.discArtArray[this.discArtRotationIndex] || this.discArtRotation; |
| 326 | |
| 327 | if (!grSet.filterAlbumArt && grm.ui.discArtImageDisplayed || !discArtImg) { |
| 328 | return; |
| 329 | } |
| 330 | |
| 331 | if (this.discArtShadowImg.image) { |
| 332 | const shadowImg = this.discArtShadowImg.image; |
| 333 | gr.DrawImage(shadowImg, -this.discArtShadow, grm.ui.albumArtSize.y - this.discArtShadow, shadowImg.Width, shadowImg.Height, 0, 0, shadowImg.Width, shadowImg.Height); |
| 334 | } |
| 335 | |
| 336 | gr.DrawImage(discArtImg, this.discArtSize.x, this.discArtSize.y, this.discArtSize.w, this.discArtSize.h, 0, 0, discArtImg.Width, discArtImg.Height, 0); |
| 337 | } |
| 338 | |
| 339 | /** |
| 340 | * Draws the metadata grid on the left side in the Details panel. |
| 341 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 342 | */ |
| 343 | drawGrid(gr) { |
| 344 | if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.displayDetails) return; |
| 345 | |
| 346 | grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 元数据表格'); |
| 347 | |
| 348 | gr.SetSmoothingMode(SmoothingMode.HighQuality); |
| 349 | gr.SetInterpolationMode(InterpolationMode.HighQualityBicubic); |
| 350 | |
| 351 | this.setGridMetrics(gr); |
| 352 | this.gridTop = this.gridTopStart; |
| 353 | |
| 354 | if (this.gridContentWidth > 150) { |
| 355 | const spacing = Math.floor(this.gridLineSpacing * 0.33); |
| 356 | const spacing2 = Math.floor(this.gridLineSpacing * 0.5); |
| 357 | |
| 358 | // * Artist |
| 359 | if (grSet.showGridArtist_layout) { |
| 360 | this.gridTop += this.drawGridArtist(gr) + spacing; |
| 361 | } |
| 362 | |
| 363 | // * Title |
| 364 | if (grSet.showGridTitle_layout) { |
| 365 | this.gridTop += this.drawGridTitle(gr) + spacing; |
| 366 | } else if (!grSet.showGridArtist_layout) { |
| 367 | this.gridTop += this.drawGridAlbum(gr) + spacing; |
| 368 | } |
| 369 | |
| 370 | // * Timeline |
| 371 | if (grSet.showGridTimeline_layout) { |
| 372 | this.setGridTimelineSize(this.gridMarginLeft, this.gridTop + spacing, grm.ui.albumArtSize.x - this.gridMarginLeft * 2, this.timelineH); |
| 373 | this.drawGridTimeline(gr); |
| 374 | this.gridTop += this.timelineH + this.gridLineSpacing; |
| 375 | } |
| 376 | |
| 377 | // * Album |
| 378 | if (grSet.showGridArtist_layout || grSet.showGridTitle_layout) { |
| 379 | this.gridTop += this.drawGridAlbum(gr) + spacing2; |
| 380 | } |
| 381 | |
| 382 | // * Columns key and value |
| 383 | this.drawGridColumns(gr); |
| 384 | } |
| 385 | |
| 386 | gr.SetInterpolationMode(InterpolationMode.Default); |
| 387 | |
| 388 | grm.debug.setDebugProfile(false, 'print', '绘图 -> 元数据表格'); |
| 389 | } |
| 390 | |
| 391 | /** |
| 392 | * Draws the custom metadata grid menu. |
| 393 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 394 | */ |
| 395 | drawGridMenu(gr) { |
| 396 | if (!grm.ui.displayMetadataGridMenu || grSet.layout !== 'default') return; |
| 397 | |
| 398 | const x = grm.ui.albumArtSize.x - 1; |
| 399 | const y = grm.ui.topMenuHeight; |
| 400 | const width = grm.ui.ww; |
| 401 | const height = grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight; |
| 402 | |
| 403 | gr.FillSolidRect(x, y, width, height, pl.col.bg); |
| 404 | for (const c of CustomMenu.controlList) c.draw(gr); |
| 405 | |
| 406 | if (CustomMenu.activeControl && CustomMenu.activeControl instanceof CustomMenuDropDown && CustomMenu.activeControl.isSelectUp) { |
| 407 | CustomMenu.activeControl.draw(gr); |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | /** |
| 412 | * Draws the artist on the metadata grid in the Details panel. |
| 413 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 414 | * @returns {number} The height of the artist. |
| 415 | */ |
| 416 | drawGridArtist(gr) { |
| 417 | if (!grStr.artist) return 0; |
| 418 | |
| 419 | // * Apply better anti-aliasing on smaller font sizes in HD res |
| 420 | gr.SetTextRenderingHint(!RES._4K && (grSet.gridArtistFontSize_layout < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit); |
| 421 | |
| 422 | const artistColor = ['white', 'black', 'reborn', 'random'].includes(grSet.theme) ? grCol.detailsText : grSet.theme === 'cream' ? pl.col.header_artist_normal : pl.col.header_artist_playing; |
| 423 | DrawString(gr, grm.ui.getFormattedString('gridArtist'), grFont.gridArtist, artistColor, this.gridMarginLeft, Math.round(this.gridTop), this.gridContentWidth, this.gridArtistNumLinesHeight, Stringformat.Trim_Ellipsis_Char); |
| 424 | |
| 425 | // * Artist country flags |
| 426 | if (grStr.artist && grSet.showGridArtistFlags_layout) { |
| 427 | grm.ui.drawArtistCountryFlag(gr, 'metadataGrid'); |
| 428 | } |
| 429 | |
| 430 | this.gridArtistTop = this.gridTop; |
| 431 | this.gridArtistBottom = this.gridTop + this.gridArtistNumLinesHeight; |
| 432 | return this.gridArtistNumLinesHeight; |
| 433 | } |
| 434 | |
| 435 | /** |
| 436 | * Draws the track title on the metadata grid in the Details panel. |
| 437 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 438 | * @returns {number} The height of the title. |
| 439 | */ |
| 440 | drawGridTitle(gr) { |
| 441 | if (!grStr.title) return 0; |
| 442 | |
| 443 | // * Apply better anti-aliasing on smaller font sizes in HD res |
| 444 | gr.SetTextRenderingHint(!RES._4K && (grSet.gridTitleFontSize_layout < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit); |
| 445 | |
| 446 | DrawString(gr, grm.ui.getFormattedString('gridTitle'), grFont.gridTitle, grCol.detailsText, this.gridMarginLeft, Math.round(this.gridTop), this.gridContentWidth, this.gridTitleNumLinesHeight, Stringformat.Trim_Ellipsis_Char); |
| 447 | |
| 448 | this.gridTitleTop = this.gridTop; |
| 449 | this.gridTitleBottom = this.gridTop + this.gridTitleNumLinesHeight; |
| 450 | return this.gridTitleNumLinesHeight; |
| 451 | } |
| 452 | |
| 453 | /** |
| 454 | * Draws the album on the metadata grid in the Details panel. |
| 455 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 456 | * @returns {number} The height of the album. |
| 457 | */ |
| 458 | drawGridAlbum(gr) { |
| 459 | if (!grStr.album) return 0; |
| 460 | |
| 461 | // * Apply better anti-aliasing on smaller font sizes in HD res |
| 462 | gr.SetTextRenderingHint(!RES._4K && (grSet.gridAlbumFontSize_layout < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit); |
| 463 | |
| 464 | DrawString(gr, grStr.album, grFont.gridAlbum, grCol.detailsText, this.gridMarginLeft, Math.round(this.gridTop), this.gridContentWidth, this.gridAlbumNumLinesHeight, Stringformat.Trim_Ellipsis_Char); |
| 465 | |
| 466 | this.gridAlbumTop = this.gridTop; |
| 467 | this.gridAlbumBottom = this.gridTop + this.gridAlbumNumLinesHeight; |
| 468 | return this.gridAlbumNumLinesHeight; |
| 469 | } |
| 470 | |
| 471 | /** |
| 472 | * Draws the column key and column value on the metadata grid in the Details panel. |
| 473 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 474 | */ |
| 475 | drawGridColumns(gr) { |
| 476 | for (let k = 0; k < grStr.grid.length; k++) { |
| 477 | this.gridColumnKey = grStr.grid[k].label; |
| 478 | this.gridColumnValue = grStr.grid[k].val; |
| 479 | this.gridTxtRec = gr.MeasureString(this.gridColumnValue, grFont.gridVal, 0, 0, this.gridColumnValueWidth, grm.ui.wh); |
| 480 | this.gridColumnCellHeight = this.gridTxtRec.Height + 5; |
| 481 | this.gridColumnTop = this.gridTop; |
| 482 | |
| 483 | let gridShowLastFmImage = false; |
| 484 | let gridShowReleaseFlagImage = false; |
| 485 | let gridShowCodecLogoImage = false; |
| 486 | let gridShowChannelLogoImage = false; |
| 487 | let gridDropShadow = false; |
| 488 | let gridValueColor = grCol.detailsText; |
| 489 | |
| 490 | if (this.gridColumnValue.length) { |
| 491 | const columnKey = { |
| 492 | '目录': () => { |
| 493 | gridShowReleaseFlagImage = grSet.showGridReleaseFlags_layout; |
| 494 | if (grSet.showGridReleaseFlags_layout === 'logo') { |
| 495 | this.gridColumnValue = this.gridColumnValue.replace($('%releasecountry%'), ''); |
| 496 | } |
| 497 | }, |
| 498 | '发行地区': () => { |
| 499 | gridShowReleaseFlagImage = grSet.showGridReleaseFlags_layout; |
| 500 | if (grSet.showGridReleaseFlags_layout === 'logo') this.gridColumnValue = ''; |
| 501 | }, |
| 502 | '编解码': () => { |
| 503 | gridShowCodecLogoImage = grSet.showGridCodecLogo_layout; |
| 504 | this.gridColumnValue = grSet.showGridCodecLogo_layout === 'logo' ? '' : this.getCodecString(); |
| 505 | this.gridColumnCellHeight = this.gridColumnValueHeight + 5; |
| 506 | }, |
| 507 | '声道': () => { |
| 508 | gridShowChannelLogoImage = grSet.showGridChannelLogo_layout; |
| 509 | this.gridColumnValue = grSet.showGridChannelLogo_layout === 'logo' ? '' : this.getChannelString($('%channels%')); |
| 510 | this.gridColumnCellHeight = this.gridColumnValueHeight + 5; |
| 511 | }, |
| 512 | '热门': () => { |
| 513 | gridValueColor = grCol.detailsHotness; |
| 514 | gridDropShadow = true; |
| 515 | }, |
| 516 | '播放次数': () => { |
| 517 | gridShowLastFmImage = true; |
| 518 | }, |
| 519 | '评级': () => { |
| 520 | gridValueColor = grCol.detailsRating; |
| 521 | gridDropShadow = true; |
| 522 | }, |
| 523 | 'default': () => { |
| 524 | let matchCount = 0; |
| 525 | // * On small player sizes, there is no space for all metadata entries. |
| 526 | // * Hide them and only display entries from basicMeta. |
| 527 | if (this.basicMetadataDisplay(this.gridColumnKey)) { |
| 528 | this.gridColumnValue = ''; |
| 529 | this.gridColumnKey = ''; |
| 530 | matchCount++; |
| 531 | } |
| 532 | this.gridTop -= this.gridColumnCellHeight * matchCount; |
| 533 | } |
| 534 | }; |
| 535 | (columnKey[this.gridColumnKey] || columnKey.default)(); |
| 536 | |
| 537 | if (this.gridTop + this.gridTxtRec.Height < grm.ui.albumArtSize.y + grm.ui.albumArtSize.h) { |
| 538 | // * Apply better anti-aliasing on smaller font sizes in HD res |
| 539 | gr.SetTextRenderingHint(!RES._4K && (grSet.gridKeyFontSize_layout < 17 || grSet.gridValueFontSize_layout + SCALE(1) < 18 || grSet.displayScale < 100) ? TextRenderingHint.ClearTypeGridFit : TextRenderingHint.AntiAliasGridFit); |
| 540 | |
| 541 | if (gridDropShadow) { |
| 542 | const gridBorderWidth = SCALE(0.5); |
| 543 | gr.DrawString(this.gridColumnValue, grFont.gridVal, grCol.primary_rgb_s050, Math.round(this.gridColumnValueLeft + gridBorderWidth), Math.round(this.gridTop + gridBorderWidth), this.gridColumnValueWidth, this.gridColumnCellHeight, StringFormat(0, 0, 4)); |
| 544 | gr.DrawString(this.gridColumnValue, grFont.gridVal, grCol.primary_rgb_s050, Math.round(this.gridColumnValueLeft - gridBorderWidth), Math.round(this.gridTop + gridBorderWidth), this.gridColumnValueWidth, this.gridColumnCellHeight, StringFormat(0, 0, 4)); |
| 545 | gr.DrawString(this.gridColumnValue, grFont.gridVal, grCol.primary_rgb_s050, Math.round(this.gridColumnValueLeft + gridBorderWidth), Math.round(this.gridTop - gridBorderWidth), this.gridColumnValueWidth, this.gridColumnCellHeight, StringFormat(0, 0, 4)); |
| 546 | gr.DrawString(this.gridColumnValue, grFont.gridVal, grCol.primary_rgb_s050, Math.round(this.gridColumnValueLeft - gridBorderWidth), Math.round(this.gridTop - gridBorderWidth), this.gridColumnValueWidth, this.gridColumnCellHeight, StringFormat(0, 0, 4)); |
| 547 | } |
| 548 | gr.DrawString(this.gridColumnKey, grFont.gridKey, grCol.detailsText, this.gridMarginLeft, Math.round(this.gridTop), this.gridColumnKeyWidth, this.gridColumnCellHeight, Stringformat.Trim_Ellipsis_Char); |
| 549 | gr.DrawString(this.gridColumnValue, grFont.gridVal, gridValueColor, this.gridColumnValueLeft, Math.round(this.gridTop), this.gridColumnValueWidth, this.gridColumnCellHeight, StringFormat(0, 0, 4)); |
| 550 | |
| 551 | // * Release flag |
| 552 | if (gridShowReleaseFlagImage) { |
| 553 | this.drawGridReleaseFlag(gr); |
| 554 | } |
| 555 | // * Codec logo |
| 556 | if (gridShowCodecLogoImage) { |
| 557 | this.drawGridCodecLogo(gr); |
| 558 | } |
| 559 | // * Channel logo |
| 560 | if (gridShowChannelLogoImage) { |
| 561 | this.drawGridChannelLogo(gr); |
| 562 | } |
| 563 | // * Last.fm logo |
| 564 | if (gridShowLastFmImage) { |
| 565 | this.drawGridLastfmLogo(gr); |
| 566 | } |
| 567 | this.gridTop += this.gridColumnCellHeight + 5; |
| 568 | } |
| 569 | } |
| 570 | } |
| 571 | } |
| 572 | |
| 573 | /** |
| 574 | * Draws an image on the metadata grid in the Details panel. |
| 575 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 576 | * @param {GdiBitmap} image - The image to draw. |
| 577 | * @param {boolean} showLogoOnly - Whether to show only the logo. |
| 578 | * @param {number} xOffset - The offset added to x position. |
| 579 | * @param {number} yOffset - The offset added to y position. |
| 580 | * @param {number} cellHeightAdjustment - The adjustment applied to cell height. |
| 581 | */ |
| 582 | drawGridImage(gr, image, showLogoOnly, xOffset = 0, yOffset = 0, cellHeightAdjustment = 0) { |
| 583 | if (image == null) return; |
| 584 | |
| 585 | // Calculate metrics and ratios |
| 586 | const gridColumnValueMetrics = gr.MeasureString(showLogoOnly ? '' : this.gridColumnValue, grFont.gridVal, 0, 0, this.gridColumnValueWidth, grm.ui.wh); |
| 587 | const heightRatio = (gr.CalcTextHeight(showLogoOnly ? 'Ag' : this.gridColumnValue, grFont.gridVal) - cellHeightAdjustment) / image.Height; |
| 588 | const logoHeight = Math.round(image.Height * heightRatio); |
| 589 | const logoWidth = Math.round(image.Width * heightRatio); |
| 590 | |
| 591 | // Get the width of the last line |
| 592 | const newLineWidth = gr.EstimateLineWrap(this.gridColumnValue, grFont.gridVal, this.gridTxtRec.Lines === 1 ? this.gridColumnValueWidth : this.gridTxtRec.Width); |
| 593 | const lastLineIndex = newLineWidth.length - 1; |
| 594 | const lastLineWidth = newLineWidth[lastLineIndex] || gridColumnValueMetrics.Width; |
| 595 | |
| 596 | // Initial positions |
| 597 | const stringWidth = lastLineWidth + xOffset; |
| 598 | let xPos = this.gridColumnValueLeft + stringWidth; |
| 599 | let yPos = this.gridTop + yOffset; |
| 600 | |
| 601 | // Adjust positions if the logo width exceeds the grid column width and move logo to the next line |
| 602 | if (xPos + logoWidth > this.gridColumnValueLeft + this.gridColumnValueWidth) { |
| 603 | const textHeight = gr.CalcTextHeight('Ag', grFont.gridVal); |
| 604 | xPos = this.gridColumnValueLeft; |
| 605 | yPos += textHeight; |
| 606 | this.gridTxtRec = { ...this.gridTxtRec, Lines: this.gridTxtRec.Lines + 1, Height: this.gridTxtRec.Height + textHeight }; |
| 607 | this.gridColumnCellHeight = this.gridTxtRec.Height + 5; |
| 608 | } |
| 609 | |
| 610 | gr.DrawImage(image, xPos, yPos, logoWidth, logoHeight, 0, 0, image.Width, image.Height); |
| 611 | } |
| 612 | |
| 613 | /** |
| 614 | * Draws the release flag on the metadata grid in the Details panel. |
| 615 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 616 | */ |
| 617 | drawGridReleaseFlag(gr) { |
| 618 | if (this.gridReleaseFlagImg == null) return; |
| 619 | |
| 620 | const logoOnly = grSet.showGridReleaseFlags_layout === 'logo' && this.gridColumnKey === '发行地区'; |
| 621 | const lineHeight = this.gridTxtRec.Height / this.gridTxtRec.Lines; |
| 622 | const yCorr = (this.gridTxtRec.Lines - 1) * lineHeight; |
| 623 | |
| 624 | this.drawGridImage(gr, this.gridReleaseFlagImg, logoOnly, SCALE(logoOnly ? 0 : 8), logoOnly ? 0 : yCorr); |
| 625 | } |
| 626 | |
| 627 | /** |
| 628 | * Draws the codec logo on the metadata grid in the Details panel. |
| 629 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 630 | */ |
| 631 | drawGridCodecLogo(gr) { |
| 632 | if (this.gridCodecLogo == null) { |
| 633 | this.loadGridCodecLogo(); |
| 634 | if (this.gridCodecLogo == null) return; |
| 635 | } |
| 636 | |
| 637 | const logoOnly = grSet.showGridCodecLogo_layout === 'logo'; |
| 638 | this.drawGridImage(gr, this.gridCodecLogo, logoOnly, SCALE(logoOnly ? 0 : 8), logoOnly ? -1 : 2); |
| 639 | } |
| 640 | |
| 641 | /** |
| 642 | * Draws the channel logo on the metadata grid in the Details panel. |
| 643 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 644 | */ |
| 645 | drawGridChannelLogo(gr) { |
| 646 | if (this.gridChannelLogo == null) { |
| 647 | this.loadGridChannelLogo(); |
| 648 | if (this.gridChannelLogo == null) return; |
| 649 | } |
| 650 | |
| 651 | const logoOnly = grSet.showGridChannelLogo_layout === 'logo'; |
| 652 | this.drawGridImage(gr, this.gridChannelLogo, logoOnly, SCALE(logoOnly ? 0 : 8), logoOnly ? -1 : 2); |
| 653 | } |
| 654 | |
| 655 | /** |
| 656 | * Draws the last.fm logo on the metadata grid in the Details panel. |
| 657 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 658 | */ |
| 659 | drawGridLastfmLogo(gr) { |
| 660 | if (!this.playCountVerifiedByLastFm) return; |
| 661 | |
| 662 | const lastFmImg = gdi.Image(grPath.lastFmImageRed); |
| 663 | const lastFmWhiteImg = gdi.Image(grPath.lastFmImageWhite); |
| 664 | const lastFmLogo = grCol.lightBgDetails ? lastFmImg : lastFmWhiteImg; |
| 665 | const lineHeight = this.gridTxtRec.Height / this.gridTxtRec.Lines; |
| 666 | const yCorr = (this.gridTxtRec.Lines - 1) * lineHeight; |
| 667 | |
| 668 | this.drawGridImage(gr, lastFmLogo, false, SCALE(8), yCorr, 6); |
| 669 | } |
| 670 | |
| 671 | /** |
| 672 | * Draws the band logo on the bottom left side in the Details panel. |
| 673 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 674 | */ |
| 675 | drawBandLogo(gr) { |
| 676 | if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.albumArt || grSet.layout !== 'default' || !grm.ui.displayDetails || |
| 677 | grSet.lyricsLayout === 'full' && grm.ui.displayLyrics) { |
| 678 | return; |
| 679 | } |
| 680 | |
| 681 | grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 艺术家标识'); |
| 682 | |
| 683 | const availableSpace = grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - this.gridTop; |
| 684 | const logo = grCol.lightBgDetails || grm.ui.noAlbumArtStub ? (this.bandLogoInverted || this.bandLogo) : this.bandLogo; |
| 685 | |
| 686 | if (logo && availableSpace > 75) { |
| 687 | let logoWidth = Math.min(HD_4K(logo.Width / 2, logo.Width), grm.ui.albumArtSize.x - grm.ui.ww * 0.05); |
| 688 | const heightScale = Math.min(logoWidth / logo.Width, availableSpace / logo.Height); |
| 689 | logoWidth = logo.Width * heightScale; // Adjust logoWidth after heightScale is potentially updated |
| 690 | |
| 691 | const logoX = Math.round(grm.ui.isStreaming ? SCALE(40) : grm.ui.albumArtSize.x / 2 - logoWidth / 2); |
| 692 | const logoY = Math.round(grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - (logo.Height * heightScale)) - HD_4K(4, 24); |
| 693 | const logoW = Math.round(logoWidth); |
| 694 | const logoH = Math.round(logo.Height * heightScale); |
| 695 | |
| 696 | gr.DrawImage(logo, logoX, logoY, logoW, logoH, 0, 0, logo.Width, logo.Height, 0); |
| 697 | } |
| 698 | |
| 699 | grm.debug.setDebugProfile(false, 'print', '绘图 -> 艺术家标识'); |
| 700 | } |
| 701 | |
| 702 | /** |
| 703 | * Draws the label logo on the bottom right side in the Details panel. |
| 704 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 705 | */ |
| 706 | drawLabelLogo(gr) { |
| 707 | if (!fb.IsPlaying && !grSet.panelBrowseMode || !grm.ui.albumArt || grSet.layout !== 'default' || !grm.ui.displayDetails || |
| 708 | grSet.lyricsLayout === 'full' && grm.ui.displayLyrics) { |
| 709 | return; |
| 710 | } |
| 711 | |
| 712 | grm.debug.setDebugProfile(grm.debug.showDrawExtendedTiming, 'create', '绘图 -> 唱片公司标识'); |
| 713 | |
| 714 | if (this.labelLogo.length > 0) { |
| 715 | const lightBg = grSet.labelArtOnBg ? grCol.lightBgMain : grCol.lightBgDetails; |
| 716 | const labels = lightBg || grm.ui.noAlbumArtStub ? (this.labelLogoInverted.length ? this.labelLogoInverted : this.labelLogo) : this.labelLogo; |
| 717 | const rightSideGap = 20; // How close last label is to right edge |
| 718 | const leftEdgeGap = (grm.ui.albumArtOffCenter ? 20 : 40) * HD_4K(1, 1.8); // Space between art and label |
| 719 | const leftEdgeWidth = HD_4K(30, 45); // How far label background extends on left |
| 720 | const maxLabelWidth = SCALE(200); |
| 721 | let leftEdge = 0; |
| 722 | let topEdge = 0; |
| 723 | let totalLabelWidth = 0; |
| 724 | let labelAreaWidth = 0; |
| 725 | let labelSpacing = 0; |
| 726 | let labelWidth; |
| 727 | let labelHeight; |
| 728 | |
| 729 | for (const label of labels) { |
| 730 | if (label.Width > maxLabelWidth) { |
| 731 | totalLabelWidth += maxLabelWidth; |
| 732 | } else { |
| 733 | totalLabelWidth += RES._4K && label.Width < 200 ? label.Width * 2 : label.Width; |
| 734 | } |
| 735 | } |
| 736 | if (!this.cachedLabelLastLeftEdge) { // We don't want to recalculate this every screen refresh |
| 737 | grm.debug.debugLog('图标 => 重新计算最后左边缘'); |
| 738 | this.shadowImgLabel = null; |
| 739 | labelWidth = Math.round(totalLabelWidth / labels.length); |
| 740 | labelHeight = Math.round(labels[0].Height * labelWidth / labels[0].Width); // Might be recalc'd below |
| 741 | if (grm.ui.albumArt) { |
| 742 | if (this.discArt && grSet.displayDiscArt) { |
| 743 | leftEdge = Math.round(Math.max(grm.ui.albumArtSize.x + grm.ui.albumArtScaled.Width + 5, grm.ui.ww * 0.975 - totalLabelWidth + 1)); |
| 744 | const discCenter = {}; |
| 745 | discCenter.x = Math.round(this.discArtSize.x + this.discArtSize.w / 2); |
| 746 | discCenter.y = Math.round(this.discArtSize.y + this.discArtSize.h / 2); |
| 747 | const radius = discCenter.y - this.discArtSize.y; |
| 748 | const radiusSquared = radius * radius; |
| 749 | let posValid = false; |
| 750 | |
| 751 | while (!posValid) { |
| 752 | const allLabelsWidth = Math.max(Math.min(Math.round((grm.ui.ww - leftEdge - rightSideGap) / labels.length), maxLabelWidth), 50); |
| 753 | //console.log("leftEdge = " + leftEdge + ", grm.ui.ww-leftEdge-10 = " + (grm.ui.ww-leftEdge-10) + ", allLabelsWidth=" + allLabelsWidth); |
| 754 | const maxWidth = RES._4K && labels[0].Width < 200 ? labels[0].Width * 2 : labels[0].Width; |
| 755 | labelWidth = (allLabelsWidth > maxWidth) ? maxWidth : allLabelsWidth; |
| 756 | labelHeight = Math.round(labels[0].Height * labelWidth / labels[0].Width); // Width is based on height scale |
| 757 | topEdge = Math.round(grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - labelHeight); |
| 758 | |
| 759 | const a = topEdge - discCenter.y + 1; // Adding 1 to a and b so that the border just touches the edge of the discArt |
| 760 | const b = leftEdge - discCenter.x + 1; |
| 761 | |
| 762 | if ((a * a + b * b) > radiusSquared) { |
| 763 | posValid = true; |
| 764 | } else { |
| 765 | leftEdge += 4; |
| 766 | } |
| 767 | } |
| 768 | } else { |
| 769 | leftEdge = Math.round(Math.max(grm.ui.albumArtSize.x + grm.ui.albumArtSize.w + leftEdgeWidth + leftEdgeGap, grm.ui.ww * 0.975 - totalLabelWidth + 1)); |
| 770 | } |
| 771 | } else { |
| 772 | leftEdge = Math.round(grm.ui.ww * 0.975 - totalLabelWidth); |
| 773 | } |
| 774 | labelAreaWidth = grm.ui.ww - leftEdge - rightSideGap; |
| 775 | this.cachedLabelLastLeftEdge = leftEdge; |
| 776 | this.cachedLabelLastHeight = labelHeight; |
| 777 | } |
| 778 | else { // Already calculated |
| 779 | leftEdge = this.cachedLabelLastLeftEdge; |
| 780 | labelHeight = this.cachedLabelLastHeight; |
| 781 | labelAreaWidth = grm.ui.ww - leftEdge - rightSideGap; |
| 782 | } |
| 783 | if (labelAreaWidth >= SCALE(50)) { |
| 784 | if (labels.length > 1) { |
| 785 | labelSpacing = Math.min(12, Math.max(3, Math.round((labelAreaWidth / (labels.length - 1)) * 0.048))); // Spacing should be proportional, and between 3 and 12 pixels |
| 786 | } |
| 787 | // console.log('labelAreaWidth = ' + labelAreaWidth + ", labelSpacing = " + labelSpacing); |
| 788 | const allLabelsWidth = Math.max(Math.min(Math.round((labelAreaWidth - (labelSpacing * (labels.length - 1))) / labels.length), maxLabelWidth), 50); // allLabelsWidth must be between 50 and 200 pixels wide |
| 789 | const origLabelHeight = labelHeight; |
| 790 | let labelX = leftEdge; |
| 791 | topEdge = grm.ui.albumArtSize.y + grm.ui.albumArtSize.h - labelHeight - 20; |
| 792 | |
| 793 | if (!grSet.labelArtOnBg && !grSet.noDiscArtBg || grSet.noDiscArtBg && grSet.displayDiscArt && this.discArt) { |
| 794 | if (!['black', 'nblue', 'ngreen', 'nred', 'ngold'].includes(grSet.theme)) { |
| 795 | if (!this.shadowImgLabel) { |
| 796 | this.shadowImgLabel = ShadowRect(this.discArtShadow, this.discArtShadow, grm.ui.ww - labelX + leftEdgeWidth, labelHeight + 40, this.discArtShadow, grCol.shadow); |
| 797 | } |
| 798 | gr.DrawImage(this.shadowImgLabel, labelX - leftEdgeWidth - this.discArtShadow, topEdge - 20 - this.discArtShadow, grm.ui.ww - labelX + leftEdgeWidth + 2 * this.discArtShadow, labelHeight + 40 + 2 * this.discArtShadow, |
| 799 | 0, 0, this.shadowImgLabel.Width, this.shadowImgLabel.Height); |
| 800 | } |
| 801 | gr.SetSmoothingMode(SmoothingMode.None); // Disable smoothing |
| 802 | gr.FillSolidRect(labelX - leftEdgeWidth, topEdge - 20, grm.ui.ww - labelX + leftEdgeWidth, labelHeight + 40, grCol.detailsBg); |
| 803 | gr.DrawRect(labelX - leftEdgeWidth, topEdge - 20, grm.ui.ww - labelX + leftEdgeWidth, labelHeight + 40 - 1, 1, grCol.shadow); |
| 804 | gr.SetSmoothingMode(SmoothingMode.HighQuality); |
| 805 | } |
| 806 | for (let i = 0; i < labels.length; i++) { |
| 807 | // allLabelsWidth can never be greater than 200, so if a label image is 161 pixels wide, never draw it wider than 161 |
| 808 | const maxWidth = RES._4K && labels[i].Width < 200 ? labels[i].Width * 2 : labels[i].Width; |
| 809 | labelWidth = (allLabelsWidth > maxWidth) ? maxWidth : allLabelsWidth; |
| 810 | labelHeight = Math.round(labels[i].Height * labelWidth / labels[i].Width); // Width is based on height scale |
| 811 | |
| 812 | gr.DrawImage(labels[i], labelX, Math.round(topEdge + origLabelHeight / 2 - labelHeight / 2), labelWidth, labelHeight, 0, 0, this.labelLogo[i].Width, this.labelLogo[i].Height); |
| 813 | labelX += labelWidth + labelSpacing; |
| 814 | } |
| 815 | } |
| 816 | } |
| 817 | |
| 818 | grm.debug.setDebugProfile(false, 'print', '绘图 -> 唱片公司标识'); |
| 819 | } |
| 820 | // #endregion |
| 821 | |
| 822 | // * PLUBLIC METHODS - METRICS * // |
| 823 | // #region PUBLIC METHODS - METRICS |
| 824 | /** |
| 825 | * Sets the metadata grid metrics and caches all calculated values. |
| 826 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 827 | */ |
| 828 | setGridMetrics(gr) { |
| 829 | if (this.cachedGridMetrics) return; |
| 830 | |
| 831 | const metricsPromises = [ |
| 832 | new Promise((resolve) => this.setGridMainMetrics(gr, resolve)), |
| 833 | new Promise((resolve) => this.setGridTextMetrics(gr, resolve)) |
| 834 | ]; |
| 835 | |
| 836 | Promise.all(metricsPromises).then(() => { |
| 837 | this.cachedGridMetrics = this.gridColumnValueBottom > this.gridColumnTop && !grm.display.hasPlayerSizeChanged(); |
| 838 | }); |
| 839 | } |
| 840 | |
| 841 | /** |
| 842 | * Sets the metadata grid main sizes. |
| 843 | * This includes calculating margins, content width, and column dimensions. |
| 844 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 845 | * @param {Function} metricsCalculated - The callback function to be executed after calculations are finished. |
| 846 | */ |
| 847 | setGridMainMetrics(gr, metricsCalculated) { |
| 848 | this.discArtShadow = SCALE(6); |
| 849 | this.gridTooltipHeight = SCALE(100); |
| 850 | this.timelineH = SCALE(8); |
| 851 | |
| 852 | this.gridMarginLeft = grm.ui.edgeMargin; |
| 853 | this.gridTopStart = grm.ui.albumArtSize.y ? grm.ui.albumArtSize.y + grm.ui.edgeMargin : grm.ui.topMenuHeight + grm.ui.edgeMargin; |
| 854 | this.gridTop = this.gridTopStart; |
| 855 | this.gridContentWidth = Math.floor((!grm.ui.albumArt && this.discArt ? this.discArtSize.x : grm.ui.albumArtSize.x) - grm.ui.edgeMargin * 1.5); |
| 856 | |
| 857 | this.gridColumnKeyWidth = CalcGridMaxTextWidth(gr, grStr.grid, grFont.gridKey); |
| 858 | this.gridColumnKeyHeight = gr.MeasureString('Ag', grFont.gridKey, 0, 0, this.gridContentWidth, grm.ui.wh).Height; |
| 859 | this.gridColumnKeyBottom = this.gridColumnTop + this.gridColumnKeyHeight; |
| 860 | |
| 861 | this.gridColumnValueWidth = this.gridContentWidth - this.gridColumnMargin - this.gridColumnKeyWidth + SCALE(5); |
| 862 | this.gridColumnValueHeight = gr.MeasureString('Ag', grFont.gridVal, 0, 0, this.gridContentWidth, grm.ui.wh).Height; |
| 863 | this.gridColumnValueLeft = this.gridMarginLeft + this.gridColumnKeyWidth + this.gridColumnMargin; |
| 864 | this.gridColumnValueBottom = this.gridColumnTop + this.gridColumnValueHeight; |
| 865 | |
| 866 | metricsCalculated(); |
| 867 | } |
| 868 | |
| 869 | /** |
| 870 | * Sets the metadata grid text sizes. |
| 871 | * This includes calculating wrap information and dimensions for artist, title, album, and other text elements based on the grid configuration. |
| 872 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 873 | * @param {Function} metricsCalculated - The callback function to be executed after calculations are finished. |
| 874 | */ |
| 875 | setGridTextMetrics(gr, metricsCalculated) { |
| 876 | if (grSet.showGridArtist_layout) { |
| 877 | this.gridFlagSizeW = grm.ui.getFlagSizeWidth('metadataGrid'); |
| 878 | this.gridFlagSizeWhiteSpace = grm.ui.getFlagSizeWhiteSpace('metadataGrid'); |
| 879 | this.gridArtistWrapInfo = CalcWrapSpace(gr, grStr.artist, grFont.gridArtist, this.gridContentWidth, this.cachedGridWrapSpace); |
| 880 | this.gridArtistWrapLinesExceed = this.gridArtistWrapInfo.lineCount > 2; |
| 881 | this.gridArtistWrapWidth = this.gridArtistWrapInfo.totalWrapSpace - this.gridFlagSizeW; |
| 882 | this.gridArtistWidth = Math.ceil(gr.MeasureString(grStr.artist, grFont.gridArtist, 0, 0, 0, 0, Stringformat.Trim_Ellipsis_Char | Stringformat.Measure_Trailing_Spaces).Width + this.gridFlagSizeW + this.gridArtistWrapWidth); |
| 883 | this.gridArtistHeight = gr.MeasureString(grStr.artist, grFont.gridArtist, 0, 0, 0, 0).Height; |
| 884 | this.gridArtistTxtRec = gr.MeasureString(grStr.artist, grFont.gridArtist, 0, 0, grSet.showGridArtistFlags_layout && grm.ui.flagImgs.length ? this.gridContentWidth - this.gridFlagSizeW : this.gridContentWidth, grm.ui.wh); |
| 885 | this.gridArtistNumLines = Math.min(2, this.gridArtistTxtRec.Lines); |
| 886 | this.gridArtistNumLinesHeight = gr.CalcTextHeight(grStr.artist, grFont.gridArtist) * this.gridArtistNumLines; |
| 887 | } |
| 888 | if (grSet.showGridTitle_layout) { |
| 889 | this.gridTrackNumWidth = Math.ceil(gr.MeasureString(grStr.tracknum, grFont.gridTrackNumber, 0, 0, 0, 0).Width); |
| 890 | this.gridTitleWrapInfo = CalcWrapSpace(gr, grm.ui.getFormattedString('gridTitle'), grFont.gridTitle, this.gridContentWidth, this.cachedGridWrapSpace); |
| 891 | this.gridTitleWrapLinesExceed = this.gridTitleWrapInfo.lineCount > 2; |
| 892 | this.gridTitleWrapWidth = this.gridTitleWrapInfo.totalWrapSpace; |
| 893 | this.gridTitleWidth = Math.ceil(gr.MeasureString(grStr.title, grFont.gridTitle, 0, 0, 0, 0, Stringformat.Trim_Ellipsis_Char | Stringformat.Measure_Trailing_Spaces).Width + this.gridTrackNumWidth + this.gridTrackNumSpacing + this.gridTitleWrapWidth); |
| 894 | this.gridTitleHeight = gr.MeasureString(grStr.title, grFont.gridTitle, 0, 0, 0, 0).Height; |
| 895 | this.gridTitleTxtRec = gr.MeasureString(grm.ui.getFormattedString('gridTitle'), grFont.gridTitle, 0, 0, this.gridContentWidth, grm.ui.wh); |
| 896 | this.gridTitleNumLines = Math.min(2, this.gridTitleTxtRec.Lines); |
| 897 | this.gridTitleNumLinesHeight = gr.CalcTextHeight(grStr.title, grFont.gridTitle) * this.gridTitleNumLines; |
| 898 | } |
| 899 | this.gridAlbumWrapInfo = CalcWrapSpace(gr, grStr.album, grFont.gridAlbum, this.gridContentWidth, this.cachedGridWrapSpace); |
| 900 | this.gridAlbumWrapLinesExceed = this.gridAlbumWrapInfo.lineCount > (grSet.showGridArtist_layout || grSet.showGridTitle_layout ? 2 : 3); |
| 901 | this.gridAlbumWrapWidth = this.gridAlbumWrapInfo.totalWrapSpace; |
| 902 | this.gridAlbumWidth = Math.ceil(gr.MeasureString(grStr.album, grFont.gridAlbum, 0, 0, 0, 0, Stringformat.Trim_Ellipsis_Char | Stringformat.Measure_Trailing_Spaces).Width) + this.gridAlbumWrapWidth; |
| 903 | this.gridAlbumHeight = gr.MeasureString(grStr.album, grFont.gridAlbum, 0, 0, 0, 0).Height; |
| 904 | this.gridAlbumTxtRec = gr.MeasureString(grStr.album, grFont.gridAlbum, 0, 0, this.gridContentWidth, grm.ui.wh); |
| 905 | this.gridAlbumNumLines = Math.min(grSet.showGridArtist_layout || grSet.showGridTitle_layout ? 2 : 3, this.gridAlbumTxtRec.Lines); |
| 906 | this.gridAlbumNumLinesHeight = gr.CalcTextHeight(grStr.album, grFont.gridAlbum) * this.gridAlbumNumLines; |
| 907 | |
| 908 | metricsCalculated(); |
| 909 | } |
| 910 | // #endregion |
| 911 | |
| 912 | // * PUBLIC METHODS - COMMON * // |
| 913 | // #region PUBLIC METHODS - COMMON |
| 914 | /** |
| 915 | * Clears individual cache properties, the specified cache type, or all caches. |
| 916 | * @param {string} [type] - The type of cache to clear. Can be 'metrics', 'discArt', 'codecLogo', 'channelLogo', 'bandLogo', 'labelLogo'. If not provided, all caches will be cleared. |
| 917 | * @param {string} [property] - The specific property to clear within the cache type. Applicable only if `type` is provided. |
| 918 | * @param {boolean} [clearArtCache] - Whether to clear everything in the artCache object. |
| 919 | * @param {boolean} [keepDiscArt] - Whether to keep the disc art. This is considered only when `type` is 'discArt' or not provided (clearing all caches). |
| 920 | * @example |
| 921 | * // Clear an individual property within a specific cache type |
| 922 | * clearCache('metrics', 'cachedGridMetrics'); |
| 923 | * @example |
| 924 | * // Clear a specific cache type |
| 925 | * clearCache('metrics'); |
| 926 | * @example |
| 927 | * // Clear all caches |
| 928 | * clearCache(); |
| 929 | * @example |
| 930 | * // Clear all caches and the artCache |
| 931 | * clearCache(undefined, undefined, true); |
| 932 | */ |
| 933 | clearCache(type, property, clearArtCache, keepDiscArt) { |
| 934 | const cacheActions = { |
| 935 | metrics: () => { |
| 936 | this.cachedGridWrapSpace = {}; |
| 937 | this.cachedGridMetrics = false; |
| 938 | this.cachedLabelLastLeftEdge = 0; |
| 939 | this.cachedLabelLastHeight = 0; |
| 940 | }, |
| 941 | discArt: () => { |
| 942 | this.discArt = keepDiscArt ? this.discArt : null; |
| 943 | this.discArtCover = null; |
| 944 | this.discArtArray = []; |
| 945 | this.discArtRotation = null; |
| 946 | }, |
| 947 | codecLogo: () => { |
| 948 | this.gridCodecLogo = null; |
| 949 | }, |
| 950 | channelLogo: () => { |
| 951 | this.gridChannelLogo = null; |
| 952 | }, |
| 953 | bandLogo: () => { |
| 954 | this.bandLogo = null; |
| 955 | this.bandLogoInverted = null; |
| 956 | }, |
| 957 | labelLogo: () => { |
| 958 | this.labelLogo = []; |
| 959 | this.labelLogoInverted = []; |
| 960 | } |
| 961 | }; |
| 962 | |
| 963 | if (clearArtCache) { |
| 964 | grm.artCache && grm.artCache.clear(); |
| 965 | grm.debug.debugLog('详情缓存 => 已清除图片缓存'); |
| 966 | } |
| 967 | |
| 968 | if (type) { |
| 969 | // * Clear individual cache property |
| 970 | if (property && Object.hasOwnProperty.call(this, property)) { |
| 971 | this[property] = null; |
| 972 | grm.debug.debugLog(`详情缓存 => 已清除缓存类型 "${type}" 中的属性 "${property}"`); |
| 973 | } |
| 974 | // * Clear specific cache type |
| 975 | else if (cacheActions[type]) { |
| 976 | cacheActions[type](); |
| 977 | grm.debug.debugLog(`详情缓存 => 已清除缓存类型 "${type}"`); |
| 978 | } |
| 979 | return; |
| 980 | } |
| 981 | |
| 982 | // * Clear all caches |
| 983 | for (const action in cacheActions) { |
| 984 | cacheActions[action](); |
| 985 | } |
| 986 | grm.debug.debugLog('详情缓存 => 已清除全部缓存'); |
| 987 | } |
| 988 | |
| 989 | /** |
| 990 | * Clears timers based on the timer type. |
| 991 | * @param {string} [type] - The type of timer to clear. If not provided, all timers will be cleared. |
| 992 | * - 'discArt'. |
| 993 | */ |
| 994 | clearTimer(type) { |
| 995 | const timers = { |
| 996 | discArt: { |
| 997 | timer: this.discArtRotationTimer, |
| 998 | clear: clearInterval, |
| 999 | log: '计时器 => 碟片旋转计时器已清除' |
| 1000 | } |
| 1001 | }; |
| 1002 | |
| 1003 | const clearTimerByType = (type) => { |
| 1004 | const { timer, clear, log } = timers[type]; |
| 1005 | if (timer) { |
| 1006 | clear(timer); |
| 1007 | timers[type].timer = null; |
| 1008 | } |
| 1009 | grm.debug.debugLog(log); |
| 1010 | }; |
| 1011 | |
| 1012 | if (type && timers[type]) { |
| 1013 | clearTimerByType(type); |
| 1014 | } else { |
| 1015 | for (const key in timers) { |
| 1016 | clearTimerByType(key); |
| 1017 | } |
| 1018 | } |
| 1019 | } |
| 1020 | // #endregion |
| 1021 | |
| 1022 | // * PUBLIC METHODS - METADATA GRID * // |
| 1023 | // #region PUBLIC METHODS - METADATA GRID |
| 1024 | /** |
| 1025 | * Initializes the metadata grid menu and toggles its open/close state. |
| 1026 | */ |
| 1027 | initGridMenuState() { |
| 1028 | if (grSet.layout !== 'default') { |
| 1029 | const msg = grm.msg.getMessage('main', 'metadataGridLiveEdit'); |
| 1030 | fb.ShowPopupMessage(msg, '元数据表格实时编辑'); |
| 1031 | return; |
| 1032 | } |
| 1033 | |
| 1034 | grm.ui.displayMetadataGridMenu = !grm.ui.displayMetadataGridMenu; |
| 1035 | grm.ui.displayCustomThemeMenu = false; |
| 1036 | |
| 1037 | if (grm.ui.displayMetadataGridMenu) { |
| 1038 | if (!grm.ui.displayDetails) { |
| 1039 | grm.ui.displayDetails = true; |
| 1040 | grm.ui.displayPlaylist = false; |
| 1041 | grm.ui.displayLibrary = false; |
| 1042 | grm.ui.displayBiography = false; |
| 1043 | grm.ui.resizeArtwork(true); |
| 1044 | } |
| 1045 | |
| 1046 | grm.gridMenu.initMetadataGridMenu(1); |
| 1047 | } |
| 1048 | |
| 1049 | grm.button.initButtonState(); |
| 1050 | window.Repaint(); |
| 1051 | } |
| 1052 | |
| 1053 | /** |
| 1054 | * Determines whether basic metadata should be displayed based on the grid column width. |
| 1055 | * @param {string} gridColumnKey - The grid column key. |
| 1056 | * @returns {boolean} True if basic metadata should be displayed, otherwise false. |
| 1057 | */ |
| 1058 | basicMetadataDisplay(gridColumnKey) { |
| 1059 | const resolutions = [ |
| 1060 | { displayRes: 'HD', maxW: 1250, maxH: 800 }, |
| 1061 | { displayRes: 'QHD', maxW: 1350, maxH: 900 }, |
| 1062 | { displayRes: '4K', maxW: 2350, maxH: 1550 } |
| 1063 | ]; |
| 1064 | |
| 1065 | const basicMeta = ['年份', '唱片公司', '流派', '编解码', '声道', '来源', '数据', '播放次数', '评级']; |
| 1066 | const smallRes = resolutions.some(res => grSet.displayRes === res.displayRes && (grm.ui.ww < res.maxW || grm.ui.wh < res.maxH)); |
| 1067 | |
| 1068 | return grSet.autoHideGridMetadata && grSet.layout === 'default' && smallRes && !basicMeta.includes(gridColumnKey); |
| 1069 | } |
| 1070 | |
| 1071 | /** |
| 1072 | * 获取编解码器字符串,如果编解码器是DTS,则返回'DCA'. |
| 1073 | * @returns {string} 编解码器字符串或'DCA' 如果编解码器是 DTS. |
| 1074 | */ |
| 1075 | getCodecString() { |
| 1076 | const codec = $('$lower($if2(%codec%,$ext(%path%)))'); |
| 1077 | if (['dts', 'dca (dts coherent acoustics)'].includes(codec)) { |
| 1078 | return 'DCA'; // 如果编解码是DTS,则仅显示DCA缩写 |
| 1079 | } |
| 1080 | return codec; |
| 1081 | } |
| 1082 | |
| 1083 | /** |
| 1084 | * Gets the channel string based on the provided channel type. |
| 1085 | * @param {string} channelType - The type of the channel (e.g., 'mono', 'stereo'). |
| 1086 | * @returns {string} The channel string or an empty string if the channel type is not found. |
| 1087 | */ |
| 1088 | getChannelString(channelType) { |
| 1089 | const channelMapping = { |
| 1090 | 'mono': { number: 1, string: '单声道' }, |
| 1091 | '单声道': { number: 1, string: '单声道' }, |
| 1092 | 'stereo': { number: 2, string: '立体声' }, |
| 1093 | '立体声': { number: 2, string: '立体声' }, |
| 1094 | '3ch': { number: 3, string: '中置' }, |
| 1095 | '3 声道': { number: 3, string: '中置' }, |
| 1096 | '4ch': { number: 4, string: '四声道' }, |
| 1097 | '4 声道': { number: 4, string: '四声道' }, |
| 1098 | '5ch': { number: 5, string: '环绕' }, |
| 1099 | '5 声道': { number: 5, string: '环绕' }, |
| 1100 | '6ch': { number: 6, string: '环绕' }, |
| 1101 | '6 声道': { number: 6, string: '环绕' }, |
| 1102 | '7ch': { number: 7, string: '环绕' }, |
| 1103 | '7 声道': { number: 7, string: '环绕' }, |
| 1104 | '8ch': { number: 8, string: '环绕' }, |
| 1105 | '8 声道': { number: 8, string: '环绕' }, |
| 1106 | '10ch': { number: 10, string: '环绕' }, |
| 1107 | '10 声道':{ number: 10, string: '环绕' }, |
| 1108 | '12ch': { number: 12, string: '环绕' }, |
| 1109 | '12 声道':{ number: 12, string: '环绕' } |
| 1110 | }; |
| 1111 | |
| 1112 | const channel = channelMapping[channelType]; |
| 1113 | if (!channel) return ''; |
| 1114 | |
| 1115 | if (grSet.showGridChannelLogo_layout === 'textlogo') { |
| 1116 | return channel.string; |
| 1117 | } else if (grSet.showGridChannelLogo_layout === false) { |
| 1118 | return `${channel.number} ${Unicode.MiddleDot} ${channel.string}`; |
| 1119 | } else { |
| 1120 | return ''; |
| 1121 | } |
| 1122 | } |
| 1123 | |
| 1124 | /** |
| 1125 | * Gets the grid tooltip string based on the specified type. |
| 1126 | * @param {string} type - The type of metadata ('artist', 'title', 'album'). |
| 1127 | * @returns {string} The tooltip string. |
| 1128 | */ |
| 1129 | getGridTooltip(type) { |
| 1130 | const tooltipType = { |
| 1131 | artist: grStr.artist, |
| 1132 | title: `${grStr.tracknum} ${grStr.title} ${grStr.composer}`, |
| 1133 | album: `${grStr.album} ${grStr.composer}` |
| 1134 | }; |
| 1135 | return tooltipType[type]; |
| 1136 | } |
| 1137 | |
| 1138 | /** |
| 1139 | * Handles the grid tooltip. If a tooltip is ready, it displays and then clears it. |
| 1140 | * @param {number} x - The x-coordinate. |
| 1141 | * @param {number} y - The y-coordinate. |
| 1142 | */ |
| 1143 | handleGridTooltip(x, y) { |
| 1144 | const artistTooltipRange = this.mouseInMetadataGrid(x, y, 'artist'); |
| 1145 | const titleTooltipRange = this.mouseInMetadataGrid(x, y, 'title'); |
| 1146 | const albumTooltipRange = this.mouseInMetadataGrid(x, y, 'album'); |
| 1147 | |
| 1148 | if (!artistTooltipRange && !titleTooltipRange && !albumTooltipRange) return; |
| 1149 | |
| 1150 | const showArtistToolTip = artistTooltipRange && grSet.showGridArtist_layout && ( |
| 1151 | this.gridArtistWidth > this.gridContentWidth * 2 |
| 1152 | || |
| 1153 | this.gridArtistWrapLinesExceed |
| 1154 | ); |
| 1155 | |
| 1156 | const showTitleToolTip = titleTooltipRange && grSet.showGridTitle_layout && ( |
| 1157 | this.gridTitleWidth > this.gridContentWidth * 2 |
| 1158 | || |
| 1159 | this.gridTitleWrapLinesExceed |
| 1160 | ); |
| 1161 | |
| 1162 | const showAlbumToolTip = albumTooltipRange && ( |
| 1163 | !grSet.showGridArtist_layout && !grSet.showGridTitle_layout && (this.gridAlbumWidth > this.gridContentWidth * 3) |
| 1164 | || |
| 1165 | (grSet.showGridArtist_layout || grSet.showGridTitle_layout) && (this.gridAlbumWidth > this.gridContentWidth * 2) |
| 1166 | || |
| 1167 | this.gridAlbumWrapLinesExceed |
| 1168 | ); |
| 1169 | |
| 1170 | const tooltip = |
| 1171 | showArtistToolTip ? this.getGridTooltip('artist') : |
| 1172 | showTitleToolTip ? this.getGridTooltip('title') : |
| 1173 | showAlbumToolTip ? this.getGridTooltip('album') : ''; |
| 1174 | |
| 1175 | if (tooltip.length) { // * Display tooltip |
| 1176 | const offset = SCALE(30); |
| 1177 | this.gridTooltipText = tooltip; |
| 1178 | grm.ttip.showDelayed(this.gridTooltipText); |
| 1179 | grm.ui.repaintStyledTooltips(grm.ui.styledToolTipX - offset * 2, grm.ui.styledToolTipY - offset, grm.ui.styledToolTipW + offset * 4, grm.ui.styledToolTipH + offset * 2); |
| 1180 | } else { // * Clear tooltip |
| 1181 | this.gridTooltipText = ''; |
| 1182 | grm.ttip.stop(); |
| 1183 | window.Repaint(); |
| 1184 | } |
| 1185 | } |
| 1186 | |
| 1187 | /** |
| 1188 | * Loads the codec logo of the now playing track, displayed in the metadata grid in Details. |
| 1189 | * @param {FbMetadbHandle} metadb - The metadb of the track. |
| 1190 | */ |
| 1191 | loadGridCodecLogo(metadb = grm.ui.initMetadb()) { |
| 1192 | let codec = $('$lower($if2(%codec%,$ext(%path%)))', metadb); |
| 1193 | let format = $('$lower($ext(%path%))', metadb); |
| 1194 | |
| 1195 | // Foobar bug showing wrong metadata when DTS is in wav file format |
| 1196 | if (codec === 'pcm' && (format === 'cue' || format === 'wav')) { |
| 1197 | codec = $('$lower($if2(%codec%,$ext(%path%)))'); |
| 1198 | format = $('$lower($ext(%path%))'); |
| 1199 | } |
| 1200 | |
| 1201 | const codecFormat = { |
| 1202 | 'aac': 'aac', 'aac acm codec': 'aac', 'mp4': 'aac', |
| 1203 | 'ac3': 'ac3', 'atsc a/52': 'ac3', 'e-ac3': 'ac3', 'atsc a/52a (ac-3)': 'ac3', |
| 1204 | 'aiff': 'pcm-aiff', |
| 1205 | 'alac': 'alac', |
| 1206 | 'alaw': 'alaw', 'ccitt a-law': 'alaw', |
| 1207 | 'amr': 'amr', |
| 1208 | 'ape': 'ape', 'monkey\'s audio': 'ape', |
| 1209 | 'caf': 'caf', |
| 1210 | 'dsd': format === 'iso' ? 'dsd-sacd' : 'dsd', |
| 1211 | 'dst': 'dsd-sacd', |
| 1212 | 'dts': 'dts', 'dca (dts coherent acoustics)': 'dts', |
| 1213 | 'dxd': format === 'iso' ? 'dsd-sacd' : 'dxd', |
| 1214 | 'flac': 'flac', |
| 1215 | 'gsm': 'gsm', 'gsm 6.10': 'gsm', |
| 1216 | 'imaadpcm': 'imaadpcm', 'ima adpcm': 'imaadpcm', |
| 1217 | 'la': 'la', |
| 1218 | 'mid': 'mid', |
| 1219 | 'mlp': 'mlp', |
| 1220 | 'mod': 'mod', |
| 1221 | 'mp2': 'mp2', |
| 1222 | 'mp3': 'mp3', 'mpeg layer-3': 'mp3', |
| 1223 | 'mpc': 'musepack', 'musepack': 'musepack', |
| 1224 | 'msadpcm': 'msadpcm', 'microsoft adpcm': 'msadpcm', |
| 1225 | 'ofr': 'ofr', 'optimfrog': 'ofr', |
| 1226 | 'ogg': 'ogg', 'vorbis': 'ogg', |
| 1227 | 'opus': 'opus', |
| 1228 | 'pcm': format === 'aiff' ? 'pcm-aiff' : ['w64', 'wav'].includes(format) ? 'pcm-wav' : 'pcm', |
| 1229 | 'qoa': 'qoa', |
| 1230 | 'shn': 'shn', 'shorten': 'shn', |
| 1231 | 'spx': 'spx', 'speex': 'spx', |
| 1232 | 'tak': 'tak', |
| 1233 | 'tta': 'tta', 'true audio': 'tta', |
| 1234 | 'ulaw': 'ulaw', 'ccitt u-law': 'ulaw', |
| 1235 | 'usac': 'usac', |
| 1236 | 'wav': 'pcm-wav', |
| 1237 | 'w64': 'pcm-wav', |
| 1238 | 'wma': 'wma', |
| 1239 | 'wv': 'wavpack', 'wavpack': 'wavpack' |
| 1240 | }; |
| 1241 | |
| 1242 | let logoName; |
| 1243 | const HDCD = $('%__hdcd%') === 'yes'; |
| 1244 | const codecName = codecFormat[codec] || codecFormat[format]; |
| 1245 | |
| 1246 | if (codec.startsWith('dsd')) { |
| 1247 | logoName = codecFormat.dsd; |
| 1248 | } else if (codec.startsWith('dxd')) { |
| 1249 | logoName = codecFormat.dxd; |
| 1250 | } else if (codec.startsWith('dst')) { |
| 1251 | logoName = codecFormat.dst; |
| 1252 | } else { |
| 1253 | logoName = HDCD && codecName === 'pcm-wav' ? 'pcm-hdcd' : HDCD ? `${codecName}-hdcd` : codecName; |
| 1254 | } |
| 1255 | |
| 1256 | const bw = grCol.lightBgDetails ? 'black' : 'white'; |
| 1257 | const path = `${grPath.images}codec\\${logoName}-${bw}.png`; |
| 1258 | |
| 1259 | this.gridCodecLogo = gdi.Image(path); |
| 1260 | } |
| 1261 | |
| 1262 | /** |
| 1263 | * Loads the channel logo of the now playing track, displayed in the metadata grid in Details. |
| 1264 | * @param {FbMetadbHandle} metadb - The metadb of the track. |
| 1265 | */ |
| 1266 | loadGridChannelLogo(metadb = grm.ui.initMetadb()) { |
| 1267 | const codec = $('$lower($if2(%codec%,$ext(%path%)))', metadb); |
| 1268 | const format = $('$lower($ext(%path%))', metadb); |
| 1269 | |
| 1270 | // Foobar bug showing wrong metadata when DTS is in wav file format |
| 1271 | const channels = codec === 'pcm' && (format === 'cue' || format === 'wav') ? $('%channels%') : $('%channels%', metadb); |
| 1272 | |
| 1273 | const type = |
| 1274 | (grSet.layout === 'default' && grSet.showGridChannelLogo_default === 'textlogo' || |
| 1275 | grSet.layout === 'artwork' && grSet.showGridChannelLogo_artwork === 'textlogo') ? '_text' : ''; |
| 1276 | |
| 1277 | const bw = grCol.lightBgDetails ? 'black' : 'white'; |
| 1278 | |
| 1279 | const channelFormat = { |
| 1280 | 'mono': '10_mono', |
| 1281 | '单声道': '10_mono', |
| 1282 | 'stereo': '20_stereo', |
| 1283 | '立体声': '20_stereo', |
| 1284 | '3ch': '30_center', |
| 1285 | '3 声道': '30_center', |
| 1286 | '4ch': '40_quad', |
| 1287 | '4 声道': '40_quad', |
| 1288 | '5ch': '50_surround', |
| 1289 | '5 声道': '50_surround', |
| 1290 | '6ch': '51_surround', |
| 1291 | '6 声道': '51_surround', |
| 1292 | '7ch': '61_surround', |
| 1293 | '7 声道': '61_surround', |
| 1294 | '8ch': '71_surround', |
| 1295 | '8 声道': '71_surround', |
| 1296 | '10ch': '91_surround', |
| 1297 | '10 声道':'91_surround', |
| 1298 | '12ch': '111_surround', |
| 1299 | '12 声道':'111_surround' |
| 1300 | }; |
| 1301 | |
| 1302 | const channelName = channelFormat[channels]; |
| 1303 | const channelLogoPath = (channelName) => `${grPath.images}channels\\${channelName}${type}-${bw}.png`; |
| 1304 | if (channelName) this.gridChannelLogo = gdi.Image(channelLogoPath(channelName)); |
| 1305 | } |
| 1306 | |
| 1307 | /** |
| 1308 | * Loads the release country flags, displayed in the metadata grid in Details. |
| 1309 | * @param {FbMetadbHandle} metadb - The metadb of the track. |
| 1310 | */ |
| 1311 | loadGridReleaseCountryFlag(metadb = undefined) { |
| 1312 | if (!grSet.showGridReleaseFlags_layout) return; |
| 1313 | this.gridReleaseFlagImg = grm.ui.loadFlagImage($(grTF.releaseCountry, metadb)); |
| 1314 | } |
| 1315 | |
| 1316 | /** |
| 1317 | * Updates the metadata grid in Details, reuses last value for last played unless provided one. |
| 1318 | * @param {string} currentLastPlayed - The current value of the "Last Played" metadata field. |
| 1319 | * @param {string} currentPlayingPlaylist - The current active playlist that is being played from. |
| 1320 | * @param {FbMetadbHandle} metadb - The metadb of the track. |
| 1321 | * @returns {Array|null} The updated metadata grid, which is an array of objects with properties `label`, `val` and `age`. |
| 1322 | */ |
| 1323 | updateGrid(currentLastPlayed, currentPlayingPlaylist, metadb = undefined) { |
| 1324 | if (!grCfg.metadataGrid) return null; |
| 1325 | |
| 1326 | currentLastPlayed = (grStr && grStr.grid ? grStr.grid.find(value => value.label === '最近播放') || {} : {}).val; |
| 1327 | grStr.grid = []; |
| 1328 | |
| 1329 | for (const key of grCfg.metadataGrid) { |
| 1330 | let val = $(key.val, metadb); |
| 1331 | if (val && key.label) { |
| 1332 | if (key.age) { |
| 1333 | val = $(`$date(${val})`, metadb); // Never show time |
| 1334 | const age = CalcAgeDateString(val); |
| 1335 | if (age) val += ` (${age})`; |
| 1336 | } |
| 1337 | grStr.grid.push({ |
| 1338 | age: key.age, |
| 1339 | label: key.label, |
| 1340 | val |
| 1341 | }); |
| 1342 | } |
| 1343 | } |
| 1344 | if (typeof currentLastPlayed !== 'undefined') { |
| 1345 | const lp = grStr.grid.find(value => value.label === '最近播放'); |
| 1346 | if (lp) { |
| 1347 | lp.val = $Date(currentLastPlayed); |
| 1348 | if (CalcAgeDateString(lp.val)) { |
| 1349 | lp.val += ` (${CalcAgeDateString(lp.val)})`; |
| 1350 | } |
| 1351 | } |
| 1352 | } |
| 1353 | if (typeof currentPlayingPlaylist !== 'undefined') { |
| 1354 | const pl = grStr.grid.find(value => value.label === '播放列表'); |
| 1355 | if (pl) { |
| 1356 | pl.val = currentPlayingPlaylist; |
| 1357 | } |
| 1358 | } |
| 1359 | |
| 1360 | return grStr.grid; |
| 1361 | } |
| 1362 | |
| 1363 | /** |
| 1364 | * Updates the metadata grid codec and channel logo in Details. |
| 1365 | * This method is primarily used to refresh the colors of the logos. |
| 1366 | */ |
| 1367 | updateGridLogos() { |
| 1368 | this.clearCache('codecLogo'); |
| 1369 | this.clearCache('channelLogo'); |
| 1370 | } |
| 1371 | |
| 1372 | /** |
| 1373 | * Updates the metadata grid positions in Details. |
| 1374 | * This method is primarily used to refresh the coordinates for mouseInMetadataGrid. |
| 1375 | */ |
| 1376 | updateGridPos() { |
| 1377 | this.gridTop = 0; |
| 1378 | this.gridArtistTop = 0; |
| 1379 | this.gridArtistBottom = 0; |
| 1380 | this.gridTitleTop = 0; |
| 1381 | this.gridTitleBottom = 0; |
| 1382 | this.gridAlbumTop = 0; |
| 1383 | this.gridAlbumBottom = 0; |
| 1384 | } |
| 1385 | // #endregion |
| 1386 | |
| 1387 | // * PUBLIC METHODS - METADATA GRID TIMELINE * // |
| 1388 | // #region PUBLIC METHODS - METADATA GRID TIMELINE |
| 1389 | /** |
| 1390 | * Draws the timeline above the metadata grid in Details. |
| 1391 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 1392 | */ |
| 1393 | drawGridTimeline(gr) { |
| 1394 | gr.SetSmoothingMode(SmoothingMode.None); // Disable smoothing |
| 1395 | gr.FillSolidRect(this.gridMarginLeft, this.timelineY, this.timelineDrawWidth + this.timelineExtraLeftSpace + this.timelineLineWidth, this.timelineH, grCol.timelineAdded); |
| 1396 | |
| 1397 | if (grSet.theme.startsWith('custom')) { |
| 1398 | gr.DrawRect(this.timelineX - 2, this.timelineY - 2, this.timelineW + 3, this.timelineH + 3, 1, grCol.timelineFrame); |
| 1399 | } |
| 1400 | |
| 1401 | if (this.timelineFirstPlayedPercent >= 0 && this.timelineLastPlayedPercent >= 0) { |
| 1402 | const x1 = Math.floor(this.timelineDrawWidth * this.timelineFirstPlayedPercent) + this.timelineExtraLeftSpace; |
| 1403 | const x2 = Math.floor(this.timelineDrawWidth * this.timelineLastPlayedPercent) + this.timelineExtraLeftSpace; |
| 1404 | gr.FillSolidRect(x1 + this.gridMarginLeft, this.timelineY, this.timelineDrawWidth - x1 + this.timelineExtraLeftSpace, this.timelineH, grCol.timelinePlayed); |
| 1405 | gr.FillSolidRect(x2 + this.gridMarginLeft, this.timelineY, this.timelineDrawWidth - x2 + this.timelineExtraLeftSpace + this.timelineLineWidth, this.timelineH, grCol.timelineUnplayed); |
| 1406 | } |
| 1407 | |
| 1408 | for (let i = 0; i < this.timelinePlayedTimesPercents.length; i++) { |
| 1409 | const x = Math.floor(this.timelineDrawWidth * this.timelinePlayedTimesPercents[i]) + this.gridMarginLeft + this.timelineExtraLeftSpace; |
| 1410 | if (!Number.isNaN(x) && x <= this.timelineW + this.gridMarginLeft * 2) { |
| 1411 | const linePos = Math.max(this.gridMarginLeft, Math.min(x, x)); |
| 1412 | gr.DrawLine(linePos, this.timelineY, linePos, this.timelineY + this.timelineH, this.timelineLineWidth, this.timelinePlayCol); |
| 1413 | } else { |
| 1414 | // console.log('Played Times Error! ratio: ' + this.playedTimesPercents[i], 'x: ' + x); |
| 1415 | } |
| 1416 | } |
| 1417 | |
| 1418 | gr.SetSmoothingMode(SmoothingMode.AntiAlias); |
| 1419 | } |
| 1420 | |
| 1421 | /** |
| 1422 | * Handles the grid timeline tooltip. If a tooltip is ready, it displays and then clears it. |
| 1423 | * @param {number} x - The x-coordinate. |
| 1424 | * @param {number} y - The y-coordinate. |
| 1425 | */ |
| 1426 | handleGridTimelineTooltip(x, y) { |
| 1427 | if (!this.mouseInMetadataGrid(x, y, 'timeline') || !grSet.showGridTimeline_layout || this.timelinePlayedTimesPercents.length === 0) { |
| 1428 | return; |
| 1429 | } |
| 1430 | |
| 1431 | let tooltip = ''; |
| 1432 | const percent = ToFixed((x + this.timelineX - this.gridMarginLeft * 2 - this.timelineExtraLeftSpace) / this.timelineDrawWidth, 3); |
| 1433 | const timezoneOffset = UpdateTimezoneOffset(); |
| 1434 | |
| 1435 | for (let i = 0; i < this.timelinePlayedTimesPercents.length; i++) { |
| 1436 | if (Math.abs(percent - this.timelinePlayedTimesPercents[i]) <= this.timelineLeeway) { |
| 1437 | const date = new Date(this.timelinePlayedTimes[i]); |
| 1438 | tooltip += tooltip.length ? '\n' : ''; |
| 1439 | tooltip += date.toLocaleString(); |
| 1440 | } |
| 1441 | else if (percent < this.timelinePlayedTimesPercents[i]) { |
| 1442 | if (!tooltip.length) { |
| 1443 | const added = i === 0 ? DateDiff($Date('[%added%]'), this.timelinePlayedTimes[0], timezoneOffset) : DateDiff(new Date(this.timelinePlayedTimes[i - 1]).toISOString(), this.timelinePlayedTimes[i], timezoneOffset); |
| 1444 | tooltip = added ? (i === 0 ? `添加 ${added} 后首次播放` : `已 ${added} 无播放`) : ''; |
| 1445 | } |
| 1446 | break; |
| 1447 | } |
| 1448 | } |
| 1449 | |
| 1450 | if (tooltip.length) { |
| 1451 | this.gridTimelineTooltipText = tooltip; |
| 1452 | grm.ttip.showImmediate(tooltip); |
| 1453 | window.RepaintRect(this.timelineX, this.timelineY, this.timelineW, this.timelineH); |
| 1454 | } else { |
| 1455 | this.gridTimelineTooltipText = ''; |
| 1456 | grm.ttip.stop(); |
| 1457 | window.Repaint(); |
| 1458 | } |
| 1459 | } |
| 1460 | |
| 1461 | /** |
| 1462 | * Sets the width and position of the timeline. |
| 1463 | * @param {number} x - The x-coordinate. |
| 1464 | * @param {number} y - The y-coordinate. |
| 1465 | * @param {number} width - The width of the timeline. |
| 1466 | * @param {number} height - The height of the timeline. |
| 1467 | */ |
| 1468 | setGridTimelineSize(x, y, width, height) { |
| 1469 | if (this.timelineX === x && this.timelineY === y && this.timelineW === width) { |
| 1470 | return; |
| 1471 | } |
| 1472 | |
| 1473 | this.timelineX = x; |
| 1474 | this.timelineY = y; |
| 1475 | this.timelineW = width; |
| 1476 | this.timelineH = height; |
| 1477 | |
| 1478 | this.timelineLineWidth = HD_4K(2, 3); |
| 1479 | this.timelineExtraLeftSpace = SCALE(3); // Add a little space to the left so songs that were played a long time ago show more in the "added" stage |
| 1480 | this.timelineDrawWidth = Math.floor(this.timelineW - this.timelineExtraLeftSpace - 1 - this.timelineLineWidth / 2); |
| 1481 | this.timelineLeeway = (1 / this.timelineDrawWidth) * (this.timelineLineWidth + SCALE(2)) / 2; |
| 1482 | } |
| 1483 | |
| 1484 | /** |
| 1485 | * Sets the first and last played percentages, as well as the played time ratios and values. |
| 1486 | * @param {number} firstPlayed - The percentage of the total play time that represents the first time the item was played. |
| 1487 | * @param {number} lastPlayed - The percentage of the total play time that represents the last time the item was played. |
| 1488 | * @param {number} playedTimeRatios - The percentage of time played for each playedTimesValues. |
| 1489 | * @param {number} playedTimesValues - Contains the actual played times for each interval. |
| 1490 | * For example, if the intervals are divided into 5 parts, playedTimesValues would be an |
| 1491 | * array of 5 numbers representing the played times for each interval. |
| 1492 | */ |
| 1493 | setGridTimelinePlayTimes(firstPlayed, lastPlayed, playedTimeRatios, playedTimesValues) { |
| 1494 | this.timelineFirstPlayedPercent = firstPlayed; |
| 1495 | this.timelineLastPlayedPercent = lastPlayed; |
| 1496 | this.timelinePlayedTimesPercents = playedTimeRatios; |
| 1497 | this.timelinePlayedTimes = playedTimesValues; |
| 1498 | } |
| 1499 | |
| 1500 | /** |
| 1501 | * Sets date ratios based on various time-related properties of a music track. |
| 1502 | * @param {boolean} dontUpdateLastPlayed - Whether the last played date should be updated or not. |
| 1503 | * @param {string} currentLastPlayed - The current value of the last played time. |
| 1504 | * @param {FbMetadbHandle} metadb - The metadb of the track. |
| 1505 | */ |
| 1506 | setGridTimelineDateRatios(dontUpdateLastPlayed = false, currentLastPlayed, metadb = undefined) { |
| 1507 | const newDate = new Date(); |
| 1508 | const timezoneOffset = UpdateTimezoneOffset(); |
| 1509 | |
| 1510 | let ratio; |
| 1511 | let lfmPlayedTimesJsonLast = ''; |
| 1512 | let playedTimesJsonLast = ''; |
| 1513 | let playedTimesRatios = []; |
| 1514 | let lfmPlayedTimes = []; |
| 1515 | let playedTimes = []; |
| 1516 | |
| 1517 | let added = ToTime($('$if2(%added_enhanced%,%added%)', metadb), timezoneOffset); |
| 1518 | let lastPlayed = ToTime($('$if2(%last_played_enhanced%,%last_played%)', metadb), timezoneOffset); |
| 1519 | const firstPlayed = ToTime($('$if2(%first_played_enhanced%,%first_played%)', metadb), timezoneOffset); |
| 1520 | const today = DateToYMD(newDate); |
| 1521 | |
| 1522 | if (dontUpdateLastPlayed && $Date(lastPlayed) === today) { |
| 1523 | lastPlayed = ToTime(currentLastPlayed, timezoneOffset); |
| 1524 | } |
| 1525 | |
| 1526 | if (Component.EnhancedPlaycount) { |
| 1527 | const playedTimesJson = $('[%played_times_js%]', metadb); |
| 1528 | const lastfmJson = $('[%lastfm_played_times_js%]', metadb); |
| 1529 | const log = ''; // ! Don't need this crap to flood the console // playedTimesJson === playedTimesJsonLast && lastfmJson === lfmPlayedTimesJsonLast ? false : grCfg.settings.showDebugLog; |
| 1530 | lfmPlayedTimesJsonLast = lastfmJson; |
| 1531 | playedTimesJsonLast = playedTimesJson; |
| 1532 | lfmPlayedTimes = ParseJson(lastfmJson, 'lastfm: ', log); |
| 1533 | playedTimes = ParseJson(playedTimesJson, 'foobar: ', log); |
| 1534 | } |
| 1535 | else { |
| 1536 | playedTimes.push(firstPlayed); |
| 1537 | playedTimes.push(lastPlayed); |
| 1538 | } |
| 1539 | |
| 1540 | if (firstPlayed) { |
| 1541 | if (!added) { |
| 1542 | added = firstPlayed; |
| 1543 | } |
| 1544 | const age = CalcAge(added); |
| 1545 | |
| 1546 | this.timelineFirstPlayedRatio = CalcAgeRatio(firstPlayed, age); |
| 1547 | this.timelineLastPlayedRatio = CalcAgeRatio(lastPlayed, age); |
| 1548 | if (this.timelineLastPlayedRatio < this.timelineFirstPlayedRatio) { |
| 1549 | // Due to daylight savings time, if there's a single play before the time changed lastPlayed could be < firstPlayed |
| 1550 | this.timelineLastPlayedRatio = this.timelineFirstPlayedRatio; |
| 1551 | } |
| 1552 | |
| 1553 | if (playedTimes.length) { |
| 1554 | for (let i = 0; i < playedTimes.length; i++) { |
| 1555 | ratio = CalcAgeRatio(playedTimes[i], age); |
| 1556 | playedTimesRatios.push(ratio); |
| 1557 | } |
| 1558 | } else { |
| 1559 | playedTimesRatios = [this.timelineFirstPlayedRatio, this.timelineLastPlayedRatio]; |
| 1560 | playedTimes = [firstPlayed, lastPlayed]; |
| 1561 | } |
| 1562 | |
| 1563 | let j = 0; |
| 1564 | const tempPlayedTimesRatios = playedTimesRatios.slice(); |
| 1565 | tempPlayedTimesRatios.push(1.0001); // Pick up every last.fm time after lastPlayed fb knows about |
| 1566 | for (let i = 0; i < tempPlayedTimesRatios.length; i++) { |
| 1567 | while (j < lfmPlayedTimes.length && (ratio = CalcAgeRatio(lfmPlayedTimes[j], age)) < tempPlayedTimesRatios[i]) { |
| 1568 | playedTimesRatios.push(ratio); |
| 1569 | playedTimes.push(lfmPlayedTimes[j]); |
| 1570 | j++; |
| 1571 | } |
| 1572 | if (ratio === tempPlayedTimesRatios[i]) { // Skip one instance |
| 1573 | // console.log('skipped -->', ratio); |
| 1574 | j++; |
| 1575 | } |
| 1576 | } |
| 1577 | playedTimesRatios.sort((a, b) => a - b); |
| 1578 | playedTimes.sort((a, b) => a - b); |
| 1579 | |
| 1580 | this.timelineFirstPlayedRatio = playedTimesRatios[0]; |
| 1581 | this.timelineLastPlayedRatio = playedTimesRatios[Math.max(0, playedTimesRatios.length - (dontUpdateLastPlayed ? 2 : 1))]; |
| 1582 | } |
| 1583 | else { |
| 1584 | this.timelineFirstPlayedRatio = 0.33; |
| 1585 | this.timelineLastPlayedRatio = 0.66; |
| 1586 | } |
| 1587 | this.setGridTimelinePlayTimes(this.timelineFirstPlayedRatio, this.timelineLastPlayedRatio, playedTimesRatios, playedTimes); |
| 1588 | } |
| 1589 | |
| 1590 | /** |
| 1591 | * Updates the timeline by setting the sizes, colors, and last played dates. |
| 1592 | * @param {boolean} updateLastPlayed - Whether to update the last played date. |
| 1593 | * @param {FbMetadbHandle} metadb - The metadb of the track. |
| 1594 | */ |
| 1595 | updateGridTimeline(updateLastPlayed, metadb) { |
| 1596 | this.setGridTimelineSize(this.gridMarginLeft, this.gridTop + Math.floor(this.gridLineSpacing * 0.33), grm.ui.albumArtSize.x - this.gridMarginLeft * 2, this.timelineH); |
| 1597 | |
| 1598 | if (!updateLastPlayed) return; |
| 1599 | |
| 1600 | const lastPlayed = $(grTF.last_played, metadb); |
| 1601 | this.setGridTimelineDateRatios($Date(grm.ui.currentLastPlayed) !== $Date(lastPlayed), grm.ui.currentLastPlayed, metadb); |
| 1602 | |
| 1603 | if (lastPlayed.length) { |
| 1604 | const today = DateToYMD(new Date()); |
| 1605 | if (!grm.ui.currentLastPlayed.length || $Date(lastPlayed) !== today) { |
| 1606 | grm.ui.currentLastPlayed = lastPlayed; |
| 1607 | } |
| 1608 | } |
| 1609 | } |
| 1610 | // #endregion |
| 1611 | |
| 1612 | // * PUBLIC METHODS - DISC ART * // |
| 1613 | // #region PUBLIC METHODS - DISC ART |
| 1614 | /** |
| 1615 | * Creates and masks an image to the disc art. |
| 1616 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 1617 | * @param {number} x - The X-coordinate of the disc area. |
| 1618 | * @param {number} y - The Y-coordinate of the disc area. |
| 1619 | * @param {number} w - The width of the mask. |
| 1620 | * @param {number} h - The height of the mask. |
| 1621 | * @param {number} srcX - The X-coordinate of the source image. |
| 1622 | * @param {number} srcY - The Y-coordinate of the source image. |
| 1623 | * @param {number} srcW - The width of the source image. |
| 1624 | * @param {number} srcH - The height of the source image. |
| 1625 | * @param {number} [angle] - The angle of the mask in degrees. Default 0. |
| 1626 | * @param {number} [alpha] - The alpha of the mask. Values 0-255. |
| 1627 | * @returns {GdiGraphics} The rounded masked image. |
| 1628 | */ |
| 1629 | createDiscArtAlbumArtMask(gr, x, y, w, h, srcX, srcY, srcW, srcH, angle, alpha) { |
| 1630 | if (w < 1 || h < 1) return null; |
| 1631 | |
| 1632 | // * First draw album art in the background |
| 1633 | gr.DrawImage(grm.ui.albumArtScaled, x, y, w, h, 0, 0, w, h, 0, alpha); |
| 1634 | |
| 1635 | // * Mask |
| 1636 | const maskImg = gdi.CreateImage(w, h); |
| 1637 | let g = maskImg.GetGraphics(); |
| 1638 | g.FillEllipse(this.discArtSize.x - grm.ui.albumArtSize.x + this.discArtShadow - SCALE(4), this.discArtSize.y - grm.ui.albumArtSize.y + SCALE(2), |
| 1639 | this.discArtSize.w - this.discArtShadow + SCALE(4), this.discArtSize.h - this.discArtShadow + SCALE(2), 0xffffffff); |
| 1640 | maskImg.ReleaseGraphics(g); |
| 1641 | |
| 1642 | // * Album art |
| 1643 | const albumArtImg = gdi.CreateImage(w, h); |
| 1644 | g = albumArtImg.GetGraphics(); |
| 1645 | g.DrawImage(grm.ui.albumArtScaled, 0, 0, w, h, 0, 0, grm.ui.albumArtScaled.Width, grm.ui.albumArtScaled.Height); |
| 1646 | albumArtImg.ReleaseGraphics(g); |
| 1647 | |
| 1648 | const mask = maskImg.Resize(w, h); |
| 1649 | albumArtImg.ApplyMask(mask); |
| 1650 | |
| 1651 | return gr.DrawImage(albumArtImg, x, y, w, h, 0, 0, w, h, 0, 255); |
| 1652 | } |
| 1653 | |
| 1654 | /** |
| 1655 | * Creates the album cover mask for the disc art stub. |
| 1656 | * @param {GdiBitmap} img - The image to apply the mask to. |
| 1657 | * @param {number} w - The width of the mask. |
| 1658 | * @param {number} h - The height of the mask. |
| 1659 | */ |
| 1660 | createDiscArtCoverMask(img, w, h) { |
| 1661 | const { w: discArtW, h: discArtH } = this.discArtSize; |
| 1662 | const lineW = SCALE(25); |
| 1663 | |
| 1664 | const outerRingX = lineW * 0.5; |
| 1665 | const outerRingY = lineW * 0.5; |
| 1666 | const outerRingW = discArtW - lineW; |
| 1667 | const outerRingH = discArtH - lineW; |
| 1668 | |
| 1669 | const innerRingSize = discArtH * 0.666 + lineW * 0.5; |
| 1670 | const innerCenterX = discArtW * 0.5; |
| 1671 | const innerCenterY = discArtH * 0.5; |
| 1672 | const innerRadiusX = discArtW * 0.5 - innerRingSize * 0.5; |
| 1673 | const innerRadiusY = discArtH * 0.5 - innerRingSize * 0.5; |
| 1674 | |
| 1675 | const innerRingX = innerCenterX - innerRadiusX; |
| 1676 | const innerRingY = innerCenterY - innerRadiusY; |
| 1677 | const innerRingW = innerRadiusX * 2; |
| 1678 | const innerRingH = innerRadiusY * 2; |
| 1679 | |
| 1680 | const mask = GDI(discArtW, discArtH, true, g => { |
| 1681 | g.SetSmoothingMode(SmoothingMode.AntiAlias); |
| 1682 | g.FillSolidRect(0, 0, discArtW, discArtH, RGB(255, 255, 255)); |
| 1683 | g.FillEllipse(outerRingX, outerRingY, outerRingW, outerRingH, RGB(0, 0, 0)); // Outer ring |
| 1684 | g.FillEllipse(innerRingX, innerRingY, innerRingW, innerRingH, RGB(255, 255, 255)); // Inner ring |
| 1685 | }); |
| 1686 | |
| 1687 | img.ApplyMask(mask.Resize(w, h)); |
| 1688 | } |
| 1689 | |
| 1690 | /** |
| 1691 | * Combines disc art with album cover art if conditions are met. |
| 1692 | * @param {boolean} applyMask - Whether to apply the disc art cover mask or not. |
| 1693 | * @returns {GdiBitmap} The combined image. |
| 1694 | */ |
| 1695 | combineDiscArtWithCover(applyMask) { |
| 1696 | if (['cdAlbumCover', 'vinylAlbumCover'].includes(grSet.discArtStub) && |
| 1697 | (!this.discArtFound && (!grSet.noDiscArtStub || grSet.showDiscArtStub)) && |
| 1698 | this.discArtCover && this.discArtCover.Width > 0 && this.discArtCover.Height > 0) { |
| 1699 | if (applyMask) { |
| 1700 | this.createDiscArtCoverMask(this.discArtCover, this.discArtCover.Width, this.discArtCover.Height); |
| 1701 | } |
| 1702 | return CombineImages(this.discArt, this.discArtCover, this.discArtSize.w, this.discArtSize.h); |
| 1703 | } |
| 1704 | return this.discArt; |
| 1705 | } |
| 1706 | |
| 1707 | /** |
| 1708 | * Disposes the disc art image when changing or deactivating disc art. |
| 1709 | * @param {GdiBitmap} discArtImg - The loaded disc art image. |
| 1710 | */ |
| 1711 | disposeDiscArt(discArtImg) { |
| 1712 | this.discArtSize = new ImageSize(0, 0, 0, 0); |
| 1713 | discArtImg = null; |
| 1714 | } |
| 1715 | |
| 1716 | /** |
| 1717 | * Fetches new disc art when a new album is being played. |
| 1718 | */ |
| 1719 | fetchDiscArt() { |
| 1720 | if (!grm.ui.displayDetails) { |
| 1721 | this.clearCache('discArt'); |
| 1722 | return; |
| 1723 | } |
| 1724 | |
| 1725 | grm.debug.setDebugProfile(grm.debug.showDebugTiming || grCfg.settings.showDebugPerformanceOverlay, 'create', '获取碟片'); |
| 1726 | |
| 1727 | if (grSet.displayDiscArt && !grm.ui.isStreaming) { |
| 1728 | this.loadDiscArt(this.findDiscArtPath()); |
| 1729 | } |
| 1730 | |
| 1731 | grm.debug.setDebugProfile(false, 'print', '获取碟片'); |
| 1732 | } |
| 1733 | |
| 1734 | /** |
| 1735 | * Finds the path to the disc art or disc art stub. |
| 1736 | * @returns {string} The path to the disc art or disc art stub. |
| 1737 | */ |
| 1738 | findDiscArtPath() { |
| 1739 | const discArtImagePaths = grPath.discArtImagePaths(); |
| 1740 | const discArtStubPaths = grPath.discArtStubPaths(); |
| 1741 | |
| 1742 | if (grSet.noDiscArtStub || grSet.showDiscArtStub) { |
| 1743 | for (const path of discArtImagePaths) { |
| 1744 | if (IsFile(path)) { |
| 1745 | this.discArtFound = true; |
| 1746 | return path; |
| 1747 | } |
| 1748 | } |
| 1749 | } |
| 1750 | |
| 1751 | this.discArtFound = false; |
| 1752 | |
| 1753 | return grSet.noDiscArtStub ? '' : discArtStubPaths[grSet.discArtStub] || grPath.discArtCustomStub; |
| 1754 | } |
| 1755 | |
| 1756 | /** |
| 1757 | * Initializes the disc art when the Details panel is opened or closed. |
| 1758 | */ |
| 1759 | initDiscArt() { |
| 1760 | if (!grm.ui.displayDetails) { |
| 1761 | this.clearCache('discArt'); |
| 1762 | this.clearTimer('discArt'); |
| 1763 | return; |
| 1764 | } |
| 1765 | |
| 1766 | if (!this.discArtCover && grm.ui.albumArtList.length) { |
| 1767 | const artIndex = grm.ui.albumArtList[grm.ui.albumArtIndex]; |
| 1768 | if (artIndex && grm.artCache) { |
| 1769 | this.discArtCover = grm.artCache.getImage(artIndex, 2) || |
| 1770 | (grm.ui.albumArt && grm.artCache.encache(grm.ui.albumArt, artIndex, 2)); |
| 1771 | } |
| 1772 | } |
| 1773 | |
| 1774 | if (grSet.displayDiscArt && !grm.ui.isStreaming) { |
| 1775 | if (this.discArt) { |
| 1776 | this.updateDiscArt(); |
| 1777 | } else { |
| 1778 | this.fetchDiscArt(); |
| 1779 | } |
| 1780 | } |
| 1781 | } |
| 1782 | |
| 1783 | initDiscArtStub() { |
| 1784 | if (!grSet.displayDiscArt || grSet.noDiscArtStub) return; |
| 1785 | |
| 1786 | const stubPath = grPath.discArtStubPaths()[grSet.discArtStub] || grPath.discArtCustomStub; |
| 1787 | if (!stubPath || grm.artCache.getImage(stubPath)) return; // already cached |
| 1788 | |
| 1789 | gdi.LoadImageAsyncV2(window.ID, stubPath).then(img => { |
| 1790 | if (img) grm.artCache.encache(img, stubPath); |
| 1791 | }); |
| 1792 | } |
| 1793 | |
| 1794 | /** |
| 1795 | * Loads the disc art from the given path. |
| 1796 | * @param {string} discArtPath - The path to the disc art. |
| 1797 | */ |
| 1798 | loadDiscArt(discArtPath) { |
| 1799 | const tempDiscArt = grm.ui.albumArtFromCache ? grm.artCache.getImage(discArtPath) : null; |
| 1800 | |
| 1801 | if (tempDiscArt) { |
| 1802 | this.disposeDiscArt(this.discArt); |
| 1803 | this.discArt = tempDiscArt; |
| 1804 | if (grm.ui.displayDetails) this.updateDiscArt(); |
| 1805 | return; |
| 1806 | } |
| 1807 | |
| 1808 | gdi.LoadImageAsyncV2(window.ID, discArtPath).then(discArtImg => { |
| 1809 | this.disposeDiscArt(this.discArt); // Delay disposal so we don't get flashing |
| 1810 | this.discArt = grm.artCache.encache(discArtImg, discArtPath); |
| 1811 | |
| 1812 | if (!this.discArt && !grSet.noDiscArtStub) { |
| 1813 | grm.ui.handleArtworkError('discArt'); |
| 1814 | } else { |
| 1815 | this.updateDiscArt(); |
| 1816 | } |
| 1817 | |
| 1818 | this.clearCache('metrics', 'cachedLabelLastLeftEdge'); // Recalc label location |
| 1819 | grm.debug.repaintWindow(); |
| 1820 | }); |
| 1821 | } |
| 1822 | |
| 1823 | /** |
| 1824 | * Resizes and resets the size and position of the disc art. |
| 1825 | * @param {boolean} resetDiscArtPosition - Whether the position of the disc art should be reset. |
| 1826 | */ |
| 1827 | resizeDiscArt(resetDiscArtPosition) { |
| 1828 | if (!this.discArt) { |
| 1829 | this.discArtSize = new ImageSize(0, 0, 0, 0); |
| 1830 | return; |
| 1831 | } |
| 1832 | |
| 1833 | this.setDiscArtScaleFactor(); |
| 1834 | this.setDiscArtSize(resetDiscArtPosition); |
| 1835 | this.setDiscArtPosition(resetDiscArtPosition); |
| 1836 | this.setDiscArtShadow(); |
| 1837 | } |
| 1838 | |
| 1839 | /** |
| 1840 | * Repaints the disc art area to only cover the necessary region based on album art opacity settings and disc art layering. |
| 1841 | */ |
| 1842 | repaintDiscArt() { |
| 1843 | const discArtLeftEdge = ( |
| 1844 | grSet.detailsAlbumArtOpacity !== 255 || grSet.detailsAlbumArtDiscAreaOpacity !== 255 || grSet.discArtOnTop |
| 1845 | ) ? this.discArtSize.x : grm.ui.albumArtSize.x + grm.ui.albumArtSize.w - 1; |
| 1846 | |
| 1847 | window.RepaintRect( |
| 1848 | discArtLeftEdge, this.discArtSize.y, |
| 1849 | this.discArtSize.w - (discArtLeftEdge - this.discArtSize.x), this.discArtSize.h, |
| 1850 | !grSet.discArtOnTop && !grm.ui.displayLyrics |
| 1851 | ); |
| 1852 | } |
| 1853 | |
| 1854 | /** |
| 1855 | * Repaints the metadata grid area to only cover the necessary region. |
| 1856 | */ |
| 1857 | repaintMetadataGrid() { |
| 1858 | if (!grm.ui.displayDetails) return; |
| 1859 | window.RepaintRect(0, grm.ui.topMenuHeight, Math.max(grm.ui.albumArtSize.x, SCALE(40)), grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight); |
| 1860 | } |
| 1861 | |
| 1862 | /** |
| 1863 | * Set the scale factor for the disc art based on the window size and layout. |
| 1864 | */ |
| 1865 | setDiscArtScaleFactor() { |
| 1866 | const discArtMaxHeight = grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight; |
| 1867 | const scaleFactor = grm.ui.displayPlaylist || grm.ui.displayLibrary ? 0.5 : 0.75; |
| 1868 | const discScale = Math.min(grm.ui.ww * scaleFactor / this.discArt.Width, (discArtMaxHeight - SCALE(16)) / this.discArt.Height); |
| 1869 | this.discArtScaleFactor = discScale; |
| 1870 | } |
| 1871 | |
| 1872 | /** |
| 1873 | * Set the size of the disc art based on its scale, window state, and layout settings. |
| 1874 | * @param {boolean} resetDiscArtPosition - Whether the position of the disc art should be reset. |
| 1875 | */ |
| 1876 | setDiscArtSize(resetDiscArtPosition) { |
| 1877 | const discArtSizeCorr = SCALE(4); |
| 1878 | |
| 1879 | const discArtSize = |
| 1880 | grm.ui.hasArtwork ? grm.ui.albumArtSize.h - discArtSizeCorr : |
| 1881 | Math.floor(this.discArt.Width * this.discArtScaleFactor) - discArtSizeCorr; |
| 1882 | |
| 1883 | if (resetDiscArtPosition) { |
| 1884 | this.discArtSize = { w: discArtSize, h: discArtSize }; |
| 1885 | } else { |
| 1886 | this.discArtSize.w = Math.max(this.discArtSize.w, discArtSize); |
| 1887 | this.discArtSize.h = this.discArtSize.w; |
| 1888 | } |
| 1889 | } |
| 1890 | |
| 1891 | /** |
| 1892 | * Set the position of the disc art based on the window size and layout settings. |
| 1893 | * @param {boolean} resetDiscArtPosition - Whether the position of the disc art should be reset. |
| 1894 | */ |
| 1895 | setDiscArtPosition(resetDiscArtPosition) { |
| 1896 | const discArtSizeCorr = SCALE(4); |
| 1897 | const discArtMargin = SCALE(2); |
| 1898 | const discArtMarginRight = SCALE(36); |
| 1899 | const discArtMaxHeight = grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight; |
| 1900 | |
| 1901 | if (grm.ui.hasArtwork) { |
| 1902 | const baseX = grm.ui.ww - grm.ui.albumArtSize.h - discArtMarginRight; |
| 1903 | |
| 1904 | const adjustedX = grm.ui.albumArtSize.x + grm.ui.albumArtSize.w - |
| 1905 | (grm.ui.albumArtSize.h - discArtSizeCorr) * (1 - grSet.discArtDisplayAmount) - |
| 1906 | (grSet.discArtDisplayAmount === 1 || grSet.discArtDisplayAmount === 0.5 ? 0 : discArtMarginRight); |
| 1907 | |
| 1908 | const discArtX = Math.floor( |
| 1909 | grSet.discArtDisplayAmount === 1 ? baseX : |
| 1910 | grSet.discArtDisplayAmount === 0.5 ? Math.min(baseX, adjustedX) : |
| 1911 | adjustedX |
| 1912 | ); |
| 1913 | |
| 1914 | this.discArtSize.x = resetDiscArtPosition ? discArtX : Math.max(this.discArtSize.x, discArtX); |
| 1915 | this.discArtSize.y = resetDiscArtPosition ? (grm.ui.albumArtSize.y + discArtMargin) : |
| 1916 | Math.min(this.discArtSize.y > 0 ? this.discArtSize.y : |
| 1917 | (grm.ui.albumArtSize.y + discArtMargin), grm.ui.albumArtSize.y + discArtMargin); |
| 1918 | |
| 1919 | if (this.discArtSize.x + this.discArtSize.w > grm.ui.ww) { |
| 1920 | this.discArtSize.x = grm.ui.ww - this.discArtSize.w - discArtMarginRight; |
| 1921 | } |
| 1922 | |
| 1923 | return; |
| 1924 | } |
| 1925 | |
| 1926 | // * Set no disc art x-coordinate |
| 1927 | const discArtOffCenter = this.discArtScaleFactor === (grm.ui.ww * 0.75 / this.discArt.Width); |
| 1928 | |
| 1929 | const discArtCenterX = |
| 1930 | discArtOffCenter ? Math.round(grm.ui.ww * 0.66 - grm.ui.edgeMargin) : |
| 1931 | (grm.ui.displayPlaylist || grm.ui.displayLibrary) ? grm.ui.ww * 0.25 : |
| 1932 | grm.ui.ww * 0.5; |
| 1933 | |
| 1934 | this.discArtSize.x = Math.floor(discArtCenterX - this.discArtSize.w * 0.5); |
| 1935 | |
| 1936 | // * Set no disc art y-coordinate |
| 1937 | const restrictedWidth = this.discArtScaleFactor !== (discArtMaxHeight - SCALE(16)) / this.discArt.Height; |
| 1938 | const discArtCenterY = grm.ui.topMenuHeight + Math.floor(((discArtMaxHeight - SCALE(16)) / 2) - this.discArtSize.h / 2); |
| 1939 | this.discArtSize.y = restrictedWidth ? Math.min(discArtCenterY, 160) : grm.ui.topMenuHeight + discArtMargin; |
| 1940 | |
| 1941 | grm.ui.hasArtwork = true; |
| 1942 | } |
| 1943 | |
| 1944 | /** |
| 1945 | * Sets up async precomputation of disc art frames, prioritized from current index. |
| 1946 | * @param {GdiBitmap} combinedImg - The base image to rotate. |
| 1947 | * @param {string} currentAlbumId - Unique ID to detect album changes. |
| 1948 | * @param {number} rotationDegreeIncrement - Degrees per frame. |
| 1949 | */ |
| 1950 | setDiscArtPrecomputation(combinedImg, currentAlbumId, rotationDegreeIncrement) { |
| 1951 | let batchCount = 0; |
| 1952 | let frameTimeAvg = 0; |
| 1953 | let precomputeIndex = (this.discArtRotationIndex + 1) % grSet.spinDiscArtImageCount; |
| 1954 | let precomputeTimer = null; |
| 1955 | let performanceTierCurrent = 'medium'; |
| 1956 | |
| 1957 | const performanceTiers = { |
| 1958 | low: { batchSize: 1, batchDelay: 75 }, // 50-100ms |
| 1959 | medium: { batchSize: 2, batchDelay: 25 }, // 20-40ms |
| 1960 | high: { batchSize: 4, batchDelay: 10 } // 10-20ms |
| 1961 | }; |
| 1962 | |
| 1963 | const updatePerformanceTier = (frameTime) => { |
| 1964 | frameTimeAvg = (frameTimeAvg + frameTime) / 2; |
| 1965 | const performanceTierNew = frameTimeAvg > 50 ? 'low' : frameTimeAvg < 10 ? 'high' : 'medium'; |
| 1966 | if (performanceTierNew === performanceTierCurrent) return; |
| 1967 | performanceTierCurrent = performanceTierNew; |
| 1968 | const tier = performanceTiers[performanceTierCurrent]; |
| 1969 | grm.debug.debugLog(`碟片 => 适应 ${performanceTierCurrent} 性能: 批量大小=${tier.batchSize}, 批量延迟=${tier.batchDelay} 毫秒 (avgFrameTime=${Math.round(frameTimeAvg)} 毫秒)`); |
| 1970 | }; |
| 1971 | |
| 1972 | const precomputeBatch = () => { |
| 1973 | if (this.discArt.Path !== currentAlbumId) return; |
| 1974 | |
| 1975 | const tier = performanceTiers[performanceTierCurrent]; |
| 1976 | let computedInBatch = 0; |
| 1977 | |
| 1978 | while (computedInBatch < tier.batchSize && !this.discArtArray[precomputeIndex]) { |
| 1979 | const rotationDegrees = rotationDegreeIncrement * precomputeIndex; |
| 1980 | const frameStart = Date.now(); |
| 1981 | this.discArtArray[precomputeIndex] = RotateImage(combinedImg, this.discArtSize.w, this.discArtSize.h, rotationDegrees, grm.artCache.discArtImgMaxRes); |
| 1982 | const frameTime = Date.now() - frameStart; |
| 1983 | updatePerformanceTier(frameTime); // Update per-frame for quicker response |
| 1984 | grm.debug.debugLog(`碟片 => 预计算碟片图像: ${precomputeIndex} (${this.discArtSize.w}x${this.discArtSize.h}) 旋转: ${rotationDegrees} 度`); |
| 1985 | computedInBatch++; |
| 1986 | precomputeIndex = (precomputeIndex + 1) % grSet.spinDiscArtImageCount; |
| 1987 | } |
| 1988 | |
| 1989 | batchCount++; |
| 1990 | |
| 1991 | if (this.discArtArray.every(frame => !!frame)) { |
| 1992 | grm.debug.debugLog('碟片 => 所有帧均已预先计算'); |
| 1993 | return; |
| 1994 | } |
| 1995 | |
| 1996 | if (computedInBatch > 0) { |
| 1997 | precomputeTimer = setTimeout(precomputeBatch, tier.batchDelay); |
| 1998 | } |
| 1999 | }; |
| 2000 | |
| 2001 | // Start immediately but async |
| 2002 | setTimeout(precomputeBatch, 0); |
| 2003 | |
| 2004 | // Cleanup |
| 2005 | this.clearTimer = (type) => { |
| 2006 | if (type === 'discArt' && precomputeTimer) { |
| 2007 | clearTimeout(precomputeTimer); |
| 2008 | grm.debug.debugLog('碟片 => 清除预计算计时器'); |
| 2009 | } |
| 2010 | Details.prototype.clearTimer.call(this, type); |
| 2011 | delete this.clearTimer; // Restore to prototype chain |
| 2012 | }; |
| 2013 | } |
| 2014 | |
| 2015 | /** |
| 2016 | * Sets and creates the disc art rotation animation with RotateImg(). |
| 2017 | * @returns {GdiBitmap} The rotated disc art image. |
| 2018 | */ |
| 2019 | setDiscArtRotation() { |
| 2020 | if (!grSet.displayDiscArt || grm.ui.albumArtCorrupt || !grm.ui.albumArt || !this.discArt || this.discArtSize.w < 1) { |
| 2021 | return null; |
| 2022 | } |
| 2023 | |
| 2024 | // Drawing discArt rotated is slow, so first draw it rotated into the discArtRotation image, and then draw discArtRotation image unrotated in on_paint. |
| 2025 | const vinylAdjustedTrackNumFormat = `$num($if(${grTF.vinyl_tracknum},$sub($mul(${grTF.vinyl_tracknum},2),1),$if2(%tracknumber%,1)),1)`; |
| 2026 | let tracknum = parseInt($(vinylAdjustedTrackNumFormat)) - 1; |
| 2027 | if (!grSet.rotateDiscArt || Number.isNaN(tracknum)) tracknum = 0; |
| 2028 | |
| 2029 | const tracknumRotation = tracknum * grSet.rotationAmt % 360; |
| 2030 | const combinedImg = this.combineDiscArtWithCover(true); |
| 2031 | |
| 2032 | this.discArtRotation = RotateImage(combinedImg, this.discArtSize.w, this.discArtSize.h, tracknumRotation, grm.artCache.discArtImgMaxRes); |
| 2033 | this.discArtRotationIndex = Math.round(tracknumRotation / (360 / grSet.spinDiscArtImageCount)) % grSet.spinDiscArtImageCount; |
| 2034 | |
| 2035 | return this.discArtRotation; |
| 2036 | } |
| 2037 | |
| 2038 | /** |
| 2039 | * Sets the disc art timer with different set interval values for rotating the disc art. |
| 2040 | */ |
| 2041 | setDiscArtRotationTimer() { |
| 2042 | this.clearTimer('discArt'); |
| 2043 | |
| 2044 | if (grSet.layout !== 'default' || !grm.ui.displayDetails || |
| 2045 | grm.ui.albumArtCorrupt || !grm.ui.albumArt || !this.discArt || !this.discArtSize.w || |
| 2046 | !fb.IsPlaying || fb.IsPaused || !grSet.displayDiscArt || !grSet.spinDiscArt) { |
| 2047 | return; |
| 2048 | } |
| 2049 | |
| 2050 | grm.debug.debugLog(`碟片 => 使用异步预计算启动延迟旋转: ${grSet.spinDiscArtImageCount} 帧, 每 ${grSet.spinDiscArtRedrawInterval} 毫秒 1 次`); |
| 2051 | |
| 2052 | const rotationDegreeIncrement = 360 / grSet.spinDiscArtImageCount; |
| 2053 | const combinedImg = this.combineDiscArtWithCover(false); |
| 2054 | const currentAlbumId = this.discArt.Path; |
| 2055 | |
| 2056 | // Main animation timer |
| 2057 | this.discArtRotationTimer = setInterval(() => { |
| 2058 | const intendedIndex = (this.discArtRotationIndex + 1) % grSet.spinDiscArtImageCount; |
| 2059 | |
| 2060 | let displayIndex = intendedIndex; |
| 2061 | if (!this.discArtArray[intendedIndex]) { |
| 2062 | // Nearest available: prioritize smallest angular distance, alternating fwd/bwd |
| 2063 | const count = grSet.spinDiscArtImageCount; |
| 2064 | let nearestFound = false; |
| 2065 | for (let dist = 0; dist < count; dist++) { |
| 2066 | const fwd = (intendedIndex + dist) % count; |
| 2067 | if (this.discArtArray[fwd]) { |
| 2068 | displayIndex = fwd; |
| 2069 | nearestFound = true; |
| 2070 | break; |
| 2071 | } |
| 2072 | |
| 2073 | const bwd = (intendedIndex - dist + count) % count; |
| 2074 | if (this.discArtArray[bwd]) { |
| 2075 | displayIndex = bwd; |
| 2076 | nearestFound = true; |
| 2077 | break; |
| 2078 | } |
| 2079 | } |
| 2080 | if (!nearestFound) displayIndex = 0; // Ultimate fallback |
| 2081 | |
| 2082 | grm.debug.debugLog(`碟片 => 帧 ${intendedIndex} 未就绪,显示最接近的 ${displayIndex}`); |
| 2083 | |
| 2084 | // Emergency compute intended (sync for immediacy, but only one frame) |
| 2085 | const rotationDegrees = rotationDegreeIncrement * intendedIndex; |
| 2086 | this.discArtArray[intendedIndex] = RotateImage(combinedImg, this.discArtSize.w, this.discArtSize.h, rotationDegrees, grm.artCache.discArtImgMaxRes); |
| 2087 | grm.debug.debugLog(`碟片 => 紧急计算碟片: ${intendedIndex} (${this.discArtSize.w}x${this.discArtSize.h}) 旋转: ${rotationDegrees} 度`); |
| 2088 | } |
| 2089 | |
| 2090 | this.discArtRotationIndex = intendedIndex; // Advance intended for next tick |
| 2091 | this.repaintDiscArt(); // Repaint with displayIndex (but since we just computed if missing, often same) |
| 2092 | }, grSet.spinDiscArtRedrawInterval); |
| 2093 | |
| 2094 | // Start precomputation |
| 2095 | this.setDiscArtPrecomputation(combinedImg, currentAlbumId, rotationDegreeIncrement); |
| 2096 | } |
| 2097 | |
| 2098 | /** |
| 2099 | * Sets the drop shadow for disc art. |
| 2100 | */ |
| 2101 | setDiscArtShadow() { |
| 2102 | const isDisabled = !grm.ui.displayDetails || !grSet.displayDiscArt || grSet.layout === 'compact'; |
| 2103 | const isMissing = !this.discArt || !grm.ui.hasArtwork && !grm.ui.noAlbumArtStub; |
| 2104 | const isCached = this.discArtShadowImg && this.discArtShadowImg.image && this.discArtShadowImg.size === this.discArtSize.h; |
| 2105 | |
| 2106 | if (isDisabled || isMissing || isCached) return; |
| 2107 | |
| 2108 | grm.debug.setDebugProfile(grm.debug.showDebugTiming || grCfg.settings.showDebugPerformanceOverlay, 'create', '创建碟片阴影'); |
| 2109 | |
| 2110 | const discArtMargin = SCALE(2); |
| 2111 | |
| 2112 | if (grm.ui.albumArtSize.w > 0 || this.discArtSize.w > 0) { |
| 2113 | const width = this.discArt |
| 2114 | ? this.discArtSize.x + this.discArtSize.w + 2 * this.discArtShadow |
| 2115 | : grm.ui.albumArtSize.x + grm.ui.albumArtSize.w + 2 * this.discArtShadow; |
| 2116 | |
| 2117 | const height = this.discArt |
| 2118 | ? this.discArtSize.h + discArtMargin + 2 * this.discArtShadow |
| 2119 | : grm.ui.albumArtSize.h + 2 * this.discArtShadow; |
| 2120 | |
| 2121 | const newShadowImg = gdi.CreateImage(width, height); |
| 2122 | |
| 2123 | if (grSet.layout === 'default' && newShadowImg) { |
| 2124 | const shimg = newShadowImg.GetGraphics(); |
| 2125 | |
| 2126 | if (this.discArt) { |
| 2127 | const offset = this.discArtSize.w * 0.40; // Don't change this value |
| 2128 | const xVal = this.discArtSize.x; |
| 2129 | const shadowOffset = this.discArtShadow * 2; |
| 2130 | |
| 2131 | shimg.DrawEllipse(xVal + shadowOffset, shadowOffset + discArtMargin, this.discArtSize.w - shadowOffset, this.discArtSize.w - shadowOffset, shadowOffset, grCol.discArtShadow); // outer shadow |
| 2132 | shimg.DrawEllipse(xVal + this.discArtShadow + offset, offset + this.discArtShadow + discArtMargin, this.discArtSize.w - offset * 2, this.discArtSize.h - offset * 2, 60, grCol.discArtShadow); // inner shadow |
| 2133 | } |
| 2134 | |
| 2135 | newShadowImg.ReleaseGraphics(shimg); |
| 2136 | newShadowImg.StackBlur(this.discArtShadow); |
| 2137 | } |
| 2138 | |
| 2139 | this.discArtShadowImg.image = newShadowImg; |
| 2140 | this.discArtShadowImg.size = this.discArtSize.h; |
| 2141 | } |
| 2142 | |
| 2143 | grm.debug.setDebugProfile(false, 'print', '创建碟片阴影'); |
| 2144 | } |
| 2145 | |
| 2146 | /** |
| 2147 | * Updates the disc art by resizing artwork, creating rotation, and setting the rotation timer. |
| 2148 | */ |
| 2149 | updateDiscArt() { |
| 2150 | grm.ui.resizeArtwork(true); |
| 2151 | this.setDiscArtRotation(); |
| 2152 | |
| 2153 | if (!grSet.spinDiscArt) return; |
| 2154 | |
| 2155 | this.discArtArray = []; // Clear last image |
| 2156 | this.setDiscArtRotationTimer(); |
| 2157 | } |
| 2158 | // #endregion |
| 2159 | |
| 2160 | // * PUBLIC METHODS - BAND & LABEL LOGO * // |
| 2161 | // #region PUBLIC METHODS - BAND & LABEL LOGO |
| 2162 | /** |
| 2163 | * Gets the band logo path if it exists at various paths. |
| 2164 | * @param {string} bandStr - The name of the band. |
| 2165 | * @returns {string} The path of the band logo if it exists. |
| 2166 | */ |
| 2167 | getBandLogoPath(bandStr) { |
| 2168 | if (!bandStr) return ''; |
| 2169 | |
| 2170 | const testBandLogoPath = (imgDir, name) => { |
| 2171 | const logoPath = `${imgDir}${name}.png`; |
| 2172 | if (IsFile(logoPath)) { |
| 2173 | grm.debug.debugLog(`图标 => 找到艺术家标识: ${logoPath}`); |
| 2174 | return logoPath; |
| 2175 | } |
| 2176 | return ''; |
| 2177 | }; |
| 2178 | |
| 2179 | const bandLogoPath = |
| 2180 | testBandLogoPath(grPath.artistlogos, bandStr) || // Try 800x310 white |
| 2181 | testBandLogoPath(grPath.artistlogosColor, bandStr); // Try 800x310 color |
| 2182 | |
| 2183 | return bandLogoPath || ''; |
| 2184 | } |
| 2185 | |
| 2186 | /** |
| 2187 | * Gets the band logo and its inverted version based on the current playing album artist in Details. |
| 2188 | * @param {FbMetadbHandle} metadb - The metadb of the track. |
| 2189 | */ |
| 2190 | getBandLogo(metadb = undefined) { |
| 2191 | this.clearCache('bandLogo'); |
| 2192 | let path; |
| 2193 | |
| 2194 | const artists = GetMetaValues('%artist%', metadb); |
| 2195 | const trackArtist = ReplaceIllegalChars($('[%track artist%]', metadb)); |
| 2196 | const albumArtists = GetMetaValues('%album artist%', metadb); |
| 2197 | |
| 2198 | const artistList = [ |
| 2199 | ...artists.flatMap(artist => [ |
| 2200 | ReplaceIllegalChars(artist), ReplaceIllegalChars(artist).replace(Regex.TextPrefixThe, '') |
| 2201 | ]), |
| 2202 | trackArtist, |
| 2203 | ...albumArtists.flatMap(artist => [ |
| 2204 | ReplaceIllegalChars(artist), ReplaceIllegalChars(artist).replace(Regex.TextPrefixThe, '') |
| 2205 | ]) |
| 2206 | ]; |
| 2207 | const uniqueArtistList = [...new Set(artistList)]; |
| 2208 | |
| 2209 | for (const artist of uniqueArtistList) { |
| 2210 | path = this.getBandLogoPath(artist); |
| 2211 | if (path) break; |
| 2212 | } |
| 2213 | |
| 2214 | if (!path) return; |
| 2215 | |
| 2216 | this.bandLogo = grm.artCache.getImage(path); |
| 2217 | if (!this.bandLogo) { |
| 2218 | const logo = gdi.Image(path); |
| 2219 | if (logo) { |
| 2220 | this.bandLogo = grm.artCache.encache(logo, path); |
| 2221 | this.bandLogoInverted = grm.artCache.encache(logo.InvertColours(), `${path}-inv`); |
| 2222 | } |
| 2223 | } |
| 2224 | |
| 2225 | this.bandLogoInverted = grm.artCache.getImage(`${path}-inv`); |
| 2226 | if (!this.bandLogoInverted && this.bandLogo) { |
| 2227 | this.bandLogoInverted = grm.artCache.encache(this.bandLogo.InvertColours(), `${path}-inv`); |
| 2228 | } |
| 2229 | } |
| 2230 | |
| 2231 | /** |
| 2232 | * Gets label logos based on current playing album artist in Details. |
| 2233 | * @param {FbMetadbHandle} metadb - The metadb of the track. |
| 2234 | */ |
| 2235 | getLabelLogo(metadb) { |
| 2236 | this.clearCache('labelLogo'); |
| 2237 | const labelFields = ['label', 'publisher', 'discogs_label']; |
| 2238 | const labels = new Set(labelFields.flatMap(label => GetMetaValues(label, metadb))); |
| 2239 | |
| 2240 | for (const label of labels) { |
| 2241 | const addLabel = this.loadLabelLogo(label); |
| 2242 | if (addLabel != null) { |
| 2243 | this.labelLogo.push(addLabel); |
| 2244 | try { |
| 2245 | this.labelLogoInverted.push(addLabel.InvertColours()); |
| 2246 | } catch (e) {} |
| 2247 | } |
| 2248 | } |
| 2249 | } |
| 2250 | |
| 2251 | /** |
| 2252 | * Loads the label logo image for a given record label in Details. |
| 2253 | * @param {string} publisherString - The name of a record label or publisher. |
| 2254 | * @returns {GdiBitmap|null} The record label logo as a gdi image object or null if not found. |
| 2255 | */ |
| 2256 | loadLabelLogo(publisherString) { |
| 2257 | const date = new Date(); |
| 2258 | const lastSearchYear = date.getFullYear(); |
| 2259 | let dir = grPath.labelsBase; |
| 2260 | let labelStr = ReplaceIllegalChars(publisherString); |
| 2261 | let recordLabel = null; |
| 2262 | |
| 2263 | if (!labelStr) return recordLabel; |
| 2264 | |
| 2265 | // * Clean up the label string |
| 2266 | const cleanLabelString = (str) => str |
| 2267 | .replace(Regex.ArtImageLabelSuffix, '') |
| 2268 | .replace(Regex.EdgeDotSpaceTrailing, '') |
| 2269 | .replace(Regex.TextDash, '-'); |
| 2270 | |
| 2271 | // * Check for label folders by year |
| 2272 | const checkLabelFolders = (label) => { |
| 2273 | const startYear = parseInt($('$year(%date%)')); |
| 2274 | const baseDir = `${dir}${label}\\`; |
| 2275 | |
| 2276 | for (let year = startYear; year <= lastSearchYear; year++) { |
| 2277 | const yearFolder = `${baseDir}${year}`; |
| 2278 | if (IsFolder(yearFolder)) { |
| 2279 | grm.debug.debugLog(`图标 => 找到 ${label} 年份 ${year}文件夹.`); |
| 2280 | return `${yearFolder}\\`; |
| 2281 | } |
| 2282 | } |
| 2283 | |
| 2284 | grm.debug.debugLog(`图标 => 找到 ${label} 文件夹并使用最新图标.`); |
| 2285 | return baseDir; |
| 2286 | }; |
| 2287 | |
| 2288 | // * Check if a folder exists for the initial label string |
| 2289 | const folderExists = (label) => IsFolder(`${dir}${label}`); |
| 2290 | if (folderExists(labelStr)) { |
| 2291 | dir = checkLabelFolders(labelStr); |
| 2292 | } else { |
| 2293 | labelStr = cleanLabelString(labelStr); |
| 2294 | if (folderExists(labelStr)) { |
| 2295 | dir = checkLabelFolders(labelStr); |
| 2296 | } |
| 2297 | } |
| 2298 | |
| 2299 | // * Reinitialize to original string for file search |
| 2300 | labelStr = ReplaceIllegalChars(publisherString); |
| 2301 | |
| 2302 | // * Get the file path for the initial label string |
| 2303 | const searchFile = (label) => `${dir}${label}.png`; |
| 2304 | let label = searchFile(labelStr); |
| 2305 | |
| 2306 | // * Load the record label image |
| 2307 | if (IsFile(label)) { |
| 2308 | recordLabel = gdi.Image(label); |
| 2309 | grm.debug.debugLog('图标 => 找到唱片商标:', label, !recordLabel ? '<无法加载>' : ''); |
| 2310 | } else { |
| 2311 | labelStr = cleanLabelString(labelStr); |
| 2312 | label = searchFile(labelStr); |
| 2313 | if (IsFile(label)) { |
| 2314 | recordLabel = gdi.Image(label); |
| 2315 | } else { |
| 2316 | label = searchFile(`${labelStr} Records`); |
| 2317 | if (IsFile(label)) { |
| 2318 | recordLabel = gdi.Image(label); |
| 2319 | } |
| 2320 | } |
| 2321 | } |
| 2322 | |
| 2323 | return recordLabel; |
| 2324 | } |
| 2325 | // #endregion |
| 2326 | |
| 2327 | // * PUBLIC METHODS - CALLBACKS * // |
| 2328 | // #region PUBLIC METHODS - CALLBACKS |
| 2329 | /** |
| 2330 | * Checks if the mouse is within the boundaries of the metadata grid in Details. |
| 2331 | * @global |
| 2332 | * @param {number} x - The x-coordinate. |
| 2333 | * @param {number} y - The y-coordinate. |
| 2334 | * @param {string} boundary - The boundary to check ('artist', 'title', 'album', 'tagKey', 'tagValue', 'timeline', 'grid'). |
| 2335 | * @returns {boolean} True or false. |
| 2336 | */ |
| 2337 | mouseInMetadataGrid(x, y, boundary) { |
| 2338 | return this.gridSectionBounds[boundary] ? this.gridSectionBounds[boundary](x, y) : false; |
| 2339 | } |
| 2340 | |
| 2341 | /** |
| 2342 | * Handles the tooltip when the mouse is in the metadata grid tooltip area. |
| 2343 | * @param {number} x - The x-coordinate. |
| 2344 | * @param {number} y - The y-coordinate. |
| 2345 | * @param {number} m - The mouse mask. |
| 2346 | */ |
| 2347 | on_mouse_move(x, y, m) { |
| 2348 | if (grSet.showTooltipMain || grSet.showTooltipTruncated) { |
| 2349 | this.handleGridTooltip(x, y); |
| 2350 | } |
| 2351 | if (grSet.showTooltipTimeline) { |
| 2352 | this.handleGridTimelineTooltip(x, y); |
| 2353 | } |
| 2354 | } |
| 2355 | // #endregion |
| 2356 | } |
| 2357 |
gr-main-components.js
· 161 KiB · JavaScript
原始文件
/////////////////////////////////////////////////////////////////////////////////
// * Georgia-ReBORN: A Clean - Full Dynamic Color Reborn - Foobar2000 Player * //
// * Description: Georgia-ReBORN Main Components * //
// * Author: TT * //
// * Website: https://github.com/TT-ReBORN/Georgia-ReBORN * //
// * Version: 3.0-x64-DEV * //
// * Dev. started: 22-12-2017 * //
// * Last change: 17-05-2026 * //
/////////////////////////////////////////////////////////////////////////////////
'use strict';
///////////////////////
// * IMAGE CACHING * //
///////////////////////
/**
* A class that creates album art and playlist thumbnails cache.
*/
class ArtCache {
/**
* Creates the `ArtCache` instance.
* The ArtCache is a Least-Recently Used cache meaning that each cache hit will bump
* that image to be the last image to be removed from the cache (if maxCacheSize is exceeded).
* @param {number} maxCacheSize - The maximum number of images to keep in the cache.
*/
constructor(maxCacheSize = 15) {
/**
* @typedef {object} ArtCacheObj
* @property {GdiBitmap} image - The GDI+ bitmap image object cached.
* @property {number} filesize - The size of the image file in bytes.
*/
/** @private @type {object.<string, ArtCacheObj>} The primary cache storing image objects. */
this.cache = {};
/** @private @type {object.<string, ArtCacheObj>} The secondary cache used mainly for disc art covers to prevent overwriting album art with masked images. */
this.cache2 = {};
/** @private @type {string[]} The array of cache keys in the order of their usage. */
this.cacheIndexes = [];
/** @private @type {string[]} The array of secondary cache keys in the order of their usage. */
this.cacheIndexes2 = [];
/** @private @type {number} The maximum number of images that can be stored in the primary cache. */
this.cacheMaxSize = maxCacheSize;
/** @private @type {number} The maximum number of images that can be stored in the secondary cache. */
this.cacheMaxSize2 = maxCacheSize;
/** @private @type {number} The maximum width an image can be displayed. */
this.imgMaxWidth = SCALE(1440);
/** @private @type {number} The maximum height an image can be displayed. */
this.imgMaxHeight = SCALE(872);
/**
* Because foobar x86 can allocate only 4 gigs memory, we must limit disc art res for 4K when using
* high grSet.spinDiscArtImageCount, i.e 90 (4 degrees), 120 (3 degrees), 180 (2 degrees) to prevent crash.
* When SMP has x64 support, we could try to increase this limit w (1836px max possible res for 4K).
* @public @type {number}
*/
this.discArtImgMaxRes = this.setDiscArtMaxResolution(grSet.spinDiscArtImageCount);
}
// * PUBLIC METHODS * //
// #region PUBLIC METHODS
/**
* Gets cached image if it exists under the location string. If image is found, move it's index to the end of the cacheIndexes.
* @param {string} location - The string value to check if image is cached under.
* @param {number} cacheIndex - The first or second index of the cache to check.
* @returns {GdiBitmap|null} The cached image, or null if not found or the file does not exist.
*/
getImage(location, cacheIndex = 1) {
const cache = cacheIndex === 1 ? this.cache : this.cache2;
const cacheIndexes = cacheIndex === 1 ? this.cacheIndexes : this.cacheIndexes2;
if (!cache[location] || !fso.FileExists(location)) {
// If image is not in cache or location does not exist, return to prevent crash.
return null;
}
const file = fso.GetFile(location);
const pathIndex = cacheIndexes.indexOf(location);
cacheIndexes.splice(pathIndex, 1);
if (file && file.Size === cache[location].filesize) {
cacheIndexes.push(location);
grm.debug.debugLog('Art cache => Cache hit:', location);
return cache[location].image;
}
// Size of file on disk has changed
grm.debug.debugLog(`Art cache => Cache entry was stale: ${location} [old size: ${cache[location].filesize}, new size: ${file.Size}]`);
delete cache[location]; // Was removed from cacheIndexes already
return null;
}
/**
* Gets and optionally logs the size of an image or all images in both caches if cacheIndex is 0.
* @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.
* @param {number} cacheIndex - The index of the cache to check. 0 for both, 1 for the first, 2 for the second. Defaults to 1.
* @param {boolean} logSize - Whether to log the size(s) to the console. Defaults to false.
* @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.
* @example
* - Get size of a specific image in cache 1: getImageSize('path/to/img.jpg', 1);
* - Get sizes of all images in cache 1: getImageSize(null, 1, true);
* - Get sizes of all images in both caches: getImageSize(null, 0, true);
*/
getImageSize(location, cacheIndex = 1, logSize = false) {
const processCache = (cache, prefix = '') => {
const sizes = {};
for (const [loc, cacheObj] of Object.entries(cache)) {
const formattedSize = FormatSize(cacheObj.filesize);
sizes[loc] = formattedSize;
if (logSize) {
console.log(`Art cache => ${prefix}Image at '${loc}' size: ${formattedSize}`);
}
}
return sizes;
};
if (cacheIndex === 0) { // If location is 0, process both caches
const sizes1 = processCache(this.cache, 'Cache 1 ');
const sizes2 = processCache(this.cache2, 'Cache 2 ');
return { ...sizes1, ...sizes2 }; // Merge results from both caches
}
const cache = cacheIndex === 1 ? this.cache : this.cache2;
if (location === null) { // If location is null, process all images in the specified cache.
return processCache(cache);
}
else if (cache[location]) { // Process a specific image in the specified cache.
const formattedSize = FormatSize(cache[location].filesize);
if (logSize) {
console.log(`Art cache => Image at '${location}' size: ${formattedSize}`);
}
return formattedSize;
}
return null;
}
/**
* Gets and optionally logs the total size of the cached images.
* If cacheIndex is 0, calculates for both caches combined.
* @param {number} cacheIndex - The index of the cache to calculate size for. If 0, calculates for both caches.
* @param {boolean} logSizes - Whether to log individual image sizes to the console.
* @returns {number} The total size of the cache or caches in bytes.
* @example
* - Get total size of cache 1: getTotalCacheSize(1, true);
* - Get total size of both caches combined: getTotalCacheSize(0, true);
*/
getTotalCacheSize(cacheIndex, logSizes = false) {
let totalSize = 0;
const calculateAndLogSize = (cache, cacheName = '') => {
for (const [location, cacheObj] of Object.entries(cache)) {
totalSize += cacheObj.filesize;
if (logSizes) {
const formattedSize = FormatSize(cacheObj.filesize);
console.log(`Art cache => ${cacheName} Image at '${location}' size: ${formattedSize}`);
}
}
};
if (cacheIndex === 0) { // If cacheIndex is 0, process both caches
calculateAndLogSize(this.cache, 'Cache 1');
calculateAndLogSize(this.cache2, 'Cache 2');
} else {
const cache = cacheIndex === 1 ? this.cache : this.cache2;
calculateAndLogSize(cache, `Cache ${cacheIndex}`);
}
const cacheLabel = cacheIndex === 0 ? 'Total size for both caches' : `Total size for Cache ${cacheIndex}`;
const totalFormattedSize = FormatSize(totalSize);
if (logSizes) console.log(`Art cache => ${cacheLabel}: ${totalFormattedSize}`);
return totalFormattedSize;
}
/**
* Sets the maximum resolution for disc art based on the spinDiscArtImageCount.
* @param {number} spinDiscArtImageCount - The count for spinning disc art images.
* @returns {number} The maximum resolution for the disc art image.
*/
setDiscArtMaxResolution(spinDiscArtImageCount = 72) {
const maxResByImgCount = {
36: 1500,
45: 1500,
60: 1400,
72: 1400,
90: 1300,
120: 1200,
180: 1000
};
return maxResByImgCount[spinDiscArtImageCount];
}
/**
* Adds a rescaled image to the cache under string `location` and returns the cached image.
* @param {GdiBitmap} img - The image object to cache.
* @param {string} location - The string value to cache image under. Does not need to be a path.
* @param {number} cacheIndex - The first or second index of the cache to check.
* @returns {GdiBitmap} The image stored in the cache at the specified location.
* If there is no image in the cache at that location, it returns the original image passed as a parameter.
*/
encache(img, location, cacheIndex = 1) {
const cache = cacheIndex === 1 ? this.cache : this.cache2;
const cacheIndexes = cacheIndex === 1 ? this.cacheIndexes : this.cacheIndexes2;
const cacheMaxSize = cacheIndex === 1 ? this.cacheMaxSize : this.cacheMaxSize2;
try {
let { Width: w, Height: h } = img;
// Scale image
if (w > this.imgMaxWidth || h > this.imgMaxHeight) {
const scaleFactor = Math.max(w / this.imgMaxWidth, h / this.imgMaxHeight);
w /= scaleFactor;
h /= scaleFactor;
}
const file = fso.GetFile(location);
cache[location] = { image: img.Resize(w, h), filesize: file.Size };
img = null;
// Update cache order
const pathIndex = cacheIndexes.indexOf(location);
if (pathIndex !== -1) {
cacheIndexes.splice(pathIndex, 1); // Remove from middle of cache and put on end
}
cacheIndexes.push(location);
// Maintain cache size
if (cacheIndexes.length > cacheMaxSize) {
const remove = cacheIndexes.shift();
grm.debug.debugLog('Art cache => Removing img from cache:', remove);
delete cache[remove];
}
} catch (e) {
// Do not console.log inverted band logo and label images in the process of being created
grm.ui.bandLogoInverted && console.log(`\nArt cache => <Error: Image could not be properly parsed: ${location}>\n`);
}
return cache[location] ? cache[location].image : img;
}
/**
* Completely clears all cached entries and releases memory held by scaled bitmaps.
*/
clear() {
if (grCfg.settings.showDebugLog) {
grm.debug.debugLog(`Art cache => Total cache size for Cache 1: ${this.getTotalCacheSize(1, false)}`);
grm.debug.debugLog(`Art cache => Total cache size for Cache 2: ${this.getTotalCacheSize(2, false)}`);
grm.debug.debugLog(`Art cache => Total cache size cleared: ${this.getTotalCacheSize(0, false)}`);
}
const clearCache = (cacheIndexes, cache) => {
for (const index of cacheIndexes) {
delete cache[index];
}
cacheIndexes.length = 0;
};
clearCache(this.cacheIndexes, this.cache);
clearCache(this.cacheIndexes2, this.cache2);
}
// #endregion
}
/**
* A class that creates background images for the Playlist or Library.
*/
class BackgroundImage {
/**
* Creates the `BackgroundImage` instance.
*/
constructor() {
// * BACKGROUND PANEL IMAGES * //
// #region BACKGROUND PANEL IMAGES
/** @public @type {GdiBitmap|null} The background image used for the Playlist. */
this.playlistBgImg = null;
/** @public @type {GdiBitmap|null} The background image used for the Library. */
this.libraryBgImg = null;
/** @public @type {GdiBitmap|null} The background image used for the Lyrics. */
this.lyricsBgImg = null;
// #endregion
// * ARTIST IMAGES * //
// #region ARTIST IMAGES
/** @public @type {GdiBitmap|null} The artist background image of the biography. */
this.artistBgImg = null;
/** @public @type {GdiBitmap[]} The artist list of background images. */
this.artistImgList = [];
/** @public @type {number} The artist index of the currently displayed background image for the Playlist. */
this.artistIdxPlaylist = 0;
/** @public @type {number} The artist index of the currently displayed background image for the Library. */
this.artistIdxLibrary = 0;
/** @public @type {number} The artist index of the currently displayed background image for the Lyrics. */
this.artistIdxLyrics = 0;
/** @public @type {number} The artist index of the cached biography artist image for the Playlist. */
this.artistIdxCachedPlaylist = -1;
/** @public @type {number} The artist index of the cached biography artist image for the Library. */
this.artistIdxCachedLibrary = -1;
/** @public @type {number} The artist index of the cached biography artist image for the Lyrics. */
this.artistIdxCachedLyrics = -1;
// #endregion
// * ALBUM IMAGES * //
// #region ALBUM IMAGES
/** @public @type {GdiBitmap|null} The album background image. */
this.albumBgImg = null;
/** @public @type {GdiBitmap[]} The album list of background images. */
this.albumImgList = [];
/** @public @type {number[]} The album art image index: 0 for Front, 1 for Back, and 4 for Artist. */
this.albumArtIdx = [0, 1, 4];
/** @public @type {number} The album index of the currently displayed background image for the Playlist. */
this.albumIdxPlaylist = 0;
/** @public @type {number} The album index of the currently displayed background image for the Library. */
this.albumIdxLibrary = 0;
/** @public @type {number} The album index of the currently displayed background image for the Lyrics. */
this.albumIdxLyrics = 0;
/** @public @type {number} The album index of the cached album background image for the Playlist. */
this.albumIdxCachedPlaylist = -1;
/** @public @type {number} The album index of the cached album background image for the Library. */
this.albumIdxCachedLibrary = -1;
/** @public @type {number} The album index of the cached album background image for the Lyrics. */
this.albumIdxCachedLyrics = -1;
// #endregion
// * CUSTOM IMAGES * //
// #region CUSTOM IMAGES
/** @public @type {GdiBitmap|null} The custom background image. */
this.customBgImg = null;
/** @public @type {GdiBitmap[]} The custom list of custom background images. */
this.customImgList = [];
/** @public @type {number} The custom index of the currently displayed custom background image for the Playlist. */
this.customIdxPlaylist = 0;
/** @public @type {number} The custom index of the currently displayed custom background image for the Library. */
this.customIdxLibrary = 0;
/** @public @type {number} The custom index of the currently displayed custom background image for the Lyrics. */
this.customIdxLyrics = 0;
/** @public @type {number} The custom index of the cached custom background image for the Playlist. */
this.customIdxCachedPlaylist = -1;
/** @public @type {number} The custom index of the cached custom background image for the Library. */
this.customIdxCachedLibrary = -1;
/** @public @type {number} The custom index of the cached custom background image for the Lyrics. */
this.customIdxCachedLyrics = -1;
// #endregion
// * STATE * //
// #region STATE
/** @public @type {Object} The background image fetching state for the Playlist, Library, and Lyrics. */
this.imgFetching = {};
/** @public @type {Object} The background image cycle intervals for the Playlist, Library, and Lyrics. */
this.imgCycleIntervals = {};
// #endregion
// * INITIALIZATION * //
// #region INITIALIZATION
this.initBgImageCycle();
// #endregion
}
// * PUBLIC METHODS * //
// #region PUBLIC METHODS
/**
* Draws an artist, album or custom image on the Playlist or Library's background.
* @param {GdiGraphics} gr - The GDI graphics object.
* @param {object} img - The image object containing the image and other properties.
* @param {string} scale - The scale mode ("default", "filled" or "stretched") to apply to the image.
* @param {number} x - The x-coordinate where the image should be drawn.
* @param {number} y - The y-coordinate where the image should be drawn.
* @param {number} w - The width of the area to draw the image.
* @param {number} h - The height of the area to draw the image.
* @param {number} opacity - The opacity level to apply to the image.
* @param {boolean} mask - Whether to apply a mask to the image.
* @param {number} maskOffsetY - The y-offset for the mask.
* @param {number} maskHeight - The height of the mask.
*/
drawBgImage(gr, img, scale, x, y, w, h, opacity, mask, maskOffsetY, maskHeight) {
if (!img || !img.image) return;
if (!img.scaled || img.changed) {
img.scaled = ScaleImage(img.image, scale, x, y, w, h, 0, 0, img.image.Width, img.image.Height);
}
if (mask && (!img.masked || img.changed)) {
img.masked = MaskImage(img.scaled, 0, maskOffsetY, img.scaled.Width, img.scaled.Height - maskHeight);
}
const finalImage = mask ? img.masked : img.scaled;
gr.DrawImage(finalImage, x, y, w, h, 0, 0, finalImage.Width, finalImage.Height, 0, opacity);
img.changed = false;
}
/**
* Initializes the current background image by clearing the relevant caches and fetching a new image based on the source setting.
* @param {string} panel - The panel ('playlist', 'library', 'lyrics', or false for all) in which the image is being requested.
* @param {boolean} [clearCache] - Whether to clear the background image cache.
*
* The background image cache should be cleared when:
* - The background image source is updated, clearing the previous source.
* - The playback starts a new album, clearing the previous images.
* - The player size changes, requiring a new scaling for the images.
*/
initBgImage(panel, clearCache) {
if (!grSet.playlistBgImg && !grSet.libraryBgImg && !grSet.lyricsBgImg) return;
if (clearCache) this.clearBgImageCache();
this.handleBgImageIndex(panel, 'getIndexes');
const displayPanel = {
playlist: grm.ui.displayPlaylist,
library: grm.ui.displayLibrary,
lyrics: grm.ui.displayLyrics
};
const panelSize = {
playlist: [pl.playlist.x - SCALE(1), pl.playlist.y - pl.plman.h, pl.playlist.w + SCALE(2), pl.playlist.h + pl.plman.h * 2],
library: [lib.ui.x, lib.ui.y, lib.ui.w, lib.ui.h],
lyrics: [0, grm.ui.topMenuHeight, grm.ui.ww, grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight]
};
const panelToProcess = panel ? [panel] : ['playlist', 'library', 'lyrics'].filter(p => grSet[`${p}BgImg`]);
for (const p of panelToProcess) {
this.getBgImage(p).then(img => {
this[`${p}BgImg`] = img;
this.handleBgImageIndex(p, 'setIndexes');
if (displayPanel[p]) window.RepaintRect(...panelSize[p]);
});
}
}
/**
* Initializes or clears the cycling of background images.
*/
initBgImageCycle() {
for (const panel of ['playlist', 'library', 'lyrics']) {
clearInterval(this.imgCycleIntervals[panel]);
this.imgCycleIntervals[panel] = null;
const enabled = grSet[`${panel}BgImg`];
const cycle = grSet[`${panel}BgImgCycle`];
const cycleTime = grSet[`${panel}BgImgCycleTime`];
if (!enabled || !cycle) continue;
grm.debug.debugLog(`\n>>> initImage => initImgCycle => Panel: ${CapitalizeString(panel)} - Cycle time: ${cycleTime} seconds <<<\n`);
this.imgCycleIntervals[panel] = setInterval(() => {
this.cycleBgImage(panel, 1);
}, cycleTime * 1000);
}
}
/**
* Cycles the background image for the specified panel.
* @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image should be cycled.
* @param {number} direction - The direction to cycle the images (1 for next, -1 for previous).
*/
cycleBgImage(panel, direction) {
const imgKey = this.getBgImageSourceKeys(panel);
const imgList = Array.isArray(imgKey.imgList) ? imgKey.imgList : this[imgKey.imgList];
if (!imgList.length) {
this[imgKey.imgIdx] = (this[imgKey.imgIdx] + direction + this.albumArtIdx.length) % this.albumArtIdx.length;
const imgIdx = this.albumArtIdx[this[imgKey.imgIdx]];
this.handleBgImageIndex(panel, 'setIndexes');
this.fetchBgImageEmbedded(panel, imgIdx, imgKey.bgImg, imgKey.bgImgIdx).then(() => {
this.initBgImage(panel);
});
return;
}
if (imgList.length <= 1) return;
this[imgKey.imgIdx] = (this[imgKey.imgIdx] + direction + imgList.length) % imgList.length;
this.handleBgImageIndex(panel, 'setIndexes');
this.fetchBgImage(panel, imgList, imgKey.imgIdx, imgKey.bgImg, imgKey.bgImgIdx).then(() => {
this.initBgImage(panel);
});
}
/**
* Checks if the background image is cached, and updates the relevant properties if it is.
* @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested.
* @param {string|string[]} imgList - The name of the property in `this` that contains the image list, or the image list array itself.
* @param {string} imgIdx - The name of the property in `this` that contains the current image index.
* @param {string} bgImg - The name of the property in `this` that contains the cached image.
* @param {string} bgImgIdx - The name of the property in `this` that contains the cached image index.
* @returns {{image: GdiBitmap|null, changed: boolean}} The background image and a flag indicating if it has changed.
*/
checkBgImageCache(panel, imgList, imgIdx, bgImg, bgImgIdx) {
const imgArray = Array.isArray(imgList) ? imgList : this[imgList];
const imgPath = imgArray[this[imgIdx]];
const imgCached = grm.artCache.getImage(imgPath);
if (imgCached) {
this[bgImg] = imgCached;
this[bgImgIdx] = this[imgIdx];
return { image: imgCached, changed: false };
}
return { image: null, changed: true };
}
/**
* Clears the background image cache.
*/
clearBgImageCache() {
this.playlistBgImg = null;
this.libraryBgImg = null;
this.lyricsBgImg = null;
this.artistBgImg = null;
this.artistImgList = [];
this.albumBgImg = null;
this.albumImgList = [];
this.customBgImg = null;
this.customImgList = [];
grm.debug.debugLog('Main cache => Background image cache cleared');
}
/**
* Fetches a background image, either from cache or asynchronously, and updates the relevant properties.
* @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested.
* @param {string|string[]} imgList - The name of the property in `this` that contains the image list, or the image list array itself.
* @param {string} imgIdx - The name of the property in `this` that contains the current image index.
* @param {string} bgImg - The name of the property in `this` that contains the cached image.
* @param {string} bgImgIdx - The name of the property in `this` that contains the cached image index.
* @returns {Promise<{image: GdiBitmap|null, changed: boolean}>} The background image and a flag indicating if it has changed.
*/
fetchBgImage(panel, imgList, imgIdx, bgImg, bgImgIdx) {
if (this.imgFetching[panel]) {
return Promise.resolve({ image: null, changed: false });
}
this.imgFetching[panel] = true;
const imgArray = Array.isArray(imgList) ? imgList : this[imgList];
const imgIdxLocal = (this[imgIdx] >= imgArray.length) ? 0 : this[imgIdx];
const imgPathIdx = imgArray[imgIdxLocal];
return gdi.LoadImageAsyncV2(window.ID, imgPathIdx)
.then(img => {
this[bgImg] = grm.artCache.encache(img, imgPathIdx);
this[bgImgIdx] = imgIdxLocal;
return { image: img, changed: true };
})
.catch(error => {
console.log(`\n>>> Background Image => fetchBgImage => <Error: Image could not be properly parsed: ${panel}:>\n`, error);
return { image: null, changed: false };
})
.finally(() => {
this.imgFetching[panel] = false;
});
}
/**
* Fetches and caches embedded album art if available.
* @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested.
* @param {string} imgIdx - The name of the property in `this` that contains the current image index.
* @param {string} bgImg - The name of the property in `this` that contains the cached image.
* @param {string} bgImgIdx - The name of the property in `this` that contains the cached image index.
* @returns {Promise<{image: GdiBitmap|null, changed: boolean}>} The embedded album art image and a flag indicating if it has changed.
*/
fetchBgImageEmbedded(panel, imgIdx, bgImg, bgImgIdx) {
if (this.imgFetching[panel]) {
return Promise.resolve({ image: null, changed: false });
}
this.imgFetching[panel] = true;
try {
const metadb = grm.ui.initMetadb();
if (!metadb) {
return Promise.resolve({ image: null, changed: false });
}
const imgIdxLocal = this.albumArtIdx.includes(imgIdx) ? imgIdx : 0;
const albumArt = utils.GetAlbumArtV2(metadb, imgIdxLocal);
if (albumArt) {
this[bgImg] = grm.artCache.encache(albumArt, imgIdxLocal);
this[bgImgIdx] = imgIdxLocal;
return Promise.resolve({ image: albumArt, changed: true });
}
return Promise.resolve({ image: null, changed: false });
}
catch (error) {
console.log(`\n>>> Background Image => fetchBgImageEmbedded => <Error: Image could not be properly parsed: ${panel}:>\n`, error);
return Promise.resolve({ image: null, changed: false });
}
finally {
this.imgFetching[panel] = false;
}
}
/**
* Gets the background image based on the source setting.
* @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested.
* @returns {{image: GdiBitmap|null, changed: boolean}} The background image and a flag indicating if it has changed.
*/
getBgImage(panel) {
const { imgType, imgList, imgIdx, bgImg, bgImgIdx } = this.getBgImageSourceKeys(panel);
const bgImageFormats = ParseStringToRegExp(grCfg.artworkImageFormats.bgImage);
const bgImagePattern = this.getBgImagePatterns(panel);
this[imgList] = grm.ui.getImagePathList(imgType, grm.ui.initMetadb(), bgImagePattern).filter(path => bgImageFormats.test(path));
if (!this[imgList].length) {
const embeddedIdx = this.albumArtIdx[this[imgIdx]];
return this.fetchBgImageEmbedded(panel, embeddedIdx, bgImg, bgImgIdx);
}
const { image, changed } = this.checkBgImageCache(panel, imgList, imgIdx, bgImg, bgImgIdx);
if (image) {
return Promise.resolve({ image, changed });
}
return this.fetchBgImage(panel, imgList, imgIdx, bgImg, bgImgIdx);
}
/**
* Gets the background image pattern for a given panel type.
* @param {string} panel - The panel type, which can be 'playlist', 'library', or 'lyrics'.
* @returns {RegExp} The pattern for the specified panel type.
*/
getBgImagePatterns(panel) {
const bgImagePatterns = {
playlist: grSet.playlistBgImgAlbumArtFilter && ParseStringToRegExp(grCfg.artworkPatterns.playlistBgAlbumArt),
library: grSet.libraryBgImgAlbumArtFilter && ParseStringToRegExp(grCfg.artworkPatterns.libraryBgAlbumArt),
lyrics: grSet.lyricsBgImgAlbumArtFilter && ParseStringToRegExp(grCfg.artworkPatterns.lyricsBgAlbumArt)
};
return bgImagePatterns[panel];
}
/**
* Retrieves the background image source keys based on the specified panel.
* @param {string} panel - The panel ('playlist', 'library', 'lyrics') whose background image source keys are to be retrieved.
* @returns {object} An object containing the image type, list, index, and background image keys.
*/
getBgImageSourceKeys(panel) {
const Panel = CapitalizeString(panel);
const imgSrcKeys = {
artist: { imgType: 'artistArt', imgList: 'artistImgList', imgIdx: `artistIdx${Panel}`, bgImg: 'artistBgImg', bgImgIdx: `artistIdxCached${Panel}` },
album: { imgType: 'albumArt', imgList: 'albumImgList', imgIdx: `albumIdx${Panel}`, bgImg: 'albumBgImg', bgImgIdx: `albumIdxCached${Panel}` },
custom: { imgType: 'customArt', imgList: 'customImgList', imgIdx: `customIdx${Panel}`, bgImg: 'customBgImg', bgImgIdx: `customIdxCached${Panel}` }
};
return imgSrcKeys[grSet[`${panel}BgImgSource`]];
}
/**
* Handles background image indexes for the specified panel.
* @param {string} panel - The panel ('playlist', 'library', 'lyrics', or false for all) whose background image indexes are to be retrieved.
* @param {string} action - The action to perform: 'getIndexes', 'setIndexes', or 'clearIndexes'.
*/
handleBgImageIndex(panel, action) {
const panels = panel ? [panel] : ['playlist', 'library', 'lyrics'];
const indexFields = {
playlist: ['bgImgArtistIdxPlaylist', 'bgImgAlbumIdxPlaylist', 'bgImgCustomIdxPlaylist'],
library: ['bgImgArtistIdxLibrary', 'bgImgAlbumIdxLibrary', 'bgImgCustomIdxLibrary'],
lyrics: ['bgImgArtistIdxLyrics', 'bgImgAlbumIdxLyrics', 'bgImgCustomIdxLyrics']
};
const actions = {
getIndexes: (Panel, artistIdx, albumIdx, customIdx) => {
this[`artistIdx${Panel}`] = grSet[artistIdx];
this[`albumIdx${Panel}`] = grSet[albumIdx];
this[`customIdx${Panel}`] = grSet[customIdx];
},
setIndexes: (Panel, artistIdx, albumIdx, customIdx) => {
grSet[artistIdx] = this[`artistIdx${Panel}`];
grSet[albumIdx] = this[`albumIdx${Panel}`];
grSet[customIdx] = this[`customIdx${Panel}`];
},
clearIndexes: (Panel) => {
this[`artistIdx${Panel}`] = 0;
this[`artistIdxCached${Panel}`] = -1;
this[`albumIdx${Panel}`] = 0;
this[`albumIdxCached${Panel}`] = -1;
this[`customIdx${Panel}`] = 0;
this[`customIdxCached${Panel}`] = -1;
}
};
for (const panel of panels) {
if (actions[action]) {
actions[action](CapitalizeString(panel), ...indexFields[panel]);
}
}
}
// #endregion
}
///////////////////////////
// * CPU USAGE TRACKER * //
///////////////////////////
/**
* A class that tracks and monitors CPU usage.
*/
class CPUTracker {
/**
* Create the CPUTracker instance.
* @param {Function} onChangeCallback - A callback function to call when CPU usage changes.
*/
constructor(onChangeCallback) {
/** @private @type {number} */
this.cpuUsage = 0;
/** @private @type {number} */
this.guiCpuUsage = 0;
/** @private @type {?number} */
this.cpuTrackerTimer = null;
/** @private @type {Function} */
this.onChangeCallback = onChangeCallback;
/** @private @type {{[key: string]: {sampleCount: number, currentSampleCount: number, resetSampleCount: number, acumUsage: number, averageUsage: number}}} */
this.usage = {
idle: {
sampleCount: 30,
currentSampleCount: 0,
resetSampleCount: 0,
acumUsage: 0,
averageUsage: 0
},
playing: {
sampleCount: 30,
currentSampleCount: 0,
resetSampleCount: 0,
acumUsage: 0,
averageUsage: 0
}
};
}
/**
* Gets the current CPU usage.
* @returns {number} The current CPU usage.
*/
getCpuUsage() {
return this.cpuUsage;
}
/**
* Gets the current GUI CPU usage.
* @returns {number} The current GUI CPU usage.
*/
getGuiCpuUsage() {
return this.guiCpuUsage;
}
/**
* Starts the CPU usage monitoring process.
*/
start() {
if (this.cpuTrackerTimer) return;
this.cpuTrackerTimer = setInterval(() => {
const floatUsage = Math.random() * 100; // Simulated CPU usage
const isPlaying = Math.random() > 0.5; // Simulated playback status
const isPaused = Math.random() > 0.8;
const usageType = isPlaying && !isPaused ? 'playing' : 'idle';
this.updateUsage(usageType, floatUsage);
const baseLine = this.usage[usageType].averageUsage;
this.cpuUsage = floatUsage.toFixed(1);
let usageDiff = Math.max((floatUsage - baseLine), 0);
usageDiff = (usageDiff <= 0.5 ? 0 : usageDiff); // Suppress low spikes
this.guiCpuUsage = usageDiff.toFixed(1);
if (this.onChangeCallback) {
this.onChangeCallback();
}
}, 1000);
}
/**
* Stops the CPU usage monitoring and resets usage statistics.
*/
stop() {
if (this.cpuTrackerTimer) {
clearInterval(this.cpuTrackerTimer);
this.cpuTrackerTimer = undefined;
}
this.resetUsage('idle');
this.resetUsage('playing');
}
/**
* Recalculates the average CPU usage based on a new sample.
* @param {string} type - The type of CPU usage to recalculate ('idle' or 'playing').
* @param {number} currentUsage - The new CPU usage sample.
*/
recalcAvg(type, currentUsage) {
const usageState = this.usage[type];
if (usageState.currentSampleCount < usageState.sampleCount) {
usageState.acumUsage += currentUsage;
usageState.currentSampleCount++;
usageState.averageUsage = usageState.acumUsage / usageState.currentSampleCount;
return;
}
usageState.averageUsage -= usageState.averageUsage / usageState.sampleCount;
usageState.averageUsage += currentUsage / usageState.sampleCount;
}
/**
* Resets the CPU usage data for a specified type.
* @param {string} type - The type of CPU usage to reset ('idle' or 'playing').
*/
resetUsage(type) {
const usageState = this.usage[type];
usageState.currentSampleCount = 0;
usageState.resetSampleCount = 0;
usageState.acumUsage = 0;
usageState.averageUsage = 0;
}
/**
* Updates the CPU usage data based on new sample.
* @param {string} type - The type of CPU usage to update ('idle' or 'playing').
* @param {number} currentUsage - The current CPU usage to update.
*/
updateUsage(type, currentUsage) {
const usageState = this.usage[type];
if (usageState.currentSampleCount) {
if (usageState.averageUsage - currentUsage > 2) {
if (usageState.resetSampleCount < 3) {
usageState.resetSampleCount++;
} else {
this.resetUsage(type);
}
} else if (Math.abs(currentUsage - usageState.averageUsage) < 2) {
this.recalcAvg(type, currentUsage);
}
} else {
this.recalcAvg(type, currentUsage);
}
}
}
/////////////////
// * TOOLTIP * //
/////////////////
/**
* A class that creates or stops the tooltip timer.
*/
class TooltipTimer {
/**
* Creates the `TooltipTimer` instance.
*/
constructor() {
/** @private @type {number|undefined} The timer ID for the tooltip display timeout. */
this.tooltipTimer = undefined;
/** @private @type {number|undefined} The identifier of the current tooltip caller. */
this.tooltipCaller = undefined;
}
// * PUBLIC METHODS * //
// #region PUBLIC METHODS
/**
* Displays the tooltip.
* @param {string} text - The text to show in the tooltip.
* @param {boolean} [force] - Activates the tooltip whether or not text has changed.
*/
displayTooltip(text, force) {
if (grm.ui.ttip && (grm.ui.ttip.Text !== text.toString() || force)) {
grm.ui.ttip.Text = text;
grm.ui.ttip.Activate();
}
}
/**
* Starts a tooltip.
* @param {number} id - The id of the caller.
* @param {string} text - The text to show in the tooltip.
*/
start(id, text) {
const oldCaller = this.tooltipCaller;
this.tooltipCaller = id;
if (!this.tooltipTimer && grm.ui.ttip.Text) {
this.displayTooltip(text, oldCaller !== this.tooltipCaller);
}
else { // * There can be only one tooltip present at all times, so we can kill the timer w/o any worries
if (this.tooltipTimer) {
this.forceStop();
}
if (!this.tooltipTimer) {
this.tooltipTimer = setTimeout(() => {
this.displayTooltip(text);
this.tooltipTimer = null;
}, 300);
}
}
}
/**
* Stops a tooltip.
* @param {number} id - The id of the caller.
*/
stop(id) {
if (this.tooltipCaller === id) { // Do not stop other callers
this.forceStop();
}
}
/**
* Forces the tooltip to stop.
*/
forceStop() {
this.displayTooltip('');
if (!this.tooltipTimer) return;
clearTimeout(this.tooltipTimer);
this.tooltipTimer = null;
this.tooltipCaller = null;
}
// #endregion
}
/**
* A class that creates or clears the tooltip text for normal and styled tooltips.
*/
class TooltipHandler {
/**
* Creates the `TooltipHandler` instance.
* Constructs a unique ID and a reference to the TooltipTimer instance.
*/
constructor() {
/** @private @type {number} The unique identifier for this TooltipHandler instance. */
this.id = Math.ceil(Math.random() * 10000);
/** @private @type {TooltipTimer} A reference to the TooltipTimer instance used to manage tooltip timing. */
this.timer = new TooltipTimer();
}
// * PUBLIC METHODS * //
// #region PUBLIC METHODS
/**
* Shows tooltip after delay (300ms).
* @param {string} text - The text to show in the tooltip.
*/
showDelayed(text) {
grm.ui.styledTooltipText = text;
if (!grSet.showStyledTooltips) {
this.timer.start(this.id, text);
}
}
/**
* Shows the tooltip immediately.
* @param {string} text - The text to show in the tooltip.
*/
showImmediate(text) {
grm.ui.styledTooltipText = text;
if (!grSet.showStyledTooltips) {
this.timer.stop(this.id);
this.timer.displayTooltip(text);
}
}
/**
* Clears this tooltip if this handler created it.
*/
clear() {
this.timer.stop(this.id);
}
/**
* Clears the tooltip regardless of which handler created it.
*/
stop() {
this.timer.forceStop();
}
// #endregion
}
//////////////////////////////
// * INTERFACE HYPERLINKS * //
//////////////////////////////
/**
* A class that creates clickable hyperlinks in the Playlist header and in the lower bar.
*/
class Hyperlink {
/**
* Creates the `Hyperlink` instance.
* Initializes properties for the text element in the playlist.
* @param {string} text - The text that will be displayed in the hyperlink.
* @param {GdiFont} font - The font to use.
* @param {string} type - The field name which will be searched when clicking on the hyperlink.
* @param {number} xOffset - The x-offset of the hyperlink. Negative values will be subtracted from the containerWidth to right justify.
* @param {number} yOffset - The y-offset of the hyperlink.
* @param {number} containerWidth - The width of the container the hyperlink will be in. Used for right justification purposes.
* @param {boolean} [inPlaylist] - If the hyperlink is drawing in a scrolling container like a playlist, then it is drawn differently.
*/
constructor(text, font, type, xOffset, yOffset, containerWidth, inPlaylist = false) {
/** @private @type {string} */
this.text = text;
/** @private @type {string} */
this.type = type;
/** @private @type {number} */
this.x_offset = xOffset;
/** @private @type {number} */
this.x = xOffset < 0 ? containerWidth + xOffset : xOffset;
/** @private @type {number} */
this.y_offset = yOffset;
/** @private @type {number} */
this.y = yOffset;
/** @private @type {number} */
this.container_w = containerWidth;
/** @private @type {boolean} */
this.state = HyperlinkStates.Normal;
/** @private @type {boolean} */
this.inPlaylist = inPlaylist;
this.setFont(font);
}
// * PUBLIC METHODS * //
// #region PUBLIC METHODS
/**
* Draws the hyperlink. When drawing in a playlist, we draw from the y-offset instead of y, because the playlist scrolls.
* @param {GdiGraphics} gr - The GDI graphics object.
* @param {number} color - The color of the hyperlink.
*/
draw(gr, color) {
const font = this.state === HyperlinkStates.Hovered ? this.hoverFont : this.font;
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);
}
/**
* Sets the xOffset of the hyperlink after it has been created.
* @param {number} xOffset - The x-offset of the hyperlink. Negative values will be subtracted from the containerWidth to right justify.
*/
setXOffset(xOffset) {
this.x = xOffset < 0 ? this.container_w + xOffset : xOffset;
}
/**
* Sets the vertical position of the hyperlink.
* The playlist requires subtracting 2 additional pixels from y for some reason.
* @param {number} y - The y-coordinate.
*/
setY(y) {
this.y = y + this.y_offset + (-2);
}
/**
* Sets the font for the hyperlink.
* @param {GdiFont} font - The font that will be used.
*/
setFont(font) {
this.font = font;
this.hoverFont = gdi.Font(font.Name, font.Size, font.Style | FontStyle.Underline);
this.link_dimensions = this.updateDimensions();
}
/**
* Sets the width of the container the hyperlink will be placed in.
* If hyperlink width is smaller than the container, it will be truncated.
* If the the xOffset is negative, the position will be adjusted as the container width changes.
* @param {number} w - The width.
*/
setContainerWidth(w) {
if (this.x_offset < 0) {
this.x = w + this.x_offset; // Add because offset is negative
}
this.container_w = w;
this.link_dimensions = this.updateDimensions();
this.w = Math.ceil(Math.min(this.container_w, this.link_dimensions.Width + 1));
}
/**
* Gets the width of the hyperlink.
* @returns {number} The width of the link in pixels.
*/
getWidth() {
try {
return Math.ceil(this.link_dimensions.Width);
} catch (e) {
return null;
}
}
/**
* Updates the width and height of the hyperlinks.
* @returns {number} The dimensions of the text.
*/
updateDimensions() {
try {
const measureStringScratchImg = gdi.CreateImage(1000, 200);
const gr = measureStringScratchImg.GetGraphics();
const dimensions = gr.MeasureString(this.text, this.font, 0, 0, 0, 0);
this.h = Math.ceil(dimensions.Height) + 1;
this.w = Math.min(Math.ceil(dimensions.Width) + 1, this.container_w);
measureStringScratchImg.ReleaseGraphics(gr);
return dimensions;
} catch (e) {
return null; // Probably some invalid parameters on init
}
}
/**
* Populates the result of artist, album, date or label in the "Search" playlist when a hyperlink was clicked.
*/
click() {
const populatePlaylist = (query) => {
grm.debug.debugLog(query);
try {
const handle_list = fb.GetQueryItems(fb.GetLibraryItems(), query);
if (handle_list.Count) {
pl.history.ignorePlaylistMutations = true;
const plist = plman.FindOrCreatePlaylist('Search', true);
plman.UndoBackup(plist);
handle_list.Sort();
const index = fb.IsPlaying ? handle_list.BSearch(fb.GetNowPlaying()) : -1;
if (plist === plman.PlayingPlaylist && plman.GetPlayingItemLocation().PlaylistIndex === pl && index !== -1) {
// Remove everything in playlist except currently playing song
plman.ClearPlaylistSelection(plist);
plman.SetPlaylistSelection(plist, [plman.GetPlayingItemLocation().PlaylistItemIndex], true);
plman.RemovePlaylistSelection(plist, true);
plman.ClearPlaylistSelection(plist);
handle_list.RemoveById(index);
}
else {
// Nothing playing or Search playlist is not active
plman.ClearPlaylist(plist);
}
plman.InsertPlaylistItems(plist, 0, handle_list);
plman.SortByFormat(plist, grCfg.settings.playlistSortDefault);
plman.ActivePlaylist = plist;
pl.history.ignorePlaylistMutations = false;
return true;
}
return false;
}
catch (e) {
pl.history.ignorePlaylistMutations = false;
console.log(`Could not successfully execute: ${query}`);
}
};
/** @type {string} */
let query;
switch (this.type) {
case 'update': RunCmd('https://github.com/TT-ReBORN/Georgia-ReBORN/releases'); break;
case 'date': query = grSet.showPlaylistFullDate ? `"${grTF.date}" IS ${this.text}` : `"$year(%date%)" IS ${this.text}`; break;
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;
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;
case 'label': query = `Label HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}" OR Publisher HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}"`; break;
default: query = `${this.type} IS "${this.text}"`; break;
}
if (!populatePlaylist(query)) {
const start = this.text.indexOf('[');
if (start > 0) {
query = `${this.type} IS ${this.text.slice(0, start - 3)}`; // Remove ' - [...]' from end of string in case we're showing "Album - [Deluxe Edition]", etc.
populatePlaylist(query);
}
}
}
/**
* Updates the hyperlink state.
*/
repaint() {
try {
window.RepaintRect(this.x, this.y, this.w, this.h);
} catch (e) {
// Probably already redrawing
}
}
// #endregion
// * CALLBACKS * //
// #region CALLBACKS
/**
* Sets mouse hover state for every hyperlink not created in Playlist.
* @param {object} hyperlink - The hyperlink object.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @returns {boolean} True or false.
*/
on_mouse_move(hyperlink, x, y) {
if (hyperlink.trace(x, y)) {
if (hyperlink.state !== HyperlinkStates.Hovered) {
hyperlink.state = HyperlinkStates.Hovered;
window.RepaintRect(hyperlink.x, hyperlink.y, hyperlink.w, hyperlink.h);
}
return true;
}
if (hyperlink.state !== HyperlinkStates.Normal) {
hyperlink.state = HyperlinkStates.Normal;
window.RepaintRect(hyperlink.x, hyperlink.y, hyperlink.w, hyperlink.h);
}
return false;
}
/**
* Checks if the mouse is within the boundaries of a hyperlink.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @returns {boolean} True or false.
*/
trace(x, y) {
return (this.x <= x) && (x <= this.x + this.w) && (this.y <= y) && (y <= this.y + this.h);
}
// #endregion
}
/////////////////////
// * JUMP SEARCH * //
/////////////////////
/**
* A class that creates the jump search when using keystrokes.
* Searches in the active Playlist first and when nothing found, it tries in the Library.
*/
class JumpSearch {
/**
* Creates the `JumpSearch` instance.
*/
constructor() {
/** @private @type {number} */
this.arc1 = 5;
/** @private @type {number} */
this.arc2 = 4;
/** @private @type {object} */
this.j = {
x: 0,
y: 0,
w: grSet.notificationFontSize_layout * 2,
h: grSet.notificationFontSize_layout * 2
};
/** @private @type {string} */
this.jSearch = '';
/** @private @type {boolean} */
this.jump_search = true;
/** @type {{ [key: string]: number[] }} */
this.initials = null;
}
// * PUBLIC METHODS * //
// #region PUBLIC METHODS
/**
* Draws the jump search on the playlist panel.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
draw(gr) {
if (!this.jSearch) return;
gr.SetSmoothingMode(4);
this.j.w = gr.CalcTextWidth(this.jSearch, grFont.notification) + 25;
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));
gr.DrawRoundRect(this.j.x - this.j.w / 2, this.j.y, this.j.w, this.j.h, this.arc1, this.arc1, 1, 0x64000000);
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);
// 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
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);
gr.SetSmoothingMode(0);
}
/**
* Sets the vertical position of the jump search.
* @param {number} y - The y-coordinate.
*/
setY(y) {
this.y = y;
}
// #endregion
// * CALLBACKS * //
// #region CALLBACKS
/**
* Handles key pressed events and activates the jump search.
* @param {number} code - The character code.
*/
on_char(code) {
const text = String.fromCharCode(code);
if (grSet.jumpSearchDisabled || lib.panel.search.active ||
utils.IsKeyPressed(VKey.CONTROL) || utils.IsKeyPressed(VKey.ESCAPE) ||
this.jSearch === '' && text === ' ') {
return;
}
const playlistItems = plman.GetPlaylistItems(plman.ActivePlaylist);
const search = fb.TitleFormat(grSet.jumpSearchComposerOnly ? '%composer%' : '$if2(%album artist%, %artist%)').EvalWithMetadbs(playlistItems);
let focusIndex = plman.GetPlaylistFocusItemIndex(plman.ActivePlaylist);
let advance = false;
let foundInPlaylist = false;
let foundInLibrary = false;
switch (code) {
case lib.vk.back:
this.jSearch = this.jSearch.slice(0, -1);
break;
case lib.vk.enter:
this.jSearch = '';
return;
default:
this.jSearch += text;
break;
}
// * Playlist advance
if (focusIndex >= 0 && focusIndex < search.length && (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true))) {
const char = search[focusIndex].replace(Regex.LibMarkerColor, '').charAt(0).toLowerCase();
if (char === text && AllEqual(this.jSearch)) {
this.jSearch = this.jSearch.slice(0, 1);
advance = true;
}
}
// * Library advance
else if (lib.panel.pos >= 0 && lib.panel.pos < lib.pop.tree.length && !grm.ui.displayLibrarySplit(true)) {
const char = lib.pop.tree[lib.panel.pos].name.replace(Regex.LibMarkerColor, '').charAt(0).toLowerCase();
if (lib.pop.tree[lib.panel.pos].sel && char === text && AllEqual(this.jSearch)) {
this.jSearch = this.jSearch.slice(0, 1);
advance = true;
}
}
switch (true) {
case advance: {
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;
let init = '';
let cur = 'currentArr';
if (!this.initials) { // reset in buildTree
this.initials = {};
// * Playlist advance
if (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true)) {
for (const [i] of playlistItems.Convert().entries()) {
const name = search[i].replace(Regex.LibMarkerColor, '');
init = name.charAt().toLowerCase();
if (cur !== init && !this.initials[init]) {
this.initials[init] = [i];
cur = init;
} else {
this.initials[init].push(i);
}
}
}
// * Library advance
else {
for (const [i, v] of lib.pop.tree.entries()) {
if (!v.root) {
const nm = v.name.replace(Regex.LibMarkerColor, '');
init = nm.charAt().toLowerCase();
if (cur !== init && !this.initials[init]) {
this.initials[init] = [i];
cur = init;
} else {
this.initials[init].push(i);
}
}
}
}
}
this.jump_search = false;
// * Playlist advance
if (focusIndex >= 0 && focusIndex < search.length && (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true))) {
this.matches = this.initials[text];
grm.debug.debugLog('Playlist advance results', this.matches); // Debug
this.ix = this.matches.indexOf(focusIndex);
this.ix++;
if (this.ix >= this.matches.length) this.ix = 0;
focusIndex = this.matches[this.ix];
this.jump_search = true;
}
// * Library advance
else if (lib.panel.pos >= 0 && lib.panel.pos < lib.pop.tree.length && !grm.ui.displayLibrarySplit(true)) {
this.matches = this.initials[text];
grm.debug.debugLog('Library advance results', this.matches); // Debug, can remove this soon
this.ix = this.matches.indexOf(lib.panel.pos);
this.ix++;
if (this.ix >= this.matches.length) this.ix = 0;
lib.panel.pos = this.matches[this.ix];
this.jump_search = true;
}
// * Playlist advance
if (this.jump_search && (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true))) {
plman.ClearPlaylistSelection(plman.ActivePlaylist);
plman.SetPlaylistFocusItem(plman.ActivePlaylist, focusIndex);
plman.SetPlaylistSelectionSingle(plman.ActivePlaylist, focusIndex, true);
window.Repaint();
}
// * Library advance
else if (this.jump_search && !grm.ui.displayLibrarySplit(true)) {
lib.pop.clearSelected();
lib.pop.sel_items = [];
lib.pop.tree[lib.panel.pos].sel = true;
lib.pop.setPos(lib.panel.pos);
lib.pop.getTreeSel();
lib.lib.treeState(false, libSet.rememberTree);
window.Repaint();
if (lib.panel.imgView) lib.pop.showItem(lib.panel.pos, 'focus');
else {
const row = (lib.panel.pos * lib.ui.row.h - lib.sbar.scroll) / lib.ui.row.h;
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);
}
if (libSet.libSource) {
if (lib.pop.autoFill.key) lib.pop.load(lib.pop.sel_items, true, false, false, !libSet.sendToCur, false);
lib.pop.track(lib.pop.autoFill.key);
} 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]);
}
else {
window.Repaint();
}
lib.timer.clear(lib.timer.jsearch2);
lib.timer.jsearch2.id = setTimeout(() => {
this.jSearch = '';
window.Repaint();
lib.timer.jsearch2.id = null;
}, 2200);
}
break;
case !advance:
if (utils.IsKeyPressed(VKey.TAB) || utils.IsKeyPressed(VKey.CONTROL) || utils.IsKeyPressed(VKey.ESCAPE) || utils.IsKeyPressed(VKey.MULTIPLY) || utils.IsKeyPressed(VKey.SUBTRACT)) return;
if (!lib.panel.search.active) {
let pos = -1;
lib.pop.clearSelected();
if (!this.jSearch) return;
lib.pop.sel_items = [];
this.jump_search = true;
window.Repaint();
lib.timer.clear(lib.timer.jsearch1);
lib.timer.jsearch1.id = setTimeout(() => {
// * First search in the Playlist
playlistItems.Convert().some((v, i) => {
const name = search[i].replace(Regex.LibMarkerColor, '');
if (name.substring(0, this.jSearch.length).toLowerCase() === this.jSearch.toLowerCase()) {
foundInPlaylist = true;
pos = i;
plman.ClearPlaylistSelection(plman.ActivePlaylist);
plman.SetPlaylistFocusItem(plman.ActivePlaylist, pos);
plman.SetPlaylistSelectionSingle(plman.ActivePlaylist, pos, true);
grm.debug.debugLog(`Jumpsearch: "${name}" found in Playlist`); // Debug, can remove this soon
return true;
}
return false;
});
// * If no Playlist results found, try search query in the Library
if (!foundInPlaylist && grSet.jumpSearchIncludeLibrary && grSet.layout !== 'compact') {
lib.pop.tree.some((v, i) => {
const name = v.name.replace(Regex.LibMarkerColor, '');
if (name !== lib.panel.rootName && name.substring(0, this.jSearch.length).toLowerCase() === this.jSearch.toLowerCase()) {
foundInPlaylist = false;
foundInLibrary = true;
pos = i;
v.sel = true;
lib.pop.setPos(pos);
if (lib.pop.autoFill.key) lib.pop.getTreeSel();
lib.lib.treeState(false, libSet.rememberTree);
grm.debug.debugLog(`Jumpsearch: "${name}" found in Library`); // Debug, can remove this soon
return true;
}
return false;
});
}
if (!foundInPlaylist && !foundInLibrary) {
this.jump_search = false;
grm.debug.debugLog('Jumpsearch: No results were found'); // Debug, can remove this soon
}
window.Repaint();
if (foundInPlaylist) {
grm.ui.displayPlaylist = true;
grm.ui.displayLibrary = grSet.libraryLayout === 'split' && grm.ui.displayPlaylist;
grm.ui.displayBiography = false;
grm.ui.displayLyrics = false;
grm.button.initButtonState();
}
else if (foundInLibrary && grSet.jumpSearchIncludeLibrary) {
grm.ui.displayPlaylist = grSet.libraryLayout === 'split' && grm.ui.displayPlaylist;
grm.ui.displayLibrary = true;
grm.ui.displayBiography = false;
grm.ui.displayLyrics = false;
lib.pop.showItem(pos, 'focus');
this.jSearch = ''; // Reset to avoid conflict with other query
grm.button.initButtonState();
}
lib.timer.jsearch1.id = null;
}, 500);
lib.timer.clear(lib.timer.jsearch2);
lib.timer.jsearch2.id = setTimeout(() => {
this.jSearch = '';
window.Repaint();
lib.timer.jsearch2.id = null;
}, 1200);
}
}
}
/**
* Sets the size and position of the jump search and updates them on window resizing.
*/
on_size() {
this.j.h = grSet.notificationFontSize_layout * 2;
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);
this.j.y = Math.round((grm.ui.wh + grm.ui.topMenuHeight - grm.ui.lowerBarHeight - this.j.h) / 2);
this.arc1 = Math.min(5, this.j.h / 2);
this.arc2 = Math.min(4, (this.j.h - 2) / 2);
}
// #endregion
}
//////////////////////
// * PROGRESS BAR * //
//////////////////////
/**
* A class that creates the progress bar in the lower bar when enabled.
* Quick access via right click context menu on lower bar.
*/
class ProgressBar {
/**
* Creates the `ProgressBar` instance.
*/
constructor() {
/** @public @type {number} The x-coordinate of the progress bar. */
this.x = grm.ui.edgeMargin;
/** @public @type {number} The y-coordinate of the progress bar. */
this.y = 0;
/** @public @type {number} The width of the progress bar. */
this.w = grm.ui.ww - grm.ui.edgeMarginBoth;
/** @public @type {number} The height of the progress bar. */
this.h = grm.ui.seekbarHeight;
/** @public @type {number} The arc radius for rounded corners of the progress bar. */
this.arc = Math.min(this.w, this.h) / 2;
/** @public @type {number} The length of the progress bar fill. */
this.progressLength = 0;
/** @private @type {boolean} The state that indicates if the progress bar is being dragged. */
this.drag = false;
}
// * PUBLIC METHODS * //
// #region PUBLIC METHODS
/**
* Draws the progress bar with various progress bar styles.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
draw(gr) {
if (grm.debug.showDrawExtendedTiming) grm.ui.seekbarProfiler.Reset();
const styleRounded = grSet.styleProgressBarDesign === 'rounded';
if (styleRounded) this.arc = Math.min(this.w, this.h) / 2;
gr.SetSmoothingMode(styleRounded ? SmoothingMode.AntiAlias : SmoothingMode.None);
this.drawProgressBarBg(gr);
this.drawProgressBarFill(gr);
}
/**
* Draws the progress bar background.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawProgressBarBg(gr) {
const barDesignNoDotsThin = !['dots', 'thin'].includes(grSet.styleProgressBarDesign);
const styleRounded = grSet.styleProgressBarDesign === 'rounded';
const styleDefault = grSet.styleDefault && (['blue', 'darkblue', 'red', 'cream'].includes(grSet.theme) || grSet.theme.startsWith('custom'));
const styleCream = grSet.theme === 'cream' && (grSet.styleAlternative || grSet.styleAlternative2) && (!grSet.styleBevel && !grSet.styleBlend && !grSet.styleBlend2 && grSet.styleProgressBarDesign !== 'rounded') && !grSet.systemFirstLaunch;
const styleBevelOrInner = barDesignNoDotsThin && ['bevel', 'inner'].includes(grSet.styleProgressBar);
const progressBarColor = grm.ui.isStreaming && fb.IsPlaying ? grCol.progressBarStreaming : grCol.progressBar;
if (styleRounded) {
FillRoundRect(gr, this.x, this.y, this.w, this.h, this.arc, this.arc, progressBarColor);
} else if (barDesignNoDotsThin) {
gr.FillSolidRect(this.x, this.y, this.w, this.h, progressBarColor);
}
if (styleDefault || styleCream) {
gr.DrawRect(this.x - 2, this.y - 2, this.w + 3, this.h + 3, 1, grCol.progressBarFrame);
}
if (styleBevelOrInner) {
const styleBlackReborn = grSet.styleBlackReborn && fb.IsPlaying;
const angle = grSet.styleProgressBar === 'inner' ? (styleBlackReborn ? 90 : -90) : (styleBlackReborn ? -90 : 90);
if (styleRounded) {
FillGradRoundRect(gr, this.x, this.y, this.w + SCALE(2), this.h + SCALE(2), this.arc, this.arc, angle, 0, grCol.styleProgressBar, 1);
const xLeft = this.x + SCALE(3);
const xRight = this.w + this.x - SCALE(12);
const yTop = this.y - 0.5;
const yBottom = this.y + this.h - 0.5;
FillGradRect(gr, xLeft, yTop, SCALE(9), 1, 179, grCol.styleProgressBarLineTop, 0); // Top left
FillGradRect(gr, xLeft, yBottom, SCALE(9), 1, 179, grCol.styleProgressBarLineBottom, 0); // Bottom left
FillGradRect(gr, xRight, yTop, SCALE(9), 1, 179, 0, grCol.styleProgressBarLineTop); // Top right
FillGradRect(gr, xRight, yBottom, SCALE(9), 1, 179, 0, grCol.styleProgressBarLineBottom); // Bottom right
}
else {
FillGradRect(gr, this.x, this.y, this.w, this.h, angle, 0, grCol.styleProgressBar);
}
const lineX1 = this.x + (styleRounded ? SCALE(12) : 0);
const lineX2 = this.x + this.w - (styleRounded ? SCALE(12) : 1);
gr.DrawLine(lineX1, this.y, lineX2, this.y, 1, grCol.styleProgressBarLineTop);
gr.DrawLine(lineX1, this.y + this.h, lineX2, this.y + this.h, 1, grCol.styleProgressBarLineBottom);
}
}
/**
* Draws the progress bar fill.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawProgressBarFill(gr) {
if (!fb.IsPlaying || fb.PlaybackLength <= 0) return;
const playbackRatio = fb.PlaybackTime / fb.PlaybackLength;
this.progressLength = Math.floor(this.w * playbackRatio);
const drawBarDesign = {
default: () => gr.FillSolidRect(this.x, this.y, this.progressLength, this.h, grCol.progressBarFill),
rounded: () => FillRoundRect(gr, this.x, this.y, this.progressLength, this.h, this.arc, this.arc, grCol.progressBarFill),
lines: () => this.drawBarDesignLines(gr),
blocks: () => this.drawBarDesignBlocks(gr),
dots: () => this.drawBarDesignDots(gr),
thin: () => this.drawBarDesignThin(gr)
};
drawBarDesign[grSet.styleProgressBarDesign]();
if (!['dots', 'thin'].includes(grSet.styleProgressBarDesign) && ['bevel', 'inner'].includes(grSet.styleProgressBarFill)) {
if (grSet.styleProgressBarDesign === 'rounded') {
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);
} else {
FillGradRect(gr, this.x, this.y, this.progressLength, this.h, grSet.styleProgressBarFill === 'inner' ? -90 : 89, 0, grCol.styleProgressBarFill);
}
}
else if (grSet.styleProgressBarFill === 'blend' && grm.ui.albumArt && grCol.imgBlended) {
if (grSet.styleProgressBarDesign === 'rounded') {
FillBlendedRoundRect(gr, this.x, this.y, this.progressLength + SCALE(2), this.h + SCALE(2), this.arc, this.arc, 88, grCol.imgBlended, 0);
} else {
gr.DrawImage(grCol.imgBlended, this.x, this.y, this.progressLength, this.h, 0, this.h, grCol.imgBlended.Width, grCol.imgBlended.Height);
}
}
}
/**
* Draws the progress bar fill in lines design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarDesignLines(gr) {
gr.FillSolidRect(this.x + this.progressLength, this.y, SCALE(2), grm.ui.seekbarHeight, grCol.progressBarFill);
for (let progressLine = 0; progressLine < this.progressLength; progressLine += SCALE(4)) {
gr.DrawLine(this.x + progressLine + SCALE(2), this.y, this.x + progressLine + SCALE(2), this.y + this.h, SCALE(2), grCol.progressBarFill);
}
}
/**
* Draws the progress bar fill in blocks design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarDesignBlocks(gr) {
for (let progressLine = 0; progressLine < this.progressLength; progressLine += grm.ui.seekbarHeight + SCALE(2)) {
gr.FillSolidRect(this.x + progressLine, this.y + SCALE(2), grm.ui.seekbarHeight, grm.ui.seekbarHeight - SCALE(4), grCol.progressBarFill);
}
gr.FillSolidRect(this.x + this.progressLength, this.y + 1, grm.ui.seekbarHeight, grm.ui.seekbarHeight - 1, grCol.progressBar);
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);
}
/**
* Draws the progress bar fill in dots design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarDesignDots(gr) {
for (let progressLine = 0; progressLine < this.progressLength; progressLine += SCALE(8)) {
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);
gr.SetSmoothingMode(SmoothingMode.AntiAlias);
gr.DrawEllipse(this.x + progressLine, this.y + this.h * 0.5 - SCALE(1), SCALE(2), SCALE(2), SCALE(2), grCol.progressBarFill);
}
const posFix = HD_4K(3, grSet.layout !== 'default' ? 6 : 7);
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
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
}
/**
* Draws the progress bar fill in thin design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarDesignThin(gr) {
gr.DrawLine(this.x, this.y + this.h * 0.5, this.x + this.w, this.y + this.h * 0.5, SCALE(1), grCol.progressBar);
gr.SetSmoothingMode(SmoothingMode.AntiAlias);
gr.FillSolidRect(this.x, this.y + this.h * 0.5 - SCALE(2), this.progressLength, SCALE(4), grCol.progressBarFill);
gr.FillSolidRect(this.x + this.progressLength, this.y + this.h * 0.5 - SCALE(3), SCALE(6), SCALE(6), grCol.progressBarFill);
}
/**
* Sets the vertical progress bar position.
* @param {number} y - The y-coordinate.
*/
setY(y) {
this.y = y;
}
/**
* Sets the playback time of the progress bar.
* @param {number} x - The x-coordinate.
* @private
*/
setPlaybackTime(x) {
const clampedPosition = Clamp((x - this.x) / this.w, 0, 1);
const newPlaybackTime = clampedPosition * fb.PlaybackLength;
if (fb.PlaybackTime !== newPlaybackTime) {
fb.PlaybackTime = newPlaybackTime;
}
}
/**
* Sets the refresh rate for the progress bar.
*/
setRefreshRate() {
if (grm.ui.isStreaming) {
grm.ui.seekbarTimerInterval = FPS._1;
}
else if (grSet.progressBarRefreshRate !== 'variable') {
grm.ui.seekbarTimerInterval = grSet.progressBarRefreshRate;
}
else {
const pixelsPerMillisecond = (grm.ui.ww - grm.ui.edgeMarginBoth) / fb.PlaybackLength;
const FPS_VARIABLE = Math.ceil(1000 / pixelsPerMillisecond);
grm.ui.seekbarTimerInterval = Clamp(FPS_VARIABLE, FPS._15, FPS._2);
}
}
/**
* Updates the progress bar state.
*/
repaint() {
window.RepaintRect(this.x, this.y, this.w, this.h);
}
// #endregion
// * CALLBACKS * //
// #region CALLBACKS
/**
* Checks if the mouse is within the boundaries of the progress bar.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @returns {boolean} True or false.
*/
mouseInThis(x, y) {
return (x >= this.x && y >= this.y && x < this.x + this.w && y <= this.y + this.h);
}
/**
* Handles left mouse button down click events and enables dragging.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
*/
on_mouse_lbtn_down(x, y) {
this.drag = true;
}
/**
* Handles left mouse button up click events and disables dragging and updates the playback time.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
*/
on_mouse_lbtn_up(x, y) {
this.drag = false;
if (this.mouseInThis(x, y)) {
this.setPlaybackTime(x);
}
}
/**
* Handles mouse movement events and updates the playback time based on the mouse movement if a drag event is occurring.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
*/
on_mouse_move(x, y) {
if (this.drag) {
this.setPlaybackTime(x);
}
}
/**
* Updates progress bar length when playing a new track.
* @param {FbMetadbHandle} metadb - The metadb of the track.
*/
on_playback_new_track(metadb) {
if (!metadb) return;
this.progressLength = 0;
}
/**
* Sets the size and position of the progress bar and updates them on window resizing.
* @param {number} w - The width of the window or element.
* @param {number} h - The height of the window or element.
*/
on_size(w, h) {
this.x = grm.ui.edgeMargin;
this.y = 0;
this.w = w - grm.ui.edgeMarginBoth;
this.h = grm.ui.seekbarHeight;
}
// #endregion
}
///////////////////////
// * PEAKMETER BAR * //
///////////////////////
/**
* A class that creates the peakmeter bar in the lower bar when enabled.
* Quick access via right click context menu on lower bar.
*/
class PeakmeterBar {
/**
* Creates the `PeakmeterBar` instance.
*/
constructor() {
// * GEOMETRY - STYLE HORIZONTAL * //
// #region GEOMETRY - STYLE HORIZONTAL
/** @public @type {number} The x-position of the peakmeter bar. */
this.x = grm.ui.edgeMargin;
/** @public @type {number} The y-position of the peakmeter bar. */
this.y = 0;
/** @public @type {number} The width of the peakmeter bar. */
this.w = grm.ui.ww - grm.ui.edgeMarginBoth;
/** @public @type {number} The secondary width of the peakmeter bar. */
this.w2 = 0;
/** @public @type {number} The height of the peakmeter bar. */
this.h = grm.ui.seekbarHeight;
/** @private @type {number} The height of the bar for the peakmeter. */
this.bar_h = grSet.layout !== 'default' ? SCALE(2) : SCALE(4);
/** @private @type {number} The half height of the bar for the peakmeter. */
this.bar2_h = this.bar_h * 0.5;
/** @private @type {number} The offset for the peakmeter bar. */
this.offset = 0;
/** @private @type {number} The middle offset for the peakmeter bar. */
this.middleOffset = 0;
/** @private @type {number} The middle width for the peakmeter bar. */
this.middle_w = 0;
// * Top
/** @private @type {number} The width of the outer left bar. */
this.outerLeft_w = 0;
/** @private @type {number} The old width of the outer left bar. */
this.outerLeft_w_old = 0;
/** @private @type {number} The animated width of the outer left bar. */
this.outerLeftAnim_w = 0;
/** @private @type {number} The x-position of the animated outer left bar. */
this.outerLeftAnim_x = 0;
/** @private @type {number} The offset for the outer left bar. */
this.outerLeft_k = 0;
/** @private @type {number} The x-position of the main left bar. */
this.mainLeft_x = 0;
/** @private @type {number} The x-position of the animated main left bar. */
this.mainLeftAnim_x = 0;
/** @private @type {number} The secondary x-position of the animated main left bar. */
this.mainLeftAnim2_x = 0;
/** @private @type {number} The offset for the main left bar. */
this.mainLeft_k = 0;
/** @private @type {number} The secondary offset for the main left bar. */
this.mainLeft2_k = 0;
// * Bottom
/** @private @type {number} The width of the outer right bar. */
this.outerRight_w = 0;
/** @private @type {number} The old width of the outer right bar. */
this.outerRight_w_old = 0;
/** @private @type {number} The animated width of the outer right bar. */
this.outerRightAnim_w = 0;
/** @private @type {number} The x-position of the animated outer right bar. */
this.outerRightAnim_x = 0;
/** @private @type {number} The offset for the outer right bar. */
this.outerRight_k = 0;
/** @private @type {number} The x-position of the main right bar. */
this.mainRight_x = 0;
/** @private @type {number} The x-position of the animated main right bar. */
this.mainRightAnim_x = 0;
/** @private @type {number} The secondary x-position of the animated main right bar. */
this.mainRightAnim2_x = 0;
/** @private @type {number} The offset for the main right bar. */
this.mainRight_k = 0;
/** @private @type {number} The secondary offset for the main right bar. */
this.mainRight2_k = 0;
// #endregion
// * GEOMETRY - STYLE VERTICAL * //
// #region GEOMETRY - STYLE VERTICAL
/** @private @type {number} The vertical offset for the bar. */
this.vertBar_offset = 0;
/** @private @type {number} The vertical width of the bar. */
this.vertBar_w = 0;
/** @private @type {number} The vertical height of the bar. */
this.vertBar_h = 0;
/** @private @type {number} The x-coordinate for the left vertical bar. */
this.vertLeft_x = 0;
/** @private @type {number} The x-coordinate for the right vertical bar. */
this.vertRight_x = 0;
// #endregion
// * PROGRESS BAR * //
// #region PROGRESS BAR
/** @public @type {number} The length of the progress bar. */
this.progressLength = 0;
/** @private @type {boolean} The state indicating whether the progress bar is being dragged. */
this.drag = false;
// #endregion
// * MOUSE EVENTS * //
// #region MOUSE EVENTS
/** @private @type {number} The x-coordinate position of the mouse. */
this.pos_x = 0;
/** @private @type {number} The y-coordinate position of the mouse. */
this.pos_y = 0;
/** @private @type {boolean} The state indicating whether the mouse is over the peakmeter bar. */
this.on_mouse = false;
/** @private @type {boolean} The state indicating whether the mouse wheel is being used. */
this.wheel = false;
// #endregion
// * TEXT * //
// #region TEXT
/** @private @type {GdiFont} The font used for text rendering. */
this.textFont = gdi.Font('Segoe UI', HD_4K(9, 16), 1);
/** @private @type {number} The width of the text. */
this.textWidth = 0;
/** @private @type {number} The height of the text. */
this.textHeight = 0;
/** @private @type {string} The text for the tooltip. */
this.tooltipText = '';
// #endregion
// * VOLUME * //
// #region VOLUME
/** @private @type {number[]} The middle decibel values. */
this.db_middle = [];
/** @private @type {number[]} The current decibel values. */
this.db = [];
/** @private @type {object[]} The vertical decibel values. */
this.db_vert = {};
/** @private @type {number} The middle points for the peakmeter. */
this.points_middle = 0;
/** @private @type {number} The points for the peakmeter. */
this.points = 0;
/** @private @type {number} The vertical points for the peakmeter. */
this.points_vert = 0;
/** @private @type {number[]} The left peaks for the peakmeter. */
this.leftPeaks_s = [];
/** @private @type {number[]} The right peaks for the peakmeter. */
this.rightPeaks_s = [];
// #endregion
// * COLORS * //
// #region COLORS
/** @private @type {number} The separator index for the peakmeter. */
this.separator = 0;
/** @private @type {number} The first separator value for the peakmeter. */
this.sep1 = 0;
/** @private @type {number} The second separator value for the peakmeter. */
this.sep2 = 0;
// #endregion
// * INITIALIZATION * //
// #region INITIALIZATION
grm.ui.seekbarTimerInterval = grSet.peakmeterBarRefreshRate === 'variable' ? FPS._10 : grSet.peakmeterBarRefreshRate;
this.initDecibel();
this.initPeaks();
this.initPoints();
this.initSeparator();
this.setColors();
// #endregion
}
// * PUBLIC METHODS - DRAW * //
// #region PUBLIC METHODS - DRAW
/**
* Draws the peakmeter bar in various peakmeter bar designs.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
draw(gr) {
if (!fb.IsPlaying || !AudioWizard) {
gr.FillSolidRect(this.x, this.y, this.w, this.h, grCol.bg);
gr.FillSolidRect(this.x, grm.ui.seekbarY, this.w, SCALE(grSet.layout !== 'default' ? 10 : 12), grCol.progressBar);
return;
}
if (grSet.peakmeterBarRefreshRate === 'variable' || grm.debug.showDrawExtendedTiming) {
grm.ui.seekbarProfiler.Reset();
}
this.drawPeakmeterBar(gr);
this.setAnimation();
this.setMonitoring();
this.setRefreshRate();
}
/**
* Draws the peakmeter bar design based on design setting.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawPeakmeterBar(gr) {
const drawBarDesign = {
horizontal: () => this.drawBarDesignHorizontal(gr),
horizontal_center: () => this.drawBarDesignCenter(gr),
vertical: () => this.drawBarDesignVertical(gr)
};
drawBarDesign[grSet.peakmeterBarDesign]();
}
/**
* Draws the horizontal peakmeter bar design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarDesignHorizontal(gr) {
this.drawBarGrid(gr);
for (let i = 0; i <= this.points; i++) {
const color = this.color[i];
const db = this.db[i];
const dbNext = this.db[i + 1];
const offset = i * this.offset;
this.drawHorizontalMainBars(gr, db, dbNext, offset, this.leftPeak, this.mainLeftAnim_x, this.mainLeftAnim2_x, 'mainLeft_x', this.mainLeft_y, color);
this.drawHorizontalMainBars(gr, db, dbNext, offset, this.rightPeak, this.mainRightAnim_x, this.mainRightAnim2_x, 'mainRight_x', this.mainRight_y, color);
this.drawHorizontalOuterBars(gr, db, dbNext, offset, this.leftLevel, this.outerLeftAnim_x, this.outerLeftAnim_w, this.outerLeft_y, 'outerLeft_w');
this.drawHorizontalOuterBars(gr, db, dbNext, offset, this.rightLevel, this.outerRightAnim_x, this.outerRightAnim_w, this.outerRight_y, 'outerRight_w');
this.drawOverBars(gr);
}
this.drawMiddleBars(gr);
this.drawProgressBar(gr);
this.drawBarInfo(gr);
}
/**
* Draws the main bars in the horizontal peakmeter bar design.
* @param {GdiGraphics} gr - The GDI graphics object.
* @param {number} db - The current decibel level.
* @param {number} dbNext - The next decibel level.
* @param {number} offset - The offset for drawing.
* @param {number} peak - The peak value.
* @param {number} anim_x - The animation x-coordinate.
* @param {number} anim2_x - The second animation x-coordinate.
* @param {string} main_x - The main x-coordinate property key name.
* @param {number} main_y - The main y-coordinate.
* @param {number} color - The color of the bar.
* @private
*/
drawHorizontalMainBars(gr, db, dbNext, offset, peak, anim_x, anim2_x, main_x, main_y, color) {
if (peak <= db) return;
if (peak < dbNext) this[main_x] = offset;
if (grSet.peakmeterBarMainBars) {
gr.FillSolidRect(this.x + offset, main_y, this.w2, this.bar_h, color);
}
if (grSet.peakmeterBarMainPeaks) {
const color = this.color[Math.round(this.mainLeftAnim_x / this.offset)];
gr.FillSolidRect(this.x + anim_x + this.offset, main_y, this.w2 * 0.66, this.bar_h, color);
const x = Clamp(this.x + anim2_x + this.offset + this.w2 * 0.66, this.x, this.x + this.w - this.w2 * 0.33);
const w = x > this.w + this.w2 * 0.5 ? 0 : this.w2 * 0.33;
gr.FillSolidRect(x, main_y, w, this.bar_h, color);
}
}
/**
* Draws the outer bars in the horizontal peakmeter bar design.
* @param {GdiGraphics} gr - The GDI graphics object.
* @param {number} db - The current decibel level.
* @param {number} dbNext - The next decibel level.
* @param {number} offset - The offset for drawing.
* @param {number} level - The level value.
* @param {number} anim_x - The animation x-coordinate.
* @param {number} anim_w - The animation width.
* @param {number} outer_y - The outer y-coordinate.
* @param {string} outer_w - The outer width property key name.
* @private
*/
drawHorizontalOuterBars(gr, db, dbNext, offset, level, anim_x, anim_w, outer_y, outer_w) {
if (level <= db) return;
if (level < dbNext) {
this[outer_w] = offset + this.offset / Math.abs(dbNext - db) * Math.abs(level - db) - this.x;
}
if (grSet.peakmeterBarOuterBars) {
gr.FillSolidRect(this.x, outer_y, this[outer_w], this.bar_h, this.color[1]);
}
if (grSet.peakmeterBarOuterPeaks) {
const x = Clamp(this.x + anim_x, this.x, this.x + this.w - anim_w);
gr.FillSolidRect(x, outer_y, anim_w <= 0 ? 2 : anim_w, this.bar_h, this.color[1]);
}
}
/**
* Draws the horizontal center peakmeter bar design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarDesignCenter(gr) {
this.drawBarGrid(gr);
for (let i = 0; i <= this.points; i++) {
const color = this.color[i];
const db = this.db[i];
const dbNext = this.db[i + 1];
const offset = i * this.offset;
const mainLeft_x = this.x * 0.5 + this.w * 0.5 - i * this.offset + this.w2;
const mainRight_x = this.x + this.w * 0.5 + i * this.offset - this.w2;
this.drawCenterMainBars(gr, db, dbNext, offset, this.leftPeak, this.mainLeftAnim_x, this.mainLeftAnim2_x, mainLeft_x, mainRight_x, 'mainLeft_x', this.mainLeft_y, color);
this.drawCenterMainBars(gr, db, dbNext, offset, this.rightPeak, this.mainRightAnim_x, this.mainRightAnim2_x, mainLeft_x, mainRight_x, 'mainRight_x', this.mainRight_y, color);
this.drawCenterOuterBars(gr, db, dbNext, offset, this.leftLevel, this.outerLeftAnim_x, this.outerLeftAnim_w, this.outerLeft_y, 'outerLeft_w');
this.drawCenterOuterBars(gr, db, dbNext, offset, this.rightLevel, this.outerRightAnim_x, this.outerRightAnim_w, this.outerRight_y, 'outerRight_w');
this.drawOverBars(gr);
}
this.drawMiddleBars(gr);
this.drawProgressBar(gr);
this.drawBarInfo(gr);
}
/**
* Draws the main bars in the horizontal center peakmeter bar design.
* @param {GdiGraphics} gr - The GDI graphics object.
* @param {number} db - The current decibel level.
* @param {number} dbNext - The next decibel level.
* @param {number} offset - The offset for drawing.
* @param {number} peak - The peak value.
* @param {number} anim_x - The animation x-coordinate.
* @param {number} anim2_x - The second animation x-coordinate.
* @param {number} mainLeft_x - The main left x-coordinate.
* @param {number} mainRight_x - The main right x-coordinate.
* @param {string} main_x - The main x-coordinate property key name.
* @param {number} main_y - The main y-coordinate.
* @param {string} color - The color of the bar.
* @private
*/
drawCenterMainBars(gr, db, dbNext, offset, peak, anim_x, anim2_x, mainLeft_x, mainRight_x, main_x, main_y, color) {
if (peak <= db) return;
if (peak < dbNext) this[main_x] = offset;
if (grSet.peakmeterBarMainBars) {
gr.FillSolidRect(mainLeft_x, main_y, this.w2, this.bar_h, color);
gr.FillSolidRect(mainRight_x, main_y, this.w2, this.bar_h, color);
}
if (grSet.peakmeterBarMainPeaks) {
const color = this.color[Math.round(anim_x / this.offset)];
const xLeft = this.x * 0.5 + this.w * 0.5 - (anim_x + this.offset) + this.w2 * 0.33;
const xRight = this.x + anim_x + this.offset + this.w * 0.5;
gr.FillSolidRect(xLeft, main_y, this.w2 * 0.66, this.bar_h, color);
gr.FillSolidRect(xRight, main_y, this.w2 * 0.66, this.bar_h, color);
const xLeftPeaks = this.x + this.w * 0.5 - anim2_x - this.offset - this.w2 * 0.66;
const wLeftPeaks = xLeftPeaks < this.x + this.w2 * 0.5 ? 0 : this.w2 * 0.33;
const xRightPeaks = this.x + this.w * 0.5 + anim2_x + this.offset + this.w2 * 0.66;
const wRightPeaks = xRightPeaks > this.w + this.w2 * 0.5 ? 0 : this.w2 * 0.33;
gr.FillSolidRect(xLeftPeaks, main_y, wLeftPeaks, this.bar_h, color);
gr.FillSolidRect(xRightPeaks, main_y, wRightPeaks, this.bar_h, color);
}
}
/**
* Draws the outer bars in the horizontal center peakmeter bar design.
* @param {GdiGraphics} gr - The GDI graphics object.
* @param {number} db - The current decibel level.
* @param {number} dbNext - The next decibel level.
* @param {number} offset - The offset for drawing.
* @param {number} level - The level value.
* @param {number} anim_x - The animation x-coordinate.
* @param {number} anim_w - The animation width.
* @param {number} outer_y - The outer y-coordinate.
* @param {string} outer_w - The outer width property name.
* @private
*/
drawCenterOuterBars(gr, db, dbNext, offset, level, anim_x, anim_w, outer_y, outer_w) {
if (level <= db) return;
if (level < dbNext) {
this[outer_w] = offset + this.offset / Math.abs(dbNext - db) * Math.abs(level - db) - this.x;
}
if (grSet.peakmeterBarOuterBars) {
const xLeft = Clamp(this.x + this.w * 0.5 - this[outer_w], this.x, this.w * 0.5);
const xRight = this.x + this.w * 0.5;
const w = Clamp(this[outer_w], 0, this.w * 0.5);
gr.FillSolidRect(xLeft, outer_y, w, this.bar_h, this.color[1]);
gr.FillSolidRect(xRight, outer_y, w, this.bar_h, this.color[1]);
}
if (grSet.peakmeterBarOuterPeaks) {
const x = Clamp(this.x + anim_x, this.x, this.x + this.w * 0.5 - anim_w);
const w = anim_w <= 0 ? 2 : anim_w;
const xLeftPeaks = this.w * 0.5 + this.x * 2 - x - w;
const xRightPeaks = this.w * 0.5 + x;
gr.FillSolidRect(xLeftPeaks, outer_y, w, this.bar_h, this.color[1]);
gr.FillSolidRect(xRightPeaks, outer_y, w, this.bar_h, this.color[1]);
}
}
/**
* Draws the vertical peakmeter bar design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarDesignVertical(gr) {
const peakL = Math.round(this.leftPeak);
const peakR = Math.round(this.rightPeak);
const vertBarH = this.vertBar_h * 1.5;
const toleranceBase = 0.05;
const toleranceMin = 0.1;
const toleranceMax = 1.0;
const toleranceL = Clamp(toleranceBase * Math.abs(peakL), toleranceMin, toleranceMax);
const toleranceR = Clamp(toleranceBase * Math.abs(peakR), toleranceMin, toleranceMax);
for (let i = 0; i < this.points_vert; i++) {
const dbL = this.db_vert[i];
const dbR = this.db_vert[this.points_vert - 1 - i];
const offset = this.vertBar_offset * i;
if (Math.abs(peakL - dbL) <= toleranceL) this.leftPeaks_s[i] = vertBarH;
if (Math.abs(peakR - dbR) <= toleranceR) this.rightPeaks_s[i] = vertBarH;
this.drawVerticalPeaks(gr, this.vertLeft_x, offset, this.leftPeaks_s[i]);
this.drawVerticalPeaks(gr, this.vertRight_x, offset, this.rightPeaks_s[i]);
}
if (grSet.peakmeterBarVertBaseline) {
gr.FillSolidRect(this.x, this.y + this.h - this.vertBar_h, this.w, this.vertBar_h, grCol.peakmeterBarProg);
}
this.drawProgressBar(gr);
this.drawBarInfo(gr);
}
/**
* Draws the peaks in the vertical peakmeter bar design.
* @param {GdiGraphics} gr - The GDI graphics object.
* @param {number} xBase - The base x-coordinate.
* @param {number} offset - The offset for drawing.
* @param {number} peak_s - The peak value.
* @private
*/
drawVerticalPeaks(gr, xBase, offset, peak_s) {
const x = xBase + offset;
const y = this.y + peak_s - this.vertBar_h;
if (peak_s <= this.h) {
const h = Math.min(this.h - peak_s, this.h);
gr.FillSolidRect(x, y, this.vertBar_w, h, grCol.peakmeterBarVertFill);
}
if (grSet.peakmeterBarVertPeaks && peak_s >= 0) {
gr.FillSolidRect(x, y, this.vertBar_w, this.vertBar_h, grCol.peakmeterBarVertFillPeaks);
}
}
/**
* Draws the over bars in the peakmeter bar designs.
* @param {GdiGraphics} gr - The GDI graphics object.
* @private
*/
drawOverBars(gr) {
if (!grSet.peakmeterBarOverBars) return;
const widthSize = grSet.peakmeterBarDesign === 'horizontal' ? 1 : 0.5;
const overLeft = this.outerLeftAnim_x + this.outerLeftAnim_w - (this.w * widthSize);
const overRight = this.outerRightAnim_x + this.outerRightAnim_w - (this.w * widthSize);
const outerAnim = this.outerLeftAnim_x - this.outerLeftAnim_w;
const outerAnimHalf = outerAnim * 0.5;
const xLeft = this.w - overLeft - this.x;
const xRight = this.w - overRight - this.x;
const xLeft2 = Clamp(this.w * 0.5 - overLeft - outerAnim, this.x, this.w * 0.5);
const xRight2 = Clamp(this.w * 0.5 - overRight - outerAnim, this.x, this.w * 0.5);
const wLeft = this.w - xLeft + this.x;
const wRight = this.w - xRight + this.x;
const wLeft2 = this.w - xLeft + outerAnimHalf;
const wRight2 = this.w - xRight + outerAnimHalf;
if (overLeft > 0) { // Top
gr.FillSolidRect(xLeft, this.overLeft_y, wLeft, this.bar2_h, this.color[10]);
grSet.peakmeterBarDesign === 'horizontal_center' && gr.FillSolidRect(xLeft2, this.overLeft_y, wLeft2, this.bar2_h, this.color[10]);
}
if (overRight > 0) { // Bottom
gr.FillSolidRect(xRight, this.overRight_y, wRight, this.bar2_h, this.color[10]);
grSet.peakmeterBarDesign === 'horizontal_center' && gr.FillSolidRect(xRight2, this.overRight_y, wRight2, this.bar2_h, this.color[10]);
}
}
/**
* Draws the middle bars in the peakmeter bar designs.
* @param {GdiGraphics} gr - The GDI graphics object.
* @private
*/
drawMiddleBars(gr) {
if (!grSet.peakmeterBarMiddleBars) return;
if (grSet.peakmeterBarDesign === 'horizontal') {
for (let i = 0; i <= this.points_middle; i++) {
const dbMiddle = this.db_middle[i];
const x = this.x + i * this.middleOffset;
if (this.leftPeak > dbMiddle) {
gr.FillSolidRect(x, this.middleLeft_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
}
if (this.rightPeak > dbMiddle) {
gr.FillSolidRect(x, this.middleRight_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
}
}
}
else if (grSet.peakmeterBarDesign === 'horizontal_center') {
for (let i = 0; i <= this.points_middle; i++) {
const dbMiddle = this.db_middle[i];
const x1 = this.x * 0.5 + this.w * 0.5 - i * this.middleOffset + 1;
const x2 = this.x + this.w * 0.5 + i * this.middleOffset - 1;
if (this.leftPeak > dbMiddle) {
gr.FillSolidRect(x1, this.middleLeft_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
gr.FillSolidRect(x2, this.middleLeft_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
}
if (this.rightPeak > dbMiddle) {
gr.FillSolidRect(x1, this.middleRight_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
gr.FillSolidRect(x2, this.middleRight_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill);
}
}
}
}
/**
* Draws the progress bar in the peakmeter bar designs.
* @param {GdiGraphics} gr - The GDI graphics object.
* @private
*/
drawProgressBar(gr) {
if (!fb.IsPlaying || fb.PlaybackLength <= 0 || !grSet.peakmeterBarProgBar) return;
const playbackRatio = fb.PlaybackTime / fb.PlaybackLength;
this.progressLength = Math.floor(this.w * (grSet.peakmeterBarDesign === 'horizontal_center' ? 0.5 : 1) * playbackRatio);
if (grSet.peakmeterBarDesign === 'horizontal') {
gr.FillSolidRect(this.x, this.middleLeft_y, this.w, this.bar_h, grCol.peakmeterBarProg);
gr.FillSolidRect(this.x, this.middleLeft_y, this.progressLength, this.bar_h, grCol.peakmeterBarProgFill);
}
else if (grSet.peakmeterBarDesign === 'horizontal_center') {
gr.FillSolidRect(this.x, this.middleLeft_y, this.w, this.bar_h, grCol.peakmeterBarProg);
gr.FillSolidRect(this.x + this.w * 0.5 - this.progressLength, this.middleLeft_y, this.progressLength, this.bar_h, grCol.peakmeterBarProgFill);
gr.FillSolidRect(this.x + this.w * 0.5, this.middleLeft_y, this.progressLength, this.bar_h, grCol.peakmeterBarProgFill);
}
else if (grSet.peakmeterBarDesign === 'vertical') {
gr.FillSolidRect(this.x, this.y + this.h - this.vertBar_h, this.w, this.bar_h, grCol.peakmeterBarProg);
gr.FillSolidRect(this.x, this.y + this.h - this.vertBar_h, this.progressLength, this.bar_h, grCol.peakmeterBarVertProgFill);
}
}
/**
* Draws the grid in the peakmeter bar designs.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarGrid(gr) {
if (!grSet.peakmeterBarGrid) return;
gr.FillSolidRect(this.x, this.outerLeft_y, this.w, this.bar_h, grCol.peakmeterBarProg);
gr.FillSolidRect(this.x, this.outerRight_y, this.w, this.bar_h, grCol.peakmeterBarProg);
}
/**
* Draws the bar info in the peakmeter bar designs.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarInfo(gr) {
if (!grSet.peakmeterBarInfo) return;
const infoTextColor = grCol.lowerBarArtist;
if (grSet.peakmeterBarDesign === 'horizontal') {
const text_db_w = gr.CalcTextWidth('db', this.textFont);
for (let i = 4; i <= this.points; i += 2) {
const text_w = gr.CalcTextWidth(this.db[i], this.textFont);
gr.GdiDrawText(this.db[i], this.textFont, infoTextColor, this.x + this.offset * i - text_w * 0.5, this.text_y, this.w, this.h);
}
gr.GdiDrawText('db', this.textFont, infoTextColor, this.x + this.offset * 2 - text_db_w, this.text_y, this.w, this.h);
}
else if (grSet.peakmeterBarDesign === 'horizontal_center') {
const text_db_w = gr.CalcTextWidth('db', this.textFont);
for (let i = 4; i <= this.points; i += 2) {
const textRight_w = gr.CalcTextWidth(this.db[i], this.textFont);
const textLeft_w2 = gr.CalcTextWidth(`${this.db[this.points + 3 - i]}-`, this.textFont);
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);
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);
}
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);
}
else if (grSet.peakmeterBarDesign === 'vertical') {
for (let i = 0; i <= this.points_vert; i++) {
const dbLeft = this.db_vert[i];
const dbRight = this.db_vert[this.points_vert - 1 - i];
const textLeft_w = gr.CalcTextWidth(`${dbLeft}--`, this.textFont);
const textRight_w = gr.CalcTextWidth(`${dbRight}--`, this.textFont);
const textLeft_x = this.vertLeft_x + this.vertBar_offset * i - textLeft_w / 2 + (this.vertBar_offset - this.vertBar_w);
const textRight_x = this.vertRight_x + this.vertBar_offset * i - textRight_w / 2 + (this.vertBar_offset - this.vertBar_w);
gr.GdiDrawText(dbLeft % 2 === 0 ? dbLeft : '', this.textFont, infoTextColor, textLeft_x, this.y, grm.ui.ww, grm.ui.wh);
gr.GdiDrawText(dbRight % 2 === 0 ? dbRight : '', this.textFont, infoTextColor, textRight_x, this.y, grm.ui.ww, grm.ui.wh);
}
}
}
// #endregion
// * PUBLIC METHODS - INITIALIZATION * //
// #region PUBLIC METHODS - INITIALIZATION
/**
* Initializes the decibel arrays for different configurations.
*/
initDecibel() {
this.db = [
-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,
-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
];
this.db_middle = [
-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
];
this.db_vert = {
220: [-20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2],
215: [-15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2],
210: [-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2],
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],
315: [-15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3],
310: [-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3],
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],
515: [-15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5],
510: [-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5]
}[grSet.peakmeterBarVertDbRange];
}
/**
* Initializes the points for different decibel arrays.
*/
initPoints() {
this.points_middle = this.db_middle.length;
this.points = this.db.length;
this.points_vert = this.db_vert.length;
for (let i = 0; i <= this.points_vert; i++) {
this.leftPeaks_s[i] = 0;
this.rightPeaks_s[i] = 0;
}
}
/**
* Initializes the peaks arrays for left and right channels.
*/
initPeaks() {
this.leftPeaks_s = new Array(this.points_vert + 1).fill(0);
this.rightPeaks_s = new Array(this.points_vert + 1).fill(0);
}
/**
* Initializes the separator index based on the decibel array.
*/
initSeparator() {
this.separator = this.db.indexOf(0);
this.sep1 = this.separator;
this.sep2 = this.points - this.sep1;
}
/**
* Initializes bar geometry properties.
*/
initGeometry() {
this.bar_h = grSet.layout !== 'default' ? SCALE(2) : SCALE(4);
this.offset = (grSet.peakmeterBarDesign === 'horizontal_center' ? this.w * 0.5 : this.w) / this.points;
this.middleOffset = (grSet.peakmeterBarDesign === 'horizontal_center' ? this.w * 0.5 : this.w) / this.points_middle;
this.middle_w = this.middleOffset - (grSet.peakmeterBarGaps ? 1 : 0);
this.w2 = this.offset - (grSet.peakmeterBarGaps ? 1 : 0);
this.vertBar_offset = ((this.w / this.points_vert) + ((grSet.peakmeterBarVertSize === 'min' ? 2 : grSet.peakmeterBarVertSize) / this.points_vert * 0.5)) * 0.5;
this.vertBar_w = grSet.peakmeterBarVertSize === 'min' ? Math.ceil(this.vertBar_offset * 0.1 * 0.5) : this.vertBar_offset - grSet.peakmeterBarVertSize * 0.5;
this.vertBar_h = 2;
this.vertLeft_x = this.x;
this.vertRight_x = this.vertLeft_x + this.vertBar_offset * this.points_vert;
}
// #endregion
// * PUBLIC METHODS - COMMON * //
// #region PUBLIC METHODS - COMMON
/**
* Resets the state of the peakmeter bar.
*/
reset() {
this.leftLevel = 0;
this.leftPeak = 0;
this.rightLevel = 0;
this.rightPeak = 0;
this.leftPeaks_s = [];
this.rightPeaks_s = [];
this.progressLength = 0;
this.tooltipText = '';
}
/**
* Sets all vertical peakmeter bar positions.
* Bars are ordered from top to bottom.
* @param {number} y - The y-coordinate.
*/
setY(y = grm.ui.seekbarY) {
this.y = y;
this.overLeft_y = this.y;
this.outerLeft_y = this.overLeft_y + this.bar2_h;
this.mainLeft_y = this.outerLeft_y + this.bar_h;
this.middleLeft_y = this.mainLeft_y + this.bar_h + SCALE(1);
this.middleRight_y = this.middleLeft_y + this.bar2_h;
this.mainRight_y = this.middleRight_y + this.bar2_h + SCALE(1);
this.outerRight_y = this.mainRight_y + this.bar_h;
this.overRight_y = this.outerRight_y + this.bar_h;
this.text_y = this.outerRight_y + this.bar_h * 2;
}
/**
* Monitors volume levels and peaks and sets horizontal or vertical animations based on peakmeterBarDesign.
*/
setAnimation() {
// * Set horizontal animation
if (['horizontal', 'horizontal_center'].includes(grSet.peakmeterBarDesign)) {
const increment1 = 0.09; // 0.3 ** 2
const increment2 = 1.21; // 1.1 ** 2
// * Main left middle peaks
if (this.mainLeftAnim_x <= this.mainLeft_x) {
this.mainLeftAnim_x = this.mainLeft_x;
this.mainLeftAnim2_x = this.mainLeft_x;
this.mainLeft_k = 0;
this.mainLeft2_k = 0;
}
this.mainLeft_k += increment1;
this.mainLeftAnim_x -= this.mainLeft_k;
this.mainLeft2_k += increment2;
this.mainLeftAnim2_x += this.mainLeft2_k;
// * Main right middle peaks
if (this.mainRightAnim_x <= this.mainRight_x) {
this.mainRightAnim_x = this.mainRight_x;
this.mainRightAnim2_x = this.mainRight_x;
this.mainRight_k = 0;
this.mainRight2_k = 0;
}
this.mainRight_k += increment1;
this.mainRightAnim_x -= this.mainRight_k;
this.mainRight2_k += increment2;
this.mainRightAnim2_x += this.mainRight2_k;
// * Outer left peaks
if (this.outerLeftAnim_x <= this.outerLeft_w) {
this.outerLeftAnim_x = this.outerLeft_w;
this.outerLeft_k = 0;
this.outerLeftAnim_w = this.outerLeft_w - this.outerLeft_w_old < 1 ? this.outerLeftAnim_w : this.outerLeft_w - this.outerLeft_w_old + 10;
} else {
this.outerLeft_w_old = this.outerLeft_w;
}
this.outerLeft_k += increment1;
this.outerLeftAnim_x -= this.outerLeft_k;
this.outerLeftAnim_w -= this.outerLeft_k * 2;
// * Outer right peaks
if (this.outerRightAnim_x <= this.outerRight_w) {
this.outerRightAnim_x = this.outerRight_w;
this.outerRight_k = 0;
this.outerRightAnim_w = this.outerRight_w - this.outerRight_w_old < 1 ? this.outerRightAnim_w : this.outerRight_w - this.outerRight_w_old + 10;
} else {
this.outerRight_w_old = this.outerRight_w;
}
this.outerRight_k += increment1;
this.outerRightAnim_x -= this.outerRight_k;
this.outerRightAnim_w -= this.outerRight_k * 2;
}
// * Set vertical animation
else if (grSet.peakmeterBarDesign === 'vertical') {
for (let j = 0; j < this.leftPeaks_s.length; j++) {
this.leftPeaks_s[j] = this.leftPeaks_s[j] < this.h ? this.leftPeaks_s[j] + 2 : this.h;
}
for (let j = 0; j < this.rightPeaks_s.length; j++) {
this.rightPeaks_s[j] = this.rightPeaks_s[j] < this.h ? this.rightPeaks_s[j] + 2 : this.h;
}
}
}
/**
* Sets the peakmeter bar colors.
* @param {FbMetadbHandle} metadb - The metadb of the track.
*/
setColors(metadb = fb.GetNowPlaying()) {
if (grSet.seekbar !== 'peakmeterbar') return;
let img = gdi.CreateImage(1, 1);
const g = img.GetGraphics();
img.ReleaseGraphics(g);
if (metadb) img = utils.GetAlbumArtV2(metadb, 0);
if (img) {
try {
grm.ui.albumArtCorrupt = false;
// this.colors = JSON.parse(img.GetColourSchemeJSON(4));
this.c1 = grCol.peakmeterBarFillMiddle; // this.colors[1].col;
this.c2 = grCol.peakmeterBarFillTop; // this.colors[2].col;
this.c3 = grCol.peakmeterBarFillBack; // this.colors[3].col;
} catch (e) {
grm.ui.noArtwork = true;
grm.ui.noAlbumArtStub = true;
grm.ui.albumArtCorrupt = true;
this.setDefaultColors();
}
} else {
this.setDefaultColors();
}
this.color = [];
this.color1 = [this.c2, this.c3];
this.color2 = [this.c3, this.c1];
for (let j = 0; j < this.sep1; j++) {
this.color.push(BlendColors(this.color1[0], this.color1[1], j / this.sep1));
}
for (let j = 0; j < this.sep2; j++) {
this.color.push(BlendColors(this.color2[0], this.color2[1], j / this.sep2));
}
}
/**
* Sets the default peakmeter bar colors.
*/
setDefaultColors() {
this.c1 = grCol.peakmeterBarFillMiddle; // RGB(0, 200, 255);
this.c2 = grCol.peakmeterBarFillTop; // RGB(255, 255, 0);
this.c3 = grCol.peakmeterBarFillBack; // RGB(230, 230, 30);
this.color1 = [this.c3, this.c1];
this.color2 = [this.c2, this.c3];
}
/**
* Sets monitoring of audio levels and peaks based on playback state.
* Converts and stores volume levels and peaks for both left and right channels in decibels.
*/
setMonitoring() {
if (!AudioWizard) return;
this.leftLevel = AudioWizard.PeakmeterAdjustedLeftRMS;
this.rightLevel = AudioWizard.PeakmeterAdjustedRightRMS;
this.leftPeak = AudioWizard.PeakmeterAdjustedLeftSamplePeak;
this.rightPeak = AudioWizard.PeakmeterAdjustedRightSamplePeak;
}
/**
* Sets the playback time of the progress bar.
* @param {number} x - The x-coordinate.
* @private
*/
setPlaybackTime(x) {
const clampedPosition = Clamp((x - this.x) / this.w, 0, 1);
const newPlaybackTime = clampedPosition * fb.PlaybackLength;
if (fb.PlaybackTime !== newPlaybackTime) {
fb.PlaybackTime = newPlaybackTime;
}
}
/**
* Sets the refresh rate for the peakmeter bar.
*/
setRefreshRate() {
if (grm.ui.isStreaming) { // Radio streaming refresh rate
grm.ui.seekbarTimerInterval = FPS._1;
}
else if (grSet.peakmeterBarRefreshRate !== 'variable') { // Fixed refresh rate
grm.ui.seekbarTimerInterval = grSet.peakmeterBarRefreshRate;
}
else { // Variable refresh rate calculation
const now = Date.now();
if (this.updateTimeLast && (now - this.updateTimeLast) < 250) return; // Check every 250 ms
this.updateTimeLast = now;
if (this.profilerPaintTimeLast === undefined) {
this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time;
}
const timeDifference = grm.ui.seekbarProfiler.Time - this.profilerPaintTimeLast;
grm.ui.seekbarTimerInterval = Clamp(grm.ui.seekbarTimerInterval + (timeDifference > 0 ? 8 : -5), FPS._20, FPS._10);
this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time;
grm.ui.clearTimer('seekbar', true);
grm.ui.seekbarTimer = !fb.IsPaused ? setInterval(() => grm.ui.refreshSeekbar(), grm.ui.seekbarTimerInterval) : null;
}
}
/**
* Starts Audio Wizard's peakmeter real-time monitoring.
*/
startPeakmeter() {
const refreshRate = grSet.peakmeterBarRefreshRate === 'variable' ? 17 : grSet.peakmeterBarRefreshRate;
AudioWizard && AudioWizard.StartPeakmeterMonitoring(refreshRate, 50);
}
/**
* Stops Audio Wizard's peakmeter real-time monitoring.
*/
stopPeakmeter() {
AudioWizard && AudioWizard.StopPeakmeterMonitoring();
}
// #endregion
// * CALLBACKS * //
// #region CALLBACKS
/**
* Checks if the mouse is within the boundaries of the peakmeter bar.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @returns {boolean} True or false.
*/
mouseInThis(x, y) {
return (x >= this.x && y >= this.y && x < this.x + this.w && y <= this.y + this.h);
}
/**
* Handles left mouse button down click events and enables dragging.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
*/
on_mouse_lbtn_down(x, y) {
this.drag = true;
}
/**
* Handles left mouse button up click events and disables dragging and updates the playback time.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
*/
on_mouse_lbtn_up(x, y) {
this.drag = false;
if (this.on_mouse && this.mouseInThis(x, y)) {
this.setPlaybackTime(x);
}
}
/**
* Handle mouse leave events.
*/
on_mouse_leave() {
this.drag = false;
this.on_mouse = false;
}
/**
* Handles mouse movement events and updates the playback time based on the mouse movement if a drag event is occurring.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
*/
on_mouse_move(x, y) {
this.on_mouse = true;
this.pos_x = x <= this.textWidth ? this.x + this.textWidth : this.x + x;
this.pos_y = y <= this.textHeight ? this.textHeight : y;
if (this.drag) {
this.setPlaybackTime(x);
}
if (this.tooltipText) {
this.wheel = false;
this.tooltipTimer = false;
this.tooltipText = '';
grm.ttip.stop();
window.Repaint();
}
}
/**
* Handles mouse wheel events and controls the volume offset.
* @param {number} step - The wheel scroll direction.
*/
on_mouse_wheel(step) {
this.wheel = true;
if (!AudioWizard) return;
AudioWizard.PeakmeterOffset = AudioWizard.PeakmeterOffset + step;
this.tooltipText = `${Math.round(AudioWizard.PeakmeterOffset)} db`;
grm.ttip.showImmediate(this.tooltipText);
}
/**
* Updates peakmeter bar colors when playing a new track.
* @param {FbMetadbHandle} metadb - The metadb of the track.
*/
on_playback_new_track(metadb) {
if (!metadb) return;
this.startPeakmeter();
this.progressLength = 0;
this.setColors(metadb);
}
/**
* Resets the peakmeter bar on playback stop.
* @param {number} reason - The type of playback stop.
*/
on_playback_stop(reason = -1) {
if (['progressbar', 'waveformbar'].includes(grSet.seekbar)) {
return;
}
if (reason !== 2) {
this.stopPeakmeter();
this.reset();
window.Repaint();
}
}
/**
* Sets the size and position of the peakmeter bar and updates them on window resizing.
*/
on_size() {
this.x = grm.ui.edgeMargin;
this.y = grm.ui.seekbarY;
this.w = grm.ui.ww - grm.ui.edgeMarginBoth;
this.h = grm.ui.seekbarHeight;
this.initGeometry();
this.setY();
this.textFont = gdi.Font('Segoe UI', HD_4K(9, 16), 1);
}
// #endregion
}
//////////////////////
// * WAVEFORM BAR * //
//////////////////////
/**
* A class that creates the waveform bar in the lower bar when enabled.
* Quick access via right click context menu on lower bar.
*/
class WaveformBar {
/**
* Creates the `WaveformBar` instance.
*/
constructor() {
// * Dependencies
include(`${fb.ProfilePath}georgia-reborn\\externals\\Codepages.js`);
include(`${fb.ProfilePath}georgia-reborn\\externals\\lz-utf8\\lzutf8.js`);
include(`${fb.ProfilePath}georgia-reborn\\externals\\lz-string\\lz-string.min.js`);
/** @private @type {string} The match pattern used to create folder path. */
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%,\\,)), ?,,= ,,?,)';
/** @private @type {boolean} The debug flag for logging debug information. */
this.debug = false;
/** @private @type {boolean} The profile flag for logging performance information. */
this.profile = false;
/** @private @type {FbProfiler} The profiler for logging performance information. */
this.profiler = null;
// * Easy access
/** @public @type {number} The x-coordinate of the waveform bar. */
this.x = grm.ui.edgeMargin;
/** @public @type {number} The y-coordinate of the waveform bar. */
this.y = 0;
/** @public @type {number} The width of the waveform bar. */
this.w = grm.ui.ww - grm.ui.edgeMarginBoth;
/** @public @type {number} The height of the waveform bar. */
this.h = grm.ui.seekbarHeight;
// * Internals
/** @private @type {boolean} The active state of the waveform bar. */
this.active = true;
/** @private @type {string} The title format used for the waveform bar. */
this.Tf = fb.TitleFormat(this.matchPattern);
/** @private @type {number} The maximum step for the title format. */
this.TfMaxStep = fb.TitleFormat('[%BPM%]');
/** @private @type {string[]} The cache storage for the waveform data. */
this.cache = null;
/** @private @type {string} The directory for the waveform cache. */
this.cacheDir = grSet.customWaveformBarDir ? $(`${grCfg.customWaveformBarDir}`, undefined, true) : `${fb.ProfilePath}cache\\waveform\\`;
/** @private @type {string} The code page for character encoding conversion. */
this.codePage = convertCharsetToCodepage('UTF-8');
/** @private @type {string} The code page for UTF-16LE character encoding conversion. */
this.codePageV2 = convertCharsetToCodepage('UTF-16LE');
/** @private @type {number} The queue identifier for the waveform bar. */
this.queueId = null;
/** @private @type {number} The queue interval in milliseconds. */
this.queueMs = 1000;
/** @private @type {string[]} The current waveform data. */
this.current = [];
/** @private @type {number[]} The offset values for the waveform data. */
this.offset = [];
/** @private @type {number} The current step in the waveform animation. */
this.step = 0; // 0 - maxStep
/** @private @type {number} The maximum step for the waveform animation. */
this.maxStep = 4;
/** @private @type {number} The current playback time for the waveform bar. */
this.time = 0;
/** @private @type {boolean} The state indicating if the mouse is down. */
this.mouseDown = false;
/** @private @type {boolean} The state indicating if the file is allowed. Set at checkAllowedFile(). */
this.isAllowedFile = true;
/** @private @type {boolean} The state indicating if the file is a zipped file. Set at checkAllowedFile(). */
this.isZippedFile = false;
/** @private @type {boolean} The state indicating if there was an error. Set at verifyData() after retrying analysis. */
this.isError = false;
/** @private @type {boolean} The state indicating if fallback mode is active. For visualizerFallback, set at checkAllowedFile(). */
this.isFallback = false;
/** @private @type {number} The number of audio channels in the current waveform data. */
this.currentChannels = 1;
/**
* The waveform bar analysis settings.
* @typedef {object} waveformBarAnalysis
* @property {string} binaryMode - The analysis mode: 'audioWizard' | 'visualizer'.
* @property {number} resolution - The temporal resolution in points per second from 1-1000, recommended preset ranges are:
* - 100 points/sec: Very High Details (10ms, for transient-heavy audio like EDM).
* - 50 points/sec: High Details (20ms, for mastering and detailed visualization).
* - 20 points/sec: Standard Details (50ms, matches FFmpeg/astats, ideal for broadcast).
* - 15 points/sec: Balanced Details (~67ms, for smooth audio like pop or jazz).
* - 10 points/sec: Low Details (100ms, for basic visualization).
* - 5 points/sec: Very Low Details (200ms, for low-performance devices, very smooth audio).
* - 1 points/sec: Minimum Details (1000ms, for ultra-minimal previews on very slow devices).
* @property {number} timeout - The maximum duration for waveform analysis in milliseconds.
* @property {string} compressionMode - The compression mode: 'none' | 'utf-8' | 'utf-16' | '7zip'.
* @property {string} saveMode - The save behavior: 'always' | 'library' | 'never'.
* @property {boolean} autoAnalysis - Whether to automatically analyze files.
* @property {boolean} autoDelete - Whether to auto-delete analysis files when unloading the script.
* @property {boolean} visualizerFallbackAnalysis - Whether to use visualizer mode when analyzing the file.
* @property {boolean} visualizerFallback - Whether to use visualizer mode for incompatible file formats.
* @public
*/
/** @public @type {waveformBarAnalysis} */
this.analysis = {
binaryMode: grSet.waveformBarMode,
resolution: grSet.waveformBarResolution,
timeout: 60000,
compressionMode: 'utf-16',
saveMode: grSet.waveformBarSaveMode,
autoAnalysis: true,
autoDelete: grSet.waveformBarAutoDelete,
visualizerFallbackAnalysis: grSet.waveformBarFallbackAnalysis,
visualizerFallback: grSet.waveformBarFallback
};
/**
* The waveform bar binary settings.
* @typedef {object} waveformBarBinaries
* @property {string} visualizer - The visualizer binary to use.
* @public
*/
/** @public @type {waveformBarBinaries} */
this.binaries = {
audioWizard: Component.AudioWizard,
visualizer: `${fb.ProfilePath}running`
};
/**
* The waveform bar compatible file settings.
* @typedef {object} waveformBarCompatibility
* @property {RegExp} audioWizard - The regular expression to test for file types compatible with audioWizard.
* @public
*/
/** @private @type {waveformBarCompatibility} */
this.compatibleFiles = {
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'],
audioWizard: null
};
for (const key of ['audioWizard']) {
this.compatibleFiles[key] = new RegExp(`\\.(${this.compatibleFiles[`${key}List`].join('|')})$`, 'i');
}
/**
* The waveform bar fallback mode settings for visualizerFallbackAnalysis.
* @typedef {object} waveformBarFallbackMode
* @property {boolean} paint - The state that indicates whether to use the paint fallback mode.
* @property {boolean} analysis - The state that indicates whether to use the analysis fallback mode.
* @public
*/
/** @private @type {waveformBarFallbackMode} */
this.fallbackMode = {
paint: false,
analysis: false
};
/**
* The waveform bar metrics configuration.
* @typedef {object} waveformBarMetricsConfig
* @property {number} count - The number of metrics per frame.
* @property {object} index - The metric names to their frame indexes (e.g., rms: 0).
* @property {object} range - The valid ranges for each metric (e.g., [-100, 0] for dB metrics).
* @property {object} mode - The analysisMode values to metric names (e.g., rms: 'rms').
* @public
*/
/** @private @type {waveformBarMetricsConfig} */
this.metrics = {
count: 5,
index: {
rms: 0,
rms_peak: 1,
sample_peak: 2,
min: 3,
max: 4
},
mode: {
rms: 'rms',
rms_peak: 'rms_peak',
sample_peak: 'sample_peak',
waveform: 'waveform'
},
range: {
rms: [-100, 0],
rms_peak: [-100, 0],
sample_peak: [-100, 0],
min: [-1, 1],
max: [-1, 1]
}
};
/**
* The waveform bar preset settings.
* @typedef {object} waveformBarPreset
* @property {string} analysisMode - The waveform bar analysis mode `rms`, `rms_peak`, `sample_peak`, `waveform`.
* @property {string} barDesign - The waveform bar design `waveform`, `bars`, `dots`, `halfbars`.
* @property {string} paintMode - The waveform bar paint mode `full`, `partial`.
* @property {boolean} animate - The flag to display animation.
* @property {boolean} useBPM - The flag to use synced BPM.
* @property {boolean} indicator - The flag to show waveform bar progress indicator.
* @property {boolean} prepaint - The flag to prepaint waveform bar progress.
* @property {number} prepaintFront - The prepaint waveform bar progress length.
* @property {boolean} invertHalfbars - The flag to invert waveform bar halfbars.
* @public
*/
/** @public @type {waveformBarPreset} */
this.preset = {
analysisMode: grSet.waveformBarAnalysis,
barDesign: grSet.waveformBarDesign,
paintMode: grSet.waveformBarPaint,
animate: grSet.waveformBarAnimate,
useBPM: grSet.waveformBarBPM,
indicator: grSet.waveformBarIndicator,
prepaint: grSet.waveformBarPrepaint,
prepaintFront: grSet.waveformBarPrepaintFront,
invertHalfbars: grSet.waveformBarInvertHalfbars
};
/**
* The waveform bar ui settings.
* @typedef {object} waveformBarUI
* @property {number} sizeWave - The width size of drawn waveform.
* @property {number} sizeBars - The width size of drawn bars.
* @property {number} sizeDots - The width size of drawn dots.
* @property {number} sizeHalf - The width size of drawn halfbars.
* @property {number} sizeNormalizeWidth - The visualizer binary to use.
* @property {number} refreshRate - The refresh rate in ms when using animations for any type. 100 is smooth enough but the performance hit is high.
* @public
*/
/** @public @type {waveformBarUI} */
this.ui = {
sizeWave: grSet.waveformBarSizeWave,
sizeBars: grSet.waveformBarSizeBars,
sizeDots: grSet.waveformBarSizeDots,
sizeHalf: grSet.waveformBarSizeHalf,
sizeNormalizeWidth: grSet.waveformBarSizeNormalize,
refreshRate: grSet.waveformBarRefreshRate === 'variable' ? FPS._5 : grSet.waveformBarRefreshRate
};
/**
* The waveform bar wheel settings.
* @typedef {object} waveformBarWheel
* @property {number} seekSpeed - The mouse wheel seek type, 'seconds' or 'percentage.
* @property {string} seekType - The mouse wheel seek speed.
* @public
*/
/** @public @type {waveformBarWheel} */
this.wheel = {
seekSpeed: grSet.waveformBarWheelSeekSpeed,
seekType: grSet.waveformBarWheelSeekType
};
// * Initialization
this.checkConfig();
this.defaultSteps();
this.setThrottlePaint();
if (!IsFolder(this.cacheDir)) { CreateFolder(this.cacheDir); }
}
// * PUBLIC METHODS - DRAW * //
// #region PUBLIC METHODS - DRAW
/**
* Draws the waveform bar with various designs based on the current settings.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
draw(gr) {
if (!fb.IsPlaying) {
gr.FillSolidRect(this.x, grm.ui.seekbarY, this.w, SCALE(grSet.layout !== 'default' ? 10 : 12), grCol.progressBar);
this.reset();
return;
}
if (this.current.length === 0) {
this.drawBarInfo(gr);
return;
}
if (grSet.waveformBarRefreshRate === 'variable' || grm.debug.showDrawExtendedTiming) {
grm.ui.seekbarProfiler.Reset();
}
// * Set shared properties
/** @private @type {number} The time constant for the waveform bar calculation. */
this.timeConstant = fb.PlaybackLength / this.current.length;
/** @private @type {number} The current X position based on playback time. */
this.currX = this.x + this.w * ((fb.PlaybackTime / fb.PlaybackLength) || 0);
/** @private @type {number} The width of each bar in the waveform. */
this.barW = this.w / this.current.length;
/** @private @type {boolean} The state whether prepaint mode is active. */
this.prepaint = this.preset.paintMode === 'partial' && this.preset.prepaint;
/** @private @type {boolean} The state whether visualizer mode is active. */
this.visualizer = this.analysis.binaryMode === 'visualizer' || this.isFallback || this.fallbackMode.paint;
const minPointDiff = 1; // in px
const past = [{ x: 0, y: 1 }, { x: 0, y: -1 }];
let pastIndex = 0;
gr.SetSmoothingMode(SmoothingMode.AntiAlias);
for (let n = 0; n < this.current.length; n++) {
const frame = this.current[n];
const current = this.timeConstant * n;
const isPrepaintAllowed = (current - this.time) < this.preset.prepaintFront;
/** @private @type {boolean} The state whether the current frame is in prepaint mode. */
this.isPrepaint = current > this.time;
/** @private @type {number} The scaled size of the current frame. */
this.scaledSize = this.h / 2 * frame;
/** @private @type {number} The x-position of the current frame. */
this.frameX = this.x + this.barW * n;
// * Exit loop if prepaint mode conditions are met
if (this.preset.paintMode === 'partial' && !this.prepaint && this.isPrepaint) break;
if (this.prepaint && this.isPrepaint && !isPrepaintAllowed) break;
if (!this.offset[n]) this.offset[n] = 0;
// * Calculate offsets for prepainting and visualizer animation
/** @private @type {number} The offset value for prepainting and visualizer animation. */
this.offset[n] += (this.prepaint && this.isPrepaint && this.preset.animate || this.visualizer ? // Add movement when pre-painting
this.preset.barDesign === 'dots' ? Math.random() * Math.abs(this.step / this.maxStep) :
-Math.sign(frame) * Math.random() * this.scaledSize / 10 * this.step / this.maxStep : 0);
/** @private @type {number} The random offset value for the current frame. */
this.offsetRandom = this.preset.barDesign === 'dots' ? this.offset[n] : Math.sign(frame) * this.offset[n];
// * Draw the waveform bar
if (past.every((p) =>
(p.y !== Math.sign(frame) && this.preset.barDesign !== 'halfbars') ||
(p.y === Math.sign(frame) || this.preset.barDesign === 'halfbars') && (this.frameX - p.x) >= minPointDiff)) {
this.drawWaveformBar(gr);
past[pastIndex] = { x: this.frameX, y: Math.sign(frame) };
pastIndex = (pastIndex + 1) % past.length;
}
}
this.drawBarProgressLine(gr);
this.drawBarAnimation();
}
/**
* Draws the waveform bar based on the preset design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawWaveformBar(gr) {
const drawBarDesign = {
waveform: () => this.drawBarDesignWaveform(gr),
bars: () => this.drawBarDesignBars(gr),
halfbars: () => this.drawBarDesignHalfbars(gr),
dots: () => this.drawBarDesignDots(gr)
};
drawBarDesign[this.preset.barDesign]();
}
/**
* Draws the waveform bar in "waveform" design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarDesignWaveform(gr) {
const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize + this.offsetRandom, 1) : Math.min(this.scaledSize + this.offsetRandom, -1);
const zTop = this.visualizer ? Math.abs(yOffset) : yOffset;
const zBottom = this.visualizer ? -Math.abs(yOffset) : yOffset;
const { sizeWave } = this.ui;
const { colorBack, colorFront, colorsDiffer } = this.getColors();
if (zTop > 0) {
if (colorsDiffer) {
gr.FillSolidRect(this.frameX, this.y - zTop, sizeWave, zTop / 2, colorBack);
gr.FillSolidRect(this.frameX, this.y - zTop / 2, sizeWave, zTop / 2, colorFront);
} else {
gr.FillSolidRect(this.frameX, this.y - zTop, sizeWave, zTop, colorBack);
}
}
if (zBottom < 0) {
if (colorsDiffer) {
gr.FillSolidRect(this.frameX, this.y - zBottom / 2, sizeWave, -zBottom / 2, colorBack);
gr.FillSolidRect(this.frameX, this.y, sizeWave, -zBottom / 2, colorFront);
} else {
gr.FillSolidRect(this.frameX, this.y, sizeWave, -zBottom, colorBack);
}
}
}
/**
* Draws the waveform bar in "bars" design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarDesignBars(gr) {
const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize + this.offsetRandom, 1) : Math.min(this.scaledSize + this.offsetRandom, -1);
const zTop = this.visualizer ? Math.abs(yOffset) : yOffset;
const zBottom = this.visualizer ? -Math.abs(yOffset) : yOffset;
const sizeBars = this.barW * this.ui.sizeBars;
const { colorBack, colorFront, colorsDiffer } = this.getColors(true, true);
if (zTop > 0) {
if (colorsDiffer) {
gr.DrawRect(this.frameX, this.y - zTop, sizeBars, zTop / 2, 1, colorBack);
gr.DrawRect(this.frameX, this.y - zTop / 2, sizeBars, zTop / 2, 1, colorFront);
} else {
gr.DrawRect(this.frameX, this.y - zTop, sizeBars, zTop, 1, colorBack);
}
}
if (zBottom < 0) {
if (colorsDiffer) {
gr.DrawRect(this.frameX, this.y - zBottom / 2, sizeBars, -zBottom / 2, 1, colorBack);
gr.DrawRect(this.frameX, this.y, sizeBars, -zBottom / 2, 1, colorFront);
} else {
gr.DrawRect(this.frameX, this.y, sizeBars, -zBottom, 1, colorBack);
}
}
}
/**
* Draws the waveform bar in "halfbars" design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarDesignHalfbars(gr) {
const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize + this.offsetRandom, 1) : Math.min(this.scaledSize + this.offsetRandom, -1);
const y = this.preset.invertHalfbars ? Math.abs(yOffset) : yOffset;
const sizeHalf = this.visualizer ? this.barW * this.ui.sizeHalf * (this.visualizer ? 0.2 : 0.5) : this.ui.sizeHalf;
const { colorBack, colorFront, colorsDiffer } = this.getColors(false, true);
if (y > 0) {
if (colorsDiffer) {
gr.FillSolidRect(this.frameX, this.y - 2 * y + this.h * 0.5, sizeHalf, y, colorBack);
gr.FillSolidRect(this.frameX, this.y - y + this.h * 0.5, sizeHalf, y, colorFront);
} else {
gr.FillSolidRect(this.frameX, this.y - 2 * y + this.h * 0.5, sizeHalf, 2 * y, colorBack);
}
}
}
/**
* Draws the waveform bar in "dots" design.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarDesignDots(gr) {
const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize, 1) : Math.min(this.scaledSize, -1);
const dotStep = Math.max(this.h / 80, 5) + (this.offsetRandom || 1);
const dotSize = Math.max(dotStep / 25, 1) * this.ui.sizeDots;
const { colorBack, colorFront } = this.getColors();
const drawDots = (direction, startY, yOffset, color1, color2) => {
const sign = this.visualizer ? direction : Math.sign(yOffset);
const step = direction * yOffset / 2;
let currentY = startY;
for (const endY = startY - step; sign * (currentY - endY) > 0; currentY -= sign * dotStep) {
gr.DrawEllipse(this.frameX, currentY, dotSize, dotSize, 1, color1);
}
for (const endY = startY - 2 * step; sign * (currentY - endY) > 0; currentY -= sign * dotStep) {
gr.DrawEllipse(this.frameX, currentY, dotSize, dotSize, 1, color2);
}
};
drawDots(1, this.y, yOffset, colorFront, colorBack);
if (!this.visualizer) return;
drawDots(-1, this.y, yOffset, colorFront, colorBack);
}
/**
* Draws the progress line on the waveform bar.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarProgressLine(gr) {
if (!this.preset.indicator && !this.mouseDown) return;
gr.SetSmoothingMode(0);
if (this.analysis.binaryMode === 'audioWizard' || ['waveform', 'dots'].includes(this.preset.barDesign)) {
const minBarW = Math.round(Math.max(this.barW, SCALE(1)));
gr.DrawLine(this.currX, this.y - this.h * 0.5, this.currX, this.y + this.h * 0.5, minBarW, grCol.waveformBarIndicator);
}
}
/**
* Draws information text when waveform data is loading or when it is not available.
* @param {GdiGraphics} gr - The GDI graphics object.
*/
drawBarInfo(gr) {
if (pl.col.row_nowplaying_bg === null) return; // * Wait until nowplaying bg has a new color to prevent flashing
const DT_CENTER = DrawText.VCenter | DrawText.Center | DrawText.EndEllipsis | DrawText.CalcRect | DrawText.NoPrefix;
const bgColor = grSet.theme === 'reborn' ? pl.col.row_nowplaying_bg : grCol.transportEllipseBg;
const message =
!this.isAllowedFile && !this.isFallback && this.analysis.binaryMode !== 'visualizer' ? 'Incompatible file format' :
!this.analysis.autoAnalysis ? 'Waveform bar file not found' :
this.isError ? 'Waveform bar file can not be analyzed' :
this.active ? 'Loading' : '';
gr.FillSolidRect(this.x, this.y - this.h * 0.5, this.w, this.h, bgColor);
gr.GdiDrawText(message, grFont.lowerBarWave, pl.col.header_artist_normal, this.x, this.y - this.h * 0.5, this.w, this.h, DT_CENTER);
}
/**
* Draw the waveform bar animation.
*/
drawBarAnimation() {
if (this.prepaint && this.preset.animate || this.visualizer) {
if (this.step >= this.maxStep) {
this.step = -this.step;
} else {
if (this.step === 0) { this.offset = []; }
this.step++;
}
}
if (fb.IsPlaying && !fb.IsPaused) {
this.setRefreshRate();
if (this.visualizer) {
this.throttlePaint();
}
else if (this.current.length && (this.prepaint || this.preset.paintMode === 'partial' || this.preset.indicator)) {
const paintRect = this.setPaintRect(this.time);
this.throttlePaintRect(paintRect.x, paintRect.y, paintRect.width, paintRect.height);
}
}
}
// #endregion
// * PUBLIC METHODS - INITALIZATION * //
// #region PUBLIC METHODS - INITALIZATION
/**
* Checks if the current file is allowed to be played, i.e not corrupted.
* @param {object} handle - The current file handle.
*/
checkAllowedFile(handle = fb.GetNowPlaying()) {
if (!handle) return;
const noVisual = this.analysis.binaryMode !== 'visualizer';
const validExt = this.checkCompatibleFileExtension(handle);
this.isZippedFile = handle.RawPath.includes('unpack://');
this.isAllowedFile = noVisual && validExt && !this.isZippedFile;
this.isFallback = !this.isAllowedFile && this.analysis.visualizerFallback;
}
/**
* Checks if the file extension of the current file handle is compatible.
* @param {object} handle - The current file handle.
* @param {string} mode - The analysis binary mode.
* @returns {boolean} True if the file extension is compatible, otherwise false.
*/
checkCompatibleFileExtension(handle = fb.GetNowPlaying(), mode = this.analysis.binaryMode) {
return (mode === 'visualizer') || (handle && this.compatibleFiles[mode].test(handle.Path));
}
/**
* Checks the report list of compatible file extensions for the given mode.
* @param {string} mode - The analysis binary mode.
* @returns {Array<string>} An array of compatible file extensions.
*/
checkCompatibleFileExtensionReport(mode = this.analysis.binaryMode) {
return [...this.compatibleFiles[`${mode}List`]];
}
/**
* Checks the configuration for validity, called from the constructor.
*/
checkConfig() {
if (!Object.prototype.hasOwnProperty.call(this.binaries, this.analysis.binaryMode)) {
this.analysis.binaryMode = 'visualizer';
}
if (!this.binaries[this.analysis.binaryMode]) {
fb.ShowPopupMessage(`Waveform bar => required dependency not found: ${this.analysis.binaryMode}\n\n${JSON.stringify(this.binaries[this.analysis.binaryMode])}`, window.Name);
}
if (this.preset.prepaintFront <= 0 || this.preset.prepaintFront === null) {
this.preset.prepaintFront = Infinity;
}
if (this.wheel.seekSpeed < 0) {
this.wheel.seekSpeed = 1;
} else if (this.wheel.seekSpeed > 100 && this.wheel.seekType === 'percentage') {
this.wheel.seekSpeed = 100;
}
}
/**
* Updates the config and ensures the UI is being updated properly after changing settings.
* @param {object} newConfig - The new configuration object with settings to be applied.
*/
updateConfig(newConfig) {
if (newConfig) {
DeepAssign()(this, newConfig);
}
this.checkConfig();
let recalculate = false;
if (newConfig.preset) {
if (this.preset.paintMode === 'partial' && this.preset.prepaint || this.analysis.binaryMode === 'visualizer') {
this.offset = [];
this.step = 0;
}
if (Object.prototype.hasOwnProperty.call(newConfig.preset, 'animate') ||
Object.prototype.hasOwnProperty.call(newConfig.preset, 'useBPM')) {
if (this.preset.animate && this.preset.useBPM) {
this.bpmSteps();
} else {
this.defaultSteps();
}
}
}
if (newConfig.ui) {
if (Object.prototype.hasOwnProperty.call(newConfig.ui, 'refreshRate')) {
this.setThrottlePaint();
}
if (Object.prototype.hasOwnProperty.call(newConfig.ui, 'sizeNormalizeWidth') ||
Object.prototype.hasOwnProperty.call(newConfig.ui, 'normalizeWidth')) {
recalculate = true;
}
}
if (newConfig.analysis) {
recalculate = true;
}
if (recalculate) {
this.on_playback_new_track();
} else {
this.throttlePaint();
}
}
// #endregion
// * PUBLIC METHODS - DATA * //
// #region PUBLIC METHODS - DATA
/**
* Starts the analysis process of the waveform data and updates the current state.
* @param {FbMetadbHandle} handle - The handle of the current track.
* @param {boolean} isRetry - The flag indicating whether the method call is a retry attempt.
* @returns {Promise<boolean>} The promise that resolves to `true` if analysis is successful, `false` otherwise.
*/
async analyzeDataStart(handle, isRetry) {
if (this.analysis.binaryMode === 'visualizer' || this.analysis.visualizerFallbackAnalysis) {
this.current = this.visualizerData(handle);
if (this.analysis.binaryMode === 'visualizer') {
this.normalizePoints();
return;
}
}
const { waveformBarFolder, waveformBarFile, sourceFile } = this.getPaths(handle);
const files = this.getFileConfigs();
let analysisComplete = false;
for (const file of files) {
const fileWithExt = `${waveformBarFile}${file.ext}`;
if (IsFile(fileWithExt)) {
const str = Open(fileWithExt, file.codePage) || '';
const parsed = file.decompress(str) || {};
if (parsed.data && Array.isArray(parsed.data)) {
this.current = parsed.data;
this.currentChannels = parsed.channels || 1;
} else {
this.current = Array.isArray(parsed) ? parsed : [];
this.currentChannels = 1;
}
if (this.verifyData(handle, fileWithExt, isRetry)) {
analysisComplete = true;
break;
}
}
}
if (!analysisComplete && this.analysis.autoAnalysis && IsFile(sourceFile)) {
if (this.analysis.visualizerFallbackAnalysis && this.isAllowedFile) {
this.fallbackMode.analysis = this.fallbackMode.paint = true;
this.normalizePoints();
if (this.preset.animate && this.preset.useBPM) this.bpmSteps(handle);
if (fb.IsPlaying) this.time = fb.PlaybackTime;
}
this.throttlePaint(true);
if (this.analysis.visualizerFallbackAnalysis) {
this.fallbackMode.analysis = false;
}
await this.analyzeData(handle, waveformBarFolder, waveformBarFile, sourceFile);
this.fallbackMode.analysis = this.fallbackMode.paint = false;
analysisComplete = this.verifyData(handle, undefined, isRetry);
}
this.isFallback = !analysisComplete;
this.normalizePoints(this.analysis.binaryMode !== 'visualizer' && this.ui.sizeNormalizeWidth);
}
/**
* Analyzes data of the given handle(s) and saves the results in the waveform bar cache directory.
* @param {FbMetadbHandle|FbMetadbHandleList} handle - The handle(s) to analyze.
* @param {string} waveformBarFolder - The folder where the waveform bar data should be saved.
* @param {string} waveformBarFile - The name of the waveform bar file.
* @param {string} [sourceFile] - The path of the source file.
* @returns {Promise<void>} The promise that resolves when the analysis has finished.
*/
async analyzeData(handle, waveformBarFolder, waveformBarFile, sourceFile = handle.Path || handle[0].Path) {
if (!this.isAllowedFile || !AudioWizard || AudioWizard.FullTrackProcessing) return;
if (!IsFolder(waveformBarFolder)) {
CreateFolder(waveformBarFolder);
}
try {
const handleList = (handle instanceof FbMetadbHandleList) ? handle :
new FbMetadbHandleList(Array.isArray(handle) ? handle : [handle]
);
const startTime = Date.now();
grm.debug.debugLog(`Audio Wizard => Starting waveform analysis: mode=${this.preset.analysisMode}, resolution=${this.analysis.resolution}`);
const success = await new Promise((resolve) => {
const { metadata } = GetMetadata(handleList);
AudioWizard.SetFullTrackWaveformCallback((res) => resolve(res));
AudioWizard.StartWaveformAnalysis(metadata, this.analysis.resolution);
});
if (!success) {
console.log('Audio Wizard => Waveform analysis failed - API returned error');
return;
}
const metricsPerChannel = 5;
const trackCount = AudioWizard.GetWaveformTrackCount();
for (let i = 0; i < trackCount; i++) {
const trackHandle = handleList[i];
const data = [];
const rawData = AudioWizard.GetWaveformData(i);
const channels = AudioWizard.GetWaveformTrackChannels(i);
const stepSize = metricsPerChannel * channels;
// Restructure flat array into array of arrays (one array per time step)
for (let j = 0; j < rawData.length; j += stepSize) {
const pointSlice = rawData.slice(j, j + stepSize);
const roundedPoint = pointSlice.map(v => Math.round(v * 1000) / 1000);
data.push(roundedPoint);
}
if (this.saveDataAllowed(trackHandle)) {
this.analyzeDataSave(waveformBarFile, JSON.stringify({ channels, data }));
}
if (handleList.Count === 1) {
this.current = data;
this.currentChannels = channels; // Store for normalizePoints
}
}
grm.debug.debugLog(`Audio Wizard => Analysis completed in ${(Date.now() - startTime) / 1000} seconds`);
this.throttlePaint();
}
catch (e) {
console.log(`Audio Wizard => Analysis error: ${e.message}`);
AudioWizard.StopWaveformAnalysis();
}
}
/**
* Saves the compressed data to a file.
* @param {string} waveformBarFile - The name of the waveform bar file.
* @param {string} dataStr - The data to be saved.
*/
analyzeDataSave(waveformBarFile, dataStr) {
if (this.analysis.binaryMode === 'visualizer') return;
const compression = {
'utf-16': () => SaveFSO(`${waveformBarFile}.awz.lz16`, LZString.compressToUTF16(dataStr), true),
'utf-8': () => Save(`${waveformBarFile}.awz.lz`, LZUTF8.compress(dataStr, { outputEncoding: 'Base64' })),
'none': () => Save(`${waveformBarFile}.awz.json`, dataStr)
};
(compression[this.analysis.compressionMode] || compression.none)();
}
/**
* Generates data for the visualizer.
* @param {FbMetadbHandle} handle - The handle to analyze.
* @param {string} preset - The preset to use for the visualizer.
* @param {boolean} variableLen - The flag whether the length of the data should be variable.
* @returns {Array} The data for the visualizer bar.
*/
visualizerData(handle, preset = 'classic spectrum analyzer', variableLen = false) {
const barW = this.getBarWidth();
const samplesMax = Math.floor(this.w / barW);
const samplesTotal = Math.floor(handle.Length * this.analysis.resolution);
const samples = variableLen ? samplesTotal : Math.min(samplesMax, samplesTotal);
const data = new Array(samples);
if (preset === 'classic spectrum analyzer') {
const third = Math.round(samples / 3);
const half = Math.round(samples / 2);
// * Filling first half
for (let i = 0; i < third; i++) {
const val = (Math.random() * i) / third;
data[i] = val;
}
for (let i = third; i < half; i++) {
const val = (Math.random() * i) / third;
data[i] = val;
}
// * Filling second half with reversed first half
for (let i = half, j = 0; i < samples; i++, j++) {
data[i] = data[half - 1 - j];
}
}
return data;
}
/**
* Checks if the processed waveform data is valid for audioWizard mode.
* @returns {boolean} True if the data is valid.
*/
validData() {
if (!Array.isArray(this.current) || !this.current.length) {
return false;
}
const channels = this.currentChannels || 1;
const expectedLength = this.metrics.count * channels;
return this.current.every(frame => {
if (!Array.isArray(frame) || frame.length < expectedLength) {
return false;
}
// Validate metrics for each channel
for (let ch = 0; ch < channels; ch++) {
const offset = ch * this.metrics.count;
for (const [metric, index] of Object.entries(this.metrics.index)) {
const value = frame[offset + index];
if (typeof value !== 'number' || !isFinite(value)) {
return false;
}
const [min, max] = this.metrics.range[metric];
if (value < min || value > max) {
return false;
}
}
}
return true;
});
}
/**
* Verifies if the processed data is valid.
* @param {FbMetadbHandle} handle - The handle to analyze.
* @param {string} file - The file to analyze.
* @param {boolean} isRetry - The flag whether the data should be retried.
* @returns {boolean} True if the data is valid.
*/
verifyData(handle, file, isRetry = false) {
if (this.validData()) return true;
if (file) DeleteFile(file);
if (isRetry) {
console.log('File was not successfully analyzed after retrying.');
this.isAllowedFile = false;
this.isFallback = this.analysis.visualizerFallback;
this.isError = true;
this.current = [];
} else {
console.log(`Waveform bar file not valid. Creating new one${file ? `: ${file}` : '.'}`);
this.on_playback_new_track(handle, true);
}
return false;
}
/**
* Deletes the waveform file(s) associated with the given track handle.
* @param {FbMetadbHandle} handle - The handle of the track.
*/
deleteWaveformFile(handle) {
if (!handle) return;
const { waveformBarFile } = this.getPaths(handle);
const fileConfigs = this.getFileConfigs();
for (const config of fileConfigs) {
const filePath = `${waveformBarFile}${config.ext}`;
if (IsFile(filePath)) {
try {
DeleteFile(filePath);
} catch (e) {
console.log(`Error deleting waveform file: ${filePath}`, e);
}
}
}
}
/**
* Deletes the waveform bar cache directory with its processed data.
*/
removeData() {
DeleteFolder(this.cacheDir);
}
/**
* Determines whether data should be saved based on the current analysis save mode and the handle.
* @param {FbMetadbHandle} handle - The handle to check against the save mode and media library.
* @returns {boolean} - Returns `true` if the data should be saved, `false` otherwise.
*/
saveDataAllowed(handle) {
return this.analysis.saveMode === 'always' || (this.analysis.saveMode === 'library' && handle && fb.IsMetadbInMediaLibrary(handle));
}
// #endregion
// * PUBLIC METHODS - COMMON * //
// #region PUBLIC METHODS - COMMON
/**
* Sets the max step based on the BPM of the track.
* @param {object} handle - The handle of the track.
* @returns {number} The max steps.
*/
bpmSteps(handle = fb.GetNowPlaying()) {
if (!handle) return this.defaultSteps();
// 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.
const BPM = Number(this.TfMaxStep.EvalWithMetadb(handle));
this.maxStep = Math.round(Math.min(Math.max(200 / (BPM || 100) * 2, 2), 10) * (200 / this.ui.refreshRate) ** (1 / 2));
return this.maxStep;
}
/**
* Sets the max step to a default value.
* @returns {number} The max steps.
*/
defaultSteps() {
this.maxStep = Math.round(4 * (200 / this.ui.refreshRate) ** (1 / 2));
return this.maxStep;
}
/**
* Gets the bar width based on the bar design preset.
* @returns {number} The width of the bar corresponding to the design preset.
*/
getBarWidth() {
const barWidth = {
waveform: this.ui.sizeWave,
bars: this.ui.sizeBars,
dots: this.ui.sizeDots,
halfbars: this.ui.sizeHalf
};
return barWidth[this.preset.barDesign] || 1;
}
/**
* Gets the colors for the waveform bars.
* @param {boolean} useShadeColor - The flag indicating whether to use the ShadeColor for adjustments.
* @param {boolean} highlightCurrentPosition - The flag indicating whether to highlight the current position indicator.
* @returns {object} The object containing colorBack, colorFront and colorsDiffer.
*/
getColors(useShadeColor = true, highlightCurrentPosition = false) {
if (highlightCurrentPosition && (this.preset.indicator || this.mouseDown) && this.analysis.binaryMode === 'audioWizard' &&
(this.frameX <= this.currX && this.frameX >= this.currX - 2 * this.barW)) {
return { colorBack: grCol.waveformBarIndicator, colorFront: grCol.waveformBarIndicator, colorsDiffer: false };
}
const colorBack = this.prepaint && this.isPrepaint ?
useShadeColor ? ShadeColor(grCol.waveformBarFillBack, 40) : grCol.waveformBarFillPreBack :
grCol.waveformBarFillBack;
const colorFront = this.prepaint && this.isPrepaint ?
useShadeColor ? ShadeColor(grCol.waveformBarFillFront, 20) : grCol.waveformBarFillPreFront :
grCol.waveformBarFillFront;
return { colorBack, colorFront, colorsDiffer: colorFront !== colorBack };
}
/**
* Gets the configuration for the different file types to be analyzed.
* @returns {Array<object>} An array of file configuration objects. Each object contains:
* - {string} ext - The file extension.
* - {Function} decompress - The function to decompress and parse the file content.
* - {string} codePage - The code page to be used when reading the file.
*/
getFileConfigs() {
return [
{ ext: '.awz.json', decompress: JSON.parse, codePage: this.codePage },
{ ext: '.awz.lz', decompress: str => JSON.parse(LZUTF8.decompress(str, { inputEncoding: 'Base64' })), codePage: this.codePage },
{ ext: '.awz.lz16', decompress: str => JSON.parse(LZString.decompressFromUTF16(str)), codePage: this.codePageV2 }
];
}
/**
* Gets the paths to the waveform bar cache folder and file.
* @param {object} handle - The handle of the track.
* @returns {object} The paths to the waveform bar cache folder and file.
*/
getPaths(handle) {
const id = CleanFilePath(this.Tf.EvalWithMetadb(handle)); // Ensures paths are valid!
const fileName = id.split('\\').pop();
const waveformBarFolder = this.cacheDir + (this.saveDataAllowed(handle) ? id.replace(fileName, '') : '');
const waveformBarFile = this.cacheDir + id;
const sourceFile = this.isZippedFile ? handle.Path.split('|')[0] : handle.Path;
return { waveformBarFolder, waveformBarFile, sourceFile };
}
/**
* Gets the maximum and minimum values from the frames.
* @param {number[]} frames - The array of frame values.
* @returns {object} The object containing the `upper` and `lower` values.
*/
getMaxValue(frames) {
let upper = 0;
let lower = 0;
for (let i = 0; i < frames.length; i++) {
const frame = frames[i];
upper = Math.max(upper, frame);
lower = Math.min(lower, frame);
}
return { upper, lower };
}
/**
* Gets the minimum value at a specific position in the frames.
* @param {Array} frames - The array of frame data.
* @param {number} pos - The position index in the frame data.
* @returns {number} The minimum value at the specified position.
*/
getMinValuePos(frames, pos) {
let minVal = Infinity;
for (let i = 0; i < frames.length; i++) {
const frame = frames[i];
if (frame[pos] === null) frame[pos] = -Infinity;
const val = frame[pos];
if (isFinite(val)) {
minVal = Math.min(minVal, val);
}
}
return minVal === Infinity ? 0 : minVal;
}
/**
* Gets the Normalized frame values by subtracting the maximum value from each frame.
* @param {Array} frames - The array of frame data.
* @param {number} maxVal - The maximum value to be subtracted from each frame.
* @returns {Array} The normalized frame data.
*/
getNormalizedFrameValues(frames, maxVal) {
const normalizedFrames = new Array(frames.length);
const scaledIndex = this.metrics.count; // Scaled value stored at metric.count
for (let i = 0; i < frames.length; i++) {
const frame = frames[i];
const newFrame = frame.slice();
if (newFrame[scaledIndex] !== 1) newFrame[scaledIndex] -= maxVal;
if (!isFinite(newFrame[scaledIndex])) newFrame[scaledIndex] = 0;
normalizedFrames[i] = newFrame;
}
return normalizedFrames;
}
/**
* Gets the scaled frames based on the given position, maximum value, and level type.
* @param {Array} frames - The array of frame data.
* @param {number} pos - The position index in the frame data to be scaled.
* @param {number} max - The maximum value for scaling.
* @param {boolean} isRmsLevel - Whether if RMS level scaling should be applied.
* @returns {Array} The scaled frame data.
*/
getScaledFrames(frames, pos, max, isRmsLevel) {
const scaledFrames = new Array(frames.length);
const logMax = Math.log(Math.abs(max));
for (let i = 0; i < frames.length; i++) {
const frame = frames[i];
const value = isFinite(frame[pos]) ? frame[pos] : -Infinity;
let scaledVal =
!isFinite(value) ? 1 :
isRmsLevel ? 1 - Math.abs((value - max) / max) :
Math.abs(1 - (logMax + Math.log(Math.abs(value))) / logMax);
if (!isFinite(scaledVal)) scaledVal = 0;
const newFrame = frame.slice(0, this.metrics.count);
newFrame.push(scaledVal);
scaledFrames[i] = newFrame;
}
return scaledFrames;
}
/**
* Gets the resized frames based on the given scale and new frame count.
* @param {number} scale - The scale factor for resizing.
* @param {number} frames - The current number of frames.
* @param {number} newFrames - The desired number of frames after resizing.
* @returns {Array} The resized frame data.
*/
getResizedFrames(scale, frames, newFrames) {
const data = Array(newFrames).fill(null).map(() => ({ maxAbs: 0, maxSigned: 0, val: 0, count: 0 }));
const scaleFactor = newFrames < frames ? frames / newFrames : newFrames / frames;
const isWaveform = this.preset.analysisMode === 'waveform';
if (frames === 0 || newFrames === 0) return [];
for (let i = 0, j = 0, h = 0; i < frames; i++) {
const frame = this.current[i];
if (newFrames < frames) {
if (isWaveform) { // Track max absolute and signed values for waveform
data[j].maxAbs = Math.max(data[j].maxAbs, Math.abs(frame));
data[j].maxSigned = Math.abs(frame) > Math.abs(data[j].maxSigned) ? frame : data[j].maxSigned;
data[j].count++;
h += 1;
if (h >= scaleFactor) {
j++;
h -= scaleFactor;
if (j >= newFrames) break;
}
}
else { // Averaging logic for other modes
while (h >= scaleFactor) {
const w = h - scaleFactor;
if (j + 1 < newFrames) {
data[j + 1].val += frame * w;
data[j + 1].count += w;
}
j += 2;
h = 0;
if (j >= newFrames) break;
data[j].val += frame * (1 - w);
data[j].count += (1 - w);
}
if (i % 2 === 0 && j + 1 < newFrames) {
data[j + 1].val += frame;
data[j + 1].count++;
} else {
data[j].val += frame;
data[j].count++;
h++;
}
}
}
else { // Upsampling: repeat or interpolate frames
while (h < scaleFactor && j < newFrames) {
if (isWaveform) {
data[j].maxAbs = Math.max(data[j].maxAbs, Math.abs(frame));
data[j].maxSigned = Math.abs(frame) > Math.abs(data[j].maxSigned) ? frame : data[j].maxSigned;
} else {
data[j].val += frame;
}
data[j].count++;
j++;
h++;
}
h -= scaleFactor;
}
}
return data.filter(el => el.count > 0).map(el => isWaveform ? el.maxSigned : el.val / el.count);
}
/**
* Normalizes points to ensure all points are on the same scale to prevent distortion of the waveform.
* @param {boolean} normalizeWidth - If `true`, adjusts the number of frames to match the window size.
*/
normalizePoints(normalizeWidth = false) {
if (!this.current.length) return;
// Safety filter for any unexpected invalid frames
if (this.analysis.binaryMode === 'audioWizard' && !this.isFallback && !this.fallbackMode.paint) {
this.current = this.current.filter(frame => frame != null && Array.isArray(frame) && frame.length >= this.metrics.count);
}
if (this.analysis.binaryMode === 'audioWizard' && !this.isFallback && !this.fallbackMode.paint && this.preset.analysisMode === 'waveform') {
const channels = this.currentChannels || 1;
const minIdx = this.metrics.index.min;
const maxIdx = this.metrics.index.max;
const metricsPerChannel = this.metrics.count;
this.current = this.current.map(frame => {
let globalMin = Infinity;
let globalMax = -Infinity;
// Process ALL channels
for (let ch = 0; ch < channels; ch++) {
const offset = ch * metricsPerChannel;
const chMin = frame[offset + minIdx];
const chMax = frame[offset + maxIdx];
globalMin = Math.min(globalMin, chMin);
globalMax = Math.max(globalMax, chMax);
}
// Return the value with larger magnitude
return Math.abs(globalMax) > Math.abs(globalMin) ? globalMax : globalMin;
});
}
let { upper, lower } = this.getMaxValue(this.current);
if (this.analysis.binaryMode === 'audioWizard' && !this.isFallback && !this.fallbackMode.paint && this.preset.analysisMode !== 'waveform') {
const metric = this.metrics.mode[this.preset.analysisMode];
const pos = this.metrics.index[metric];
const minVal = this.getMinValuePos(this.current, pos);
this.current = this.getScaledFrames(this.current, pos, minVal, this.preset.analysisMode === 'rms');
this.current = this.getNormalizedFrameValues(this.current, Math.min(...this.current.map(frame => frame[this.metrics.count])));
this.current = this.current.map((x, i) => Math.sign((0.5 - i % 2)) * (1 - x[this.metrics.count]));
}
else if (this.analysis.binaryMode === 'visualizer' || this.isFallback || this.fallbackMode.paint) {
const maxVal = Math.max(Math.abs(upper), Math.abs(lower));
this.current = this.current.map(frame => frame / maxVal);
}
if (normalizeWidth) {
const barW = this.getBarWidth();
const frames = this.current.length;
const newFrames = Math.floor(this.w / barW);
if (newFrames === frames) return;
this.current = this.getResizedFrames(frames / newFrames, frames, newFrames);
const bias = Math.abs(upper / lower);
upper = lower = 0;
({ upper, lower } = this.getMaxValue(this.current));
const newBias = Math.abs(upper / lower);
const biasDiff = bias - newBias;
if (biasDiff > 0.1) {
const distort = bias / newBias;
const sign = Math.sign(biasDiff);
this.current = this.current.map(frame => (sign === 1 && frame > 0) || (sign !== 1 && frame < 0) ? frame * distort : frame);
}
}
// Clamp frame values to [-1, 1] to prevent overflow/distortion from imbalanced data or edge cases
this.current = this.current.map(frame => Math.max(-1, Math.min(1, frame)));
}
/**
* Resets the state of the waveform bar.
*/
reset() {
this.current = [];
this.cache = null;
this.time = 0;
this.step = 0;
this.maxStep = 6;
this.offset = [];
this.isAllowedFile = true;
this.isZippedFile = false;
this.isError = false;
this.isFallback = false;
this.fallbackMode.paint = this.fallbackMode.analysis = false;
this.resetAnimation();
clearTimeout(this.queueId);
}
/**
* Resets the state of the waveform bar animation.
*/
resetAnimation() {
this.step = 0;
this.offset = [];
this.defaultSteps();
}
/**
* Sets the refresh rate for the waveform bar.
*/
setRefreshRate() {
if (grm.ui.isStreaming) { // Radio streaming refresh rate
this.ui.refreshRate = grm.ui.seekbarTimerInterval = FPS._1;
}
else if (grSet.waveformBarRefreshRate !== 'variable') { // Fixed refresh rate
this.ui.refreshRate = grm.ui.seekbarTimerInterval = grSet.waveformBarRefreshRate;
}
else { // Variable refresh rate calculation
const now = Date.now();
if (this.updateTimeLast && (now - this.updateTimeLast) < 250) return; // Check every 250 ms
this.updateTimeLast = now;
if (this.profilerPaintTimeLast === undefined) {
this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time;
}
const timeDifference = grm.ui.seekbarProfiler.Time - this.profilerPaintTimeLast;
this.ui.refreshRate = grm.ui.seekbarTimerInterval = Clamp(grm.ui.seekbarTimerInterval + (timeDifference > 0 ? 12 : -7), FPS._10, FPS._5);
this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time;
grm.ui.clearTimer('seekbar', true);
grm.ui.seekbarTimer = !fb.IsPaused ? setInterval(() => grm.ui.refreshSeekbar(), grm.ui.seekbarTimerInterval) : null;
}
}
/**
* Sets the rectangular area to be painted.
* @param {number} time - The current playback time.
* @returns {{ x: number, y: number, width: number, height: number }} The object containing the dimensions of the rectangle to be painted.
*/
setPaintRect(time) {
const widerModesScale = ['bars', 'halfbars'].includes(this.preset.barDesign) ? 2 : 1;
const barW = Math.ceil(Math.max(this.w / this.current.length, SCALE(2))) * widerModesScale;
const currX = this.x + (this.w * time / fb.PlaybackLength);
const prePaintW = Math.min(
this.prepaint && this.preset.prepaintFront !== Infinity || this.preset.animate
? this.preset.prepaintFront === Infinity && this.preset.animate
? Infinity
: (this.preset.prepaintFront / this.timeConstant * barW) + barW
: 2.5 * barW,
this.w - currX + barW
);
return {
x: currX - barW - grm.ui.edgeMargin,
y: this.y,
width: prePaintW + grm.ui.edgeMarginBoth,
height: this.h
};
}
/**
* Sets the throttle paint methods based on the current UI refresh rate.
*/
setThrottlePaint() {
/**
* Throttles the window repaint to improve performance by limiting the rate of repaint operations.
* This function is specifically tailored to repaint a defined rectangular area of the window.
* The repaint is controlled by the UI refresh rate.
* @param {boolean} [force] - If set to true, the repaint will be forced even if the window is not dirty.
* @private
*/
this.throttlePaint = Throttle((force = false) =>
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);
/**
* Throttles the window repaint to improve performance by limiting the rate of repaint operations.
* This function allows for the specification of the rectangular area to be repainted.
* The repaint is controlled by the UI refresh rate.
* @param {number} x - The x-coordinate of the upper-left corner of the rectangle to repaint.
* @param {number} y - The y-coordinate of the upper-left corner of the rectangle to repaint.
* @param {number} w - The width of the rectangle to repaint.
* @param {number} h - The height of the rectangle to repaint.
* @param {boolean} [force] - If set to true, the repaint will be forced even if the window is not dirty.
* @private
*/
this.throttlePaintRect = Throttle((x, y, w, h, force = false) =>
window.RepaintRect(x - SCALE(2), y - h * 0.5 - SCALE(4), w + SCALE(4), h + SCALE(8), force), this.ui.refreshRate);
}
/**
* Sets the vertical waveform bar position.
* @param {number} y - The y-coordinate.
*/
setY(y) {
this.y = y + SCALE(10);
}
/**
* This method is currently not used.
* @param {boolean} [enable] - If true, activates the component; if false, deactivates it.
*/
switch(enable = !this.active) {
if (!fb.IsPlaying) return;
const wasActive = this.active;
this.active = enable;
if (!wasActive && this.active) {
window.Repaint();
setTimeout(() => {
this.on_playback_new_track(fb.GetNowPlaying());
this.on_playback_time(fb.PlaybackTime);
}, 0);
}
else if (wasActive && !this.active) {
this.on_playback_stop(-1);
}
}
/**
* Checks if the mouse is within the boundaries of the waveform bar.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @returns {boolean} True or false.
*/
trace(x, y) {
return (x >= this.x && y >= this.y && x <= this.x + this.w && y <= this.y + this.h);
}
/**
* Updates the waveform bar with the current track information, playback time and size.
* @param {boolean} current - Whether the current track has changed or not.
*/
updateBar(current) {
if (!current) this.on_playback_new_track(fb.GetNowPlaying());
this.on_playback_time(fb.PlaybackTime);
this.on_size(grm.ui.ww, grm.ui.wh);
}
// #endregion
// * CALLBACKS * //
// #region CALLBACKS
/**
* Handles left mouse button up click events and disables dragging and updates the playback time.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @param {number} mask - The mouse mask.
* @returns {boolean} True or false.
*/
on_mouse_lbtn_up(x, y, mask) {
if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) ||
!this.active || !this.trace(x, y) || !fb.IsPlaying || this.current.length === 0) {
this.mouseDown = false;
return false;
}
this.mouseDown = false;
if (!fb.GetSelection()) return;
const barW = this.w / this.current.length;
const time = Math.round(fb.PlaybackLength / this.current.length * (x - this.x) / barW);
fb.PlaybackTime = Clamp(time, 0, fb.PlaybackLength);
this.throttlePaint(true);
return true;
}
/**
* Handles mouse movement events on the waveform bar.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @param {number} mask - The mouse mask.
*/
on_mouse_move(x, y, mask) {
if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar)) {
return;
}
this.mouseDown = (mask === MouseKey.LButton && this.on_mouse_lbtn_up(x, y, mask));
}
/**
* Handles the mouse wheel event to seek through the playback.
* @param {number} step - The wheel scroll direction.
* @returns {boolean} True or false.
*/
on_mouse_wheel(step) {
if (!this.active || !fb.GetSelection() || !fb.IsPlaying || this.current.length === 0) {
return false;
}
const seekType = {
seconds: (scroll) => scroll * this.wheel.seekSpeed,
percentage: (scroll) => (scroll * this.wheel.seekSpeed) / 100 * fb.PlaybackLength
};
const seekTypeFunc = seekType[this.wheel.seekType] || seekType.seconds;
const newTime = fb.PlaybackTime + seekTypeFunc(step);
fb.PlaybackTime = Clamp(newTime, 0, fb.PlaybackLength);
this.throttlePaint(true);
return true;
}
/**
* Resets the current waveform and processes new data for the new current playing track.
* @param {FbMetadbHandle} handle - The handle of the new track.
* @param {boolean} [isRetry] - The flag indicating whether the method call is a retry attempt.
* @returns {Promise<void>} The promise that resolves when the processing has finished.
*/
async on_playback_new_track(handle = fb.GetNowPlaying(), isRetry = false) {
if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) || !this.active || !handle) {
return;
}
this.reset();
this.checkAllowedFile(handle);
await this.analyzeDataStart(handle, isRetry);
this.resetAnimation();
if (this.preset.animate && this.preset.useBPM) this.bpmSteps(handle);
if (fb.IsPlaying) this.time = fb.PlaybackTime;
this.throttlePaint();
}
/**
* Schedules the `on_playback_new_track` event to be triggered after a specified delay.
* This is useful for debouncing the event, ensuring it is fired only once after a series of track changes.
*/
on_playback_new_track_queue() {
clearTimeout(this.queueId);
this.queueId = setTimeout(() => {
this.on_playback_new_track(...arguments);
}, this.queueMs);
}
/**
* Resets the waveform bar on playback stop.
* @param {number} reason - The type of playback stop.
*/
on_playback_stop(reason = -1) {
if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) || reason !== -1 && !this.active) {
return;
}
this.reset();
if (reason !== 2) this.throttlePaint();
}
/**
* Updates the waveform bar with throttled repaints.
* @param {number} time - The current playback time.
*/
on_playback_time(time) {
if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) || !this.active) {
return;
}
this.time = time;
if ((this.preset.paintMode === 'full' || this.preset.indicator || this.analysis.binaryMode === 'visualizer') &&
this.cache === this.current) {
return;
}
this.cache = this.current;
if (this.analysis.binaryMode === 'visualizer' || !this.current.length) {
this.throttlePaint();
}
else if (this.preset.paintMode === 'partial' || this.preset.indicator) {
const paintRect = this.setPaintRect(this.time);
this.throttlePaintRect(paintRect.x, paintRect.y, paintRect.width, paintRect.height);
}
}
/**
* Handles the waveform bar state when reloading the theme.
*/
on_script_unload() {
if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar)) return;
if (this.analysis.autoDelete) this.removeData();
}
/**
* Sets the size and position of the waveform bar and updates them on window resizing.
* @param {number} w - The width of the waveform bar.
* @param {number} h - The height of the waveform bar.
*/
on_size(w, h) {
if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar)) return;
this.x = grm.ui.edgeMargin;
this.y = 0;
this.w = w - grm.ui.edgeMarginBoth;
this.h = grm.ui.seekbarHeight;
}
// #endregion
}
| 1 | ///////////////////////////////////////////////////////////////////////////////// |
| 2 | // * Georgia-ReBORN: A Clean - Full Dynamic Color Reborn - Foobar2000 Player * // |
| 3 | // * Description: Georgia-ReBORN Main Components * // |
| 4 | // * Author: TT * // |
| 5 | // * Website: https://github.com/TT-ReBORN/Georgia-ReBORN * // |
| 6 | // * Version: 3.0-x64-DEV * // |
| 7 | // * Dev. started: 22-12-2017 * // |
| 8 | // * Last change: 17-05-2026 * // |
| 9 | ///////////////////////////////////////////////////////////////////////////////// |
| 10 | |
| 11 | |
| 12 | 'use strict'; |
| 13 | |
| 14 | |
| 15 | /////////////////////// |
| 16 | // * IMAGE CACHING * // |
| 17 | /////////////////////// |
| 18 | /** |
| 19 | * A class that creates album art and playlist thumbnails cache. |
| 20 | */ |
| 21 | class ArtCache { |
| 22 | /** |
| 23 | * Creates the `ArtCache` instance. |
| 24 | * The ArtCache is a Least-Recently Used cache meaning that each cache hit will bump |
| 25 | * that image to be the last image to be removed from the cache (if maxCacheSize is exceeded). |
| 26 | * @param {number} maxCacheSize - The maximum number of images to keep in the cache. |
| 27 | */ |
| 28 | constructor(maxCacheSize = 15) { |
| 29 | /** |
| 30 | * @typedef {object} ArtCacheObj |
| 31 | * @property {GdiBitmap} image - The GDI+ bitmap image object cached. |
| 32 | * @property {number} filesize - The size of the image file in bytes. |
| 33 | */ |
| 34 | |
| 35 | /** @private @type {object.<string, ArtCacheObj>} The primary cache storing image objects. */ |
| 36 | this.cache = {}; |
| 37 | /** @private @type {object.<string, ArtCacheObj>} The secondary cache used mainly for disc art covers to prevent overwriting album art with masked images. */ |
| 38 | this.cache2 = {}; |
| 39 | /** @private @type {string[]} The array of cache keys in the order of their usage. */ |
| 40 | this.cacheIndexes = []; |
| 41 | /** @private @type {string[]} The array of secondary cache keys in the order of their usage. */ |
| 42 | this.cacheIndexes2 = []; |
| 43 | /** @private @type {number} The maximum number of images that can be stored in the primary cache. */ |
| 44 | this.cacheMaxSize = maxCacheSize; |
| 45 | /** @private @type {number} The maximum number of images that can be stored in the secondary cache. */ |
| 46 | this.cacheMaxSize2 = maxCacheSize; |
| 47 | |
| 48 | /** @private @type {number} The maximum width an image can be displayed. */ |
| 49 | this.imgMaxWidth = SCALE(1440); |
| 50 | /** @private @type {number} The maximum height an image can be displayed. */ |
| 51 | this.imgMaxHeight = SCALE(872); |
| 52 | |
| 53 | /** |
| 54 | * Because foobar x86 can allocate only 4 gigs memory, we must limit disc art res for 4K when using |
| 55 | * high grSet.spinDiscArtImageCount, i.e 90 (4 degrees), 120 (3 degrees), 180 (2 degrees) to prevent crash. |
| 56 | * When SMP has x64 support, we could try to increase this limit w (1836px max possible res for 4K). |
| 57 | * @public @type {number} |
| 58 | */ |
| 59 | this.discArtImgMaxRes = this.setDiscArtMaxResolution(grSet.spinDiscArtImageCount); |
| 60 | } |
| 61 | |
| 62 | // * PUBLIC METHODS * // |
| 63 | // #region PUBLIC METHODS |
| 64 | /** |
| 65 | * Gets cached image if it exists under the location string. If image is found, move it's index to the end of the cacheIndexes. |
| 66 | * @param {string} location - The string value to check if image is cached under. |
| 67 | * @param {number} cacheIndex - The first or second index of the cache to check. |
| 68 | * @returns {GdiBitmap|null} The cached image, or null if not found or the file does not exist. |
| 69 | */ |
| 70 | getImage(location, cacheIndex = 1) { |
| 71 | const cache = cacheIndex === 1 ? this.cache : this.cache2; |
| 72 | const cacheIndexes = cacheIndex === 1 ? this.cacheIndexes : this.cacheIndexes2; |
| 73 | |
| 74 | if (!cache[location] || !fso.FileExists(location)) { |
| 75 | // If image is not in cache or location does not exist, return to prevent crash. |
| 76 | return null; |
| 77 | } |
| 78 | |
| 79 | const file = fso.GetFile(location); |
| 80 | const pathIndex = cacheIndexes.indexOf(location); |
| 81 | cacheIndexes.splice(pathIndex, 1); |
| 82 | |
| 83 | if (file && file.Size === cache[location].filesize) { |
| 84 | cacheIndexes.push(location); |
| 85 | grm.debug.debugLog('Art cache => Cache hit:', location); |
| 86 | return cache[location].image; |
| 87 | } |
| 88 | |
| 89 | // Size of file on disk has changed |
| 90 | grm.debug.debugLog(`Art cache => Cache entry was stale: ${location} [old size: ${cache[location].filesize}, new size: ${file.Size}]`); |
| 91 | delete cache[location]; // Was removed from cacheIndexes already |
| 92 | |
| 93 | return null; |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Gets and optionally logs the size of an image or all images in both caches if cacheIndex is 0. |
| 98 | * @param {string|null} location - The location string of the image to check. If null, process all images in the specified cache or both if cacheIndex is 0. |
| 99 | * @param {number} cacheIndex - The index of the cache to check. 0 for both, 1 for the first, 2 for the second. Defaults to 1. |
| 100 | * @param {boolean} logSize - Whether to log the size(s) to the console. Defaults to false. |
| 101 | * @returns {number|null|object} The size of the image in bytes, all images sizes if location is null and cacheIndex is specified, or null if the image is not found. |
| 102 | * @example |
| 103 | * - Get size of a specific image in cache 1: getImageSize('path/to/img.jpg', 1); |
| 104 | * - Get sizes of all images in cache 1: getImageSize(null, 1, true); |
| 105 | * - Get sizes of all images in both caches: getImageSize(null, 0, true); |
| 106 | */ |
| 107 | getImageSize(location, cacheIndex = 1, logSize = false) { |
| 108 | const processCache = (cache, prefix = '') => { |
| 109 | const sizes = {}; |
| 110 | for (const [loc, cacheObj] of Object.entries(cache)) { |
| 111 | const formattedSize = FormatSize(cacheObj.filesize); |
| 112 | sizes[loc] = formattedSize; |
| 113 | if (logSize) { |
| 114 | console.log(`Art cache => ${prefix}Image at '${loc}' size: ${formattedSize}`); |
| 115 | } |
| 116 | } |
| 117 | return sizes; |
| 118 | }; |
| 119 | |
| 120 | if (cacheIndex === 0) { // If location is 0, process both caches |
| 121 | const sizes1 = processCache(this.cache, 'Cache 1 '); |
| 122 | const sizes2 = processCache(this.cache2, 'Cache 2 '); |
| 123 | return { ...sizes1, ...sizes2 }; // Merge results from both caches |
| 124 | } |
| 125 | |
| 126 | const cache = cacheIndex === 1 ? this.cache : this.cache2; |
| 127 | |
| 128 | if (location === null) { // If location is null, process all images in the specified cache. |
| 129 | return processCache(cache); |
| 130 | } |
| 131 | else if (cache[location]) { // Process a specific image in the specified cache. |
| 132 | const formattedSize = FormatSize(cache[location].filesize); |
| 133 | if (logSize) { |
| 134 | console.log(`Art cache => Image at '${location}' size: ${formattedSize}`); |
| 135 | } |
| 136 | return formattedSize; |
| 137 | } |
| 138 | |
| 139 | return null; |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Gets and optionally logs the total size of the cached images. |
| 144 | * If cacheIndex is 0, calculates for both caches combined. |
| 145 | * @param {number} cacheIndex - The index of the cache to calculate size for. If 0, calculates for both caches. |
| 146 | * @param {boolean} logSizes - Whether to log individual image sizes to the console. |
| 147 | * @returns {number} The total size of the cache or caches in bytes. |
| 148 | * @example |
| 149 | * - Get total size of cache 1: getTotalCacheSize(1, true); |
| 150 | * - Get total size of both caches combined: getTotalCacheSize(0, true); |
| 151 | */ |
| 152 | getTotalCacheSize(cacheIndex, logSizes = false) { |
| 153 | let totalSize = 0; |
| 154 | |
| 155 | const calculateAndLogSize = (cache, cacheName = '') => { |
| 156 | for (const [location, cacheObj] of Object.entries(cache)) { |
| 157 | totalSize += cacheObj.filesize; |
| 158 | if (logSizes) { |
| 159 | const formattedSize = FormatSize(cacheObj.filesize); |
| 160 | console.log(`Art cache => ${cacheName} Image at '${location}' size: ${formattedSize}`); |
| 161 | } |
| 162 | } |
| 163 | }; |
| 164 | |
| 165 | if (cacheIndex === 0) { // If cacheIndex is 0, process both caches |
| 166 | calculateAndLogSize(this.cache, 'Cache 1'); |
| 167 | calculateAndLogSize(this.cache2, 'Cache 2'); |
| 168 | } else { |
| 169 | const cache = cacheIndex === 1 ? this.cache : this.cache2; |
| 170 | calculateAndLogSize(cache, `Cache ${cacheIndex}`); |
| 171 | } |
| 172 | |
| 173 | const cacheLabel = cacheIndex === 0 ? 'Total size for both caches' : `Total size for Cache ${cacheIndex}`; |
| 174 | const totalFormattedSize = FormatSize(totalSize); |
| 175 | if (logSizes) console.log(`Art cache => ${cacheLabel}: ${totalFormattedSize}`); |
| 176 | |
| 177 | return totalFormattedSize; |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * Sets the maximum resolution for disc art based on the spinDiscArtImageCount. |
| 182 | * @param {number} spinDiscArtImageCount - The count for spinning disc art images. |
| 183 | * @returns {number} The maximum resolution for the disc art image. |
| 184 | */ |
| 185 | setDiscArtMaxResolution(spinDiscArtImageCount = 72) { |
| 186 | const maxResByImgCount = { |
| 187 | 36: 1500, |
| 188 | 45: 1500, |
| 189 | 60: 1400, |
| 190 | 72: 1400, |
| 191 | 90: 1300, |
| 192 | 120: 1200, |
| 193 | 180: 1000 |
| 194 | }; |
| 195 | |
| 196 | return maxResByImgCount[spinDiscArtImageCount]; |
| 197 | } |
| 198 | |
| 199 | /** |
| 200 | * Adds a rescaled image to the cache under string `location` and returns the cached image. |
| 201 | * @param {GdiBitmap} img - The image object to cache. |
| 202 | * @param {string} location - The string value to cache image under. Does not need to be a path. |
| 203 | * @param {number} cacheIndex - The first or second index of the cache to check. |
| 204 | * @returns {GdiBitmap} The image stored in the cache at the specified location. |
| 205 | * If there is no image in the cache at that location, it returns the original image passed as a parameter. |
| 206 | */ |
| 207 | encache(img, location, cacheIndex = 1) { |
| 208 | const cache = cacheIndex === 1 ? this.cache : this.cache2; |
| 209 | const cacheIndexes = cacheIndex === 1 ? this.cacheIndexes : this.cacheIndexes2; |
| 210 | const cacheMaxSize = cacheIndex === 1 ? this.cacheMaxSize : this.cacheMaxSize2; |
| 211 | |
| 212 | try { |
| 213 | let { Width: w, Height: h } = img; |
| 214 | |
| 215 | // Scale image |
| 216 | if (w > this.imgMaxWidth || h > this.imgMaxHeight) { |
| 217 | const scaleFactor = Math.max(w / this.imgMaxWidth, h / this.imgMaxHeight); |
| 218 | w /= scaleFactor; |
| 219 | h /= scaleFactor; |
| 220 | } |
| 221 | |
| 222 | const file = fso.GetFile(location); |
| 223 | cache[location] = { image: img.Resize(w, h), filesize: file.Size }; |
| 224 | img = null; |
| 225 | |
| 226 | // Update cache order |
| 227 | const pathIndex = cacheIndexes.indexOf(location); |
| 228 | if (pathIndex !== -1) { |
| 229 | cacheIndexes.splice(pathIndex, 1); // Remove from middle of cache and put on end |
| 230 | } |
| 231 | cacheIndexes.push(location); |
| 232 | |
| 233 | // Maintain cache size |
| 234 | if (cacheIndexes.length > cacheMaxSize) { |
| 235 | const remove = cacheIndexes.shift(); |
| 236 | grm.debug.debugLog('Art cache => Removing img from cache:', remove); |
| 237 | delete cache[remove]; |
| 238 | } |
| 239 | } catch (e) { |
| 240 | // Do not console.log inverted band logo and label images in the process of being created |
| 241 | grm.ui.bandLogoInverted && console.log(`\nArt cache => <Error: Image could not be properly parsed: ${location}>\n`); |
| 242 | } |
| 243 | |
| 244 | return cache[location] ? cache[location].image : img; |
| 245 | } |
| 246 | |
| 247 | /** |
| 248 | * Completely clears all cached entries and releases memory held by scaled bitmaps. |
| 249 | */ |
| 250 | clear() { |
| 251 | if (grCfg.settings.showDebugLog) { |
| 252 | grm.debug.debugLog(`Art cache => Total cache size for Cache 1: ${this.getTotalCacheSize(1, false)}`); |
| 253 | grm.debug.debugLog(`Art cache => Total cache size for Cache 2: ${this.getTotalCacheSize(2, false)}`); |
| 254 | grm.debug.debugLog(`Art cache => Total cache size cleared: ${this.getTotalCacheSize(0, false)}`); |
| 255 | } |
| 256 | |
| 257 | const clearCache = (cacheIndexes, cache) => { |
| 258 | for (const index of cacheIndexes) { |
| 259 | delete cache[index]; |
| 260 | } |
| 261 | cacheIndexes.length = 0; |
| 262 | }; |
| 263 | |
| 264 | clearCache(this.cacheIndexes, this.cache); |
| 265 | clearCache(this.cacheIndexes2, this.cache2); |
| 266 | } |
| 267 | // #endregion |
| 268 | } |
| 269 | |
| 270 | |
| 271 | /** |
| 272 | * A class that creates background images for the Playlist or Library. |
| 273 | */ |
| 274 | class BackgroundImage { |
| 275 | /** |
| 276 | * Creates the `BackgroundImage` instance. |
| 277 | */ |
| 278 | constructor() { |
| 279 | // * BACKGROUND PANEL IMAGES * // |
| 280 | // #region BACKGROUND PANEL IMAGES |
| 281 | /** @public @type {GdiBitmap|null} The background image used for the Playlist. */ |
| 282 | this.playlistBgImg = null; |
| 283 | /** @public @type {GdiBitmap|null} The background image used for the Library. */ |
| 284 | this.libraryBgImg = null; |
| 285 | /** @public @type {GdiBitmap|null} The background image used for the Lyrics. */ |
| 286 | this.lyricsBgImg = null; |
| 287 | // #endregion |
| 288 | |
| 289 | // * ARTIST IMAGES * // |
| 290 | // #region ARTIST IMAGES |
| 291 | /** @public @type {GdiBitmap|null} The artist background image of the biography. */ |
| 292 | this.artistBgImg = null; |
| 293 | /** @public @type {GdiBitmap[]} The artist list of background images. */ |
| 294 | this.artistImgList = []; |
| 295 | /** @public @type {number} The artist index of the currently displayed background image for the Playlist. */ |
| 296 | this.artistIdxPlaylist = 0; |
| 297 | /** @public @type {number} The artist index of the currently displayed background image for the Library. */ |
| 298 | this.artistIdxLibrary = 0; |
| 299 | /** @public @type {number} The artist index of the currently displayed background image for the Lyrics. */ |
| 300 | this.artistIdxLyrics = 0; |
| 301 | /** @public @type {number} The artist index of the cached biography artist image for the Playlist. */ |
| 302 | this.artistIdxCachedPlaylist = -1; |
| 303 | /** @public @type {number} The artist index of the cached biography artist image for the Library. */ |
| 304 | this.artistIdxCachedLibrary = -1; |
| 305 | /** @public @type {number} The artist index of the cached biography artist image for the Lyrics. */ |
| 306 | this.artistIdxCachedLyrics = -1; |
| 307 | // #endregion |
| 308 | |
| 309 | // * ALBUM IMAGES * // |
| 310 | // #region ALBUM IMAGES |
| 311 | /** @public @type {GdiBitmap|null} The album background image. */ |
| 312 | this.albumBgImg = null; |
| 313 | /** @public @type {GdiBitmap[]} The album list of background images. */ |
| 314 | this.albumImgList = []; |
| 315 | /** @public @type {number[]} The album art image index: 0 for Front, 1 for Back, and 4 for Artist. */ |
| 316 | this.albumArtIdx = [0, 1, 4]; |
| 317 | /** @public @type {number} The album index of the currently displayed background image for the Playlist. */ |
| 318 | this.albumIdxPlaylist = 0; |
| 319 | /** @public @type {number} The album index of the currently displayed background image for the Library. */ |
| 320 | this.albumIdxLibrary = 0; |
| 321 | /** @public @type {number} The album index of the currently displayed background image for the Lyrics. */ |
| 322 | this.albumIdxLyrics = 0; |
| 323 | /** @public @type {number} The album index of the cached album background image for the Playlist. */ |
| 324 | this.albumIdxCachedPlaylist = -1; |
| 325 | /** @public @type {number} The album index of the cached album background image for the Library. */ |
| 326 | this.albumIdxCachedLibrary = -1; |
| 327 | /** @public @type {number} The album index of the cached album background image for the Lyrics. */ |
| 328 | this.albumIdxCachedLyrics = -1; |
| 329 | // #endregion |
| 330 | |
| 331 | // * CUSTOM IMAGES * // |
| 332 | // #region CUSTOM IMAGES |
| 333 | /** @public @type {GdiBitmap|null} The custom background image. */ |
| 334 | this.customBgImg = null; |
| 335 | /** @public @type {GdiBitmap[]} The custom list of custom background images. */ |
| 336 | this.customImgList = []; |
| 337 | /** @public @type {number} The custom index of the currently displayed custom background image for the Playlist. */ |
| 338 | this.customIdxPlaylist = 0; |
| 339 | /** @public @type {number} The custom index of the currently displayed custom background image for the Library. */ |
| 340 | this.customIdxLibrary = 0; |
| 341 | /** @public @type {number} The custom index of the currently displayed custom background image for the Lyrics. */ |
| 342 | this.customIdxLyrics = 0; |
| 343 | /** @public @type {number} The custom index of the cached custom background image for the Playlist. */ |
| 344 | this.customIdxCachedPlaylist = -1; |
| 345 | /** @public @type {number} The custom index of the cached custom background image for the Library. */ |
| 346 | this.customIdxCachedLibrary = -1; |
| 347 | /** @public @type {number} The custom index of the cached custom background image for the Lyrics. */ |
| 348 | this.customIdxCachedLyrics = -1; |
| 349 | // #endregion |
| 350 | |
| 351 | // * STATE * // |
| 352 | // #region STATE |
| 353 | /** @public @type {Object} The background image fetching state for the Playlist, Library, and Lyrics. */ |
| 354 | this.imgFetching = {}; |
| 355 | /** @public @type {Object} The background image cycle intervals for the Playlist, Library, and Lyrics. */ |
| 356 | this.imgCycleIntervals = {}; |
| 357 | // #endregion |
| 358 | |
| 359 | // * INITIALIZATION * // |
| 360 | // #region INITIALIZATION |
| 361 | this.initBgImageCycle(); |
| 362 | // #endregion |
| 363 | } |
| 364 | |
| 365 | // * PUBLIC METHODS * // |
| 366 | // #region PUBLIC METHODS |
| 367 | /** |
| 368 | * Draws an artist, album or custom image on the Playlist or Library's background. |
| 369 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 370 | * @param {object} img - The image object containing the image and other properties. |
| 371 | * @param {string} scale - The scale mode ("default", "filled" or "stretched") to apply to the image. |
| 372 | * @param {number} x - The x-coordinate where the image should be drawn. |
| 373 | * @param {number} y - The y-coordinate where the image should be drawn. |
| 374 | * @param {number} w - The width of the area to draw the image. |
| 375 | * @param {number} h - The height of the area to draw the image. |
| 376 | * @param {number} opacity - The opacity level to apply to the image. |
| 377 | * @param {boolean} mask - Whether to apply a mask to the image. |
| 378 | * @param {number} maskOffsetY - The y-offset for the mask. |
| 379 | * @param {number} maskHeight - The height of the mask. |
| 380 | */ |
| 381 | drawBgImage(gr, img, scale, x, y, w, h, opacity, mask, maskOffsetY, maskHeight) { |
| 382 | if (!img || !img.image) return; |
| 383 | |
| 384 | if (!img.scaled || img.changed) { |
| 385 | img.scaled = ScaleImage(img.image, scale, x, y, w, h, 0, 0, img.image.Width, img.image.Height); |
| 386 | } |
| 387 | |
| 388 | if (mask && (!img.masked || img.changed)) { |
| 389 | img.masked = MaskImage(img.scaled, 0, maskOffsetY, img.scaled.Width, img.scaled.Height - maskHeight); |
| 390 | } |
| 391 | |
| 392 | const finalImage = mask ? img.masked : img.scaled; |
| 393 | gr.DrawImage(finalImage, x, y, w, h, 0, 0, finalImage.Width, finalImage.Height, 0, opacity); |
| 394 | |
| 395 | img.changed = false; |
| 396 | } |
| 397 | |
| 398 | /** |
| 399 | * Initializes the current background image by clearing the relevant caches and fetching a new image based on the source setting. |
| 400 | * @param {string} panel - The panel ('playlist', 'library', 'lyrics', or false for all) in which the image is being requested. |
| 401 | * @param {boolean} [clearCache] - Whether to clear the background image cache. |
| 402 | * |
| 403 | * The background image cache should be cleared when: |
| 404 | * - The background image source is updated, clearing the previous source. |
| 405 | * - The playback starts a new album, clearing the previous images. |
| 406 | * - The player size changes, requiring a new scaling for the images. |
| 407 | */ |
| 408 | initBgImage(panel, clearCache) { |
| 409 | if (!grSet.playlistBgImg && !grSet.libraryBgImg && !grSet.lyricsBgImg) return; |
| 410 | |
| 411 | if (clearCache) this.clearBgImageCache(); |
| 412 | |
| 413 | this.handleBgImageIndex(panel, 'getIndexes'); |
| 414 | |
| 415 | const displayPanel = { |
| 416 | playlist: grm.ui.displayPlaylist, |
| 417 | library: grm.ui.displayLibrary, |
| 418 | lyrics: grm.ui.displayLyrics |
| 419 | }; |
| 420 | |
| 421 | const panelSize = { |
| 422 | playlist: [pl.playlist.x - SCALE(1), pl.playlist.y - pl.plman.h, pl.playlist.w + SCALE(2), pl.playlist.h + pl.plman.h * 2], |
| 423 | library: [lib.ui.x, lib.ui.y, lib.ui.w, lib.ui.h], |
| 424 | lyrics: [0, grm.ui.topMenuHeight, grm.ui.ww, grm.ui.wh - grm.ui.topMenuHeight - grm.ui.lowerBarHeight] |
| 425 | }; |
| 426 | |
| 427 | const panelToProcess = panel ? [panel] : ['playlist', 'library', 'lyrics'].filter(p => grSet[`${p}BgImg`]); |
| 428 | |
| 429 | for (const p of panelToProcess) { |
| 430 | this.getBgImage(p).then(img => { |
| 431 | this[`${p}BgImg`] = img; |
| 432 | this.handleBgImageIndex(p, 'setIndexes'); |
| 433 | if (displayPanel[p]) window.RepaintRect(...panelSize[p]); |
| 434 | }); |
| 435 | } |
| 436 | } |
| 437 | |
| 438 | /** |
| 439 | * Initializes or clears the cycling of background images. |
| 440 | */ |
| 441 | initBgImageCycle() { |
| 442 | for (const panel of ['playlist', 'library', 'lyrics']) { |
| 443 | clearInterval(this.imgCycleIntervals[panel]); |
| 444 | this.imgCycleIntervals[panel] = null; |
| 445 | |
| 446 | const enabled = grSet[`${panel}BgImg`]; |
| 447 | const cycle = grSet[`${panel}BgImgCycle`]; |
| 448 | const cycleTime = grSet[`${panel}BgImgCycleTime`]; |
| 449 | |
| 450 | if (!enabled || !cycle) continue; |
| 451 | |
| 452 | grm.debug.debugLog(`\n>>> initImage => initImgCycle => Panel: ${CapitalizeString(panel)} - Cycle time: ${cycleTime} seconds <<<\n`); |
| 453 | this.imgCycleIntervals[panel] = setInterval(() => { |
| 454 | this.cycleBgImage(panel, 1); |
| 455 | }, cycleTime * 1000); |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Cycles the background image for the specified panel. |
| 461 | * @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image should be cycled. |
| 462 | * @param {number} direction - The direction to cycle the images (1 for next, -1 for previous). |
| 463 | */ |
| 464 | cycleBgImage(panel, direction) { |
| 465 | const imgKey = this.getBgImageSourceKeys(panel); |
| 466 | const imgList = Array.isArray(imgKey.imgList) ? imgKey.imgList : this[imgKey.imgList]; |
| 467 | |
| 468 | if (!imgList.length) { |
| 469 | this[imgKey.imgIdx] = (this[imgKey.imgIdx] + direction + this.albumArtIdx.length) % this.albumArtIdx.length; |
| 470 | const imgIdx = this.albumArtIdx[this[imgKey.imgIdx]]; |
| 471 | this.handleBgImageIndex(panel, 'setIndexes'); |
| 472 | |
| 473 | this.fetchBgImageEmbedded(panel, imgIdx, imgKey.bgImg, imgKey.bgImgIdx).then(() => { |
| 474 | this.initBgImage(panel); |
| 475 | }); |
| 476 | |
| 477 | return; |
| 478 | } |
| 479 | |
| 480 | if (imgList.length <= 1) return; |
| 481 | |
| 482 | this[imgKey.imgIdx] = (this[imgKey.imgIdx] + direction + imgList.length) % imgList.length; |
| 483 | this.handleBgImageIndex(panel, 'setIndexes'); |
| 484 | |
| 485 | this.fetchBgImage(panel, imgList, imgKey.imgIdx, imgKey.bgImg, imgKey.bgImgIdx).then(() => { |
| 486 | this.initBgImage(panel); |
| 487 | }); |
| 488 | } |
| 489 | |
| 490 | /** |
| 491 | * Checks if the background image is cached, and updates the relevant properties if it is. |
| 492 | * @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested. |
| 493 | * @param {string|string[]} imgList - The name of the property in `this` that contains the image list, or the image list array itself. |
| 494 | * @param {string} imgIdx - The name of the property in `this` that contains the current image index. |
| 495 | * @param {string} bgImg - The name of the property in `this` that contains the cached image. |
| 496 | * @param {string} bgImgIdx - The name of the property in `this` that contains the cached image index. |
| 497 | * @returns {{image: GdiBitmap|null, changed: boolean}} The background image and a flag indicating if it has changed. |
| 498 | */ |
| 499 | checkBgImageCache(panel, imgList, imgIdx, bgImg, bgImgIdx) { |
| 500 | const imgArray = Array.isArray(imgList) ? imgList : this[imgList]; |
| 501 | const imgPath = imgArray[this[imgIdx]]; |
| 502 | const imgCached = grm.artCache.getImage(imgPath); |
| 503 | |
| 504 | if (imgCached) { |
| 505 | this[bgImg] = imgCached; |
| 506 | this[bgImgIdx] = this[imgIdx]; |
| 507 | return { image: imgCached, changed: false }; |
| 508 | } |
| 509 | return { image: null, changed: true }; |
| 510 | } |
| 511 | |
| 512 | /** |
| 513 | * Clears the background image cache. |
| 514 | */ |
| 515 | clearBgImageCache() { |
| 516 | this.playlistBgImg = null; |
| 517 | this.libraryBgImg = null; |
| 518 | this.lyricsBgImg = null; |
| 519 | this.artistBgImg = null; |
| 520 | this.artistImgList = []; |
| 521 | this.albumBgImg = null; |
| 522 | this.albumImgList = []; |
| 523 | this.customBgImg = null; |
| 524 | this.customImgList = []; |
| 525 | grm.debug.debugLog('Main cache => Background image cache cleared'); |
| 526 | } |
| 527 | |
| 528 | /** |
| 529 | * Fetches a background image, either from cache or asynchronously, and updates the relevant properties. |
| 530 | * @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested. |
| 531 | * @param {string|string[]} imgList - The name of the property in `this` that contains the image list, or the image list array itself. |
| 532 | * @param {string} imgIdx - The name of the property in `this` that contains the current image index. |
| 533 | * @param {string} bgImg - The name of the property in `this` that contains the cached image. |
| 534 | * @param {string} bgImgIdx - The name of the property in `this` that contains the cached image index. |
| 535 | * @returns {Promise<{image: GdiBitmap|null, changed: boolean}>} The background image and a flag indicating if it has changed. |
| 536 | */ |
| 537 | fetchBgImage(panel, imgList, imgIdx, bgImg, bgImgIdx) { |
| 538 | if (this.imgFetching[panel]) { |
| 539 | return Promise.resolve({ image: null, changed: false }); |
| 540 | } |
| 541 | |
| 542 | this.imgFetching[panel] = true; |
| 543 | |
| 544 | const imgArray = Array.isArray(imgList) ? imgList : this[imgList]; |
| 545 | const imgIdxLocal = (this[imgIdx] >= imgArray.length) ? 0 : this[imgIdx]; |
| 546 | const imgPathIdx = imgArray[imgIdxLocal]; |
| 547 | |
| 548 | return gdi.LoadImageAsyncV2(window.ID, imgPathIdx) |
| 549 | .then(img => { |
| 550 | this[bgImg] = grm.artCache.encache(img, imgPathIdx); |
| 551 | this[bgImgIdx] = imgIdxLocal; |
| 552 | return { image: img, changed: true }; |
| 553 | }) |
| 554 | .catch(error => { |
| 555 | console.log(`\n>>> Background Image => fetchBgImage => <Error: Image could not be properly parsed: ${panel}:>\n`, error); |
| 556 | return { image: null, changed: false }; |
| 557 | }) |
| 558 | .finally(() => { |
| 559 | this.imgFetching[panel] = false; |
| 560 | }); |
| 561 | } |
| 562 | |
| 563 | /** |
| 564 | * Fetches and caches embedded album art if available. |
| 565 | * @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested. |
| 566 | * @param {string} imgIdx - The name of the property in `this` that contains the current image index. |
| 567 | * @param {string} bgImg - The name of the property in `this` that contains the cached image. |
| 568 | * @param {string} bgImgIdx - The name of the property in `this` that contains the cached image index. |
| 569 | * @returns {Promise<{image: GdiBitmap|null, changed: boolean}>} The embedded album art image and a flag indicating if it has changed. |
| 570 | */ |
| 571 | fetchBgImageEmbedded(panel, imgIdx, bgImg, bgImgIdx) { |
| 572 | if (this.imgFetching[panel]) { |
| 573 | return Promise.resolve({ image: null, changed: false }); |
| 574 | } |
| 575 | |
| 576 | this.imgFetching[panel] = true; |
| 577 | |
| 578 | try { |
| 579 | const metadb = grm.ui.initMetadb(); |
| 580 | |
| 581 | if (!metadb) { |
| 582 | return Promise.resolve({ image: null, changed: false }); |
| 583 | } |
| 584 | |
| 585 | const imgIdxLocal = this.albumArtIdx.includes(imgIdx) ? imgIdx : 0; |
| 586 | const albumArt = utils.GetAlbumArtV2(metadb, imgIdxLocal); |
| 587 | |
| 588 | if (albumArt) { |
| 589 | this[bgImg] = grm.artCache.encache(albumArt, imgIdxLocal); |
| 590 | this[bgImgIdx] = imgIdxLocal; |
| 591 | return Promise.resolve({ image: albumArt, changed: true }); |
| 592 | } |
| 593 | |
| 594 | return Promise.resolve({ image: null, changed: false }); |
| 595 | } |
| 596 | catch (error) { |
| 597 | console.log(`\n>>> Background Image => fetchBgImageEmbedded => <Error: Image could not be properly parsed: ${panel}:>\n`, error); |
| 598 | return Promise.resolve({ image: null, changed: false }); |
| 599 | } |
| 600 | finally { |
| 601 | this.imgFetching[panel] = false; |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | /** |
| 606 | * Gets the background image based on the source setting. |
| 607 | * @param {string} panel - The panel ('playlist', 'library', 'lyrics') in which the image is being requested. |
| 608 | * @returns {{image: GdiBitmap|null, changed: boolean}} The background image and a flag indicating if it has changed. |
| 609 | */ |
| 610 | getBgImage(panel) { |
| 611 | const { imgType, imgList, imgIdx, bgImg, bgImgIdx } = this.getBgImageSourceKeys(panel); |
| 612 | const bgImageFormats = ParseStringToRegExp(grCfg.artworkImageFormats.bgImage); |
| 613 | const bgImagePattern = this.getBgImagePatterns(panel); |
| 614 | |
| 615 | this[imgList] = grm.ui.getImagePathList(imgType, grm.ui.initMetadb(), bgImagePattern).filter(path => bgImageFormats.test(path)); |
| 616 | |
| 617 | if (!this[imgList].length) { |
| 618 | const embeddedIdx = this.albumArtIdx[this[imgIdx]]; |
| 619 | return this.fetchBgImageEmbedded(panel, embeddedIdx, bgImg, bgImgIdx); |
| 620 | } |
| 621 | |
| 622 | const { image, changed } = this.checkBgImageCache(panel, imgList, imgIdx, bgImg, bgImgIdx); |
| 623 | if (image) { |
| 624 | return Promise.resolve({ image, changed }); |
| 625 | } |
| 626 | |
| 627 | return this.fetchBgImage(panel, imgList, imgIdx, bgImg, bgImgIdx); |
| 628 | } |
| 629 | |
| 630 | /** |
| 631 | * Gets the background image pattern for a given panel type. |
| 632 | * @param {string} panel - The panel type, which can be 'playlist', 'library', or 'lyrics'. |
| 633 | * @returns {RegExp} The pattern for the specified panel type. |
| 634 | */ |
| 635 | getBgImagePatterns(panel) { |
| 636 | const bgImagePatterns = { |
| 637 | playlist: grSet.playlistBgImgAlbumArtFilter && ParseStringToRegExp(grCfg.artworkPatterns.playlistBgAlbumArt), |
| 638 | library: grSet.libraryBgImgAlbumArtFilter && ParseStringToRegExp(grCfg.artworkPatterns.libraryBgAlbumArt), |
| 639 | lyrics: grSet.lyricsBgImgAlbumArtFilter && ParseStringToRegExp(grCfg.artworkPatterns.lyricsBgAlbumArt) |
| 640 | }; |
| 641 | |
| 642 | return bgImagePatterns[panel]; |
| 643 | } |
| 644 | |
| 645 | /** |
| 646 | * Retrieves the background image source keys based on the specified panel. |
| 647 | * @param {string} panel - The panel ('playlist', 'library', 'lyrics') whose background image source keys are to be retrieved. |
| 648 | * @returns {object} An object containing the image type, list, index, and background image keys. |
| 649 | */ |
| 650 | getBgImageSourceKeys(panel) { |
| 651 | const Panel = CapitalizeString(panel); |
| 652 | |
| 653 | const imgSrcKeys = { |
| 654 | artist: { imgType: 'artistArt', imgList: 'artistImgList', imgIdx: `artistIdx${Panel}`, bgImg: 'artistBgImg', bgImgIdx: `artistIdxCached${Panel}` }, |
| 655 | album: { imgType: 'albumArt', imgList: 'albumImgList', imgIdx: `albumIdx${Panel}`, bgImg: 'albumBgImg', bgImgIdx: `albumIdxCached${Panel}` }, |
| 656 | custom: { imgType: 'customArt', imgList: 'customImgList', imgIdx: `customIdx${Panel}`, bgImg: 'customBgImg', bgImgIdx: `customIdxCached${Panel}` } |
| 657 | }; |
| 658 | |
| 659 | return imgSrcKeys[grSet[`${panel}BgImgSource`]]; |
| 660 | } |
| 661 | |
| 662 | /** |
| 663 | * Handles background image indexes for the specified panel. |
| 664 | * @param {string} panel - The panel ('playlist', 'library', 'lyrics', or false for all) whose background image indexes are to be retrieved. |
| 665 | * @param {string} action - The action to perform: 'getIndexes', 'setIndexes', or 'clearIndexes'. |
| 666 | */ |
| 667 | handleBgImageIndex(panel, action) { |
| 668 | const panels = panel ? [panel] : ['playlist', 'library', 'lyrics']; |
| 669 | |
| 670 | const indexFields = { |
| 671 | playlist: ['bgImgArtistIdxPlaylist', 'bgImgAlbumIdxPlaylist', 'bgImgCustomIdxPlaylist'], |
| 672 | library: ['bgImgArtistIdxLibrary', 'bgImgAlbumIdxLibrary', 'bgImgCustomIdxLibrary'], |
| 673 | lyrics: ['bgImgArtistIdxLyrics', 'bgImgAlbumIdxLyrics', 'bgImgCustomIdxLyrics'] |
| 674 | }; |
| 675 | |
| 676 | const actions = { |
| 677 | getIndexes: (Panel, artistIdx, albumIdx, customIdx) => { |
| 678 | this[`artistIdx${Panel}`] = grSet[artistIdx]; |
| 679 | this[`albumIdx${Panel}`] = grSet[albumIdx]; |
| 680 | this[`customIdx${Panel}`] = grSet[customIdx]; |
| 681 | }, |
| 682 | setIndexes: (Panel, artistIdx, albumIdx, customIdx) => { |
| 683 | grSet[artistIdx] = this[`artistIdx${Panel}`]; |
| 684 | grSet[albumIdx] = this[`albumIdx${Panel}`]; |
| 685 | grSet[customIdx] = this[`customIdx${Panel}`]; |
| 686 | }, |
| 687 | clearIndexes: (Panel) => { |
| 688 | this[`artistIdx${Panel}`] = 0; |
| 689 | this[`artistIdxCached${Panel}`] = -1; |
| 690 | this[`albumIdx${Panel}`] = 0; |
| 691 | this[`albumIdxCached${Panel}`] = -1; |
| 692 | this[`customIdx${Panel}`] = 0; |
| 693 | this[`customIdxCached${Panel}`] = -1; |
| 694 | } |
| 695 | }; |
| 696 | |
| 697 | for (const panel of panels) { |
| 698 | if (actions[action]) { |
| 699 | actions[action](CapitalizeString(panel), ...indexFields[panel]); |
| 700 | } |
| 701 | } |
| 702 | } |
| 703 | // #endregion |
| 704 | } |
| 705 | |
| 706 | |
| 707 | /////////////////////////// |
| 708 | // * CPU USAGE TRACKER * // |
| 709 | /////////////////////////// |
| 710 | /** |
| 711 | * A class that tracks and monitors CPU usage. |
| 712 | */ |
| 713 | class CPUTracker { |
| 714 | /** |
| 715 | * Create the CPUTracker instance. |
| 716 | * @param {Function} onChangeCallback - A callback function to call when CPU usage changes. |
| 717 | */ |
| 718 | constructor(onChangeCallback) { |
| 719 | /** @private @type {number} */ |
| 720 | this.cpuUsage = 0; |
| 721 | /** @private @type {number} */ |
| 722 | this.guiCpuUsage = 0; |
| 723 | /** @private @type {?number} */ |
| 724 | this.cpuTrackerTimer = null; |
| 725 | /** @private @type {Function} */ |
| 726 | this.onChangeCallback = onChangeCallback; |
| 727 | /** @private @type {{[key: string]: {sampleCount: number, currentSampleCount: number, resetSampleCount: number, acumUsage: number, averageUsage: number}}} */ |
| 728 | this.usage = { |
| 729 | idle: { |
| 730 | sampleCount: 30, |
| 731 | currentSampleCount: 0, |
| 732 | resetSampleCount: 0, |
| 733 | acumUsage: 0, |
| 734 | averageUsage: 0 |
| 735 | }, |
| 736 | playing: { |
| 737 | sampleCount: 30, |
| 738 | currentSampleCount: 0, |
| 739 | resetSampleCount: 0, |
| 740 | acumUsage: 0, |
| 741 | averageUsage: 0 |
| 742 | } |
| 743 | }; |
| 744 | } |
| 745 | |
| 746 | /** |
| 747 | * Gets the current CPU usage. |
| 748 | * @returns {number} The current CPU usage. |
| 749 | */ |
| 750 | getCpuUsage() { |
| 751 | return this.cpuUsage; |
| 752 | } |
| 753 | |
| 754 | /** |
| 755 | * Gets the current GUI CPU usage. |
| 756 | * @returns {number} The current GUI CPU usage. |
| 757 | */ |
| 758 | getGuiCpuUsage() { |
| 759 | return this.guiCpuUsage; |
| 760 | } |
| 761 | |
| 762 | /** |
| 763 | * Starts the CPU usage monitoring process. |
| 764 | */ |
| 765 | start() { |
| 766 | if (this.cpuTrackerTimer) return; |
| 767 | |
| 768 | this.cpuTrackerTimer = setInterval(() => { |
| 769 | const floatUsage = Math.random() * 100; // Simulated CPU usage |
| 770 | const isPlaying = Math.random() > 0.5; // Simulated playback status |
| 771 | const isPaused = Math.random() > 0.8; |
| 772 | const usageType = isPlaying && !isPaused ? 'playing' : 'idle'; |
| 773 | |
| 774 | this.updateUsage(usageType, floatUsage); |
| 775 | |
| 776 | const baseLine = this.usage[usageType].averageUsage; |
| 777 | this.cpuUsage = floatUsage.toFixed(1); |
| 778 | let usageDiff = Math.max((floatUsage - baseLine), 0); |
| 779 | usageDiff = (usageDiff <= 0.5 ? 0 : usageDiff); // Suppress low spikes |
| 780 | this.guiCpuUsage = usageDiff.toFixed(1); |
| 781 | |
| 782 | if (this.onChangeCallback) { |
| 783 | this.onChangeCallback(); |
| 784 | } |
| 785 | }, 1000); |
| 786 | } |
| 787 | |
| 788 | /** |
| 789 | * Stops the CPU usage monitoring and resets usage statistics. |
| 790 | */ |
| 791 | stop() { |
| 792 | if (this.cpuTrackerTimer) { |
| 793 | clearInterval(this.cpuTrackerTimer); |
| 794 | this.cpuTrackerTimer = undefined; |
| 795 | } |
| 796 | |
| 797 | this.resetUsage('idle'); |
| 798 | this.resetUsage('playing'); |
| 799 | } |
| 800 | |
| 801 | /** |
| 802 | * Recalculates the average CPU usage based on a new sample. |
| 803 | * @param {string} type - The type of CPU usage to recalculate ('idle' or 'playing'). |
| 804 | * @param {number} currentUsage - The new CPU usage sample. |
| 805 | */ |
| 806 | recalcAvg(type, currentUsage) { |
| 807 | const usageState = this.usage[type]; |
| 808 | |
| 809 | if (usageState.currentSampleCount < usageState.sampleCount) { |
| 810 | usageState.acumUsage += currentUsage; |
| 811 | usageState.currentSampleCount++; |
| 812 | usageState.averageUsage = usageState.acumUsage / usageState.currentSampleCount; |
| 813 | return; |
| 814 | } |
| 815 | |
| 816 | usageState.averageUsage -= usageState.averageUsage / usageState.sampleCount; |
| 817 | usageState.averageUsage += currentUsage / usageState.sampleCount; |
| 818 | } |
| 819 | |
| 820 | /** |
| 821 | * Resets the CPU usage data for a specified type. |
| 822 | * @param {string} type - The type of CPU usage to reset ('idle' or 'playing'). |
| 823 | */ |
| 824 | resetUsage(type) { |
| 825 | const usageState = this.usage[type]; |
| 826 | usageState.currentSampleCount = 0; |
| 827 | usageState.resetSampleCount = 0; |
| 828 | usageState.acumUsage = 0; |
| 829 | usageState.averageUsage = 0; |
| 830 | } |
| 831 | |
| 832 | /** |
| 833 | * Updates the CPU usage data based on new sample. |
| 834 | * @param {string} type - The type of CPU usage to update ('idle' or 'playing'). |
| 835 | * @param {number} currentUsage - The current CPU usage to update. |
| 836 | */ |
| 837 | updateUsage(type, currentUsage) { |
| 838 | const usageState = this.usage[type]; |
| 839 | |
| 840 | if (usageState.currentSampleCount) { |
| 841 | if (usageState.averageUsage - currentUsage > 2) { |
| 842 | if (usageState.resetSampleCount < 3) { |
| 843 | usageState.resetSampleCount++; |
| 844 | } else { |
| 845 | this.resetUsage(type); |
| 846 | } |
| 847 | } else if (Math.abs(currentUsage - usageState.averageUsage) < 2) { |
| 848 | this.recalcAvg(type, currentUsage); |
| 849 | } |
| 850 | } else { |
| 851 | this.recalcAvg(type, currentUsage); |
| 852 | } |
| 853 | } |
| 854 | } |
| 855 | |
| 856 | |
| 857 | ///////////////// |
| 858 | // * TOOLTIP * // |
| 859 | ///////////////// |
| 860 | /** |
| 861 | * A class that creates or stops the tooltip timer. |
| 862 | */ |
| 863 | class TooltipTimer { |
| 864 | /** |
| 865 | * Creates the `TooltipTimer` instance. |
| 866 | */ |
| 867 | constructor() { |
| 868 | /** @private @type {number|undefined} The timer ID for the tooltip display timeout. */ |
| 869 | this.tooltipTimer = undefined; |
| 870 | /** @private @type {number|undefined} The identifier of the current tooltip caller. */ |
| 871 | this.tooltipCaller = undefined; |
| 872 | } |
| 873 | |
| 874 | // * PUBLIC METHODS * // |
| 875 | // #region PUBLIC METHODS |
| 876 | /** |
| 877 | * Displays the tooltip. |
| 878 | * @param {string} text - The text to show in the tooltip. |
| 879 | * @param {boolean} [force] - Activates the tooltip whether or not text has changed. |
| 880 | */ |
| 881 | displayTooltip(text, force) { |
| 882 | if (grm.ui.ttip && (grm.ui.ttip.Text !== text.toString() || force)) { |
| 883 | grm.ui.ttip.Text = text; |
| 884 | grm.ui.ttip.Activate(); |
| 885 | } |
| 886 | } |
| 887 | |
| 888 | /** |
| 889 | * Starts a tooltip. |
| 890 | * @param {number} id - The id of the caller. |
| 891 | * @param {string} text - The text to show in the tooltip. |
| 892 | */ |
| 893 | start(id, text) { |
| 894 | const oldCaller = this.tooltipCaller; |
| 895 | this.tooltipCaller = id; |
| 896 | |
| 897 | if (!this.tooltipTimer && grm.ui.ttip.Text) { |
| 898 | this.displayTooltip(text, oldCaller !== this.tooltipCaller); |
| 899 | } |
| 900 | else { // * There can be only one tooltip present at all times, so we can kill the timer w/o any worries |
| 901 | if (this.tooltipTimer) { |
| 902 | this.forceStop(); |
| 903 | } |
| 904 | |
| 905 | if (!this.tooltipTimer) { |
| 906 | this.tooltipTimer = setTimeout(() => { |
| 907 | this.displayTooltip(text); |
| 908 | this.tooltipTimer = null; |
| 909 | }, 300); |
| 910 | } |
| 911 | } |
| 912 | } |
| 913 | |
| 914 | /** |
| 915 | * Stops a tooltip. |
| 916 | * @param {number} id - The id of the caller. |
| 917 | */ |
| 918 | stop(id) { |
| 919 | if (this.tooltipCaller === id) { // Do not stop other callers |
| 920 | this.forceStop(); |
| 921 | } |
| 922 | } |
| 923 | |
| 924 | /** |
| 925 | * Forces the tooltip to stop. |
| 926 | */ |
| 927 | forceStop() { |
| 928 | this.displayTooltip(''); |
| 929 | if (!this.tooltipTimer) return; |
| 930 | clearTimeout(this.tooltipTimer); |
| 931 | this.tooltipTimer = null; |
| 932 | this.tooltipCaller = null; |
| 933 | } |
| 934 | // #endregion |
| 935 | } |
| 936 | |
| 937 | |
| 938 | /** |
| 939 | * A class that creates or clears the tooltip text for normal and styled tooltips. |
| 940 | */ |
| 941 | class TooltipHandler { |
| 942 | /** |
| 943 | * Creates the `TooltipHandler` instance. |
| 944 | * Constructs a unique ID and a reference to the TooltipTimer instance. |
| 945 | */ |
| 946 | constructor() { |
| 947 | /** @private @type {number} The unique identifier for this TooltipHandler instance. */ |
| 948 | this.id = Math.ceil(Math.random() * 10000); |
| 949 | /** @private @type {TooltipTimer} A reference to the TooltipTimer instance used to manage tooltip timing. */ |
| 950 | this.timer = new TooltipTimer(); |
| 951 | } |
| 952 | |
| 953 | // * PUBLIC METHODS * // |
| 954 | // #region PUBLIC METHODS |
| 955 | /** |
| 956 | * Shows tooltip after delay (300ms). |
| 957 | * @param {string} text - The text to show in the tooltip. |
| 958 | */ |
| 959 | showDelayed(text) { |
| 960 | grm.ui.styledTooltipText = text; |
| 961 | if (!grSet.showStyledTooltips) { |
| 962 | this.timer.start(this.id, text); |
| 963 | } |
| 964 | } |
| 965 | |
| 966 | /** |
| 967 | * Shows the tooltip immediately. |
| 968 | * @param {string} text - The text to show in the tooltip. |
| 969 | */ |
| 970 | showImmediate(text) { |
| 971 | grm.ui.styledTooltipText = text; |
| 972 | if (!grSet.showStyledTooltips) { |
| 973 | this.timer.stop(this.id); |
| 974 | this.timer.displayTooltip(text); |
| 975 | } |
| 976 | } |
| 977 | |
| 978 | /** |
| 979 | * Clears this tooltip if this handler created it. |
| 980 | */ |
| 981 | clear() { |
| 982 | this.timer.stop(this.id); |
| 983 | } |
| 984 | |
| 985 | /** |
| 986 | * Clears the tooltip regardless of which handler created it. |
| 987 | */ |
| 988 | stop() { |
| 989 | this.timer.forceStop(); |
| 990 | } |
| 991 | // #endregion |
| 992 | } |
| 993 | |
| 994 | |
| 995 | ////////////////////////////// |
| 996 | // * INTERFACE HYPERLINKS * // |
| 997 | ////////////////////////////// |
| 998 | /** |
| 999 | * A class that creates clickable hyperlinks in the Playlist header and in the lower bar. |
| 1000 | */ |
| 1001 | class Hyperlink { |
| 1002 | /** |
| 1003 | * Creates the `Hyperlink` instance. |
| 1004 | * Initializes properties for the text element in the playlist. |
| 1005 | * @param {string} text - The text that will be displayed in the hyperlink. |
| 1006 | * @param {GdiFont} font - The font to use. |
| 1007 | * @param {string} type - The field name which will be searched when clicking on the hyperlink. |
| 1008 | * @param {number} xOffset - The x-offset of the hyperlink. Negative values will be subtracted from the containerWidth to right justify. |
| 1009 | * @param {number} yOffset - The y-offset of the hyperlink. |
| 1010 | * @param {number} containerWidth - The width of the container the hyperlink will be in. Used for right justification purposes. |
| 1011 | * @param {boolean} [inPlaylist] - If the hyperlink is drawing in a scrolling container like a playlist, then it is drawn differently. |
| 1012 | */ |
| 1013 | constructor(text, font, type, xOffset, yOffset, containerWidth, inPlaylist = false) { |
| 1014 | /** @private @type {string} */ |
| 1015 | this.text = text; |
| 1016 | /** @private @type {string} */ |
| 1017 | this.type = type; |
| 1018 | /** @private @type {number} */ |
| 1019 | this.x_offset = xOffset; |
| 1020 | /** @private @type {number} */ |
| 1021 | this.x = xOffset < 0 ? containerWidth + xOffset : xOffset; |
| 1022 | /** @private @type {number} */ |
| 1023 | this.y_offset = yOffset; |
| 1024 | /** @private @type {number} */ |
| 1025 | this.y = yOffset; |
| 1026 | /** @private @type {number} */ |
| 1027 | this.container_w = containerWidth; |
| 1028 | /** @private @type {boolean} */ |
| 1029 | this.state = HyperlinkStates.Normal; |
| 1030 | /** @private @type {boolean} */ |
| 1031 | this.inPlaylist = inPlaylist; |
| 1032 | |
| 1033 | this.setFont(font); |
| 1034 | } |
| 1035 | |
| 1036 | // * PUBLIC METHODS * // |
| 1037 | // #region PUBLIC METHODS |
| 1038 | /** |
| 1039 | * Draws the hyperlink. When drawing in a playlist, we draw from the y-offset instead of y, because the playlist scrolls. |
| 1040 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 1041 | * @param {number} color - The color of the hyperlink. |
| 1042 | */ |
| 1043 | draw(gr, color) { |
| 1044 | const font = this.state === HyperlinkStates.Hovered ? this.hoverFont : this.font; |
| 1045 | DrawString(gr, this.text, font, color, this.x, this.inPlaylist ? this.y_offset : this.y, this.w + SCALE(1), this.h, Stringformat.Trim_Ellipsis_Char); |
| 1046 | } |
| 1047 | |
| 1048 | /** |
| 1049 | * Sets the xOffset of the hyperlink after it has been created. |
| 1050 | * @param {number} xOffset - The x-offset of the hyperlink. Negative values will be subtracted from the containerWidth to right justify. |
| 1051 | */ |
| 1052 | setXOffset(xOffset) { |
| 1053 | this.x = xOffset < 0 ? this.container_w + xOffset : xOffset; |
| 1054 | } |
| 1055 | |
| 1056 | /** |
| 1057 | * Sets the vertical position of the hyperlink. |
| 1058 | * The playlist requires subtracting 2 additional pixels from y for some reason. |
| 1059 | * @param {number} y - The y-coordinate. |
| 1060 | */ |
| 1061 | setY(y) { |
| 1062 | this.y = y + this.y_offset + (-2); |
| 1063 | } |
| 1064 | |
| 1065 | /** |
| 1066 | * Sets the font for the hyperlink. |
| 1067 | * @param {GdiFont} font - The font that will be used. |
| 1068 | */ |
| 1069 | setFont(font) { |
| 1070 | this.font = font; |
| 1071 | this.hoverFont = gdi.Font(font.Name, font.Size, font.Style | FontStyle.Underline); |
| 1072 | this.link_dimensions = this.updateDimensions(); |
| 1073 | } |
| 1074 | |
| 1075 | /** |
| 1076 | * Sets the width of the container the hyperlink will be placed in. |
| 1077 | * If hyperlink width is smaller than the container, it will be truncated. |
| 1078 | * If the the xOffset is negative, the position will be adjusted as the container width changes. |
| 1079 | * @param {number} w - The width. |
| 1080 | */ |
| 1081 | setContainerWidth(w) { |
| 1082 | if (this.x_offset < 0) { |
| 1083 | this.x = w + this.x_offset; // Add because offset is negative |
| 1084 | } |
| 1085 | this.container_w = w; |
| 1086 | this.link_dimensions = this.updateDimensions(); |
| 1087 | this.w = Math.ceil(Math.min(this.container_w, this.link_dimensions.Width + 1)); |
| 1088 | } |
| 1089 | |
| 1090 | /** |
| 1091 | * Gets the width of the hyperlink. |
| 1092 | * @returns {number} The width of the link in pixels. |
| 1093 | */ |
| 1094 | getWidth() { |
| 1095 | try { |
| 1096 | return Math.ceil(this.link_dimensions.Width); |
| 1097 | } catch (e) { |
| 1098 | return null; |
| 1099 | } |
| 1100 | } |
| 1101 | |
| 1102 | /** |
| 1103 | * Updates the width and height of the hyperlinks. |
| 1104 | * @returns {number} The dimensions of the text. |
| 1105 | */ |
| 1106 | updateDimensions() { |
| 1107 | try { |
| 1108 | const measureStringScratchImg = gdi.CreateImage(1000, 200); |
| 1109 | const gr = measureStringScratchImg.GetGraphics(); |
| 1110 | const dimensions = gr.MeasureString(this.text, this.font, 0, 0, 0, 0); |
| 1111 | this.h = Math.ceil(dimensions.Height) + 1; |
| 1112 | this.w = Math.min(Math.ceil(dimensions.Width) + 1, this.container_w); |
| 1113 | measureStringScratchImg.ReleaseGraphics(gr); |
| 1114 | return dimensions; |
| 1115 | } catch (e) { |
| 1116 | return null; // Probably some invalid parameters on init |
| 1117 | } |
| 1118 | } |
| 1119 | |
| 1120 | /** |
| 1121 | * Populates the result of artist, album, date or label in the "Search" playlist when a hyperlink was clicked. |
| 1122 | */ |
| 1123 | click() { |
| 1124 | const populatePlaylist = (query) => { |
| 1125 | grm.debug.debugLog(query); |
| 1126 | try { |
| 1127 | const handle_list = fb.GetQueryItems(fb.GetLibraryItems(), query); |
| 1128 | if (handle_list.Count) { |
| 1129 | pl.history.ignorePlaylistMutations = true; |
| 1130 | const plist = plman.FindOrCreatePlaylist('Search', true); |
| 1131 | plman.UndoBackup(plist); |
| 1132 | handle_list.Sort(); |
| 1133 | const index = fb.IsPlaying ? handle_list.BSearch(fb.GetNowPlaying()) : -1; |
| 1134 | |
| 1135 | if (plist === plman.PlayingPlaylist && plman.GetPlayingItemLocation().PlaylistIndex === pl && index !== -1) { |
| 1136 | // Remove everything in playlist except currently playing song |
| 1137 | plman.ClearPlaylistSelection(plist); |
| 1138 | plman.SetPlaylistSelection(plist, [plman.GetPlayingItemLocation().PlaylistItemIndex], true); |
| 1139 | plman.RemovePlaylistSelection(plist, true); |
| 1140 | plman.ClearPlaylistSelection(plist); |
| 1141 | |
| 1142 | handle_list.RemoveById(index); |
| 1143 | } |
| 1144 | else { |
| 1145 | // Nothing playing or Search playlist is not active |
| 1146 | plman.ClearPlaylist(plist); |
| 1147 | } |
| 1148 | |
| 1149 | plman.InsertPlaylistItems(plist, 0, handle_list); |
| 1150 | plman.SortByFormat(plist, grCfg.settings.playlistSortDefault); |
| 1151 | plman.ActivePlaylist = plist; |
| 1152 | pl.history.ignorePlaylistMutations = false; |
| 1153 | |
| 1154 | return true; |
| 1155 | } |
| 1156 | return false; |
| 1157 | } |
| 1158 | catch (e) { |
| 1159 | pl.history.ignorePlaylistMutations = false; |
| 1160 | console.log(`Could not successfully execute: ${query}`); |
| 1161 | } |
| 1162 | }; |
| 1163 | |
| 1164 | /** @type {string} */ |
| 1165 | let query; |
| 1166 | switch (this.type) { |
| 1167 | case 'update': RunCmd('https://github.com/TT-ReBORN/Georgia-ReBORN/releases'); break; |
| 1168 | case 'date': query = grSet.showPlaylistFullDate ? `"${grTF.date}" IS ${this.text}` : `"$year(%date%)" IS ${this.text}`; break; |
| 1169 | case 'artist': query = grSet.headerFlipRows ? `Album HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}"` : `Artist HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}" OR Album Artist HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}" OR ARTISTFILTER HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}"`; break; |
| 1170 | case 'album': query = grSet.headerFlipRows ? `Artist HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}" OR Album Artist HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}" OR ARTISTFILTER HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}"` : `Album HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}"`; break; |
| 1171 | case 'label': query = `Label HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}" OR Publisher HAS "${this.text.replace(Regex.PunctQuoteDouble, '')}"`; break; |
| 1172 | default: query = `${this.type} IS "${this.text}"`; break; |
| 1173 | } |
| 1174 | |
| 1175 | if (!populatePlaylist(query)) { |
| 1176 | const start = this.text.indexOf('['); |
| 1177 | if (start > 0) { |
| 1178 | query = `${this.type} IS ${this.text.slice(0, start - 3)}`; // Remove ' - [...]' from end of string in case we're showing "Album - [Deluxe Edition]", etc. |
| 1179 | populatePlaylist(query); |
| 1180 | } |
| 1181 | } |
| 1182 | } |
| 1183 | |
| 1184 | /** |
| 1185 | * Updates the hyperlink state. |
| 1186 | */ |
| 1187 | repaint() { |
| 1188 | try { |
| 1189 | window.RepaintRect(this.x, this.y, this.w, this.h); |
| 1190 | } catch (e) { |
| 1191 | // Probably already redrawing |
| 1192 | } |
| 1193 | } |
| 1194 | // #endregion |
| 1195 | |
| 1196 | // * CALLBACKS * // |
| 1197 | // #region CALLBACKS |
| 1198 | /** |
| 1199 | * Sets mouse hover state for every hyperlink not created in Playlist. |
| 1200 | * @param {object} hyperlink - The hyperlink object. |
| 1201 | * @param {number} x - The x-coordinate. |
| 1202 | * @param {number} y - The y-coordinate. |
| 1203 | * @returns {boolean} True or false. |
| 1204 | */ |
| 1205 | on_mouse_move(hyperlink, x, y) { |
| 1206 | if (hyperlink.trace(x, y)) { |
| 1207 | if (hyperlink.state !== HyperlinkStates.Hovered) { |
| 1208 | hyperlink.state = HyperlinkStates.Hovered; |
| 1209 | window.RepaintRect(hyperlink.x, hyperlink.y, hyperlink.w, hyperlink.h); |
| 1210 | } |
| 1211 | return true; |
| 1212 | } |
| 1213 | if (hyperlink.state !== HyperlinkStates.Normal) { |
| 1214 | hyperlink.state = HyperlinkStates.Normal; |
| 1215 | window.RepaintRect(hyperlink.x, hyperlink.y, hyperlink.w, hyperlink.h); |
| 1216 | } |
| 1217 | return false; |
| 1218 | } |
| 1219 | |
| 1220 | /** |
| 1221 | * Checks if the mouse is within the boundaries of a hyperlink. |
| 1222 | * @param {number} x - The x-coordinate. |
| 1223 | * @param {number} y - The y-coordinate. |
| 1224 | * @returns {boolean} True or false. |
| 1225 | */ |
| 1226 | trace(x, y) { |
| 1227 | return (this.x <= x) && (x <= this.x + this.w) && (this.y <= y) && (y <= this.y + this.h); |
| 1228 | } |
| 1229 | // #endregion |
| 1230 | } |
| 1231 | |
| 1232 | |
| 1233 | ///////////////////// |
| 1234 | // * JUMP SEARCH * // |
| 1235 | ///////////////////// |
| 1236 | /** |
| 1237 | * A class that creates the jump search when using keystrokes. |
| 1238 | * Searches in the active Playlist first and when nothing found, it tries in the Library. |
| 1239 | */ |
| 1240 | class JumpSearch { |
| 1241 | /** |
| 1242 | * Creates the `JumpSearch` instance. |
| 1243 | */ |
| 1244 | constructor() { |
| 1245 | /** @private @type {number} */ |
| 1246 | this.arc1 = 5; |
| 1247 | /** @private @type {number} */ |
| 1248 | this.arc2 = 4; |
| 1249 | /** @private @type {object} */ |
| 1250 | this.j = { |
| 1251 | x: 0, |
| 1252 | y: 0, |
| 1253 | w: grSet.notificationFontSize_layout * 2, |
| 1254 | h: grSet.notificationFontSize_layout * 2 |
| 1255 | }; |
| 1256 | /** @private @type {string} */ |
| 1257 | this.jSearch = ''; |
| 1258 | /** @private @type {boolean} */ |
| 1259 | this.jump_search = true; |
| 1260 | /** @type {{ [key: string]: number[] }} */ |
| 1261 | this.initials = null; |
| 1262 | } |
| 1263 | |
| 1264 | // * PUBLIC METHODS * // |
| 1265 | // #region PUBLIC METHODS |
| 1266 | /** |
| 1267 | * Draws the jump search on the playlist panel. |
| 1268 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 1269 | */ |
| 1270 | draw(gr) { |
| 1271 | if (!this.jSearch) return; |
| 1272 | gr.SetSmoothingMode(4); |
| 1273 | this.j.w = gr.CalcTextWidth(this.jSearch, grFont.notification) + 25; |
| 1274 | gr.FillRoundRect(this.j.x - this.j.w / 2, this.j.y, this.j.w, this.j.h, this.arc1, this.arc1, RGBtoRGBA(grCol.popupBg, 220)); |
| 1275 | gr.DrawRoundRect(this.j.x - this.j.w / 2, this.j.y, this.j.w, this.j.h, this.arc1, this.arc1, 1, 0x64000000); |
| 1276 | gr.DrawRoundRect(this.j.x - this.j.w / 2 + 1, this.j.y + 1, this.j.w - 2, this.j.h - 2, this.arc2, this.arc2, 1, 0x28ffffff); |
| 1277 | // gr.GdiDrawText(this.jSearch, grFont.notification, RGB(0, 0, 0), this.j.x - this.j.w / 2 + 1, this.j.y + 1, this.j.w, this.j.h, panel.cc); // Drop shadow not needed |
| 1278 | gr.GdiDrawText(this.jSearch, grFont.notification, this.jump_search ? grCol.popupText : 0xffff4646, this.j.x - this.j.w / 2, this.j.y, this.j.w, this.j.h, lib.panel.cc); |
| 1279 | gr.SetSmoothingMode(0); |
| 1280 | } |
| 1281 | |
| 1282 | /** |
| 1283 | * Sets the vertical position of the jump search. |
| 1284 | * @param {number} y - The y-coordinate. |
| 1285 | */ |
| 1286 | setY(y) { |
| 1287 | this.y = y; |
| 1288 | } |
| 1289 | // #endregion |
| 1290 | |
| 1291 | // * CALLBACKS * // |
| 1292 | // #region CALLBACKS |
| 1293 | /** |
| 1294 | * Handles key pressed events and activates the jump search. |
| 1295 | * @param {number} code - The character code. |
| 1296 | */ |
| 1297 | on_char(code) { |
| 1298 | const text = String.fromCharCode(code); |
| 1299 | |
| 1300 | if (grSet.jumpSearchDisabled || lib.panel.search.active || |
| 1301 | utils.IsKeyPressed(VKey.CONTROL) || utils.IsKeyPressed(VKey.ESCAPE) || |
| 1302 | this.jSearch === '' && text === ' ') { |
| 1303 | return; |
| 1304 | } |
| 1305 | |
| 1306 | const playlistItems = plman.GetPlaylistItems(plman.ActivePlaylist); |
| 1307 | const search = fb.TitleFormat(grSet.jumpSearchComposerOnly ? '%composer%' : '$if2(%album artist%, %artist%)').EvalWithMetadbs(playlistItems); |
| 1308 | let focusIndex = plman.GetPlaylistFocusItemIndex(plman.ActivePlaylist); |
| 1309 | let advance = false; |
| 1310 | let foundInPlaylist = false; |
| 1311 | let foundInLibrary = false; |
| 1312 | |
| 1313 | switch (code) { |
| 1314 | case lib.vk.back: |
| 1315 | this.jSearch = this.jSearch.slice(0, -1); |
| 1316 | break; |
| 1317 | case lib.vk.enter: |
| 1318 | this.jSearch = ''; |
| 1319 | return; |
| 1320 | default: |
| 1321 | this.jSearch += text; |
| 1322 | break; |
| 1323 | } |
| 1324 | |
| 1325 | // * Playlist advance |
| 1326 | if (focusIndex >= 0 && focusIndex < search.length && (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true))) { |
| 1327 | const char = search[focusIndex].replace(Regex.LibMarkerColor, '').charAt(0).toLowerCase(); |
| 1328 | if (char === text && AllEqual(this.jSearch)) { |
| 1329 | this.jSearch = this.jSearch.slice(0, 1); |
| 1330 | advance = true; |
| 1331 | } |
| 1332 | } |
| 1333 | // * Library advance |
| 1334 | else if (lib.panel.pos >= 0 && lib.panel.pos < lib.pop.tree.length && !grm.ui.displayLibrarySplit(true)) { |
| 1335 | const char = lib.pop.tree[lib.panel.pos].name.replace(Regex.LibMarkerColor, '').charAt(0).toLowerCase(); |
| 1336 | if (lib.pop.tree[lib.panel.pos].sel && char === text && AllEqual(this.jSearch)) { |
| 1337 | this.jSearch = this.jSearch.slice(0, 1); |
| 1338 | advance = true; |
| 1339 | } |
| 1340 | } |
| 1341 | |
| 1342 | switch (true) { |
| 1343 | case advance: { |
| 1344 | if (utils.IsKeyPressed(0x0A) || utils.IsKeyPressed(VKey.BACK) || utils.IsKeyPressed(VKey.TAB) || utils.IsKeyPressed(VKey.CONTROL) || utils.IsKeyPressed(VKey.ESCAPE) || utils.IsKeyPressed(VKey.MULTIPLY) || utils.IsKeyPressed(VKey.SUBTRACT)) return; |
| 1345 | let init = ''; |
| 1346 | let cur = 'currentArr'; |
| 1347 | if (!this.initials) { // reset in buildTree |
| 1348 | this.initials = {}; |
| 1349 | // * Playlist advance |
| 1350 | if (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true)) { |
| 1351 | for (const [i] of playlistItems.Convert().entries()) { |
| 1352 | const name = search[i].replace(Regex.LibMarkerColor, ''); |
| 1353 | init = name.charAt().toLowerCase(); |
| 1354 | if (cur !== init && !this.initials[init]) { |
| 1355 | this.initials[init] = [i]; |
| 1356 | cur = init; |
| 1357 | } else { |
| 1358 | this.initials[init].push(i); |
| 1359 | } |
| 1360 | } |
| 1361 | } |
| 1362 | // * Library advance |
| 1363 | else { |
| 1364 | for (const [i, v] of lib.pop.tree.entries()) { |
| 1365 | if (!v.root) { |
| 1366 | const nm = v.name.replace(Regex.LibMarkerColor, ''); |
| 1367 | init = nm.charAt().toLowerCase(); |
| 1368 | if (cur !== init && !this.initials[init]) { |
| 1369 | this.initials[init] = [i]; |
| 1370 | cur = init; |
| 1371 | } else { |
| 1372 | this.initials[init].push(i); |
| 1373 | } |
| 1374 | } |
| 1375 | } |
| 1376 | } |
| 1377 | } |
| 1378 | |
| 1379 | this.jump_search = false; |
| 1380 | |
| 1381 | // * Playlist advance |
| 1382 | if (focusIndex >= 0 && focusIndex < search.length && (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true))) { |
| 1383 | this.matches = this.initials[text]; |
| 1384 | grm.debug.debugLog('Playlist advance results', this.matches); // Debug |
| 1385 | this.ix = this.matches.indexOf(focusIndex); |
| 1386 | this.ix++; |
| 1387 | if (this.ix >= this.matches.length) this.ix = 0; |
| 1388 | focusIndex = this.matches[this.ix]; |
| 1389 | this.jump_search = true; |
| 1390 | } |
| 1391 | // * Library advance |
| 1392 | else if (lib.panel.pos >= 0 && lib.panel.pos < lib.pop.tree.length && !grm.ui.displayLibrarySplit(true)) { |
| 1393 | this.matches = this.initials[text]; |
| 1394 | grm.debug.debugLog('Library advance results', this.matches); // Debug, can remove this soon |
| 1395 | this.ix = this.matches.indexOf(lib.panel.pos); |
| 1396 | this.ix++; |
| 1397 | if (this.ix >= this.matches.length) this.ix = 0; |
| 1398 | lib.panel.pos = this.matches[this.ix]; |
| 1399 | this.jump_search = true; |
| 1400 | } |
| 1401 | |
| 1402 | // * Playlist advance |
| 1403 | if (this.jump_search && (grm.ui.displayPlaylist || grm.ui.displayLibrarySplit(true))) { |
| 1404 | plman.ClearPlaylistSelection(plman.ActivePlaylist); |
| 1405 | plman.SetPlaylistFocusItem(plman.ActivePlaylist, focusIndex); |
| 1406 | plman.SetPlaylistSelectionSingle(plman.ActivePlaylist, focusIndex, true); |
| 1407 | window.Repaint(); |
| 1408 | } |
| 1409 | // * Library advance |
| 1410 | else if (this.jump_search && !grm.ui.displayLibrarySplit(true)) { |
| 1411 | lib.pop.clearSelected(); |
| 1412 | lib.pop.sel_items = []; |
| 1413 | lib.pop.tree[lib.panel.pos].sel = true; |
| 1414 | lib.pop.setPos(lib.panel.pos); |
| 1415 | lib.pop.getTreeSel(); |
| 1416 | lib.lib.treeState(false, libSet.rememberTree); |
| 1417 | window.Repaint(); |
| 1418 | if (lib.panel.imgView) lib.pop.showItem(lib.panel.pos, 'focus'); |
| 1419 | else { |
| 1420 | const row = (lib.panel.pos * lib.ui.row.h - lib.sbar.scroll) / lib.ui.row.h; |
| 1421 | if (lib.sbar.rows_drawn - row < 3 || row < 0) lib.sbar.checkScroll((lib.panel.pos + 3) * lib.ui.row.h - lib.sbar.rows_drawn * lib.ui.row.h); |
| 1422 | } |
| 1423 | if (libSet.libSource) { |
| 1424 | if (lib.pop.autoFill.key) lib.pop.load(lib.pop.sel_items, true, false, false, !libSet.sendToCur, false); |
| 1425 | lib.pop.track(lib.pop.autoFill.key); |
| 1426 | } else if (lib.panel.pos >= 0 && lib.panel.pos < lib.pop.tree.length) lib.pop.setPlaylistSelection(lib.panel.pos, lib.pop.tree[lib.panel.pos]); |
| 1427 | } |
| 1428 | else { |
| 1429 | window.Repaint(); |
| 1430 | } |
| 1431 | lib.timer.clear(lib.timer.jsearch2); |
| 1432 | lib.timer.jsearch2.id = setTimeout(() => { |
| 1433 | this.jSearch = ''; |
| 1434 | window.Repaint(); |
| 1435 | lib.timer.jsearch2.id = null; |
| 1436 | }, 2200); |
| 1437 | } |
| 1438 | break; |
| 1439 | |
| 1440 | case !advance: |
| 1441 | if (utils.IsKeyPressed(VKey.TAB) || utils.IsKeyPressed(VKey.CONTROL) || utils.IsKeyPressed(VKey.ESCAPE) || utils.IsKeyPressed(VKey.MULTIPLY) || utils.IsKeyPressed(VKey.SUBTRACT)) return; |
| 1442 | if (!lib.panel.search.active) { |
| 1443 | let pos = -1; |
| 1444 | lib.pop.clearSelected(); |
| 1445 | if (!this.jSearch) return; |
| 1446 | lib.pop.sel_items = []; |
| 1447 | this.jump_search = true; |
| 1448 | window.Repaint(); |
| 1449 | lib.timer.clear(lib.timer.jsearch1); |
| 1450 | |
| 1451 | lib.timer.jsearch1.id = setTimeout(() => { |
| 1452 | // * First search in the Playlist |
| 1453 | playlistItems.Convert().some((v, i) => { |
| 1454 | const name = search[i].replace(Regex.LibMarkerColor, ''); |
| 1455 | if (name.substring(0, this.jSearch.length).toLowerCase() === this.jSearch.toLowerCase()) { |
| 1456 | foundInPlaylist = true; |
| 1457 | pos = i; |
| 1458 | plman.ClearPlaylistSelection(plman.ActivePlaylist); |
| 1459 | plman.SetPlaylistFocusItem(plman.ActivePlaylist, pos); |
| 1460 | plman.SetPlaylistSelectionSingle(plman.ActivePlaylist, pos, true); |
| 1461 | grm.debug.debugLog(`Jumpsearch: "${name}" found in Playlist`); // Debug, can remove this soon |
| 1462 | return true; |
| 1463 | } |
| 1464 | return false; |
| 1465 | }); |
| 1466 | // * If no Playlist results found, try search query in the Library |
| 1467 | if (!foundInPlaylist && grSet.jumpSearchIncludeLibrary && grSet.layout !== 'compact') { |
| 1468 | lib.pop.tree.some((v, i) => { |
| 1469 | const name = v.name.replace(Regex.LibMarkerColor, ''); |
| 1470 | if (name !== lib.panel.rootName && name.substring(0, this.jSearch.length).toLowerCase() === this.jSearch.toLowerCase()) { |
| 1471 | foundInPlaylist = false; |
| 1472 | foundInLibrary = true; |
| 1473 | pos = i; |
| 1474 | v.sel = true; |
| 1475 | lib.pop.setPos(pos); |
| 1476 | if (lib.pop.autoFill.key) lib.pop.getTreeSel(); |
| 1477 | lib.lib.treeState(false, libSet.rememberTree); |
| 1478 | grm.debug.debugLog(`Jumpsearch: "${name}" found in Library`); // Debug, can remove this soon |
| 1479 | return true; |
| 1480 | } |
| 1481 | return false; |
| 1482 | }); |
| 1483 | } |
| 1484 | |
| 1485 | if (!foundInPlaylist && !foundInLibrary) { |
| 1486 | this.jump_search = false; |
| 1487 | grm.debug.debugLog('Jumpsearch: No results were found'); // Debug, can remove this soon |
| 1488 | } |
| 1489 | |
| 1490 | window.Repaint(); |
| 1491 | |
| 1492 | if (foundInPlaylist) { |
| 1493 | grm.ui.displayPlaylist = true; |
| 1494 | grm.ui.displayLibrary = grSet.libraryLayout === 'split' && grm.ui.displayPlaylist; |
| 1495 | grm.ui.displayBiography = false; |
| 1496 | grm.ui.displayLyrics = false; |
| 1497 | grm.button.initButtonState(); |
| 1498 | } |
| 1499 | else if (foundInLibrary && grSet.jumpSearchIncludeLibrary) { |
| 1500 | grm.ui.displayPlaylist = grSet.libraryLayout === 'split' && grm.ui.displayPlaylist; |
| 1501 | grm.ui.displayLibrary = true; |
| 1502 | grm.ui.displayBiography = false; |
| 1503 | grm.ui.displayLyrics = false; |
| 1504 | lib.pop.showItem(pos, 'focus'); |
| 1505 | this.jSearch = ''; // Reset to avoid conflict with other query |
| 1506 | grm.button.initButtonState(); |
| 1507 | } |
| 1508 | |
| 1509 | lib.timer.jsearch1.id = null; |
| 1510 | }, 500); |
| 1511 | |
| 1512 | lib.timer.clear(lib.timer.jsearch2); |
| 1513 | |
| 1514 | lib.timer.jsearch2.id = setTimeout(() => { |
| 1515 | this.jSearch = ''; |
| 1516 | window.Repaint(); |
| 1517 | lib.timer.jsearch2.id = null; |
| 1518 | }, 1200); |
| 1519 | } |
| 1520 | } |
| 1521 | } |
| 1522 | |
| 1523 | /** |
| 1524 | * Sets the size and position of the jump search and updates them on window resizing. |
| 1525 | */ |
| 1526 | on_size() { |
| 1527 | this.j.h = grSet.notificationFontSize_layout * 2; |
| 1528 | this.j.x = Math.round(grSet.playlistLayout === 'full' || grSet.layout !== 'default' ? grm.ui.ww * 0.5 : grm.ui.ww * 0.5 + grm.ui.ww * 0.25); |
| 1529 | this.j.y = Math.round((grm.ui.wh + grm.ui.topMenuHeight - grm.ui.lowerBarHeight - this.j.h) / 2); |
| 1530 | this.arc1 = Math.min(5, this.j.h / 2); |
| 1531 | this.arc2 = Math.min(4, (this.j.h - 2) / 2); |
| 1532 | } |
| 1533 | // #endregion |
| 1534 | } |
| 1535 | |
| 1536 | |
| 1537 | ////////////////////// |
| 1538 | // * PROGRESS BAR * // |
| 1539 | ////////////////////// |
| 1540 | /** |
| 1541 | * A class that creates the progress bar in the lower bar when enabled. |
| 1542 | * Quick access via right click context menu on lower bar. |
| 1543 | */ |
| 1544 | class ProgressBar { |
| 1545 | /** |
| 1546 | * Creates the `ProgressBar` instance. |
| 1547 | */ |
| 1548 | constructor() { |
| 1549 | /** @public @type {number} The x-coordinate of the progress bar. */ |
| 1550 | this.x = grm.ui.edgeMargin; |
| 1551 | /** @public @type {number} The y-coordinate of the progress bar. */ |
| 1552 | this.y = 0; |
| 1553 | /** @public @type {number} The width of the progress bar. */ |
| 1554 | this.w = grm.ui.ww - grm.ui.edgeMarginBoth; |
| 1555 | /** @public @type {number} The height of the progress bar. */ |
| 1556 | this.h = grm.ui.seekbarHeight; |
| 1557 | /** @public @type {number} The arc radius for rounded corners of the progress bar. */ |
| 1558 | this.arc = Math.min(this.w, this.h) / 2; |
| 1559 | /** @public @type {number} The length of the progress bar fill. */ |
| 1560 | this.progressLength = 0; |
| 1561 | /** @private @type {boolean} The state that indicates if the progress bar is being dragged. */ |
| 1562 | this.drag = false; |
| 1563 | } |
| 1564 | |
| 1565 | // * PUBLIC METHODS * // |
| 1566 | // #region PUBLIC METHODS |
| 1567 | /** |
| 1568 | * Draws the progress bar with various progress bar styles. |
| 1569 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 1570 | */ |
| 1571 | draw(gr) { |
| 1572 | if (grm.debug.showDrawExtendedTiming) grm.ui.seekbarProfiler.Reset(); |
| 1573 | |
| 1574 | const styleRounded = grSet.styleProgressBarDesign === 'rounded'; |
| 1575 | if (styleRounded) this.arc = Math.min(this.w, this.h) / 2; |
| 1576 | |
| 1577 | gr.SetSmoothingMode(styleRounded ? SmoothingMode.AntiAlias : SmoothingMode.None); |
| 1578 | |
| 1579 | this.drawProgressBarBg(gr); |
| 1580 | this.drawProgressBarFill(gr); |
| 1581 | } |
| 1582 | |
| 1583 | /** |
| 1584 | * Draws the progress bar background. |
| 1585 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 1586 | */ |
| 1587 | drawProgressBarBg(gr) { |
| 1588 | const barDesignNoDotsThin = !['dots', 'thin'].includes(grSet.styleProgressBarDesign); |
| 1589 | const styleRounded = grSet.styleProgressBarDesign === 'rounded'; |
| 1590 | const styleDefault = grSet.styleDefault && (['blue', 'darkblue', 'red', 'cream'].includes(grSet.theme) || grSet.theme.startsWith('custom')); |
| 1591 | const styleCream = grSet.theme === 'cream' && (grSet.styleAlternative || grSet.styleAlternative2) && (!grSet.styleBevel && !grSet.styleBlend && !grSet.styleBlend2 && grSet.styleProgressBarDesign !== 'rounded') && !grSet.systemFirstLaunch; |
| 1592 | const styleBevelOrInner = barDesignNoDotsThin && ['bevel', 'inner'].includes(grSet.styleProgressBar); |
| 1593 | const progressBarColor = grm.ui.isStreaming && fb.IsPlaying ? grCol.progressBarStreaming : grCol.progressBar; |
| 1594 | |
| 1595 | if (styleRounded) { |
| 1596 | FillRoundRect(gr, this.x, this.y, this.w, this.h, this.arc, this.arc, progressBarColor); |
| 1597 | } else if (barDesignNoDotsThin) { |
| 1598 | gr.FillSolidRect(this.x, this.y, this.w, this.h, progressBarColor); |
| 1599 | } |
| 1600 | |
| 1601 | if (styleDefault || styleCream) { |
| 1602 | gr.DrawRect(this.x - 2, this.y - 2, this.w + 3, this.h + 3, 1, grCol.progressBarFrame); |
| 1603 | } |
| 1604 | |
| 1605 | if (styleBevelOrInner) { |
| 1606 | const styleBlackReborn = grSet.styleBlackReborn && fb.IsPlaying; |
| 1607 | const angle = grSet.styleProgressBar === 'inner' ? (styleBlackReborn ? 90 : -90) : (styleBlackReborn ? -90 : 90); |
| 1608 | |
| 1609 | if (styleRounded) { |
| 1610 | FillGradRoundRect(gr, this.x, this.y, this.w + SCALE(2), this.h + SCALE(2), this.arc, this.arc, angle, 0, grCol.styleProgressBar, 1); |
| 1611 | |
| 1612 | const xLeft = this.x + SCALE(3); |
| 1613 | const xRight = this.w + this.x - SCALE(12); |
| 1614 | const yTop = this.y - 0.5; |
| 1615 | const yBottom = this.y + this.h - 0.5; |
| 1616 | FillGradRect(gr, xLeft, yTop, SCALE(9), 1, 179, grCol.styleProgressBarLineTop, 0); // Top left |
| 1617 | FillGradRect(gr, xLeft, yBottom, SCALE(9), 1, 179, grCol.styleProgressBarLineBottom, 0); // Bottom left |
| 1618 | FillGradRect(gr, xRight, yTop, SCALE(9), 1, 179, 0, grCol.styleProgressBarLineTop); // Top right |
| 1619 | FillGradRect(gr, xRight, yBottom, SCALE(9), 1, 179, 0, grCol.styleProgressBarLineBottom); // Bottom right |
| 1620 | } |
| 1621 | else { |
| 1622 | FillGradRect(gr, this.x, this.y, this.w, this.h, angle, 0, grCol.styleProgressBar); |
| 1623 | } |
| 1624 | |
| 1625 | const lineX1 = this.x + (styleRounded ? SCALE(12) : 0); |
| 1626 | const lineX2 = this.x + this.w - (styleRounded ? SCALE(12) : 1); |
| 1627 | gr.DrawLine(lineX1, this.y, lineX2, this.y, 1, grCol.styleProgressBarLineTop); |
| 1628 | gr.DrawLine(lineX1, this.y + this.h, lineX2, this.y + this.h, 1, grCol.styleProgressBarLineBottom); |
| 1629 | } |
| 1630 | } |
| 1631 | |
| 1632 | /** |
| 1633 | * Draws the progress bar fill. |
| 1634 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 1635 | */ |
| 1636 | drawProgressBarFill(gr) { |
| 1637 | if (!fb.IsPlaying || fb.PlaybackLength <= 0) return; |
| 1638 | |
| 1639 | const playbackRatio = fb.PlaybackTime / fb.PlaybackLength; |
| 1640 | this.progressLength = Math.floor(this.w * playbackRatio); |
| 1641 | |
| 1642 | const drawBarDesign = { |
| 1643 | default: () => gr.FillSolidRect(this.x, this.y, this.progressLength, this.h, grCol.progressBarFill), |
| 1644 | rounded: () => FillRoundRect(gr, this.x, this.y, this.progressLength, this.h, this.arc, this.arc, grCol.progressBarFill), |
| 1645 | lines: () => this.drawBarDesignLines(gr), |
| 1646 | blocks: () => this.drawBarDesignBlocks(gr), |
| 1647 | dots: () => this.drawBarDesignDots(gr), |
| 1648 | thin: () => this.drawBarDesignThin(gr) |
| 1649 | }; |
| 1650 | |
| 1651 | drawBarDesign[grSet.styleProgressBarDesign](); |
| 1652 | |
| 1653 | if (!['dots', 'thin'].includes(grSet.styleProgressBarDesign) && ['bevel', 'inner'].includes(grSet.styleProgressBarFill)) { |
| 1654 | if (grSet.styleProgressBarDesign === 'rounded') { |
| 1655 | FillGradRoundRect(gr, this.x, this.y, this.progressLength + SCALE(2), this.h + SCALE(2), this.arc, this.arc, grSet.styleProgressBarFill === 'inner' ? -88 : 88, 0, grCol.styleProgressBarFill); |
| 1656 | } else { |
| 1657 | FillGradRect(gr, this.x, this.y, this.progressLength, this.h, grSet.styleProgressBarFill === 'inner' ? -90 : 89, 0, grCol.styleProgressBarFill); |
| 1658 | } |
| 1659 | } |
| 1660 | else if (grSet.styleProgressBarFill === 'blend' && grm.ui.albumArt && grCol.imgBlended) { |
| 1661 | if (grSet.styleProgressBarDesign === 'rounded') { |
| 1662 | FillBlendedRoundRect(gr, this.x, this.y, this.progressLength + SCALE(2), this.h + SCALE(2), this.arc, this.arc, 88, grCol.imgBlended, 0); |
| 1663 | } else { |
| 1664 | gr.DrawImage(grCol.imgBlended, this.x, this.y, this.progressLength, this.h, 0, this.h, grCol.imgBlended.Width, grCol.imgBlended.Height); |
| 1665 | } |
| 1666 | } |
| 1667 | } |
| 1668 | |
| 1669 | /** |
| 1670 | * Draws the progress bar fill in lines design. |
| 1671 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 1672 | */ |
| 1673 | drawBarDesignLines(gr) { |
| 1674 | gr.FillSolidRect(this.x + this.progressLength, this.y, SCALE(2), grm.ui.seekbarHeight, grCol.progressBarFill); |
| 1675 | |
| 1676 | for (let progressLine = 0; progressLine < this.progressLength; progressLine += SCALE(4)) { |
| 1677 | gr.DrawLine(this.x + progressLine + SCALE(2), this.y, this.x + progressLine + SCALE(2), this.y + this.h, SCALE(2), grCol.progressBarFill); |
| 1678 | } |
| 1679 | } |
| 1680 | |
| 1681 | /** |
| 1682 | * Draws the progress bar fill in blocks design. |
| 1683 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 1684 | */ |
| 1685 | drawBarDesignBlocks(gr) { |
| 1686 | for (let progressLine = 0; progressLine < this.progressLength; progressLine += grm.ui.seekbarHeight + SCALE(2)) { |
| 1687 | gr.FillSolidRect(this.x + progressLine, this.y + SCALE(2), grm.ui.seekbarHeight, grm.ui.seekbarHeight - SCALE(4), grCol.progressBarFill); |
| 1688 | } |
| 1689 | |
| 1690 | gr.FillSolidRect(this.x + this.progressLength, this.y + 1, grm.ui.seekbarHeight, grm.ui.seekbarHeight - 1, grCol.progressBar); |
| 1691 | FillGradRect(gr, this.x + this.progressLength, this.y + 1, grm.ui.seekbarHeight, grm.ui.seekbarHeight - 1, grSet.styleProgressBar === 'inner' ? grSet.styleBlackReborn && fb.IsPlaying ? 88 : -88 : grSet.styleBlackReborn && fb.IsPlaying ? -88 : 88, 0, grCol.styleProgressBar); |
| 1692 | } |
| 1693 | |
| 1694 | /** |
| 1695 | * Draws the progress bar fill in dots design. |
| 1696 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 1697 | */ |
| 1698 | drawBarDesignDots(gr) { |
| 1699 | for (let progressLine = 0; progressLine < this.progressLength; progressLine += SCALE(8)) { |
| 1700 | gr.DrawLine(this.x + this.progressLength + SCALE(10), this.y + this.h * 0.5, this.x + this.w, this.y + this.h * 0.5, SCALE(1), grCol.progressBar); |
| 1701 | gr.SetSmoothingMode(SmoothingMode.AntiAlias); |
| 1702 | gr.DrawEllipse(this.x + progressLine, this.y + this.h * 0.5 - SCALE(1), SCALE(2), SCALE(2), SCALE(2), grCol.progressBarFill); |
| 1703 | } |
| 1704 | |
| 1705 | const posFix = HD_4K(3, grSet.layout !== 'default' ? 6 : 7); |
| 1706 | gr.DrawEllipse(this.x + this.progressLength, this.y + this.h * 0.5 - grm.ui.seekbarHeight * 0.5 + SCALE(2), grm.ui.seekbarHeight - SCALE(4), grm.ui.seekbarHeight - SCALE(4), SCALE(2), grCol.progressBarFill); // Knob outline |
| 1707 | gr.DrawEllipse(this.x + this.progressLength + posFix, this.y + this.h * 0.5 - SCALE(1), SCALE(2), SCALE(2), SCALE(2), grCol.transportIconHovered); // Knob inner |
| 1708 | } |
| 1709 | |
| 1710 | /** |
| 1711 | * Draws the progress bar fill in thin design. |
| 1712 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 1713 | */ |
| 1714 | drawBarDesignThin(gr) { |
| 1715 | gr.DrawLine(this.x, this.y + this.h * 0.5, this.x + this.w, this.y + this.h * 0.5, SCALE(1), grCol.progressBar); |
| 1716 | gr.SetSmoothingMode(SmoothingMode.AntiAlias); |
| 1717 | gr.FillSolidRect(this.x, this.y + this.h * 0.5 - SCALE(2), this.progressLength, SCALE(4), grCol.progressBarFill); |
| 1718 | gr.FillSolidRect(this.x + this.progressLength, this.y + this.h * 0.5 - SCALE(3), SCALE(6), SCALE(6), grCol.progressBarFill); |
| 1719 | } |
| 1720 | |
| 1721 | /** |
| 1722 | * Sets the vertical progress bar position. |
| 1723 | * @param {number} y - The y-coordinate. |
| 1724 | */ |
| 1725 | setY(y) { |
| 1726 | this.y = y; |
| 1727 | } |
| 1728 | |
| 1729 | /** |
| 1730 | * Sets the playback time of the progress bar. |
| 1731 | * @param {number} x - The x-coordinate. |
| 1732 | * @private |
| 1733 | */ |
| 1734 | setPlaybackTime(x) { |
| 1735 | const clampedPosition = Clamp((x - this.x) / this.w, 0, 1); |
| 1736 | const newPlaybackTime = clampedPosition * fb.PlaybackLength; |
| 1737 | if (fb.PlaybackTime !== newPlaybackTime) { |
| 1738 | fb.PlaybackTime = newPlaybackTime; |
| 1739 | } |
| 1740 | } |
| 1741 | |
| 1742 | /** |
| 1743 | * Sets the refresh rate for the progress bar. |
| 1744 | */ |
| 1745 | setRefreshRate() { |
| 1746 | if (grm.ui.isStreaming) { |
| 1747 | grm.ui.seekbarTimerInterval = FPS._1; |
| 1748 | } |
| 1749 | else if (grSet.progressBarRefreshRate !== 'variable') { |
| 1750 | grm.ui.seekbarTimerInterval = grSet.progressBarRefreshRate; |
| 1751 | } |
| 1752 | else { |
| 1753 | const pixelsPerMillisecond = (grm.ui.ww - grm.ui.edgeMarginBoth) / fb.PlaybackLength; |
| 1754 | const FPS_VARIABLE = Math.ceil(1000 / pixelsPerMillisecond); |
| 1755 | grm.ui.seekbarTimerInterval = Clamp(FPS_VARIABLE, FPS._15, FPS._2); |
| 1756 | } |
| 1757 | } |
| 1758 | |
| 1759 | /** |
| 1760 | * Updates the progress bar state. |
| 1761 | */ |
| 1762 | repaint() { |
| 1763 | window.RepaintRect(this.x, this.y, this.w, this.h); |
| 1764 | } |
| 1765 | // #endregion |
| 1766 | |
| 1767 | // * CALLBACKS * // |
| 1768 | // #region CALLBACKS |
| 1769 | /** |
| 1770 | * Checks if the mouse is within the boundaries of the progress bar. |
| 1771 | * @param {number} x - The x-coordinate. |
| 1772 | * @param {number} y - The y-coordinate. |
| 1773 | * @returns {boolean} True or false. |
| 1774 | */ |
| 1775 | mouseInThis(x, y) { |
| 1776 | return (x >= this.x && y >= this.y && x < this.x + this.w && y <= this.y + this.h); |
| 1777 | } |
| 1778 | |
| 1779 | /** |
| 1780 | * Handles left mouse button down click events and enables dragging. |
| 1781 | * @param {number} x - The x-coordinate. |
| 1782 | * @param {number} y - The y-coordinate. |
| 1783 | */ |
| 1784 | on_mouse_lbtn_down(x, y) { |
| 1785 | this.drag = true; |
| 1786 | } |
| 1787 | |
| 1788 | /** |
| 1789 | * Handles left mouse button up click events and disables dragging and updates the playback time. |
| 1790 | * @param {number} x - The x-coordinate. |
| 1791 | * @param {number} y - The y-coordinate. |
| 1792 | */ |
| 1793 | on_mouse_lbtn_up(x, y) { |
| 1794 | this.drag = false; |
| 1795 | if (this.mouseInThis(x, y)) { |
| 1796 | this.setPlaybackTime(x); |
| 1797 | } |
| 1798 | } |
| 1799 | |
| 1800 | /** |
| 1801 | * Handles mouse movement events and updates the playback time based on the mouse movement if a drag event is occurring. |
| 1802 | * @param {number} x - The x-coordinate. |
| 1803 | * @param {number} y - The y-coordinate. |
| 1804 | */ |
| 1805 | on_mouse_move(x, y) { |
| 1806 | if (this.drag) { |
| 1807 | this.setPlaybackTime(x); |
| 1808 | } |
| 1809 | } |
| 1810 | |
| 1811 | /** |
| 1812 | * Updates progress bar length when playing a new track. |
| 1813 | * @param {FbMetadbHandle} metadb - The metadb of the track. |
| 1814 | */ |
| 1815 | on_playback_new_track(metadb) { |
| 1816 | if (!metadb) return; |
| 1817 | this.progressLength = 0; |
| 1818 | } |
| 1819 | |
| 1820 | /** |
| 1821 | * Sets the size and position of the progress bar and updates them on window resizing. |
| 1822 | * @param {number} w - The width of the window or element. |
| 1823 | * @param {number} h - The height of the window or element. |
| 1824 | */ |
| 1825 | on_size(w, h) { |
| 1826 | this.x = grm.ui.edgeMargin; |
| 1827 | this.y = 0; |
| 1828 | this.w = w - grm.ui.edgeMarginBoth; |
| 1829 | this.h = grm.ui.seekbarHeight; |
| 1830 | } |
| 1831 | // #endregion |
| 1832 | } |
| 1833 | |
| 1834 | |
| 1835 | /////////////////////// |
| 1836 | // * PEAKMETER BAR * // |
| 1837 | /////////////////////// |
| 1838 | /** |
| 1839 | * A class that creates the peakmeter bar in the lower bar when enabled. |
| 1840 | * Quick access via right click context menu on lower bar. |
| 1841 | */ |
| 1842 | class PeakmeterBar { |
| 1843 | /** |
| 1844 | * Creates the `PeakmeterBar` instance. |
| 1845 | */ |
| 1846 | constructor() { |
| 1847 | // * GEOMETRY - STYLE HORIZONTAL * // |
| 1848 | // #region GEOMETRY - STYLE HORIZONTAL |
| 1849 | /** @public @type {number} The x-position of the peakmeter bar. */ |
| 1850 | this.x = grm.ui.edgeMargin; |
| 1851 | /** @public @type {number} The y-position of the peakmeter bar. */ |
| 1852 | this.y = 0; |
| 1853 | /** @public @type {number} The width of the peakmeter bar. */ |
| 1854 | this.w = grm.ui.ww - grm.ui.edgeMarginBoth; |
| 1855 | /** @public @type {number} The secondary width of the peakmeter bar. */ |
| 1856 | this.w2 = 0; |
| 1857 | /** @public @type {number} The height of the peakmeter bar. */ |
| 1858 | this.h = grm.ui.seekbarHeight; |
| 1859 | /** @private @type {number} The height of the bar for the peakmeter. */ |
| 1860 | this.bar_h = grSet.layout !== 'default' ? SCALE(2) : SCALE(4); |
| 1861 | /** @private @type {number} The half height of the bar for the peakmeter. */ |
| 1862 | this.bar2_h = this.bar_h * 0.5; |
| 1863 | /** @private @type {number} The offset for the peakmeter bar. */ |
| 1864 | this.offset = 0; |
| 1865 | /** @private @type {number} The middle offset for the peakmeter bar. */ |
| 1866 | this.middleOffset = 0; |
| 1867 | /** @private @type {number} The middle width for the peakmeter bar. */ |
| 1868 | this.middle_w = 0; |
| 1869 | |
| 1870 | // * Top |
| 1871 | /** @private @type {number} The width of the outer left bar. */ |
| 1872 | this.outerLeft_w = 0; |
| 1873 | /** @private @type {number} The old width of the outer left bar. */ |
| 1874 | this.outerLeft_w_old = 0; |
| 1875 | /** @private @type {number} The animated width of the outer left bar. */ |
| 1876 | this.outerLeftAnim_w = 0; |
| 1877 | /** @private @type {number} The x-position of the animated outer left bar. */ |
| 1878 | this.outerLeftAnim_x = 0; |
| 1879 | /** @private @type {number} The offset for the outer left bar. */ |
| 1880 | this.outerLeft_k = 0; |
| 1881 | |
| 1882 | /** @private @type {number} The x-position of the main left bar. */ |
| 1883 | this.mainLeft_x = 0; |
| 1884 | /** @private @type {number} The x-position of the animated main left bar. */ |
| 1885 | this.mainLeftAnim_x = 0; |
| 1886 | /** @private @type {number} The secondary x-position of the animated main left bar. */ |
| 1887 | this.mainLeftAnim2_x = 0; |
| 1888 | /** @private @type {number} The offset for the main left bar. */ |
| 1889 | this.mainLeft_k = 0; |
| 1890 | /** @private @type {number} The secondary offset for the main left bar. */ |
| 1891 | this.mainLeft2_k = 0; |
| 1892 | |
| 1893 | // * Bottom |
| 1894 | /** @private @type {number} The width of the outer right bar. */ |
| 1895 | this.outerRight_w = 0; |
| 1896 | /** @private @type {number} The old width of the outer right bar. */ |
| 1897 | this.outerRight_w_old = 0; |
| 1898 | /** @private @type {number} The animated width of the outer right bar. */ |
| 1899 | this.outerRightAnim_w = 0; |
| 1900 | /** @private @type {number} The x-position of the animated outer right bar. */ |
| 1901 | this.outerRightAnim_x = 0; |
| 1902 | /** @private @type {number} The offset for the outer right bar. */ |
| 1903 | this.outerRight_k = 0; |
| 1904 | |
| 1905 | /** @private @type {number} The x-position of the main right bar. */ |
| 1906 | this.mainRight_x = 0; |
| 1907 | /** @private @type {number} The x-position of the animated main right bar. */ |
| 1908 | this.mainRightAnim_x = 0; |
| 1909 | /** @private @type {number} The secondary x-position of the animated main right bar. */ |
| 1910 | this.mainRightAnim2_x = 0; |
| 1911 | /** @private @type {number} The offset for the main right bar. */ |
| 1912 | this.mainRight_k = 0; |
| 1913 | /** @private @type {number} The secondary offset for the main right bar. */ |
| 1914 | this.mainRight2_k = 0; |
| 1915 | // #endregion |
| 1916 | |
| 1917 | // * GEOMETRY - STYLE VERTICAL * // |
| 1918 | // #region GEOMETRY - STYLE VERTICAL |
| 1919 | /** @private @type {number} The vertical offset for the bar. */ |
| 1920 | this.vertBar_offset = 0; |
| 1921 | /** @private @type {number} The vertical width of the bar. */ |
| 1922 | this.vertBar_w = 0; |
| 1923 | /** @private @type {number} The vertical height of the bar. */ |
| 1924 | this.vertBar_h = 0; |
| 1925 | /** @private @type {number} The x-coordinate for the left vertical bar. */ |
| 1926 | this.vertLeft_x = 0; |
| 1927 | /** @private @type {number} The x-coordinate for the right vertical bar. */ |
| 1928 | this.vertRight_x = 0; |
| 1929 | // #endregion |
| 1930 | |
| 1931 | // * PROGRESS BAR * // |
| 1932 | // #region PROGRESS BAR |
| 1933 | /** @public @type {number} The length of the progress bar. */ |
| 1934 | this.progressLength = 0; |
| 1935 | /** @private @type {boolean} The state indicating whether the progress bar is being dragged. */ |
| 1936 | this.drag = false; |
| 1937 | // #endregion |
| 1938 | |
| 1939 | // * MOUSE EVENTS * // |
| 1940 | // #region MOUSE EVENTS |
| 1941 | /** @private @type {number} The x-coordinate position of the mouse. */ |
| 1942 | this.pos_x = 0; |
| 1943 | /** @private @type {number} The y-coordinate position of the mouse. */ |
| 1944 | this.pos_y = 0; |
| 1945 | /** @private @type {boolean} The state indicating whether the mouse is over the peakmeter bar. */ |
| 1946 | this.on_mouse = false; |
| 1947 | /** @private @type {boolean} The state indicating whether the mouse wheel is being used. */ |
| 1948 | this.wheel = false; |
| 1949 | // #endregion |
| 1950 | |
| 1951 | // * TEXT * // |
| 1952 | // #region TEXT |
| 1953 | /** @private @type {GdiFont} The font used for text rendering. */ |
| 1954 | this.textFont = gdi.Font('Segoe UI', HD_4K(9, 16), 1); |
| 1955 | /** @private @type {number} The width of the text. */ |
| 1956 | this.textWidth = 0; |
| 1957 | /** @private @type {number} The height of the text. */ |
| 1958 | this.textHeight = 0; |
| 1959 | /** @private @type {string} The text for the tooltip. */ |
| 1960 | this.tooltipText = ''; |
| 1961 | // #endregion |
| 1962 | |
| 1963 | // * VOLUME * // |
| 1964 | // #region VOLUME |
| 1965 | /** @private @type {number[]} The middle decibel values. */ |
| 1966 | this.db_middle = []; |
| 1967 | /** @private @type {number[]} The current decibel values. */ |
| 1968 | this.db = []; |
| 1969 | /** @private @type {object[]} The vertical decibel values. */ |
| 1970 | this.db_vert = {}; |
| 1971 | |
| 1972 | /** @private @type {number} The middle points for the peakmeter. */ |
| 1973 | this.points_middle = 0; |
| 1974 | /** @private @type {number} The points for the peakmeter. */ |
| 1975 | this.points = 0; |
| 1976 | /** @private @type {number} The vertical points for the peakmeter. */ |
| 1977 | this.points_vert = 0; |
| 1978 | /** @private @type {number[]} The left peaks for the peakmeter. */ |
| 1979 | this.leftPeaks_s = []; |
| 1980 | /** @private @type {number[]} The right peaks for the peakmeter. */ |
| 1981 | this.rightPeaks_s = []; |
| 1982 | // #endregion |
| 1983 | |
| 1984 | // * COLORS * // |
| 1985 | // #region COLORS |
| 1986 | /** @private @type {number} The separator index for the peakmeter. */ |
| 1987 | this.separator = 0; |
| 1988 | /** @private @type {number} The first separator value for the peakmeter. */ |
| 1989 | this.sep1 = 0; |
| 1990 | /** @private @type {number} The second separator value for the peakmeter. */ |
| 1991 | this.sep2 = 0; |
| 1992 | // #endregion |
| 1993 | |
| 1994 | // * INITIALIZATION * // |
| 1995 | // #region INITIALIZATION |
| 1996 | grm.ui.seekbarTimerInterval = grSet.peakmeterBarRefreshRate === 'variable' ? FPS._10 : grSet.peakmeterBarRefreshRate; |
| 1997 | |
| 1998 | this.initDecibel(); |
| 1999 | this.initPeaks(); |
| 2000 | this.initPoints(); |
| 2001 | this.initSeparator(); |
| 2002 | this.setColors(); |
| 2003 | // #endregion |
| 2004 | } |
| 2005 | |
| 2006 | // * PUBLIC METHODS - DRAW * // |
| 2007 | // #region PUBLIC METHODS - DRAW |
| 2008 | /** |
| 2009 | * Draws the peakmeter bar in various peakmeter bar designs. |
| 2010 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2011 | */ |
| 2012 | draw(gr) { |
| 2013 | if (!fb.IsPlaying || !AudioWizard) { |
| 2014 | gr.FillSolidRect(this.x, this.y, this.w, this.h, grCol.bg); |
| 2015 | gr.FillSolidRect(this.x, grm.ui.seekbarY, this.w, SCALE(grSet.layout !== 'default' ? 10 : 12), grCol.progressBar); |
| 2016 | return; |
| 2017 | } |
| 2018 | |
| 2019 | if (grSet.peakmeterBarRefreshRate === 'variable' || grm.debug.showDrawExtendedTiming) { |
| 2020 | grm.ui.seekbarProfiler.Reset(); |
| 2021 | } |
| 2022 | |
| 2023 | this.drawPeakmeterBar(gr); |
| 2024 | this.setAnimation(); |
| 2025 | this.setMonitoring(); |
| 2026 | this.setRefreshRate(); |
| 2027 | } |
| 2028 | |
| 2029 | /** |
| 2030 | * Draws the peakmeter bar design based on design setting. |
| 2031 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2032 | */ |
| 2033 | drawPeakmeterBar(gr) { |
| 2034 | const drawBarDesign = { |
| 2035 | horizontal: () => this.drawBarDesignHorizontal(gr), |
| 2036 | horizontal_center: () => this.drawBarDesignCenter(gr), |
| 2037 | vertical: () => this.drawBarDesignVertical(gr) |
| 2038 | }; |
| 2039 | |
| 2040 | drawBarDesign[grSet.peakmeterBarDesign](); |
| 2041 | } |
| 2042 | |
| 2043 | /** |
| 2044 | * Draws the horizontal peakmeter bar design. |
| 2045 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2046 | */ |
| 2047 | drawBarDesignHorizontal(gr) { |
| 2048 | this.drawBarGrid(gr); |
| 2049 | |
| 2050 | for (let i = 0; i <= this.points; i++) { |
| 2051 | const color = this.color[i]; |
| 2052 | const db = this.db[i]; |
| 2053 | const dbNext = this.db[i + 1]; |
| 2054 | const offset = i * this.offset; |
| 2055 | |
| 2056 | this.drawHorizontalMainBars(gr, db, dbNext, offset, this.leftPeak, this.mainLeftAnim_x, this.mainLeftAnim2_x, 'mainLeft_x', this.mainLeft_y, color); |
| 2057 | this.drawHorizontalMainBars(gr, db, dbNext, offset, this.rightPeak, this.mainRightAnim_x, this.mainRightAnim2_x, 'mainRight_x', this.mainRight_y, color); |
| 2058 | |
| 2059 | this.drawHorizontalOuterBars(gr, db, dbNext, offset, this.leftLevel, this.outerLeftAnim_x, this.outerLeftAnim_w, this.outerLeft_y, 'outerLeft_w'); |
| 2060 | this.drawHorizontalOuterBars(gr, db, dbNext, offset, this.rightLevel, this.outerRightAnim_x, this.outerRightAnim_w, this.outerRight_y, 'outerRight_w'); |
| 2061 | |
| 2062 | this.drawOverBars(gr); |
| 2063 | } |
| 2064 | |
| 2065 | this.drawMiddleBars(gr); |
| 2066 | this.drawProgressBar(gr); |
| 2067 | this.drawBarInfo(gr); |
| 2068 | } |
| 2069 | |
| 2070 | /** |
| 2071 | * Draws the main bars in the horizontal peakmeter bar design. |
| 2072 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2073 | * @param {number} db - The current decibel level. |
| 2074 | * @param {number} dbNext - The next decibel level. |
| 2075 | * @param {number} offset - The offset for drawing. |
| 2076 | * @param {number} peak - The peak value. |
| 2077 | * @param {number} anim_x - The animation x-coordinate. |
| 2078 | * @param {number} anim2_x - The second animation x-coordinate. |
| 2079 | * @param {string} main_x - The main x-coordinate property key name. |
| 2080 | * @param {number} main_y - The main y-coordinate. |
| 2081 | * @param {number} color - The color of the bar. |
| 2082 | * @private |
| 2083 | */ |
| 2084 | drawHorizontalMainBars(gr, db, dbNext, offset, peak, anim_x, anim2_x, main_x, main_y, color) { |
| 2085 | if (peak <= db) return; |
| 2086 | |
| 2087 | if (peak < dbNext) this[main_x] = offset; |
| 2088 | |
| 2089 | if (grSet.peakmeterBarMainBars) { |
| 2090 | gr.FillSolidRect(this.x + offset, main_y, this.w2, this.bar_h, color); |
| 2091 | } |
| 2092 | |
| 2093 | if (grSet.peakmeterBarMainPeaks) { |
| 2094 | const color = this.color[Math.round(this.mainLeftAnim_x / this.offset)]; |
| 2095 | gr.FillSolidRect(this.x + anim_x + this.offset, main_y, this.w2 * 0.66, this.bar_h, color); |
| 2096 | |
| 2097 | const x = Clamp(this.x + anim2_x + this.offset + this.w2 * 0.66, this.x, this.x + this.w - this.w2 * 0.33); |
| 2098 | const w = x > this.w + this.w2 * 0.5 ? 0 : this.w2 * 0.33; |
| 2099 | gr.FillSolidRect(x, main_y, w, this.bar_h, color); |
| 2100 | } |
| 2101 | } |
| 2102 | |
| 2103 | /** |
| 2104 | * Draws the outer bars in the horizontal peakmeter bar design. |
| 2105 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2106 | * @param {number} db - The current decibel level. |
| 2107 | * @param {number} dbNext - The next decibel level. |
| 2108 | * @param {number} offset - The offset for drawing. |
| 2109 | * @param {number} level - The level value. |
| 2110 | * @param {number} anim_x - The animation x-coordinate. |
| 2111 | * @param {number} anim_w - The animation width. |
| 2112 | * @param {number} outer_y - The outer y-coordinate. |
| 2113 | * @param {string} outer_w - The outer width property key name. |
| 2114 | * @private |
| 2115 | */ |
| 2116 | drawHorizontalOuterBars(gr, db, dbNext, offset, level, anim_x, anim_w, outer_y, outer_w) { |
| 2117 | if (level <= db) return; |
| 2118 | |
| 2119 | if (level < dbNext) { |
| 2120 | this[outer_w] = offset + this.offset / Math.abs(dbNext - db) * Math.abs(level - db) - this.x; |
| 2121 | } |
| 2122 | |
| 2123 | if (grSet.peakmeterBarOuterBars) { |
| 2124 | gr.FillSolidRect(this.x, outer_y, this[outer_w], this.bar_h, this.color[1]); |
| 2125 | } |
| 2126 | |
| 2127 | if (grSet.peakmeterBarOuterPeaks) { |
| 2128 | const x = Clamp(this.x + anim_x, this.x, this.x + this.w - anim_w); |
| 2129 | gr.FillSolidRect(x, outer_y, anim_w <= 0 ? 2 : anim_w, this.bar_h, this.color[1]); |
| 2130 | } |
| 2131 | } |
| 2132 | |
| 2133 | /** |
| 2134 | * Draws the horizontal center peakmeter bar design. |
| 2135 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2136 | */ |
| 2137 | drawBarDesignCenter(gr) { |
| 2138 | this.drawBarGrid(gr); |
| 2139 | |
| 2140 | for (let i = 0; i <= this.points; i++) { |
| 2141 | const color = this.color[i]; |
| 2142 | const db = this.db[i]; |
| 2143 | const dbNext = this.db[i + 1]; |
| 2144 | const offset = i * this.offset; |
| 2145 | const mainLeft_x = this.x * 0.5 + this.w * 0.5 - i * this.offset + this.w2; |
| 2146 | const mainRight_x = this.x + this.w * 0.5 + i * this.offset - this.w2; |
| 2147 | |
| 2148 | this.drawCenterMainBars(gr, db, dbNext, offset, this.leftPeak, this.mainLeftAnim_x, this.mainLeftAnim2_x, mainLeft_x, mainRight_x, 'mainLeft_x', this.mainLeft_y, color); |
| 2149 | this.drawCenterMainBars(gr, db, dbNext, offset, this.rightPeak, this.mainRightAnim_x, this.mainRightAnim2_x, mainLeft_x, mainRight_x, 'mainRight_x', this.mainRight_y, color); |
| 2150 | |
| 2151 | this.drawCenterOuterBars(gr, db, dbNext, offset, this.leftLevel, this.outerLeftAnim_x, this.outerLeftAnim_w, this.outerLeft_y, 'outerLeft_w'); |
| 2152 | this.drawCenterOuterBars(gr, db, dbNext, offset, this.rightLevel, this.outerRightAnim_x, this.outerRightAnim_w, this.outerRight_y, 'outerRight_w'); |
| 2153 | |
| 2154 | this.drawOverBars(gr); |
| 2155 | } |
| 2156 | |
| 2157 | this.drawMiddleBars(gr); |
| 2158 | this.drawProgressBar(gr); |
| 2159 | this.drawBarInfo(gr); |
| 2160 | } |
| 2161 | |
| 2162 | /** |
| 2163 | * Draws the main bars in the horizontal center peakmeter bar design. |
| 2164 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2165 | * @param {number} db - The current decibel level. |
| 2166 | * @param {number} dbNext - The next decibel level. |
| 2167 | * @param {number} offset - The offset for drawing. |
| 2168 | * @param {number} peak - The peak value. |
| 2169 | * @param {number} anim_x - The animation x-coordinate. |
| 2170 | * @param {number} anim2_x - The second animation x-coordinate. |
| 2171 | * @param {number} mainLeft_x - The main left x-coordinate. |
| 2172 | * @param {number} mainRight_x - The main right x-coordinate. |
| 2173 | * @param {string} main_x - The main x-coordinate property key name. |
| 2174 | * @param {number} main_y - The main y-coordinate. |
| 2175 | * @param {string} color - The color of the bar. |
| 2176 | * @private |
| 2177 | */ |
| 2178 | drawCenterMainBars(gr, db, dbNext, offset, peak, anim_x, anim2_x, mainLeft_x, mainRight_x, main_x, main_y, color) { |
| 2179 | if (peak <= db) return; |
| 2180 | |
| 2181 | if (peak < dbNext) this[main_x] = offset; |
| 2182 | |
| 2183 | if (grSet.peakmeterBarMainBars) { |
| 2184 | gr.FillSolidRect(mainLeft_x, main_y, this.w2, this.bar_h, color); |
| 2185 | gr.FillSolidRect(mainRight_x, main_y, this.w2, this.bar_h, color); |
| 2186 | } |
| 2187 | |
| 2188 | if (grSet.peakmeterBarMainPeaks) { |
| 2189 | const color = this.color[Math.round(anim_x / this.offset)]; |
| 2190 | const xLeft = this.x * 0.5 + this.w * 0.5 - (anim_x + this.offset) + this.w2 * 0.33; |
| 2191 | const xRight = this.x + anim_x + this.offset + this.w * 0.5; |
| 2192 | gr.FillSolidRect(xLeft, main_y, this.w2 * 0.66, this.bar_h, color); |
| 2193 | gr.FillSolidRect(xRight, main_y, this.w2 * 0.66, this.bar_h, color); |
| 2194 | |
| 2195 | const xLeftPeaks = this.x + this.w * 0.5 - anim2_x - this.offset - this.w2 * 0.66; |
| 2196 | const wLeftPeaks = xLeftPeaks < this.x + this.w2 * 0.5 ? 0 : this.w2 * 0.33; |
| 2197 | const xRightPeaks = this.x + this.w * 0.5 + anim2_x + this.offset + this.w2 * 0.66; |
| 2198 | const wRightPeaks = xRightPeaks > this.w + this.w2 * 0.5 ? 0 : this.w2 * 0.33; |
| 2199 | gr.FillSolidRect(xLeftPeaks, main_y, wLeftPeaks, this.bar_h, color); |
| 2200 | gr.FillSolidRect(xRightPeaks, main_y, wRightPeaks, this.bar_h, color); |
| 2201 | } |
| 2202 | } |
| 2203 | |
| 2204 | /** |
| 2205 | * Draws the outer bars in the horizontal center peakmeter bar design. |
| 2206 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2207 | * @param {number} db - The current decibel level. |
| 2208 | * @param {number} dbNext - The next decibel level. |
| 2209 | * @param {number} offset - The offset for drawing. |
| 2210 | * @param {number} level - The level value. |
| 2211 | * @param {number} anim_x - The animation x-coordinate. |
| 2212 | * @param {number} anim_w - The animation width. |
| 2213 | * @param {number} outer_y - The outer y-coordinate. |
| 2214 | * @param {string} outer_w - The outer width property name. |
| 2215 | * @private |
| 2216 | */ |
| 2217 | drawCenterOuterBars(gr, db, dbNext, offset, level, anim_x, anim_w, outer_y, outer_w) { |
| 2218 | if (level <= db) return; |
| 2219 | |
| 2220 | if (level < dbNext) { |
| 2221 | this[outer_w] = offset + this.offset / Math.abs(dbNext - db) * Math.abs(level - db) - this.x; |
| 2222 | } |
| 2223 | |
| 2224 | if (grSet.peakmeterBarOuterBars) { |
| 2225 | const xLeft = Clamp(this.x + this.w * 0.5 - this[outer_w], this.x, this.w * 0.5); |
| 2226 | const xRight = this.x + this.w * 0.5; |
| 2227 | const w = Clamp(this[outer_w], 0, this.w * 0.5); |
| 2228 | gr.FillSolidRect(xLeft, outer_y, w, this.bar_h, this.color[1]); |
| 2229 | gr.FillSolidRect(xRight, outer_y, w, this.bar_h, this.color[1]); |
| 2230 | } |
| 2231 | |
| 2232 | if (grSet.peakmeterBarOuterPeaks) { |
| 2233 | const x = Clamp(this.x + anim_x, this.x, this.x + this.w * 0.5 - anim_w); |
| 2234 | const w = anim_w <= 0 ? 2 : anim_w; |
| 2235 | const xLeftPeaks = this.w * 0.5 + this.x * 2 - x - w; |
| 2236 | const xRightPeaks = this.w * 0.5 + x; |
| 2237 | gr.FillSolidRect(xLeftPeaks, outer_y, w, this.bar_h, this.color[1]); |
| 2238 | gr.FillSolidRect(xRightPeaks, outer_y, w, this.bar_h, this.color[1]); |
| 2239 | } |
| 2240 | } |
| 2241 | |
| 2242 | /** |
| 2243 | * Draws the vertical peakmeter bar design. |
| 2244 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2245 | */ |
| 2246 | drawBarDesignVertical(gr) { |
| 2247 | const peakL = Math.round(this.leftPeak); |
| 2248 | const peakR = Math.round(this.rightPeak); |
| 2249 | const vertBarH = this.vertBar_h * 1.5; |
| 2250 | |
| 2251 | const toleranceBase = 0.05; |
| 2252 | const toleranceMin = 0.1; |
| 2253 | const toleranceMax = 1.0; |
| 2254 | const toleranceL = Clamp(toleranceBase * Math.abs(peakL), toleranceMin, toleranceMax); |
| 2255 | const toleranceR = Clamp(toleranceBase * Math.abs(peakR), toleranceMin, toleranceMax); |
| 2256 | |
| 2257 | for (let i = 0; i < this.points_vert; i++) { |
| 2258 | const dbL = this.db_vert[i]; |
| 2259 | const dbR = this.db_vert[this.points_vert - 1 - i]; |
| 2260 | const offset = this.vertBar_offset * i; |
| 2261 | |
| 2262 | if (Math.abs(peakL - dbL) <= toleranceL) this.leftPeaks_s[i] = vertBarH; |
| 2263 | if (Math.abs(peakR - dbR) <= toleranceR) this.rightPeaks_s[i] = vertBarH; |
| 2264 | |
| 2265 | this.drawVerticalPeaks(gr, this.vertLeft_x, offset, this.leftPeaks_s[i]); |
| 2266 | this.drawVerticalPeaks(gr, this.vertRight_x, offset, this.rightPeaks_s[i]); |
| 2267 | } |
| 2268 | |
| 2269 | if (grSet.peakmeterBarVertBaseline) { |
| 2270 | gr.FillSolidRect(this.x, this.y + this.h - this.vertBar_h, this.w, this.vertBar_h, grCol.peakmeterBarProg); |
| 2271 | } |
| 2272 | |
| 2273 | this.drawProgressBar(gr); |
| 2274 | this.drawBarInfo(gr); |
| 2275 | } |
| 2276 | |
| 2277 | /** |
| 2278 | * Draws the peaks in the vertical peakmeter bar design. |
| 2279 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2280 | * @param {number} xBase - The base x-coordinate. |
| 2281 | * @param {number} offset - The offset for drawing. |
| 2282 | * @param {number} peak_s - The peak value. |
| 2283 | * @private |
| 2284 | */ |
| 2285 | drawVerticalPeaks(gr, xBase, offset, peak_s) { |
| 2286 | const x = xBase + offset; |
| 2287 | const y = this.y + peak_s - this.vertBar_h; |
| 2288 | |
| 2289 | if (peak_s <= this.h) { |
| 2290 | const h = Math.min(this.h - peak_s, this.h); |
| 2291 | gr.FillSolidRect(x, y, this.vertBar_w, h, grCol.peakmeterBarVertFill); |
| 2292 | } |
| 2293 | |
| 2294 | if (grSet.peakmeterBarVertPeaks && peak_s >= 0) { |
| 2295 | gr.FillSolidRect(x, y, this.vertBar_w, this.vertBar_h, grCol.peakmeterBarVertFillPeaks); |
| 2296 | } |
| 2297 | } |
| 2298 | |
| 2299 | /** |
| 2300 | * Draws the over bars in the peakmeter bar designs. |
| 2301 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2302 | * @private |
| 2303 | */ |
| 2304 | drawOverBars(gr) { |
| 2305 | if (!grSet.peakmeterBarOverBars) return; |
| 2306 | |
| 2307 | const widthSize = grSet.peakmeterBarDesign === 'horizontal' ? 1 : 0.5; |
| 2308 | const overLeft = this.outerLeftAnim_x + this.outerLeftAnim_w - (this.w * widthSize); |
| 2309 | const overRight = this.outerRightAnim_x + this.outerRightAnim_w - (this.w * widthSize); |
| 2310 | |
| 2311 | const outerAnim = this.outerLeftAnim_x - this.outerLeftAnim_w; |
| 2312 | const outerAnimHalf = outerAnim * 0.5; |
| 2313 | |
| 2314 | const xLeft = this.w - overLeft - this.x; |
| 2315 | const xRight = this.w - overRight - this.x; |
| 2316 | const xLeft2 = Clamp(this.w * 0.5 - overLeft - outerAnim, this.x, this.w * 0.5); |
| 2317 | const xRight2 = Clamp(this.w * 0.5 - overRight - outerAnim, this.x, this.w * 0.5); |
| 2318 | |
| 2319 | const wLeft = this.w - xLeft + this.x; |
| 2320 | const wRight = this.w - xRight + this.x; |
| 2321 | const wLeft2 = this.w - xLeft + outerAnimHalf; |
| 2322 | const wRight2 = this.w - xRight + outerAnimHalf; |
| 2323 | |
| 2324 | if (overLeft > 0) { // Top |
| 2325 | gr.FillSolidRect(xLeft, this.overLeft_y, wLeft, this.bar2_h, this.color[10]); |
| 2326 | grSet.peakmeterBarDesign === 'horizontal_center' && gr.FillSolidRect(xLeft2, this.overLeft_y, wLeft2, this.bar2_h, this.color[10]); |
| 2327 | } |
| 2328 | if (overRight > 0) { // Bottom |
| 2329 | gr.FillSolidRect(xRight, this.overRight_y, wRight, this.bar2_h, this.color[10]); |
| 2330 | grSet.peakmeterBarDesign === 'horizontal_center' && gr.FillSolidRect(xRight2, this.overRight_y, wRight2, this.bar2_h, this.color[10]); |
| 2331 | } |
| 2332 | } |
| 2333 | |
| 2334 | /** |
| 2335 | * Draws the middle bars in the peakmeter bar designs. |
| 2336 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2337 | * @private |
| 2338 | */ |
| 2339 | drawMiddleBars(gr) { |
| 2340 | if (!grSet.peakmeterBarMiddleBars) return; |
| 2341 | |
| 2342 | if (grSet.peakmeterBarDesign === 'horizontal') { |
| 2343 | for (let i = 0; i <= this.points_middle; i++) { |
| 2344 | const dbMiddle = this.db_middle[i]; |
| 2345 | const x = this.x + i * this.middleOffset; |
| 2346 | |
| 2347 | if (this.leftPeak > dbMiddle) { |
| 2348 | gr.FillSolidRect(x, this.middleLeft_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill); |
| 2349 | } |
| 2350 | if (this.rightPeak > dbMiddle) { |
| 2351 | gr.FillSolidRect(x, this.middleRight_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill); |
| 2352 | } |
| 2353 | } |
| 2354 | } |
| 2355 | else if (grSet.peakmeterBarDesign === 'horizontal_center') { |
| 2356 | for (let i = 0; i <= this.points_middle; i++) { |
| 2357 | const dbMiddle = this.db_middle[i]; |
| 2358 | const x1 = this.x * 0.5 + this.w * 0.5 - i * this.middleOffset + 1; |
| 2359 | const x2 = this.x + this.w * 0.5 + i * this.middleOffset - 1; |
| 2360 | |
| 2361 | if (this.leftPeak > dbMiddle) { |
| 2362 | gr.FillSolidRect(x1, this.middleLeft_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill); |
| 2363 | gr.FillSolidRect(x2, this.middleLeft_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill); |
| 2364 | } |
| 2365 | if (this.rightPeak > dbMiddle) { |
| 2366 | gr.FillSolidRect(x1, this.middleRight_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill); |
| 2367 | gr.FillSolidRect(x2, this.middleRight_y, this.middle_w, this.bar2_h, grCol.peakmeterBarProgFill); |
| 2368 | } |
| 2369 | } |
| 2370 | } |
| 2371 | } |
| 2372 | |
| 2373 | /** |
| 2374 | * Draws the progress bar in the peakmeter bar designs. |
| 2375 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2376 | * @private |
| 2377 | */ |
| 2378 | drawProgressBar(gr) { |
| 2379 | if (!fb.IsPlaying || fb.PlaybackLength <= 0 || !grSet.peakmeterBarProgBar) return; |
| 2380 | |
| 2381 | const playbackRatio = fb.PlaybackTime / fb.PlaybackLength; |
| 2382 | this.progressLength = Math.floor(this.w * (grSet.peakmeterBarDesign === 'horizontal_center' ? 0.5 : 1) * playbackRatio); |
| 2383 | |
| 2384 | if (grSet.peakmeterBarDesign === 'horizontal') { |
| 2385 | gr.FillSolidRect(this.x, this.middleLeft_y, this.w, this.bar_h, grCol.peakmeterBarProg); |
| 2386 | gr.FillSolidRect(this.x, this.middleLeft_y, this.progressLength, this.bar_h, grCol.peakmeterBarProgFill); |
| 2387 | } |
| 2388 | else if (grSet.peakmeterBarDesign === 'horizontal_center') { |
| 2389 | gr.FillSolidRect(this.x, this.middleLeft_y, this.w, this.bar_h, grCol.peakmeterBarProg); |
| 2390 | gr.FillSolidRect(this.x + this.w * 0.5 - this.progressLength, this.middleLeft_y, this.progressLength, this.bar_h, grCol.peakmeterBarProgFill); |
| 2391 | gr.FillSolidRect(this.x + this.w * 0.5, this.middleLeft_y, this.progressLength, this.bar_h, grCol.peakmeterBarProgFill); |
| 2392 | } |
| 2393 | else if (grSet.peakmeterBarDesign === 'vertical') { |
| 2394 | gr.FillSolidRect(this.x, this.y + this.h - this.vertBar_h, this.w, this.bar_h, grCol.peakmeterBarProg); |
| 2395 | gr.FillSolidRect(this.x, this.y + this.h - this.vertBar_h, this.progressLength, this.bar_h, grCol.peakmeterBarVertProgFill); |
| 2396 | } |
| 2397 | } |
| 2398 | |
| 2399 | /** |
| 2400 | * Draws the grid in the peakmeter bar designs. |
| 2401 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2402 | */ |
| 2403 | drawBarGrid(gr) { |
| 2404 | if (!grSet.peakmeterBarGrid) return; |
| 2405 | gr.FillSolidRect(this.x, this.outerLeft_y, this.w, this.bar_h, grCol.peakmeterBarProg); |
| 2406 | gr.FillSolidRect(this.x, this.outerRight_y, this.w, this.bar_h, grCol.peakmeterBarProg); |
| 2407 | } |
| 2408 | |
| 2409 | /** |
| 2410 | * Draws the bar info in the peakmeter bar designs. |
| 2411 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 2412 | */ |
| 2413 | drawBarInfo(gr) { |
| 2414 | if (!grSet.peakmeterBarInfo) return; |
| 2415 | |
| 2416 | const infoTextColor = grCol.lowerBarArtist; |
| 2417 | |
| 2418 | if (grSet.peakmeterBarDesign === 'horizontal') { |
| 2419 | const text_db_w = gr.CalcTextWidth('db', this.textFont); |
| 2420 | |
| 2421 | for (let i = 4; i <= this.points; i += 2) { |
| 2422 | const text_w = gr.CalcTextWidth(this.db[i], this.textFont); |
| 2423 | gr.GdiDrawText(this.db[i], this.textFont, infoTextColor, this.x + this.offset * i - text_w * 0.5, this.text_y, this.w, this.h); |
| 2424 | } |
| 2425 | |
| 2426 | gr.GdiDrawText('db', this.textFont, infoTextColor, this.x + this.offset * 2 - text_db_w, this.text_y, this.w, this.h); |
| 2427 | } |
| 2428 | else if (grSet.peakmeterBarDesign === 'horizontal_center') { |
| 2429 | const text_db_w = gr.CalcTextWidth('db', this.textFont); |
| 2430 | |
| 2431 | for (let i = 4; i <= this.points; i += 2) { |
| 2432 | const textRight_w = gr.CalcTextWidth(this.db[i], this.textFont); |
| 2433 | const textLeft_w2 = gr.CalcTextWidth(`${this.db[this.points + 3 - i]}-`, this.textFont); |
| 2434 | |
| 2435 | gr.GdiDrawText(this.db[i], this.textFont, infoTextColor, this.w * 0.5 + this.offset * i - textRight_w * 0.5, this.text_y, this.w, this.h); |
| 2436 | gr.GdiDrawText(this.db[this.points + 3 - i], this.textFont, infoTextColor, this.x + this.offset * i - textLeft_w2 * 1.5, this.text_y, this.w, this.h); |
| 2437 | } |
| 2438 | |
| 2439 | gr.GdiDrawText('db', this.textFont, infoTextColor, this.w * 0.5 + this.offset * 2 - text_db_w * 0.5, this.text_y, this.w, this.h); |
| 2440 | } |
| 2441 | else if (grSet.peakmeterBarDesign === 'vertical') { |
| 2442 | for (let i = 0; i <= this.points_vert; i++) { |
| 2443 | const dbLeft = this.db_vert[i]; |
| 2444 | const dbRight = this.db_vert[this.points_vert - 1 - i]; |
| 2445 | |
| 2446 | const textLeft_w = gr.CalcTextWidth(`${dbLeft}--`, this.textFont); |
| 2447 | const textRight_w = gr.CalcTextWidth(`${dbRight}--`, this.textFont); |
| 2448 | const textLeft_x = this.vertLeft_x + this.vertBar_offset * i - textLeft_w / 2 + (this.vertBar_offset - this.vertBar_w); |
| 2449 | const textRight_x = this.vertRight_x + this.vertBar_offset * i - textRight_w / 2 + (this.vertBar_offset - this.vertBar_w); |
| 2450 | |
| 2451 | gr.GdiDrawText(dbLeft % 2 === 0 ? dbLeft : '', this.textFont, infoTextColor, textLeft_x, this.y, grm.ui.ww, grm.ui.wh); |
| 2452 | gr.GdiDrawText(dbRight % 2 === 0 ? dbRight : '', this.textFont, infoTextColor, textRight_x, this.y, grm.ui.ww, grm.ui.wh); |
| 2453 | } |
| 2454 | } |
| 2455 | } |
| 2456 | // #endregion |
| 2457 | |
| 2458 | // * PUBLIC METHODS - INITIALIZATION * // |
| 2459 | // #region PUBLIC METHODS - INITIALIZATION |
| 2460 | /** |
| 2461 | * Initializes the decibel arrays for different configurations. |
| 2462 | */ |
| 2463 | initDecibel() { |
| 2464 | this.db = [ |
| 2465 | -20, -19.5, -19, -18.5, -18, -17.5, -17, -16.5, -16, -15.5, -15, -14.5, -14, -13.5, -13, -12.5, -12, -11.5, -11, -10.5, |
| 2466 | -10, -9.5, -9, -8.5, -8, -7.5, -7, -6.5, -6, -5.5, -5, -4.5, -4, -3.5, -3, -2.5, -2, -1.5, -1, -0.5, 0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5 |
| 2467 | ]; |
| 2468 | this.db_middle = [ |
| 2469 | -70, -65, -62.5, -60, -57.5, -55, -52.5, -50, -47.5, -45, -42.5, -40, -37.5, -35, -32.5, -30, -27.5, -25, -22.5 |
| 2470 | ]; |
| 2471 | this.db_vert = { |
| 2472 | 220: [-20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2], |
| 2473 | 215: [-15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2], |
| 2474 | 210: [-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2], |
| 2475 | 320: [-20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3], |
| 2476 | 315: [-15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3], |
| 2477 | 310: [-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3], |
| 2478 | 520: [-20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5], |
| 2479 | 515: [-15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5], |
| 2480 | 510: [-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5] |
| 2481 | }[grSet.peakmeterBarVertDbRange]; |
| 2482 | } |
| 2483 | |
| 2484 | /** |
| 2485 | * Initializes the points for different decibel arrays. |
| 2486 | */ |
| 2487 | initPoints() { |
| 2488 | this.points_middle = this.db_middle.length; |
| 2489 | this.points = this.db.length; |
| 2490 | this.points_vert = this.db_vert.length; |
| 2491 | |
| 2492 | for (let i = 0; i <= this.points_vert; i++) { |
| 2493 | this.leftPeaks_s[i] = 0; |
| 2494 | this.rightPeaks_s[i] = 0; |
| 2495 | } |
| 2496 | } |
| 2497 | |
| 2498 | /** |
| 2499 | * Initializes the peaks arrays for left and right channels. |
| 2500 | */ |
| 2501 | initPeaks() { |
| 2502 | this.leftPeaks_s = new Array(this.points_vert + 1).fill(0); |
| 2503 | this.rightPeaks_s = new Array(this.points_vert + 1).fill(0); |
| 2504 | } |
| 2505 | |
| 2506 | /** |
| 2507 | * Initializes the separator index based on the decibel array. |
| 2508 | */ |
| 2509 | initSeparator() { |
| 2510 | this.separator = this.db.indexOf(0); |
| 2511 | this.sep1 = this.separator; |
| 2512 | this.sep2 = this.points - this.sep1; |
| 2513 | } |
| 2514 | |
| 2515 | /** |
| 2516 | * Initializes bar geometry properties. |
| 2517 | */ |
| 2518 | initGeometry() { |
| 2519 | this.bar_h = grSet.layout !== 'default' ? SCALE(2) : SCALE(4); |
| 2520 | |
| 2521 | this.offset = (grSet.peakmeterBarDesign === 'horizontal_center' ? this.w * 0.5 : this.w) / this.points; |
| 2522 | this.middleOffset = (grSet.peakmeterBarDesign === 'horizontal_center' ? this.w * 0.5 : this.w) / this.points_middle; |
| 2523 | this.middle_w = this.middleOffset - (grSet.peakmeterBarGaps ? 1 : 0); |
| 2524 | this.w2 = this.offset - (grSet.peakmeterBarGaps ? 1 : 0); |
| 2525 | |
| 2526 | this.vertBar_offset = ((this.w / this.points_vert) + ((grSet.peakmeterBarVertSize === 'min' ? 2 : grSet.peakmeterBarVertSize) / this.points_vert * 0.5)) * 0.5; |
| 2527 | this.vertBar_w = grSet.peakmeterBarVertSize === 'min' ? Math.ceil(this.vertBar_offset * 0.1 * 0.5) : this.vertBar_offset - grSet.peakmeterBarVertSize * 0.5; |
| 2528 | this.vertBar_h = 2; |
| 2529 | this.vertLeft_x = this.x; |
| 2530 | this.vertRight_x = this.vertLeft_x + this.vertBar_offset * this.points_vert; |
| 2531 | } |
| 2532 | // #endregion |
| 2533 | |
| 2534 | // * PUBLIC METHODS - COMMON * // |
| 2535 | // #region PUBLIC METHODS - COMMON |
| 2536 | /** |
| 2537 | * Resets the state of the peakmeter bar. |
| 2538 | */ |
| 2539 | reset() { |
| 2540 | this.leftLevel = 0; |
| 2541 | this.leftPeak = 0; |
| 2542 | this.rightLevel = 0; |
| 2543 | this.rightPeak = 0; |
| 2544 | this.leftPeaks_s = []; |
| 2545 | this.rightPeaks_s = []; |
| 2546 | this.progressLength = 0; |
| 2547 | this.tooltipText = ''; |
| 2548 | } |
| 2549 | |
| 2550 | /** |
| 2551 | * Sets all vertical peakmeter bar positions. |
| 2552 | * Bars are ordered from top to bottom. |
| 2553 | * @param {number} y - The y-coordinate. |
| 2554 | */ |
| 2555 | setY(y = grm.ui.seekbarY) { |
| 2556 | this.y = y; |
| 2557 | this.overLeft_y = this.y; |
| 2558 | this.outerLeft_y = this.overLeft_y + this.bar2_h; |
| 2559 | this.mainLeft_y = this.outerLeft_y + this.bar_h; |
| 2560 | this.middleLeft_y = this.mainLeft_y + this.bar_h + SCALE(1); |
| 2561 | this.middleRight_y = this.middleLeft_y + this.bar2_h; |
| 2562 | this.mainRight_y = this.middleRight_y + this.bar2_h + SCALE(1); |
| 2563 | this.outerRight_y = this.mainRight_y + this.bar_h; |
| 2564 | this.overRight_y = this.outerRight_y + this.bar_h; |
| 2565 | this.text_y = this.outerRight_y + this.bar_h * 2; |
| 2566 | } |
| 2567 | |
| 2568 | /** |
| 2569 | * Monitors volume levels and peaks and sets horizontal or vertical animations based on peakmeterBarDesign. |
| 2570 | */ |
| 2571 | setAnimation() { |
| 2572 | // * Set horizontal animation |
| 2573 | if (['horizontal', 'horizontal_center'].includes(grSet.peakmeterBarDesign)) { |
| 2574 | const increment1 = 0.09; // 0.3 ** 2 |
| 2575 | const increment2 = 1.21; // 1.1 ** 2 |
| 2576 | |
| 2577 | // * Main left middle peaks |
| 2578 | if (this.mainLeftAnim_x <= this.mainLeft_x) { |
| 2579 | this.mainLeftAnim_x = this.mainLeft_x; |
| 2580 | this.mainLeftAnim2_x = this.mainLeft_x; |
| 2581 | this.mainLeft_k = 0; |
| 2582 | this.mainLeft2_k = 0; |
| 2583 | } |
| 2584 | this.mainLeft_k += increment1; |
| 2585 | this.mainLeftAnim_x -= this.mainLeft_k; |
| 2586 | this.mainLeft2_k += increment2; |
| 2587 | this.mainLeftAnim2_x += this.mainLeft2_k; |
| 2588 | |
| 2589 | // * Main right middle peaks |
| 2590 | if (this.mainRightAnim_x <= this.mainRight_x) { |
| 2591 | this.mainRightAnim_x = this.mainRight_x; |
| 2592 | this.mainRightAnim2_x = this.mainRight_x; |
| 2593 | this.mainRight_k = 0; |
| 2594 | this.mainRight2_k = 0; |
| 2595 | } |
| 2596 | this.mainRight_k += increment1; |
| 2597 | this.mainRightAnim_x -= this.mainRight_k; |
| 2598 | this.mainRight2_k += increment2; |
| 2599 | this.mainRightAnim2_x += this.mainRight2_k; |
| 2600 | |
| 2601 | // * Outer left peaks |
| 2602 | if (this.outerLeftAnim_x <= this.outerLeft_w) { |
| 2603 | this.outerLeftAnim_x = this.outerLeft_w; |
| 2604 | this.outerLeft_k = 0; |
| 2605 | this.outerLeftAnim_w = this.outerLeft_w - this.outerLeft_w_old < 1 ? this.outerLeftAnim_w : this.outerLeft_w - this.outerLeft_w_old + 10; |
| 2606 | } else { |
| 2607 | this.outerLeft_w_old = this.outerLeft_w; |
| 2608 | } |
| 2609 | this.outerLeft_k += increment1; |
| 2610 | this.outerLeftAnim_x -= this.outerLeft_k; |
| 2611 | this.outerLeftAnim_w -= this.outerLeft_k * 2; |
| 2612 | |
| 2613 | // * Outer right peaks |
| 2614 | if (this.outerRightAnim_x <= this.outerRight_w) { |
| 2615 | this.outerRightAnim_x = this.outerRight_w; |
| 2616 | this.outerRight_k = 0; |
| 2617 | this.outerRightAnim_w = this.outerRight_w - this.outerRight_w_old < 1 ? this.outerRightAnim_w : this.outerRight_w - this.outerRight_w_old + 10; |
| 2618 | } else { |
| 2619 | this.outerRight_w_old = this.outerRight_w; |
| 2620 | } |
| 2621 | this.outerRight_k += increment1; |
| 2622 | this.outerRightAnim_x -= this.outerRight_k; |
| 2623 | this.outerRightAnim_w -= this.outerRight_k * 2; |
| 2624 | } |
| 2625 | // * Set vertical animation |
| 2626 | else if (grSet.peakmeterBarDesign === 'vertical') { |
| 2627 | for (let j = 0; j < this.leftPeaks_s.length; j++) { |
| 2628 | this.leftPeaks_s[j] = this.leftPeaks_s[j] < this.h ? this.leftPeaks_s[j] + 2 : this.h; |
| 2629 | } |
| 2630 | for (let j = 0; j < this.rightPeaks_s.length; j++) { |
| 2631 | this.rightPeaks_s[j] = this.rightPeaks_s[j] < this.h ? this.rightPeaks_s[j] + 2 : this.h; |
| 2632 | } |
| 2633 | } |
| 2634 | } |
| 2635 | |
| 2636 | /** |
| 2637 | * Sets the peakmeter bar colors. |
| 2638 | * @param {FbMetadbHandle} metadb - The metadb of the track. |
| 2639 | */ |
| 2640 | setColors(metadb = fb.GetNowPlaying()) { |
| 2641 | if (grSet.seekbar !== 'peakmeterbar') return; |
| 2642 | |
| 2643 | let img = gdi.CreateImage(1, 1); |
| 2644 | const g = img.GetGraphics(); |
| 2645 | img.ReleaseGraphics(g); |
| 2646 | |
| 2647 | if (metadb) img = utils.GetAlbumArtV2(metadb, 0); |
| 2648 | |
| 2649 | if (img) { |
| 2650 | try { |
| 2651 | grm.ui.albumArtCorrupt = false; |
| 2652 | // this.colors = JSON.parse(img.GetColourSchemeJSON(4)); |
| 2653 | this.c1 = grCol.peakmeterBarFillMiddle; // this.colors[1].col; |
| 2654 | this.c2 = grCol.peakmeterBarFillTop; // this.colors[2].col; |
| 2655 | this.c3 = grCol.peakmeterBarFillBack; // this.colors[3].col; |
| 2656 | } catch (e) { |
| 2657 | grm.ui.noArtwork = true; |
| 2658 | grm.ui.noAlbumArtStub = true; |
| 2659 | grm.ui.albumArtCorrupt = true; |
| 2660 | this.setDefaultColors(); |
| 2661 | } |
| 2662 | } else { |
| 2663 | this.setDefaultColors(); |
| 2664 | } |
| 2665 | |
| 2666 | this.color = []; |
| 2667 | this.color1 = [this.c2, this.c3]; |
| 2668 | this.color2 = [this.c3, this.c1]; |
| 2669 | |
| 2670 | for (let j = 0; j < this.sep1; j++) { |
| 2671 | this.color.push(BlendColors(this.color1[0], this.color1[1], j / this.sep1)); |
| 2672 | } |
| 2673 | for (let j = 0; j < this.sep2; j++) { |
| 2674 | this.color.push(BlendColors(this.color2[0], this.color2[1], j / this.sep2)); |
| 2675 | } |
| 2676 | } |
| 2677 | |
| 2678 | /** |
| 2679 | * Sets the default peakmeter bar colors. |
| 2680 | */ |
| 2681 | setDefaultColors() { |
| 2682 | this.c1 = grCol.peakmeterBarFillMiddle; // RGB(0, 200, 255); |
| 2683 | this.c2 = grCol.peakmeterBarFillTop; // RGB(255, 255, 0); |
| 2684 | this.c3 = grCol.peakmeterBarFillBack; // RGB(230, 230, 30); |
| 2685 | this.color1 = [this.c3, this.c1]; |
| 2686 | this.color2 = [this.c2, this.c3]; |
| 2687 | } |
| 2688 | |
| 2689 | /** |
| 2690 | * Sets monitoring of audio levels and peaks based on playback state. |
| 2691 | * Converts and stores volume levels and peaks for both left and right channels in decibels. |
| 2692 | */ |
| 2693 | setMonitoring() { |
| 2694 | if (!AudioWizard) return; |
| 2695 | |
| 2696 | this.leftLevel = AudioWizard.PeakmeterAdjustedLeftRMS; |
| 2697 | this.rightLevel = AudioWizard.PeakmeterAdjustedRightRMS; |
| 2698 | this.leftPeak = AudioWizard.PeakmeterAdjustedLeftSamplePeak; |
| 2699 | this.rightPeak = AudioWizard.PeakmeterAdjustedRightSamplePeak; |
| 2700 | } |
| 2701 | |
| 2702 | /** |
| 2703 | * Sets the playback time of the progress bar. |
| 2704 | * @param {number} x - The x-coordinate. |
| 2705 | * @private |
| 2706 | */ |
| 2707 | setPlaybackTime(x) { |
| 2708 | const clampedPosition = Clamp((x - this.x) / this.w, 0, 1); |
| 2709 | const newPlaybackTime = clampedPosition * fb.PlaybackLength; |
| 2710 | if (fb.PlaybackTime !== newPlaybackTime) { |
| 2711 | fb.PlaybackTime = newPlaybackTime; |
| 2712 | } |
| 2713 | } |
| 2714 | |
| 2715 | /** |
| 2716 | * Sets the refresh rate for the peakmeter bar. |
| 2717 | */ |
| 2718 | setRefreshRate() { |
| 2719 | if (grm.ui.isStreaming) { // Radio streaming refresh rate |
| 2720 | grm.ui.seekbarTimerInterval = FPS._1; |
| 2721 | } |
| 2722 | else if (grSet.peakmeterBarRefreshRate !== 'variable') { // Fixed refresh rate |
| 2723 | grm.ui.seekbarTimerInterval = grSet.peakmeterBarRefreshRate; |
| 2724 | } |
| 2725 | else { // Variable refresh rate calculation |
| 2726 | const now = Date.now(); |
| 2727 | if (this.updateTimeLast && (now - this.updateTimeLast) < 250) return; // Check every 250 ms |
| 2728 | this.updateTimeLast = now; |
| 2729 | |
| 2730 | if (this.profilerPaintTimeLast === undefined) { |
| 2731 | this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time; |
| 2732 | } |
| 2733 | |
| 2734 | const timeDifference = grm.ui.seekbarProfiler.Time - this.profilerPaintTimeLast; |
| 2735 | grm.ui.seekbarTimerInterval = Clamp(grm.ui.seekbarTimerInterval + (timeDifference > 0 ? 8 : -5), FPS._20, FPS._10); |
| 2736 | this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time; |
| 2737 | |
| 2738 | grm.ui.clearTimer('seekbar', true); |
| 2739 | grm.ui.seekbarTimer = !fb.IsPaused ? setInterval(() => grm.ui.refreshSeekbar(), grm.ui.seekbarTimerInterval) : null; |
| 2740 | } |
| 2741 | } |
| 2742 | |
| 2743 | /** |
| 2744 | * Starts Audio Wizard's peakmeter real-time monitoring. |
| 2745 | */ |
| 2746 | startPeakmeter() { |
| 2747 | const refreshRate = grSet.peakmeterBarRefreshRate === 'variable' ? 17 : grSet.peakmeterBarRefreshRate; |
| 2748 | AudioWizard && AudioWizard.StartPeakmeterMonitoring(refreshRate, 50); |
| 2749 | } |
| 2750 | |
| 2751 | /** |
| 2752 | * Stops Audio Wizard's peakmeter real-time monitoring. |
| 2753 | */ |
| 2754 | stopPeakmeter() { |
| 2755 | AudioWizard && AudioWizard.StopPeakmeterMonitoring(); |
| 2756 | } |
| 2757 | // #endregion |
| 2758 | |
| 2759 | // * CALLBACKS * // |
| 2760 | // #region CALLBACKS |
| 2761 | /** |
| 2762 | * Checks if the mouse is within the boundaries of the peakmeter bar. |
| 2763 | * @param {number} x - The x-coordinate. |
| 2764 | * @param {number} y - The y-coordinate. |
| 2765 | * @returns {boolean} True or false. |
| 2766 | */ |
| 2767 | mouseInThis(x, y) { |
| 2768 | return (x >= this.x && y >= this.y && x < this.x + this.w && y <= this.y + this.h); |
| 2769 | } |
| 2770 | |
| 2771 | /** |
| 2772 | * Handles left mouse button down click events and enables dragging. |
| 2773 | * @param {number} x - The x-coordinate. |
| 2774 | * @param {number} y - The y-coordinate. |
| 2775 | */ |
| 2776 | on_mouse_lbtn_down(x, y) { |
| 2777 | this.drag = true; |
| 2778 | } |
| 2779 | |
| 2780 | /** |
| 2781 | * Handles left mouse button up click events and disables dragging and updates the playback time. |
| 2782 | * @param {number} x - The x-coordinate. |
| 2783 | * @param {number} y - The y-coordinate. |
| 2784 | */ |
| 2785 | on_mouse_lbtn_up(x, y) { |
| 2786 | this.drag = false; |
| 2787 | if (this.on_mouse && this.mouseInThis(x, y)) { |
| 2788 | this.setPlaybackTime(x); |
| 2789 | } |
| 2790 | } |
| 2791 | |
| 2792 | /** |
| 2793 | * Handle mouse leave events. |
| 2794 | */ |
| 2795 | on_mouse_leave() { |
| 2796 | this.drag = false; |
| 2797 | this.on_mouse = false; |
| 2798 | } |
| 2799 | |
| 2800 | /** |
| 2801 | * Handles mouse movement events and updates the playback time based on the mouse movement if a drag event is occurring. |
| 2802 | * @param {number} x - The x-coordinate. |
| 2803 | * @param {number} y - The y-coordinate. |
| 2804 | */ |
| 2805 | on_mouse_move(x, y) { |
| 2806 | this.on_mouse = true; |
| 2807 | this.pos_x = x <= this.textWidth ? this.x + this.textWidth : this.x + x; |
| 2808 | this.pos_y = y <= this.textHeight ? this.textHeight : y; |
| 2809 | |
| 2810 | if (this.drag) { |
| 2811 | this.setPlaybackTime(x); |
| 2812 | } |
| 2813 | |
| 2814 | if (this.tooltipText) { |
| 2815 | this.wheel = false; |
| 2816 | this.tooltipTimer = false; |
| 2817 | this.tooltipText = ''; |
| 2818 | grm.ttip.stop(); |
| 2819 | window.Repaint(); |
| 2820 | } |
| 2821 | } |
| 2822 | |
| 2823 | /** |
| 2824 | * Handles mouse wheel events and controls the volume offset. |
| 2825 | * @param {number} step - The wheel scroll direction. |
| 2826 | */ |
| 2827 | on_mouse_wheel(step) { |
| 2828 | this.wheel = true; |
| 2829 | |
| 2830 | if (!AudioWizard) return; |
| 2831 | |
| 2832 | AudioWizard.PeakmeterOffset = AudioWizard.PeakmeterOffset + step; |
| 2833 | this.tooltipText = `${Math.round(AudioWizard.PeakmeterOffset)} db`; |
| 2834 | grm.ttip.showImmediate(this.tooltipText); |
| 2835 | } |
| 2836 | |
| 2837 | /** |
| 2838 | * Updates peakmeter bar colors when playing a new track. |
| 2839 | * @param {FbMetadbHandle} metadb - The metadb of the track. |
| 2840 | */ |
| 2841 | on_playback_new_track(metadb) { |
| 2842 | if (!metadb) return; |
| 2843 | this.startPeakmeter(); |
| 2844 | this.progressLength = 0; |
| 2845 | this.setColors(metadb); |
| 2846 | } |
| 2847 | |
| 2848 | /** |
| 2849 | * Resets the peakmeter bar on playback stop. |
| 2850 | * @param {number} reason - The type of playback stop. |
| 2851 | */ |
| 2852 | on_playback_stop(reason = -1) { |
| 2853 | if (['progressbar', 'waveformbar'].includes(grSet.seekbar)) { |
| 2854 | return; |
| 2855 | } |
| 2856 | |
| 2857 | if (reason !== 2) { |
| 2858 | this.stopPeakmeter(); |
| 2859 | this.reset(); |
| 2860 | window.Repaint(); |
| 2861 | } |
| 2862 | } |
| 2863 | |
| 2864 | /** |
| 2865 | * Sets the size and position of the peakmeter bar and updates them on window resizing. |
| 2866 | */ |
| 2867 | on_size() { |
| 2868 | this.x = grm.ui.edgeMargin; |
| 2869 | this.y = grm.ui.seekbarY; |
| 2870 | this.w = grm.ui.ww - grm.ui.edgeMarginBoth; |
| 2871 | this.h = grm.ui.seekbarHeight; |
| 2872 | |
| 2873 | this.initGeometry(); |
| 2874 | this.setY(); |
| 2875 | |
| 2876 | this.textFont = gdi.Font('Segoe UI', HD_4K(9, 16), 1); |
| 2877 | } |
| 2878 | // #endregion |
| 2879 | } |
| 2880 | |
| 2881 | |
| 2882 | ////////////////////// |
| 2883 | // * WAVEFORM BAR * // |
| 2884 | ////////////////////// |
| 2885 | /** |
| 2886 | * A class that creates the waveform bar in the lower bar when enabled. |
| 2887 | * Quick access via right click context menu on lower bar. |
| 2888 | */ |
| 2889 | class WaveformBar { |
| 2890 | /** |
| 2891 | * Creates the `WaveformBar` instance. |
| 2892 | */ |
| 2893 | constructor() { |
| 2894 | // * Dependencies |
| 2895 | include(`${fb.ProfilePath}georgia-reborn\\externals\\Codepages.js`); |
| 2896 | include(`${fb.ProfilePath}georgia-reborn\\externals\\lz-utf8\\lzutf8.js`); |
| 2897 | include(`${fb.ProfilePath}georgia-reborn\\externals\\lz-string\\lz-string.min.js`); |
| 2898 | |
| 2899 | /** @private @type {string} The match pattern used to create folder path. */ |
| 2900 | this.matchPattern = '$replace($ascii([$replace($if2($meta(ALBUMARTIST,0),$meta(ARTIST,0)),\\,)]\\[$replace([$if3(%original release date%,%originaldate%,%date%,%fy_upload_date%,) - ]%ALBUM%,\\,)]\\%TRACKNUMBER% - $replace(%TITLE%,\\,)), ?,,= ,,?,)'; |
| 2901 | /** @private @type {boolean} The debug flag for logging debug information. */ |
| 2902 | this.debug = false; |
| 2903 | /** @private @type {boolean} The profile flag for logging performance information. */ |
| 2904 | this.profile = false; |
| 2905 | /** @private @type {FbProfiler} The profiler for logging performance information. */ |
| 2906 | this.profiler = null; |
| 2907 | |
| 2908 | // * Easy access |
| 2909 | /** @public @type {number} The x-coordinate of the waveform bar. */ |
| 2910 | this.x = grm.ui.edgeMargin; |
| 2911 | /** @public @type {number} The y-coordinate of the waveform bar. */ |
| 2912 | this.y = 0; |
| 2913 | /** @public @type {number} The width of the waveform bar. */ |
| 2914 | this.w = grm.ui.ww - grm.ui.edgeMarginBoth; |
| 2915 | /** @public @type {number} The height of the waveform bar. */ |
| 2916 | this.h = grm.ui.seekbarHeight; |
| 2917 | |
| 2918 | // * Internals |
| 2919 | /** @private @type {boolean} The active state of the waveform bar. */ |
| 2920 | this.active = true; |
| 2921 | /** @private @type {string} The title format used for the waveform bar. */ |
| 2922 | this.Tf = fb.TitleFormat(this.matchPattern); |
| 2923 | /** @private @type {number} The maximum step for the title format. */ |
| 2924 | this.TfMaxStep = fb.TitleFormat('[%BPM%]'); |
| 2925 | /** @private @type {string[]} The cache storage for the waveform data. */ |
| 2926 | this.cache = null; |
| 2927 | /** @private @type {string} The directory for the waveform cache. */ |
| 2928 | this.cacheDir = grSet.customWaveformBarDir ? $(`${grCfg.customWaveformBarDir}`, undefined, true) : `${fb.ProfilePath}cache\\waveform\\`; |
| 2929 | /** @private @type {string} The code page for character encoding conversion. */ |
| 2930 | this.codePage = convertCharsetToCodepage('UTF-8'); |
| 2931 | /** @private @type {string} The code page for UTF-16LE character encoding conversion. */ |
| 2932 | this.codePageV2 = convertCharsetToCodepage('UTF-16LE'); |
| 2933 | /** @private @type {number} The queue identifier for the waveform bar. */ |
| 2934 | this.queueId = null; |
| 2935 | /** @private @type {number} The queue interval in milliseconds. */ |
| 2936 | this.queueMs = 1000; |
| 2937 | /** @private @type {string[]} The current waveform data. */ |
| 2938 | this.current = []; |
| 2939 | /** @private @type {number[]} The offset values for the waveform data. */ |
| 2940 | this.offset = []; |
| 2941 | /** @private @type {number} The current step in the waveform animation. */ |
| 2942 | this.step = 0; // 0 - maxStep |
| 2943 | /** @private @type {number} The maximum step for the waveform animation. */ |
| 2944 | this.maxStep = 4; |
| 2945 | /** @private @type {number} The current playback time for the waveform bar. */ |
| 2946 | this.time = 0; |
| 2947 | /** @private @type {boolean} The state indicating if the mouse is down. */ |
| 2948 | this.mouseDown = false; |
| 2949 | /** @private @type {boolean} The state indicating if the file is allowed. Set at checkAllowedFile(). */ |
| 2950 | this.isAllowedFile = true; |
| 2951 | /** @private @type {boolean} The state indicating if the file is a zipped file. Set at checkAllowedFile(). */ |
| 2952 | this.isZippedFile = false; |
| 2953 | /** @private @type {boolean} The state indicating if there was an error. Set at verifyData() after retrying analysis. */ |
| 2954 | this.isError = false; |
| 2955 | /** @private @type {boolean} The state indicating if fallback mode is active. For visualizerFallback, set at checkAllowedFile(). */ |
| 2956 | this.isFallback = false; |
| 2957 | /** @private @type {number} The number of audio channels in the current waveform data. */ |
| 2958 | this.currentChannels = 1; |
| 2959 | |
| 2960 | /** |
| 2961 | * The waveform bar analysis settings. |
| 2962 | * @typedef {object} waveformBarAnalysis |
| 2963 | * @property {string} binaryMode - The analysis mode: 'audioWizard' | 'visualizer'. |
| 2964 | * @property {number} resolution - The temporal resolution in points per second from 1-1000, recommended preset ranges are: |
| 2965 | * - 100 points/sec: Very High Details (10ms, for transient-heavy audio like EDM). |
| 2966 | * - 50 points/sec: High Details (20ms, for mastering and detailed visualization). |
| 2967 | * - 20 points/sec: Standard Details (50ms, matches FFmpeg/astats, ideal for broadcast). |
| 2968 | * - 15 points/sec: Balanced Details (~67ms, for smooth audio like pop or jazz). |
| 2969 | * - 10 points/sec: Low Details (100ms, for basic visualization). |
| 2970 | * - 5 points/sec: Very Low Details (200ms, for low-performance devices, very smooth audio). |
| 2971 | * - 1 points/sec: Minimum Details (1000ms, for ultra-minimal previews on very slow devices). |
| 2972 | * @property {number} timeout - The maximum duration for waveform analysis in milliseconds. |
| 2973 | * @property {string} compressionMode - The compression mode: 'none' | 'utf-8' | 'utf-16' | '7zip'. |
| 2974 | * @property {string} saveMode - The save behavior: 'always' | 'library' | 'never'. |
| 2975 | * @property {boolean} autoAnalysis - Whether to automatically analyze files. |
| 2976 | * @property {boolean} autoDelete - Whether to auto-delete analysis files when unloading the script. |
| 2977 | * @property {boolean} visualizerFallbackAnalysis - Whether to use visualizer mode when analyzing the file. |
| 2978 | * @property {boolean} visualizerFallback - Whether to use visualizer mode for incompatible file formats. |
| 2979 | * @public |
| 2980 | */ |
| 2981 | /** @public @type {waveformBarAnalysis} */ |
| 2982 | this.analysis = { |
| 2983 | binaryMode: grSet.waveformBarMode, |
| 2984 | resolution: grSet.waveformBarResolution, |
| 2985 | timeout: 60000, |
| 2986 | compressionMode: 'utf-16', |
| 2987 | saveMode: grSet.waveformBarSaveMode, |
| 2988 | autoAnalysis: true, |
| 2989 | autoDelete: grSet.waveformBarAutoDelete, |
| 2990 | visualizerFallbackAnalysis: grSet.waveformBarFallbackAnalysis, |
| 2991 | visualizerFallback: grSet.waveformBarFallback |
| 2992 | }; |
| 2993 | |
| 2994 | /** |
| 2995 | * The waveform bar binary settings. |
| 2996 | * @typedef {object} waveformBarBinaries |
| 2997 | * @property {string} visualizer - The visualizer binary to use. |
| 2998 | * @public |
| 2999 | */ |
| 3000 | /** @public @type {waveformBarBinaries} */ |
| 3001 | this.binaries = { |
| 3002 | audioWizard: Component.AudioWizard, |
| 3003 | visualizer: `${fb.ProfilePath}running` |
| 3004 | }; |
| 3005 | |
| 3006 | /** |
| 3007 | * The waveform bar compatible file settings. |
| 3008 | * @typedef {object} waveformBarCompatibility |
| 3009 | * @property {RegExp} audioWizard - The regular expression to test for file types compatible with audioWizard. |
| 3010 | * @public |
| 3011 | */ |
| 3012 | /** @private @type {waveformBarCompatibility} */ |
| 3013 | this.compatibleFiles = { |
| 3014 | audioWizardList: ['2sf', 'aa', 'aac', 'ac3', 'ac4', 'aiff', 'ape', 'cue', 'dff', 'dsf', 'dts', 'eac3', 'flac', 'hmi', 'iso', 'la', 'lpcm', 'm4a', 'minincsf', 'mp2', 'mp3', 'mp4', 'mpc', 'ogg', 'ogx', 'opus', 'ra', 'snd', 'shn', 'spc', 'tak', 'tta', 'vgm', 'wav', 'wma', 'wv'], |
| 3015 | audioWizard: null |
| 3016 | }; |
| 3017 | for (const key of ['audioWizard']) { |
| 3018 | this.compatibleFiles[key] = new RegExp(`\\.(${this.compatibleFiles[`${key}List`].join('|')})$`, 'i'); |
| 3019 | } |
| 3020 | |
| 3021 | /** |
| 3022 | * The waveform bar fallback mode settings for visualizerFallbackAnalysis. |
| 3023 | * @typedef {object} waveformBarFallbackMode |
| 3024 | * @property {boolean} paint - The state that indicates whether to use the paint fallback mode. |
| 3025 | * @property {boolean} analysis - The state that indicates whether to use the analysis fallback mode. |
| 3026 | * @public |
| 3027 | */ |
| 3028 | /** @private @type {waveformBarFallbackMode} */ |
| 3029 | this.fallbackMode = { |
| 3030 | paint: false, |
| 3031 | analysis: false |
| 3032 | }; |
| 3033 | |
| 3034 | /** |
| 3035 | * The waveform bar metrics configuration. |
| 3036 | * @typedef {object} waveformBarMetricsConfig |
| 3037 | * @property {number} count - The number of metrics per frame. |
| 3038 | * @property {object} index - The metric names to their frame indexes (e.g., rms: 0). |
| 3039 | * @property {object} range - The valid ranges for each metric (e.g., [-100, 0] for dB metrics). |
| 3040 | * @property {object} mode - The analysisMode values to metric names (e.g., rms: 'rms'). |
| 3041 | * @public |
| 3042 | */ |
| 3043 | /** @private @type {waveformBarMetricsConfig} */ |
| 3044 | this.metrics = { |
| 3045 | count: 5, |
| 3046 | index: { |
| 3047 | rms: 0, |
| 3048 | rms_peak: 1, |
| 3049 | sample_peak: 2, |
| 3050 | min: 3, |
| 3051 | max: 4 |
| 3052 | }, |
| 3053 | mode: { |
| 3054 | rms: 'rms', |
| 3055 | rms_peak: 'rms_peak', |
| 3056 | sample_peak: 'sample_peak', |
| 3057 | waveform: 'waveform' |
| 3058 | }, |
| 3059 | range: { |
| 3060 | rms: [-100, 0], |
| 3061 | rms_peak: [-100, 0], |
| 3062 | sample_peak: [-100, 0], |
| 3063 | min: [-1, 1], |
| 3064 | max: [-1, 1] |
| 3065 | } |
| 3066 | }; |
| 3067 | |
| 3068 | /** |
| 3069 | * The waveform bar preset settings. |
| 3070 | * @typedef {object} waveformBarPreset |
| 3071 | * @property {string} analysisMode - The waveform bar analysis mode `rms`, `rms_peak`, `sample_peak`, `waveform`. |
| 3072 | * @property {string} barDesign - The waveform bar design `waveform`, `bars`, `dots`, `halfbars`. |
| 3073 | * @property {string} paintMode - The waveform bar paint mode `full`, `partial`. |
| 3074 | * @property {boolean} animate - The flag to display animation. |
| 3075 | * @property {boolean} useBPM - The flag to use synced BPM. |
| 3076 | * @property {boolean} indicator - The flag to show waveform bar progress indicator. |
| 3077 | * @property {boolean} prepaint - The flag to prepaint waveform bar progress. |
| 3078 | * @property {number} prepaintFront - The prepaint waveform bar progress length. |
| 3079 | * @property {boolean} invertHalfbars - The flag to invert waveform bar halfbars. |
| 3080 | * @public |
| 3081 | */ |
| 3082 | /** @public @type {waveformBarPreset} */ |
| 3083 | this.preset = { |
| 3084 | analysisMode: grSet.waveformBarAnalysis, |
| 3085 | barDesign: grSet.waveformBarDesign, |
| 3086 | paintMode: grSet.waveformBarPaint, |
| 3087 | animate: grSet.waveformBarAnimate, |
| 3088 | useBPM: grSet.waveformBarBPM, |
| 3089 | indicator: grSet.waveformBarIndicator, |
| 3090 | prepaint: grSet.waveformBarPrepaint, |
| 3091 | prepaintFront: grSet.waveformBarPrepaintFront, |
| 3092 | invertHalfbars: grSet.waveformBarInvertHalfbars |
| 3093 | }; |
| 3094 | |
| 3095 | /** |
| 3096 | * The waveform bar ui settings. |
| 3097 | * @typedef {object} waveformBarUI |
| 3098 | * @property {number} sizeWave - The width size of drawn waveform. |
| 3099 | * @property {number} sizeBars - The width size of drawn bars. |
| 3100 | * @property {number} sizeDots - The width size of drawn dots. |
| 3101 | * @property {number} sizeHalf - The width size of drawn halfbars. |
| 3102 | * @property {number} sizeNormalizeWidth - The visualizer binary to use. |
| 3103 | * @property {number} refreshRate - The refresh rate in ms when using animations for any type. 100 is smooth enough but the performance hit is high. |
| 3104 | * @public |
| 3105 | */ |
| 3106 | /** @public @type {waveformBarUI} */ |
| 3107 | this.ui = { |
| 3108 | sizeWave: grSet.waveformBarSizeWave, |
| 3109 | sizeBars: grSet.waveformBarSizeBars, |
| 3110 | sizeDots: grSet.waveformBarSizeDots, |
| 3111 | sizeHalf: grSet.waveformBarSizeHalf, |
| 3112 | sizeNormalizeWidth: grSet.waveformBarSizeNormalize, |
| 3113 | refreshRate: grSet.waveformBarRefreshRate === 'variable' ? FPS._5 : grSet.waveformBarRefreshRate |
| 3114 | }; |
| 3115 | |
| 3116 | /** |
| 3117 | * The waveform bar wheel settings. |
| 3118 | * @typedef {object} waveformBarWheel |
| 3119 | * @property {number} seekSpeed - The mouse wheel seek type, 'seconds' or 'percentage. |
| 3120 | * @property {string} seekType - The mouse wheel seek speed. |
| 3121 | * @public |
| 3122 | */ |
| 3123 | /** @public @type {waveformBarWheel} */ |
| 3124 | this.wheel = { |
| 3125 | seekSpeed: grSet.waveformBarWheelSeekSpeed, |
| 3126 | seekType: grSet.waveformBarWheelSeekType |
| 3127 | }; |
| 3128 | |
| 3129 | // * Initialization |
| 3130 | this.checkConfig(); |
| 3131 | this.defaultSteps(); |
| 3132 | this.setThrottlePaint(); |
| 3133 | if (!IsFolder(this.cacheDir)) { CreateFolder(this.cacheDir); } |
| 3134 | } |
| 3135 | |
| 3136 | // * PUBLIC METHODS - DRAW * // |
| 3137 | // #region PUBLIC METHODS - DRAW |
| 3138 | /** |
| 3139 | * Draws the waveform bar with various designs based on the current settings. |
| 3140 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 3141 | */ |
| 3142 | draw(gr) { |
| 3143 | if (!fb.IsPlaying) { |
| 3144 | gr.FillSolidRect(this.x, grm.ui.seekbarY, this.w, SCALE(grSet.layout !== 'default' ? 10 : 12), grCol.progressBar); |
| 3145 | this.reset(); |
| 3146 | return; |
| 3147 | } |
| 3148 | |
| 3149 | if (this.current.length === 0) { |
| 3150 | this.drawBarInfo(gr); |
| 3151 | return; |
| 3152 | } |
| 3153 | |
| 3154 | if (grSet.waveformBarRefreshRate === 'variable' || grm.debug.showDrawExtendedTiming) { |
| 3155 | grm.ui.seekbarProfiler.Reset(); |
| 3156 | } |
| 3157 | |
| 3158 | // * Set shared properties |
| 3159 | /** @private @type {number} The time constant for the waveform bar calculation. */ |
| 3160 | this.timeConstant = fb.PlaybackLength / this.current.length; |
| 3161 | /** @private @type {number} The current X position based on playback time. */ |
| 3162 | this.currX = this.x + this.w * ((fb.PlaybackTime / fb.PlaybackLength) || 0); |
| 3163 | /** @private @type {number} The width of each bar in the waveform. */ |
| 3164 | this.barW = this.w / this.current.length; |
| 3165 | /** @private @type {boolean} The state whether prepaint mode is active. */ |
| 3166 | this.prepaint = this.preset.paintMode === 'partial' && this.preset.prepaint; |
| 3167 | /** @private @type {boolean} The state whether visualizer mode is active. */ |
| 3168 | this.visualizer = this.analysis.binaryMode === 'visualizer' || this.isFallback || this.fallbackMode.paint; |
| 3169 | |
| 3170 | const minPointDiff = 1; // in px |
| 3171 | const past = [{ x: 0, y: 1 }, { x: 0, y: -1 }]; |
| 3172 | let pastIndex = 0; |
| 3173 | |
| 3174 | gr.SetSmoothingMode(SmoothingMode.AntiAlias); |
| 3175 | |
| 3176 | for (let n = 0; n < this.current.length; n++) { |
| 3177 | const frame = this.current[n]; |
| 3178 | const current = this.timeConstant * n; |
| 3179 | const isPrepaintAllowed = (current - this.time) < this.preset.prepaintFront; |
| 3180 | |
| 3181 | /** @private @type {boolean} The state whether the current frame is in prepaint mode. */ |
| 3182 | this.isPrepaint = current > this.time; |
| 3183 | /** @private @type {number} The scaled size of the current frame. */ |
| 3184 | this.scaledSize = this.h / 2 * frame; |
| 3185 | /** @private @type {number} The x-position of the current frame. */ |
| 3186 | this.frameX = this.x + this.barW * n; |
| 3187 | |
| 3188 | // * Exit loop if prepaint mode conditions are met |
| 3189 | if (this.preset.paintMode === 'partial' && !this.prepaint && this.isPrepaint) break; |
| 3190 | if (this.prepaint && this.isPrepaint && !isPrepaintAllowed) break; |
| 3191 | if (!this.offset[n]) this.offset[n] = 0; |
| 3192 | |
| 3193 | // * Calculate offsets for prepainting and visualizer animation |
| 3194 | /** @private @type {number} The offset value for prepainting and visualizer animation. */ |
| 3195 | this.offset[n] += (this.prepaint && this.isPrepaint && this.preset.animate || this.visualizer ? // Add movement when pre-painting |
| 3196 | this.preset.barDesign === 'dots' ? Math.random() * Math.abs(this.step / this.maxStep) : |
| 3197 | -Math.sign(frame) * Math.random() * this.scaledSize / 10 * this.step / this.maxStep : 0); |
| 3198 | |
| 3199 | /** @private @type {number} The random offset value for the current frame. */ |
| 3200 | this.offsetRandom = this.preset.barDesign === 'dots' ? this.offset[n] : Math.sign(frame) * this.offset[n]; |
| 3201 | |
| 3202 | // * Draw the waveform bar |
| 3203 | if (past.every((p) => |
| 3204 | (p.y !== Math.sign(frame) && this.preset.barDesign !== 'halfbars') || |
| 3205 | (p.y === Math.sign(frame) || this.preset.barDesign === 'halfbars') && (this.frameX - p.x) >= minPointDiff)) { |
| 3206 | this.drawWaveformBar(gr); |
| 3207 | |
| 3208 | past[pastIndex] = { x: this.frameX, y: Math.sign(frame) }; |
| 3209 | pastIndex = (pastIndex + 1) % past.length; |
| 3210 | } |
| 3211 | } |
| 3212 | |
| 3213 | this.drawBarProgressLine(gr); |
| 3214 | this.drawBarAnimation(); |
| 3215 | } |
| 3216 | |
| 3217 | /** |
| 3218 | * Draws the waveform bar based on the preset design. |
| 3219 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 3220 | */ |
| 3221 | drawWaveformBar(gr) { |
| 3222 | const drawBarDesign = { |
| 3223 | waveform: () => this.drawBarDesignWaveform(gr), |
| 3224 | bars: () => this.drawBarDesignBars(gr), |
| 3225 | halfbars: () => this.drawBarDesignHalfbars(gr), |
| 3226 | dots: () => this.drawBarDesignDots(gr) |
| 3227 | }; |
| 3228 | |
| 3229 | drawBarDesign[this.preset.barDesign](); |
| 3230 | } |
| 3231 | |
| 3232 | /** |
| 3233 | * Draws the waveform bar in "waveform" design. |
| 3234 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 3235 | */ |
| 3236 | drawBarDesignWaveform(gr) { |
| 3237 | const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize + this.offsetRandom, 1) : Math.min(this.scaledSize + this.offsetRandom, -1); |
| 3238 | const zTop = this.visualizer ? Math.abs(yOffset) : yOffset; |
| 3239 | const zBottom = this.visualizer ? -Math.abs(yOffset) : yOffset; |
| 3240 | const { sizeWave } = this.ui; |
| 3241 | const { colorBack, colorFront, colorsDiffer } = this.getColors(); |
| 3242 | |
| 3243 | if (zTop > 0) { |
| 3244 | if (colorsDiffer) { |
| 3245 | gr.FillSolidRect(this.frameX, this.y - zTop, sizeWave, zTop / 2, colorBack); |
| 3246 | gr.FillSolidRect(this.frameX, this.y - zTop / 2, sizeWave, zTop / 2, colorFront); |
| 3247 | } else { |
| 3248 | gr.FillSolidRect(this.frameX, this.y - zTop, sizeWave, zTop, colorBack); |
| 3249 | } |
| 3250 | } |
| 3251 | |
| 3252 | if (zBottom < 0) { |
| 3253 | if (colorsDiffer) { |
| 3254 | gr.FillSolidRect(this.frameX, this.y - zBottom / 2, sizeWave, -zBottom / 2, colorBack); |
| 3255 | gr.FillSolidRect(this.frameX, this.y, sizeWave, -zBottom / 2, colorFront); |
| 3256 | } else { |
| 3257 | gr.FillSolidRect(this.frameX, this.y, sizeWave, -zBottom, colorBack); |
| 3258 | } |
| 3259 | } |
| 3260 | } |
| 3261 | |
| 3262 | /** |
| 3263 | * Draws the waveform bar in "bars" design. |
| 3264 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 3265 | */ |
| 3266 | drawBarDesignBars(gr) { |
| 3267 | const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize + this.offsetRandom, 1) : Math.min(this.scaledSize + this.offsetRandom, -1); |
| 3268 | const zTop = this.visualizer ? Math.abs(yOffset) : yOffset; |
| 3269 | const zBottom = this.visualizer ? -Math.abs(yOffset) : yOffset; |
| 3270 | const sizeBars = this.barW * this.ui.sizeBars; |
| 3271 | const { colorBack, colorFront, colorsDiffer } = this.getColors(true, true); |
| 3272 | |
| 3273 | if (zTop > 0) { |
| 3274 | if (colorsDiffer) { |
| 3275 | gr.DrawRect(this.frameX, this.y - zTop, sizeBars, zTop / 2, 1, colorBack); |
| 3276 | gr.DrawRect(this.frameX, this.y - zTop / 2, sizeBars, zTop / 2, 1, colorFront); |
| 3277 | } else { |
| 3278 | gr.DrawRect(this.frameX, this.y - zTop, sizeBars, zTop, 1, colorBack); |
| 3279 | } |
| 3280 | } |
| 3281 | |
| 3282 | if (zBottom < 0) { |
| 3283 | if (colorsDiffer) { |
| 3284 | gr.DrawRect(this.frameX, this.y - zBottom / 2, sizeBars, -zBottom / 2, 1, colorBack); |
| 3285 | gr.DrawRect(this.frameX, this.y, sizeBars, -zBottom / 2, 1, colorFront); |
| 3286 | } else { |
| 3287 | gr.DrawRect(this.frameX, this.y, sizeBars, -zBottom, 1, colorBack); |
| 3288 | } |
| 3289 | } |
| 3290 | } |
| 3291 | |
| 3292 | /** |
| 3293 | * Draws the waveform bar in "halfbars" design. |
| 3294 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 3295 | */ |
| 3296 | drawBarDesignHalfbars(gr) { |
| 3297 | const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize + this.offsetRandom, 1) : Math.min(this.scaledSize + this.offsetRandom, -1); |
| 3298 | const y = this.preset.invertHalfbars ? Math.abs(yOffset) : yOffset; |
| 3299 | const sizeHalf = this.visualizer ? this.barW * this.ui.sizeHalf * (this.visualizer ? 0.2 : 0.5) : this.ui.sizeHalf; |
| 3300 | const { colorBack, colorFront, colorsDiffer } = this.getColors(false, true); |
| 3301 | |
| 3302 | if (y > 0) { |
| 3303 | if (colorsDiffer) { |
| 3304 | gr.FillSolidRect(this.frameX, this.y - 2 * y + this.h * 0.5, sizeHalf, y, colorBack); |
| 3305 | gr.FillSolidRect(this.frameX, this.y - y + this.h * 0.5, sizeHalf, y, colorFront); |
| 3306 | } else { |
| 3307 | gr.FillSolidRect(this.frameX, this.y - 2 * y + this.h * 0.5, sizeHalf, 2 * y, colorBack); |
| 3308 | } |
| 3309 | } |
| 3310 | } |
| 3311 | |
| 3312 | /** |
| 3313 | * Draws the waveform bar in "dots" design. |
| 3314 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 3315 | */ |
| 3316 | drawBarDesignDots(gr) { |
| 3317 | const yOffset = this.scaledSize > 0 ? Math.max(this.scaledSize, 1) : Math.min(this.scaledSize, -1); |
| 3318 | const dotStep = Math.max(this.h / 80, 5) + (this.offsetRandom || 1); |
| 3319 | const dotSize = Math.max(dotStep / 25, 1) * this.ui.sizeDots; |
| 3320 | const { colorBack, colorFront } = this.getColors(); |
| 3321 | |
| 3322 | const drawDots = (direction, startY, yOffset, color1, color2) => { |
| 3323 | const sign = this.visualizer ? direction : Math.sign(yOffset); |
| 3324 | const step = direction * yOffset / 2; |
| 3325 | let currentY = startY; |
| 3326 | |
| 3327 | for (const endY = startY - step; sign * (currentY - endY) > 0; currentY -= sign * dotStep) { |
| 3328 | gr.DrawEllipse(this.frameX, currentY, dotSize, dotSize, 1, color1); |
| 3329 | } |
| 3330 | |
| 3331 | for (const endY = startY - 2 * step; sign * (currentY - endY) > 0; currentY -= sign * dotStep) { |
| 3332 | gr.DrawEllipse(this.frameX, currentY, dotSize, dotSize, 1, color2); |
| 3333 | } |
| 3334 | }; |
| 3335 | |
| 3336 | drawDots(1, this.y, yOffset, colorFront, colorBack); |
| 3337 | if (!this.visualizer) return; |
| 3338 | drawDots(-1, this.y, yOffset, colorFront, colorBack); |
| 3339 | } |
| 3340 | |
| 3341 | /** |
| 3342 | * Draws the progress line on the waveform bar. |
| 3343 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 3344 | */ |
| 3345 | drawBarProgressLine(gr) { |
| 3346 | if (!this.preset.indicator && !this.mouseDown) return; |
| 3347 | |
| 3348 | gr.SetSmoothingMode(0); |
| 3349 | |
| 3350 | if (this.analysis.binaryMode === 'audioWizard' || ['waveform', 'dots'].includes(this.preset.barDesign)) { |
| 3351 | const minBarW = Math.round(Math.max(this.barW, SCALE(1))); |
| 3352 | gr.DrawLine(this.currX, this.y - this.h * 0.5, this.currX, this.y + this.h * 0.5, minBarW, grCol.waveformBarIndicator); |
| 3353 | } |
| 3354 | } |
| 3355 | |
| 3356 | /** |
| 3357 | * Draws information text when waveform data is loading or when it is not available. |
| 3358 | * @param {GdiGraphics} gr - The GDI graphics object. |
| 3359 | */ |
| 3360 | drawBarInfo(gr) { |
| 3361 | if (pl.col.row_nowplaying_bg === null) return; // * Wait until nowplaying bg has a new color to prevent flashing |
| 3362 | |
| 3363 | const DT_CENTER = DrawText.VCenter | DrawText.Center | DrawText.EndEllipsis | DrawText.CalcRect | DrawText.NoPrefix; |
| 3364 | const bgColor = grSet.theme === 'reborn' ? pl.col.row_nowplaying_bg : grCol.transportEllipseBg; |
| 3365 | const message = |
| 3366 | !this.isAllowedFile && !this.isFallback && this.analysis.binaryMode !== 'visualizer' ? 'Incompatible file format' : |
| 3367 | !this.analysis.autoAnalysis ? 'Waveform bar file not found' : |
| 3368 | this.isError ? 'Waveform bar file can not be analyzed' : |
| 3369 | this.active ? 'Loading' : ''; |
| 3370 | |
| 3371 | gr.FillSolidRect(this.x, this.y - this.h * 0.5, this.w, this.h, bgColor); |
| 3372 | gr.GdiDrawText(message, grFont.lowerBarWave, pl.col.header_artist_normal, this.x, this.y - this.h * 0.5, this.w, this.h, DT_CENTER); |
| 3373 | } |
| 3374 | |
| 3375 | /** |
| 3376 | * Draw the waveform bar animation. |
| 3377 | */ |
| 3378 | drawBarAnimation() { |
| 3379 | if (this.prepaint && this.preset.animate || this.visualizer) { |
| 3380 | if (this.step >= this.maxStep) { |
| 3381 | this.step = -this.step; |
| 3382 | } else { |
| 3383 | if (this.step === 0) { this.offset = []; } |
| 3384 | this.step++; |
| 3385 | } |
| 3386 | } |
| 3387 | |
| 3388 | if (fb.IsPlaying && !fb.IsPaused) { |
| 3389 | this.setRefreshRate(); |
| 3390 | |
| 3391 | if (this.visualizer) { |
| 3392 | this.throttlePaint(); |
| 3393 | } |
| 3394 | else if (this.current.length && (this.prepaint || this.preset.paintMode === 'partial' || this.preset.indicator)) { |
| 3395 | const paintRect = this.setPaintRect(this.time); |
| 3396 | this.throttlePaintRect(paintRect.x, paintRect.y, paintRect.width, paintRect.height); |
| 3397 | } |
| 3398 | } |
| 3399 | } |
| 3400 | // #endregion |
| 3401 | |
| 3402 | // * PUBLIC METHODS - INITALIZATION * // |
| 3403 | // #region PUBLIC METHODS - INITALIZATION |
| 3404 | /** |
| 3405 | * Checks if the current file is allowed to be played, i.e not corrupted. |
| 3406 | * @param {object} handle - The current file handle. |
| 3407 | */ |
| 3408 | checkAllowedFile(handle = fb.GetNowPlaying()) { |
| 3409 | if (!handle) return; |
| 3410 | |
| 3411 | const noVisual = this.analysis.binaryMode !== 'visualizer'; |
| 3412 | const validExt = this.checkCompatibleFileExtension(handle); |
| 3413 | |
| 3414 | this.isZippedFile = handle.RawPath.includes('unpack://'); |
| 3415 | this.isAllowedFile = noVisual && validExt && !this.isZippedFile; |
| 3416 | this.isFallback = !this.isAllowedFile && this.analysis.visualizerFallback; |
| 3417 | } |
| 3418 | |
| 3419 | /** |
| 3420 | * Checks if the file extension of the current file handle is compatible. |
| 3421 | * @param {object} handle - The current file handle. |
| 3422 | * @param {string} mode - The analysis binary mode. |
| 3423 | * @returns {boolean} True if the file extension is compatible, otherwise false. |
| 3424 | */ |
| 3425 | checkCompatibleFileExtension(handle = fb.GetNowPlaying(), mode = this.analysis.binaryMode) { |
| 3426 | return (mode === 'visualizer') || (handle && this.compatibleFiles[mode].test(handle.Path)); |
| 3427 | } |
| 3428 | |
| 3429 | /** |
| 3430 | * Checks the report list of compatible file extensions for the given mode. |
| 3431 | * @param {string} mode - The analysis binary mode. |
| 3432 | * @returns {Array<string>} An array of compatible file extensions. |
| 3433 | */ |
| 3434 | checkCompatibleFileExtensionReport(mode = this.analysis.binaryMode) { |
| 3435 | return [...this.compatibleFiles[`${mode}List`]]; |
| 3436 | } |
| 3437 | |
| 3438 | /** |
| 3439 | * Checks the configuration for validity, called from the constructor. |
| 3440 | */ |
| 3441 | checkConfig() { |
| 3442 | if (!Object.prototype.hasOwnProperty.call(this.binaries, this.analysis.binaryMode)) { |
| 3443 | this.analysis.binaryMode = 'visualizer'; |
| 3444 | } |
| 3445 | if (!this.binaries[this.analysis.binaryMode]) { |
| 3446 | fb.ShowPopupMessage(`Waveform bar => required dependency not found: ${this.analysis.binaryMode}\n\n${JSON.stringify(this.binaries[this.analysis.binaryMode])}`, window.Name); |
| 3447 | } |
| 3448 | |
| 3449 | if (this.preset.prepaintFront <= 0 || this.preset.prepaintFront === null) { |
| 3450 | this.preset.prepaintFront = Infinity; |
| 3451 | } |
| 3452 | |
| 3453 | if (this.wheel.seekSpeed < 0) { |
| 3454 | this.wheel.seekSpeed = 1; |
| 3455 | } else if (this.wheel.seekSpeed > 100 && this.wheel.seekType === 'percentage') { |
| 3456 | this.wheel.seekSpeed = 100; |
| 3457 | } |
| 3458 | } |
| 3459 | |
| 3460 | /** |
| 3461 | * Updates the config and ensures the UI is being updated properly after changing settings. |
| 3462 | * @param {object} newConfig - The new configuration object with settings to be applied. |
| 3463 | */ |
| 3464 | updateConfig(newConfig) { |
| 3465 | if (newConfig) { |
| 3466 | DeepAssign()(this, newConfig); |
| 3467 | } |
| 3468 | |
| 3469 | this.checkConfig(); |
| 3470 | let recalculate = false; |
| 3471 | |
| 3472 | if (newConfig.preset) { |
| 3473 | if (this.preset.paintMode === 'partial' && this.preset.prepaint || this.analysis.binaryMode === 'visualizer') { |
| 3474 | this.offset = []; |
| 3475 | this.step = 0; |
| 3476 | } |
| 3477 | if (Object.prototype.hasOwnProperty.call(newConfig.preset, 'animate') || |
| 3478 | Object.prototype.hasOwnProperty.call(newConfig.preset, 'useBPM')) { |
| 3479 | if (this.preset.animate && this.preset.useBPM) { |
| 3480 | this.bpmSteps(); |
| 3481 | } else { |
| 3482 | this.defaultSteps(); |
| 3483 | } |
| 3484 | } |
| 3485 | } |
| 3486 | |
| 3487 | if (newConfig.ui) { |
| 3488 | if (Object.prototype.hasOwnProperty.call(newConfig.ui, 'refreshRate')) { |
| 3489 | this.setThrottlePaint(); |
| 3490 | } |
| 3491 | if (Object.prototype.hasOwnProperty.call(newConfig.ui, 'sizeNormalizeWidth') || |
| 3492 | Object.prototype.hasOwnProperty.call(newConfig.ui, 'normalizeWidth')) { |
| 3493 | recalculate = true; |
| 3494 | } |
| 3495 | } |
| 3496 | |
| 3497 | if (newConfig.analysis) { |
| 3498 | recalculate = true; |
| 3499 | } |
| 3500 | |
| 3501 | if (recalculate) { |
| 3502 | this.on_playback_new_track(); |
| 3503 | } else { |
| 3504 | this.throttlePaint(); |
| 3505 | } |
| 3506 | } |
| 3507 | // #endregion |
| 3508 | |
| 3509 | // * PUBLIC METHODS - DATA * // |
| 3510 | // #region PUBLIC METHODS - DATA |
| 3511 | /** |
| 3512 | * Starts the analysis process of the waveform data and updates the current state. |
| 3513 | * @param {FbMetadbHandle} handle - The handle of the current track. |
| 3514 | * @param {boolean} isRetry - The flag indicating whether the method call is a retry attempt. |
| 3515 | * @returns {Promise<boolean>} The promise that resolves to `true` if analysis is successful, `false` otherwise. |
| 3516 | */ |
| 3517 | async analyzeDataStart(handle, isRetry) { |
| 3518 | if (this.analysis.binaryMode === 'visualizer' || this.analysis.visualizerFallbackAnalysis) { |
| 3519 | this.current = this.visualizerData(handle); |
| 3520 | |
| 3521 | if (this.analysis.binaryMode === 'visualizer') { |
| 3522 | this.normalizePoints(); |
| 3523 | return; |
| 3524 | } |
| 3525 | } |
| 3526 | |
| 3527 | const { waveformBarFolder, waveformBarFile, sourceFile } = this.getPaths(handle); |
| 3528 | const files = this.getFileConfigs(); |
| 3529 | let analysisComplete = false; |
| 3530 | |
| 3531 | for (const file of files) { |
| 3532 | const fileWithExt = `${waveformBarFile}${file.ext}`; |
| 3533 | if (IsFile(fileWithExt)) { |
| 3534 | const str = Open(fileWithExt, file.codePage) || ''; |
| 3535 | const parsed = file.decompress(str) || {}; |
| 3536 | |
| 3537 | if (parsed.data && Array.isArray(parsed.data)) { |
| 3538 | this.current = parsed.data; |
| 3539 | this.currentChannels = parsed.channels || 1; |
| 3540 | } else { |
| 3541 | this.current = Array.isArray(parsed) ? parsed : []; |
| 3542 | this.currentChannels = 1; |
| 3543 | } |
| 3544 | |
| 3545 | if (this.verifyData(handle, fileWithExt, isRetry)) { |
| 3546 | analysisComplete = true; |
| 3547 | break; |
| 3548 | } |
| 3549 | } |
| 3550 | } |
| 3551 | |
| 3552 | if (!analysisComplete && this.analysis.autoAnalysis && IsFile(sourceFile)) { |
| 3553 | if (this.analysis.visualizerFallbackAnalysis && this.isAllowedFile) { |
| 3554 | this.fallbackMode.analysis = this.fallbackMode.paint = true; |
| 3555 | this.normalizePoints(); |
| 3556 | if (this.preset.animate && this.preset.useBPM) this.bpmSteps(handle); |
| 3557 | if (fb.IsPlaying) this.time = fb.PlaybackTime; |
| 3558 | } |
| 3559 | |
| 3560 | this.throttlePaint(true); |
| 3561 | if (this.analysis.visualizerFallbackAnalysis) { |
| 3562 | this.fallbackMode.analysis = false; |
| 3563 | } |
| 3564 | |
| 3565 | await this.analyzeData(handle, waveformBarFolder, waveformBarFile, sourceFile); |
| 3566 | this.fallbackMode.analysis = this.fallbackMode.paint = false; |
| 3567 | analysisComplete = this.verifyData(handle, undefined, isRetry); |
| 3568 | } |
| 3569 | |
| 3570 | this.isFallback = !analysisComplete; |
| 3571 | this.normalizePoints(this.analysis.binaryMode !== 'visualizer' && this.ui.sizeNormalizeWidth); |
| 3572 | } |
| 3573 | |
| 3574 | /** |
| 3575 | * Analyzes data of the given handle(s) and saves the results in the waveform bar cache directory. |
| 3576 | * @param {FbMetadbHandle|FbMetadbHandleList} handle - The handle(s) to analyze. |
| 3577 | * @param {string} waveformBarFolder - The folder where the waveform bar data should be saved. |
| 3578 | * @param {string} waveformBarFile - The name of the waveform bar file. |
| 3579 | * @param {string} [sourceFile] - The path of the source file. |
| 3580 | * @returns {Promise<void>} The promise that resolves when the analysis has finished. |
| 3581 | */ |
| 3582 | async analyzeData(handle, waveformBarFolder, waveformBarFile, sourceFile = handle.Path || handle[0].Path) { |
| 3583 | if (!this.isAllowedFile || !AudioWizard || AudioWizard.FullTrackProcessing) return; |
| 3584 | |
| 3585 | if (!IsFolder(waveformBarFolder)) { |
| 3586 | CreateFolder(waveformBarFolder); |
| 3587 | } |
| 3588 | |
| 3589 | try { |
| 3590 | const handleList = (handle instanceof FbMetadbHandleList) ? handle : |
| 3591 | new FbMetadbHandleList(Array.isArray(handle) ? handle : [handle] |
| 3592 | ); |
| 3593 | const startTime = Date.now(); |
| 3594 | |
| 3595 | grm.debug.debugLog(`Audio Wizard => Starting waveform analysis: mode=${this.preset.analysisMode}, resolution=${this.analysis.resolution}`); |
| 3596 | |
| 3597 | const success = await new Promise((resolve) => { |
| 3598 | const { metadata } = GetMetadata(handleList); |
| 3599 | AudioWizard.SetFullTrackWaveformCallback((res) => resolve(res)); |
| 3600 | AudioWizard.StartWaveformAnalysis(metadata, this.analysis.resolution); |
| 3601 | }); |
| 3602 | |
| 3603 | if (!success) { |
| 3604 | console.log('Audio Wizard => Waveform analysis failed - API returned error'); |
| 3605 | return; |
| 3606 | } |
| 3607 | |
| 3608 | const metricsPerChannel = 5; |
| 3609 | const trackCount = AudioWizard.GetWaveformTrackCount(); |
| 3610 | |
| 3611 | for (let i = 0; i < trackCount; i++) { |
| 3612 | const trackHandle = handleList[i]; |
| 3613 | const data = []; |
| 3614 | const rawData = AudioWizard.GetWaveformData(i); |
| 3615 | const channels = AudioWizard.GetWaveformTrackChannels(i); |
| 3616 | const stepSize = metricsPerChannel * channels; |
| 3617 | |
| 3618 | // Restructure flat array into array of arrays (one array per time step) |
| 3619 | for (let j = 0; j < rawData.length; j += stepSize) { |
| 3620 | const pointSlice = rawData.slice(j, j + stepSize); |
| 3621 | const roundedPoint = pointSlice.map(v => Math.round(v * 1000) / 1000); |
| 3622 | data.push(roundedPoint); |
| 3623 | } |
| 3624 | |
| 3625 | if (this.saveDataAllowed(trackHandle)) { |
| 3626 | this.analyzeDataSave(waveformBarFile, JSON.stringify({ channels, data })); |
| 3627 | } |
| 3628 | |
| 3629 | if (handleList.Count === 1) { |
| 3630 | this.current = data; |
| 3631 | this.currentChannels = channels; // Store for normalizePoints |
| 3632 | } |
| 3633 | } |
| 3634 | |
| 3635 | grm.debug.debugLog(`Audio Wizard => Analysis completed in ${(Date.now() - startTime) / 1000} seconds`); |
| 3636 | this.throttlePaint(); |
| 3637 | } |
| 3638 | catch (e) { |
| 3639 | console.log(`Audio Wizard => Analysis error: ${e.message}`); |
| 3640 | AudioWizard.StopWaveformAnalysis(); |
| 3641 | } |
| 3642 | } |
| 3643 | |
| 3644 | /** |
| 3645 | * Saves the compressed data to a file. |
| 3646 | * @param {string} waveformBarFile - The name of the waveform bar file. |
| 3647 | * @param {string} dataStr - The data to be saved. |
| 3648 | */ |
| 3649 | analyzeDataSave(waveformBarFile, dataStr) { |
| 3650 | if (this.analysis.binaryMode === 'visualizer') return; |
| 3651 | |
| 3652 | const compression = { |
| 3653 | 'utf-16': () => SaveFSO(`${waveformBarFile}.awz.lz16`, LZString.compressToUTF16(dataStr), true), |
| 3654 | 'utf-8': () => Save(`${waveformBarFile}.awz.lz`, LZUTF8.compress(dataStr, { outputEncoding: 'Base64' })), |
| 3655 | 'none': () => Save(`${waveformBarFile}.awz.json`, dataStr) |
| 3656 | }; |
| 3657 | |
| 3658 | (compression[this.analysis.compressionMode] || compression.none)(); |
| 3659 | } |
| 3660 | |
| 3661 | /** |
| 3662 | * Generates data for the visualizer. |
| 3663 | * @param {FbMetadbHandle} handle - The handle to analyze. |
| 3664 | * @param {string} preset - The preset to use for the visualizer. |
| 3665 | * @param {boolean} variableLen - The flag whether the length of the data should be variable. |
| 3666 | * @returns {Array} The data for the visualizer bar. |
| 3667 | */ |
| 3668 | visualizerData(handle, preset = 'classic spectrum analyzer', variableLen = false) { |
| 3669 | const barW = this.getBarWidth(); |
| 3670 | const samplesMax = Math.floor(this.w / barW); |
| 3671 | const samplesTotal = Math.floor(handle.Length * this.analysis.resolution); |
| 3672 | const samples = variableLen ? samplesTotal : Math.min(samplesMax, samplesTotal); |
| 3673 | const data = new Array(samples); |
| 3674 | |
| 3675 | if (preset === 'classic spectrum analyzer') { |
| 3676 | const third = Math.round(samples / 3); |
| 3677 | const half = Math.round(samples / 2); |
| 3678 | |
| 3679 | // * Filling first half |
| 3680 | for (let i = 0; i < third; i++) { |
| 3681 | const val = (Math.random() * i) / third; |
| 3682 | data[i] = val; |
| 3683 | } |
| 3684 | for (let i = third; i < half; i++) { |
| 3685 | const val = (Math.random() * i) / third; |
| 3686 | data[i] = val; |
| 3687 | } |
| 3688 | // * Filling second half with reversed first half |
| 3689 | for (let i = half, j = 0; i < samples; i++, j++) { |
| 3690 | data[i] = data[half - 1 - j]; |
| 3691 | } |
| 3692 | } |
| 3693 | |
| 3694 | return data; |
| 3695 | } |
| 3696 | |
| 3697 | /** |
| 3698 | * Checks if the processed waveform data is valid for audioWizard mode. |
| 3699 | * @returns {boolean} True if the data is valid. |
| 3700 | */ |
| 3701 | validData() { |
| 3702 | if (!Array.isArray(this.current) || !this.current.length) { |
| 3703 | return false; |
| 3704 | } |
| 3705 | |
| 3706 | const channels = this.currentChannels || 1; |
| 3707 | const expectedLength = this.metrics.count * channels; |
| 3708 | |
| 3709 | return this.current.every(frame => { |
| 3710 | if (!Array.isArray(frame) || frame.length < expectedLength) { |
| 3711 | return false; |
| 3712 | } |
| 3713 | |
| 3714 | // Validate metrics for each channel |
| 3715 | for (let ch = 0; ch < channels; ch++) { |
| 3716 | const offset = ch * this.metrics.count; |
| 3717 | |
| 3718 | for (const [metric, index] of Object.entries(this.metrics.index)) { |
| 3719 | const value = frame[offset + index]; |
| 3720 | if (typeof value !== 'number' || !isFinite(value)) { |
| 3721 | return false; |
| 3722 | } |
| 3723 | const [min, max] = this.metrics.range[metric]; |
| 3724 | if (value < min || value > max) { |
| 3725 | return false; |
| 3726 | } |
| 3727 | } |
| 3728 | } |
| 3729 | |
| 3730 | return true; |
| 3731 | }); |
| 3732 | } |
| 3733 | |
| 3734 | /** |
| 3735 | * Verifies if the processed data is valid. |
| 3736 | * @param {FbMetadbHandle} handle - The handle to analyze. |
| 3737 | * @param {string} file - The file to analyze. |
| 3738 | * @param {boolean} isRetry - The flag whether the data should be retried. |
| 3739 | * @returns {boolean} True if the data is valid. |
| 3740 | */ |
| 3741 | verifyData(handle, file, isRetry = false) { |
| 3742 | if (this.validData()) return true; |
| 3743 | |
| 3744 | if (file) DeleteFile(file); |
| 3745 | |
| 3746 | if (isRetry) { |
| 3747 | console.log('File was not successfully analyzed after retrying.'); |
| 3748 | this.isAllowedFile = false; |
| 3749 | this.isFallback = this.analysis.visualizerFallback; |
| 3750 | this.isError = true; |
| 3751 | this.current = []; |
| 3752 | } else { |
| 3753 | console.log(`Waveform bar file not valid. Creating new one${file ? `: ${file}` : '.'}`); |
| 3754 | this.on_playback_new_track(handle, true); |
| 3755 | } |
| 3756 | |
| 3757 | return false; |
| 3758 | } |
| 3759 | |
| 3760 | /** |
| 3761 | * Deletes the waveform file(s) associated with the given track handle. |
| 3762 | * @param {FbMetadbHandle} handle - The handle of the track. |
| 3763 | */ |
| 3764 | deleteWaveformFile(handle) { |
| 3765 | if (!handle) return; |
| 3766 | |
| 3767 | const { waveformBarFile } = this.getPaths(handle); |
| 3768 | const fileConfigs = this.getFileConfigs(); |
| 3769 | |
| 3770 | for (const config of fileConfigs) { |
| 3771 | const filePath = `${waveformBarFile}${config.ext}`; |
| 3772 | if (IsFile(filePath)) { |
| 3773 | try { |
| 3774 | DeleteFile(filePath); |
| 3775 | } catch (e) { |
| 3776 | console.log(`Error deleting waveform file: ${filePath}`, e); |
| 3777 | } |
| 3778 | } |
| 3779 | } |
| 3780 | } |
| 3781 | |
| 3782 | /** |
| 3783 | * Deletes the waveform bar cache directory with its processed data. |
| 3784 | */ |
| 3785 | removeData() { |
| 3786 | DeleteFolder(this.cacheDir); |
| 3787 | } |
| 3788 | |
| 3789 | /** |
| 3790 | * Determines whether data should be saved based on the current analysis save mode and the handle. |
| 3791 | * @param {FbMetadbHandle} handle - The handle to check against the save mode and media library. |
| 3792 | * @returns {boolean} - Returns `true` if the data should be saved, `false` otherwise. |
| 3793 | */ |
| 3794 | saveDataAllowed(handle) { |
| 3795 | return this.analysis.saveMode === 'always' || (this.analysis.saveMode === 'library' && handle && fb.IsMetadbInMediaLibrary(handle)); |
| 3796 | } |
| 3797 | // #endregion |
| 3798 | |
| 3799 | // * PUBLIC METHODS - COMMON * // |
| 3800 | // #region PUBLIC METHODS - COMMON |
| 3801 | /** |
| 3802 | * Sets the max step based on the BPM of the track. |
| 3803 | * @param {object} handle - The handle of the track. |
| 3804 | * @returns {number} The max steps. |
| 3805 | */ |
| 3806 | bpmSteps(handle = fb.GetNowPlaying()) { |
| 3807 | if (!handle) return this.defaultSteps(); |
| 3808 | |
| 3809 | // Don't allow anything faster than 2 steps or slower than 10 (scaled to 200 ms refresh rate) and consider setting tracks having 100 BPM as default. |
| 3810 | const BPM = Number(this.TfMaxStep.EvalWithMetadb(handle)); |
| 3811 | this.maxStep = Math.round(Math.min(Math.max(200 / (BPM || 100) * 2, 2), 10) * (200 / this.ui.refreshRate) ** (1 / 2)); |
| 3812 | |
| 3813 | return this.maxStep; |
| 3814 | } |
| 3815 | |
| 3816 | /** |
| 3817 | * Sets the max step to a default value. |
| 3818 | * @returns {number} The max steps. |
| 3819 | */ |
| 3820 | defaultSteps() { |
| 3821 | this.maxStep = Math.round(4 * (200 / this.ui.refreshRate) ** (1 / 2)); |
| 3822 | return this.maxStep; |
| 3823 | } |
| 3824 | |
| 3825 | /** |
| 3826 | * Gets the bar width based on the bar design preset. |
| 3827 | * @returns {number} The width of the bar corresponding to the design preset. |
| 3828 | */ |
| 3829 | getBarWidth() { |
| 3830 | const barWidth = { |
| 3831 | waveform: this.ui.sizeWave, |
| 3832 | bars: this.ui.sizeBars, |
| 3833 | dots: this.ui.sizeDots, |
| 3834 | halfbars: this.ui.sizeHalf |
| 3835 | }; |
| 3836 | |
| 3837 | return barWidth[this.preset.barDesign] || 1; |
| 3838 | } |
| 3839 | |
| 3840 | /** |
| 3841 | * Gets the colors for the waveform bars. |
| 3842 | * @param {boolean} useShadeColor - The flag indicating whether to use the ShadeColor for adjustments. |
| 3843 | * @param {boolean} highlightCurrentPosition - The flag indicating whether to highlight the current position indicator. |
| 3844 | * @returns {object} The object containing colorBack, colorFront and colorsDiffer. |
| 3845 | */ |
| 3846 | getColors(useShadeColor = true, highlightCurrentPosition = false) { |
| 3847 | if (highlightCurrentPosition && (this.preset.indicator || this.mouseDown) && this.analysis.binaryMode === 'audioWizard' && |
| 3848 | (this.frameX <= this.currX && this.frameX >= this.currX - 2 * this.barW)) { |
| 3849 | return { colorBack: grCol.waveformBarIndicator, colorFront: grCol.waveformBarIndicator, colorsDiffer: false }; |
| 3850 | } |
| 3851 | |
| 3852 | const colorBack = this.prepaint && this.isPrepaint ? |
| 3853 | useShadeColor ? ShadeColor(grCol.waveformBarFillBack, 40) : grCol.waveformBarFillPreBack : |
| 3854 | grCol.waveformBarFillBack; |
| 3855 | |
| 3856 | const colorFront = this.prepaint && this.isPrepaint ? |
| 3857 | useShadeColor ? ShadeColor(grCol.waveformBarFillFront, 20) : grCol.waveformBarFillPreFront : |
| 3858 | grCol.waveformBarFillFront; |
| 3859 | |
| 3860 | return { colorBack, colorFront, colorsDiffer: colorFront !== colorBack }; |
| 3861 | } |
| 3862 | |
| 3863 | /** |
| 3864 | * Gets the configuration for the different file types to be analyzed. |
| 3865 | * @returns {Array<object>} An array of file configuration objects. Each object contains: |
| 3866 | * - {string} ext - The file extension. |
| 3867 | * - {Function} decompress - The function to decompress and parse the file content. |
| 3868 | * - {string} codePage - The code page to be used when reading the file. |
| 3869 | */ |
| 3870 | getFileConfigs() { |
| 3871 | return [ |
| 3872 | { ext: '.awz.json', decompress: JSON.parse, codePage: this.codePage }, |
| 3873 | { ext: '.awz.lz', decompress: str => JSON.parse(LZUTF8.decompress(str, { inputEncoding: 'Base64' })), codePage: this.codePage }, |
| 3874 | { ext: '.awz.lz16', decompress: str => JSON.parse(LZString.decompressFromUTF16(str)), codePage: this.codePageV2 } |
| 3875 | ]; |
| 3876 | } |
| 3877 | |
| 3878 | /** |
| 3879 | * Gets the paths to the waveform bar cache folder and file. |
| 3880 | * @param {object} handle - The handle of the track. |
| 3881 | * @returns {object} The paths to the waveform bar cache folder and file. |
| 3882 | */ |
| 3883 | getPaths(handle) { |
| 3884 | const id = CleanFilePath(this.Tf.EvalWithMetadb(handle)); // Ensures paths are valid! |
| 3885 | const fileName = id.split('\\').pop(); |
| 3886 | const waveformBarFolder = this.cacheDir + (this.saveDataAllowed(handle) ? id.replace(fileName, '') : ''); |
| 3887 | const waveformBarFile = this.cacheDir + id; |
| 3888 | const sourceFile = this.isZippedFile ? handle.Path.split('|')[0] : handle.Path; |
| 3889 | |
| 3890 | return { waveformBarFolder, waveformBarFile, sourceFile }; |
| 3891 | } |
| 3892 | |
| 3893 | /** |
| 3894 | * Gets the maximum and minimum values from the frames. |
| 3895 | * @param {number[]} frames - The array of frame values. |
| 3896 | * @returns {object} The object containing the `upper` and `lower` values. |
| 3897 | */ |
| 3898 | getMaxValue(frames) { |
| 3899 | let upper = 0; |
| 3900 | let lower = 0; |
| 3901 | |
| 3902 | for (let i = 0; i < frames.length; i++) { |
| 3903 | const frame = frames[i]; |
| 3904 | upper = Math.max(upper, frame); |
| 3905 | lower = Math.min(lower, frame); |
| 3906 | } |
| 3907 | |
| 3908 | return { upper, lower }; |
| 3909 | } |
| 3910 | |
| 3911 | /** |
| 3912 | * Gets the minimum value at a specific position in the frames. |
| 3913 | * @param {Array} frames - The array of frame data. |
| 3914 | * @param {number} pos - The position index in the frame data. |
| 3915 | * @returns {number} The minimum value at the specified position. |
| 3916 | */ |
| 3917 | getMinValuePos(frames, pos) { |
| 3918 | let minVal = Infinity; |
| 3919 | |
| 3920 | for (let i = 0; i < frames.length; i++) { |
| 3921 | const frame = frames[i]; |
| 3922 | if (frame[pos] === null) frame[pos] = -Infinity; |
| 3923 | const val = frame[pos]; |
| 3924 | if (isFinite(val)) { |
| 3925 | minVal = Math.min(minVal, val); |
| 3926 | } |
| 3927 | } |
| 3928 | |
| 3929 | return minVal === Infinity ? 0 : minVal; |
| 3930 | } |
| 3931 | |
| 3932 | /** |
| 3933 | * Gets the Normalized frame values by subtracting the maximum value from each frame. |
| 3934 | * @param {Array} frames - The array of frame data. |
| 3935 | * @param {number} maxVal - The maximum value to be subtracted from each frame. |
| 3936 | * @returns {Array} The normalized frame data. |
| 3937 | */ |
| 3938 | getNormalizedFrameValues(frames, maxVal) { |
| 3939 | const normalizedFrames = new Array(frames.length); |
| 3940 | const scaledIndex = this.metrics.count; // Scaled value stored at metric.count |
| 3941 | |
| 3942 | for (let i = 0; i < frames.length; i++) { |
| 3943 | const frame = frames[i]; |
| 3944 | const newFrame = frame.slice(); |
| 3945 | |
| 3946 | if (newFrame[scaledIndex] !== 1) newFrame[scaledIndex] -= maxVal; |
| 3947 | if (!isFinite(newFrame[scaledIndex])) newFrame[scaledIndex] = 0; |
| 3948 | |
| 3949 | normalizedFrames[i] = newFrame; |
| 3950 | } |
| 3951 | |
| 3952 | return normalizedFrames; |
| 3953 | } |
| 3954 | |
| 3955 | /** |
| 3956 | * Gets the scaled frames based on the given position, maximum value, and level type. |
| 3957 | * @param {Array} frames - The array of frame data. |
| 3958 | * @param {number} pos - The position index in the frame data to be scaled. |
| 3959 | * @param {number} max - The maximum value for scaling. |
| 3960 | * @param {boolean} isRmsLevel - Whether if RMS level scaling should be applied. |
| 3961 | * @returns {Array} The scaled frame data. |
| 3962 | */ |
| 3963 | getScaledFrames(frames, pos, max, isRmsLevel) { |
| 3964 | const scaledFrames = new Array(frames.length); |
| 3965 | const logMax = Math.log(Math.abs(max)); |
| 3966 | |
| 3967 | for (let i = 0; i < frames.length; i++) { |
| 3968 | const frame = frames[i]; |
| 3969 | const value = isFinite(frame[pos]) ? frame[pos] : -Infinity; |
| 3970 | |
| 3971 | let scaledVal = |
| 3972 | !isFinite(value) ? 1 : |
| 3973 | isRmsLevel ? 1 - Math.abs((value - max) / max) : |
| 3974 | Math.abs(1 - (logMax + Math.log(Math.abs(value))) / logMax); |
| 3975 | |
| 3976 | if (!isFinite(scaledVal)) scaledVal = 0; |
| 3977 | |
| 3978 | const newFrame = frame.slice(0, this.metrics.count); |
| 3979 | newFrame.push(scaledVal); |
| 3980 | scaledFrames[i] = newFrame; |
| 3981 | } |
| 3982 | |
| 3983 | return scaledFrames; |
| 3984 | } |
| 3985 | |
| 3986 | /** |
| 3987 | * Gets the resized frames based on the given scale and new frame count. |
| 3988 | * @param {number} scale - The scale factor for resizing. |
| 3989 | * @param {number} frames - The current number of frames. |
| 3990 | * @param {number} newFrames - The desired number of frames after resizing. |
| 3991 | * @returns {Array} The resized frame data. |
| 3992 | */ |
| 3993 | getResizedFrames(scale, frames, newFrames) { |
| 3994 | const data = Array(newFrames).fill(null).map(() => ({ maxAbs: 0, maxSigned: 0, val: 0, count: 0 })); |
| 3995 | const scaleFactor = newFrames < frames ? frames / newFrames : newFrames / frames; |
| 3996 | const isWaveform = this.preset.analysisMode === 'waveform'; |
| 3997 | |
| 3998 | if (frames === 0 || newFrames === 0) return []; |
| 3999 | |
| 4000 | for (let i = 0, j = 0, h = 0; i < frames; i++) { |
| 4001 | const frame = this.current[i]; |
| 4002 | |
| 4003 | if (newFrames < frames) { |
| 4004 | if (isWaveform) { // Track max absolute and signed values for waveform |
| 4005 | data[j].maxAbs = Math.max(data[j].maxAbs, Math.abs(frame)); |
| 4006 | data[j].maxSigned = Math.abs(frame) > Math.abs(data[j].maxSigned) ? frame : data[j].maxSigned; |
| 4007 | data[j].count++; |
| 4008 | h += 1; |
| 4009 | if (h >= scaleFactor) { |
| 4010 | j++; |
| 4011 | h -= scaleFactor; |
| 4012 | if (j >= newFrames) break; |
| 4013 | } |
| 4014 | } |
| 4015 | else { // Averaging logic for other modes |
| 4016 | while (h >= scaleFactor) { |
| 4017 | const w = h - scaleFactor; |
| 4018 | if (j + 1 < newFrames) { |
| 4019 | data[j + 1].val += frame * w; |
| 4020 | data[j + 1].count += w; |
| 4021 | } |
| 4022 | j += 2; |
| 4023 | h = 0; |
| 4024 | if (j >= newFrames) break; |
| 4025 | data[j].val += frame * (1 - w); |
| 4026 | data[j].count += (1 - w); |
| 4027 | } |
| 4028 | if (i % 2 === 0 && j + 1 < newFrames) { |
| 4029 | data[j + 1].val += frame; |
| 4030 | data[j + 1].count++; |
| 4031 | } else { |
| 4032 | data[j].val += frame; |
| 4033 | data[j].count++; |
| 4034 | h++; |
| 4035 | } |
| 4036 | } |
| 4037 | } |
| 4038 | else { // Upsampling: repeat or interpolate frames |
| 4039 | while (h < scaleFactor && j < newFrames) { |
| 4040 | if (isWaveform) { |
| 4041 | data[j].maxAbs = Math.max(data[j].maxAbs, Math.abs(frame)); |
| 4042 | data[j].maxSigned = Math.abs(frame) > Math.abs(data[j].maxSigned) ? frame : data[j].maxSigned; |
| 4043 | } else { |
| 4044 | data[j].val += frame; |
| 4045 | } |
| 4046 | data[j].count++; |
| 4047 | j++; |
| 4048 | h++; |
| 4049 | } |
| 4050 | h -= scaleFactor; |
| 4051 | } |
| 4052 | } |
| 4053 | |
| 4054 | return data.filter(el => el.count > 0).map(el => isWaveform ? el.maxSigned : el.val / el.count); |
| 4055 | } |
| 4056 | |
| 4057 | /** |
| 4058 | * Normalizes points to ensure all points are on the same scale to prevent distortion of the waveform. |
| 4059 | * @param {boolean} normalizeWidth - If `true`, adjusts the number of frames to match the window size. |
| 4060 | */ |
| 4061 | normalizePoints(normalizeWidth = false) { |
| 4062 | if (!this.current.length) return; |
| 4063 | |
| 4064 | // Safety filter for any unexpected invalid frames |
| 4065 | if (this.analysis.binaryMode === 'audioWizard' && !this.isFallback && !this.fallbackMode.paint) { |
| 4066 | this.current = this.current.filter(frame => frame != null && Array.isArray(frame) && frame.length >= this.metrics.count); |
| 4067 | } |
| 4068 | |
| 4069 | if (this.analysis.binaryMode === 'audioWizard' && !this.isFallback && !this.fallbackMode.paint && this.preset.analysisMode === 'waveform') { |
| 4070 | const channels = this.currentChannels || 1; |
| 4071 | const minIdx = this.metrics.index.min; |
| 4072 | const maxIdx = this.metrics.index.max; |
| 4073 | const metricsPerChannel = this.metrics.count; |
| 4074 | |
| 4075 | this.current = this.current.map(frame => { |
| 4076 | let globalMin = Infinity; |
| 4077 | let globalMax = -Infinity; |
| 4078 | |
| 4079 | // Process ALL channels |
| 4080 | for (let ch = 0; ch < channels; ch++) { |
| 4081 | const offset = ch * metricsPerChannel; |
| 4082 | const chMin = frame[offset + minIdx]; |
| 4083 | const chMax = frame[offset + maxIdx]; |
| 4084 | |
| 4085 | globalMin = Math.min(globalMin, chMin); |
| 4086 | globalMax = Math.max(globalMax, chMax); |
| 4087 | } |
| 4088 | |
| 4089 | // Return the value with larger magnitude |
| 4090 | return Math.abs(globalMax) > Math.abs(globalMin) ? globalMax : globalMin; |
| 4091 | }); |
| 4092 | } |
| 4093 | |
| 4094 | let { upper, lower } = this.getMaxValue(this.current); |
| 4095 | |
| 4096 | if (this.analysis.binaryMode === 'audioWizard' && !this.isFallback && !this.fallbackMode.paint && this.preset.analysisMode !== 'waveform') { |
| 4097 | const metric = this.metrics.mode[this.preset.analysisMode]; |
| 4098 | const pos = this.metrics.index[metric]; |
| 4099 | const minVal = this.getMinValuePos(this.current, pos); |
| 4100 | |
| 4101 | this.current = this.getScaledFrames(this.current, pos, minVal, this.preset.analysisMode === 'rms'); |
| 4102 | this.current = this.getNormalizedFrameValues(this.current, Math.min(...this.current.map(frame => frame[this.metrics.count]))); |
| 4103 | this.current = this.current.map((x, i) => Math.sign((0.5 - i % 2)) * (1 - x[this.metrics.count])); |
| 4104 | } |
| 4105 | else if (this.analysis.binaryMode === 'visualizer' || this.isFallback || this.fallbackMode.paint) { |
| 4106 | const maxVal = Math.max(Math.abs(upper), Math.abs(lower)); |
| 4107 | this.current = this.current.map(frame => frame / maxVal); |
| 4108 | } |
| 4109 | |
| 4110 | if (normalizeWidth) { |
| 4111 | const barW = this.getBarWidth(); |
| 4112 | const frames = this.current.length; |
| 4113 | const newFrames = Math.floor(this.w / barW); |
| 4114 | |
| 4115 | if (newFrames === frames) return; |
| 4116 | |
| 4117 | this.current = this.getResizedFrames(frames / newFrames, frames, newFrames); |
| 4118 | |
| 4119 | const bias = Math.abs(upper / lower); |
| 4120 | upper = lower = 0; |
| 4121 | ({ upper, lower } = this.getMaxValue(this.current)); |
| 4122 | const newBias = Math.abs(upper / lower); |
| 4123 | const biasDiff = bias - newBias; |
| 4124 | |
| 4125 | if (biasDiff > 0.1) { |
| 4126 | const distort = bias / newBias; |
| 4127 | const sign = Math.sign(biasDiff); |
| 4128 | this.current = this.current.map(frame => (sign === 1 && frame > 0) || (sign !== 1 && frame < 0) ? frame * distort : frame); |
| 4129 | } |
| 4130 | } |
| 4131 | |
| 4132 | // Clamp frame values to [-1, 1] to prevent overflow/distortion from imbalanced data or edge cases |
| 4133 | this.current = this.current.map(frame => Math.max(-1, Math.min(1, frame))); |
| 4134 | } |
| 4135 | |
| 4136 | /** |
| 4137 | * Resets the state of the waveform bar. |
| 4138 | */ |
| 4139 | reset() { |
| 4140 | this.current = []; |
| 4141 | this.cache = null; |
| 4142 | this.time = 0; |
| 4143 | this.step = 0; |
| 4144 | this.maxStep = 6; |
| 4145 | this.offset = []; |
| 4146 | this.isAllowedFile = true; |
| 4147 | this.isZippedFile = false; |
| 4148 | this.isError = false; |
| 4149 | this.isFallback = false; |
| 4150 | this.fallbackMode.paint = this.fallbackMode.analysis = false; |
| 4151 | this.resetAnimation(); |
| 4152 | clearTimeout(this.queueId); |
| 4153 | } |
| 4154 | |
| 4155 | /** |
| 4156 | * Resets the state of the waveform bar animation. |
| 4157 | */ |
| 4158 | resetAnimation() { |
| 4159 | this.step = 0; |
| 4160 | this.offset = []; |
| 4161 | this.defaultSteps(); |
| 4162 | } |
| 4163 | |
| 4164 | /** |
| 4165 | * Sets the refresh rate for the waveform bar. |
| 4166 | */ |
| 4167 | setRefreshRate() { |
| 4168 | if (grm.ui.isStreaming) { // Radio streaming refresh rate |
| 4169 | this.ui.refreshRate = grm.ui.seekbarTimerInterval = FPS._1; |
| 4170 | } |
| 4171 | else if (grSet.waveformBarRefreshRate !== 'variable') { // Fixed refresh rate |
| 4172 | this.ui.refreshRate = grm.ui.seekbarTimerInterval = grSet.waveformBarRefreshRate; |
| 4173 | } |
| 4174 | else { // Variable refresh rate calculation |
| 4175 | const now = Date.now(); |
| 4176 | if (this.updateTimeLast && (now - this.updateTimeLast) < 250) return; // Check every 250 ms |
| 4177 | this.updateTimeLast = now; |
| 4178 | |
| 4179 | if (this.profilerPaintTimeLast === undefined) { |
| 4180 | this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time; |
| 4181 | } |
| 4182 | |
| 4183 | const timeDifference = grm.ui.seekbarProfiler.Time - this.profilerPaintTimeLast; |
| 4184 | this.ui.refreshRate = grm.ui.seekbarTimerInterval = Clamp(grm.ui.seekbarTimerInterval + (timeDifference > 0 ? 12 : -7), FPS._10, FPS._5); |
| 4185 | this.profilerPaintTimeLast = grm.ui.seekbarProfiler.Time; |
| 4186 | |
| 4187 | grm.ui.clearTimer('seekbar', true); |
| 4188 | grm.ui.seekbarTimer = !fb.IsPaused ? setInterval(() => grm.ui.refreshSeekbar(), grm.ui.seekbarTimerInterval) : null; |
| 4189 | } |
| 4190 | } |
| 4191 | |
| 4192 | /** |
| 4193 | * Sets the rectangular area to be painted. |
| 4194 | * @param {number} time - The current playback time. |
| 4195 | * @returns {{ x: number, y: number, width: number, height: number }} The object containing the dimensions of the rectangle to be painted. |
| 4196 | */ |
| 4197 | setPaintRect(time) { |
| 4198 | const widerModesScale = ['bars', 'halfbars'].includes(this.preset.barDesign) ? 2 : 1; |
| 4199 | const barW = Math.ceil(Math.max(this.w / this.current.length, SCALE(2))) * widerModesScale; |
| 4200 | const currX = this.x + (this.w * time / fb.PlaybackLength); |
| 4201 | |
| 4202 | const prePaintW = Math.min( |
| 4203 | this.prepaint && this.preset.prepaintFront !== Infinity || this.preset.animate |
| 4204 | ? this.preset.prepaintFront === Infinity && this.preset.animate |
| 4205 | ? Infinity |
| 4206 | : (this.preset.prepaintFront / this.timeConstant * barW) + barW |
| 4207 | : 2.5 * barW, |
| 4208 | this.w - currX + barW |
| 4209 | ); |
| 4210 | |
| 4211 | return { |
| 4212 | x: currX - barW - grm.ui.edgeMargin, |
| 4213 | y: this.y, |
| 4214 | width: prePaintW + grm.ui.edgeMarginBoth, |
| 4215 | height: this.h |
| 4216 | }; |
| 4217 | } |
| 4218 | |
| 4219 | /** |
| 4220 | * Sets the throttle paint methods based on the current UI refresh rate. |
| 4221 | */ |
| 4222 | setThrottlePaint() { |
| 4223 | /** |
| 4224 | * Throttles the window repaint to improve performance by limiting the rate of repaint operations. |
| 4225 | * This function is specifically tailored to repaint a defined rectangular area of the window. |
| 4226 | * The repaint is controlled by the UI refresh rate. |
| 4227 | * @param {boolean} [force] - If set to true, the repaint will be forced even if the window is not dirty. |
| 4228 | * @private |
| 4229 | */ |
| 4230 | this.throttlePaint = Throttle((force = false) => |
| 4231 | window.RepaintRect(this.x - SCALE(2), this.y - this.h * 0.5 - SCALE(4), this.w + SCALE(4), this.h + SCALE(8), force), this.ui.refreshRate); |
| 4232 | |
| 4233 | /** |
| 4234 | * Throttles the window repaint to improve performance by limiting the rate of repaint operations. |
| 4235 | * This function allows for the specification of the rectangular area to be repainted. |
| 4236 | * The repaint is controlled by the UI refresh rate. |
| 4237 | * @param {number} x - The x-coordinate of the upper-left corner of the rectangle to repaint. |
| 4238 | * @param {number} y - The y-coordinate of the upper-left corner of the rectangle to repaint. |
| 4239 | * @param {number} w - The width of the rectangle to repaint. |
| 4240 | * @param {number} h - The height of the rectangle to repaint. |
| 4241 | * @param {boolean} [force] - If set to true, the repaint will be forced even if the window is not dirty. |
| 4242 | * @private |
| 4243 | */ |
| 4244 | this.throttlePaintRect = Throttle((x, y, w, h, force = false) => |
| 4245 | window.RepaintRect(x - SCALE(2), y - h * 0.5 - SCALE(4), w + SCALE(4), h + SCALE(8), force), this.ui.refreshRate); |
| 4246 | } |
| 4247 | |
| 4248 | /** |
| 4249 | * Sets the vertical waveform bar position. |
| 4250 | * @param {number} y - The y-coordinate. |
| 4251 | */ |
| 4252 | setY(y) { |
| 4253 | this.y = y + SCALE(10); |
| 4254 | } |
| 4255 | |
| 4256 | /** |
| 4257 | * This method is currently not used. |
| 4258 | * @param {boolean} [enable] - If true, activates the component; if false, deactivates it. |
| 4259 | */ |
| 4260 | switch(enable = !this.active) { |
| 4261 | if (!fb.IsPlaying) return; |
| 4262 | |
| 4263 | const wasActive = this.active; |
| 4264 | this.active = enable; |
| 4265 | |
| 4266 | if (!wasActive && this.active) { |
| 4267 | window.Repaint(); |
| 4268 | setTimeout(() => { |
| 4269 | this.on_playback_new_track(fb.GetNowPlaying()); |
| 4270 | this.on_playback_time(fb.PlaybackTime); |
| 4271 | }, 0); |
| 4272 | } |
| 4273 | else if (wasActive && !this.active) { |
| 4274 | this.on_playback_stop(-1); |
| 4275 | } |
| 4276 | } |
| 4277 | |
| 4278 | /** |
| 4279 | * Checks if the mouse is within the boundaries of the waveform bar. |
| 4280 | * @param {number} x - The x-coordinate. |
| 4281 | * @param {number} y - The y-coordinate. |
| 4282 | * @returns {boolean} True or false. |
| 4283 | */ |
| 4284 | trace(x, y) { |
| 4285 | return (x >= this.x && y >= this.y && x <= this.x + this.w && y <= this.y + this.h); |
| 4286 | } |
| 4287 | |
| 4288 | /** |
| 4289 | * Updates the waveform bar with the current track information, playback time and size. |
| 4290 | * @param {boolean} current - Whether the current track has changed or not. |
| 4291 | */ |
| 4292 | updateBar(current) { |
| 4293 | if (!current) this.on_playback_new_track(fb.GetNowPlaying()); |
| 4294 | this.on_playback_time(fb.PlaybackTime); |
| 4295 | this.on_size(grm.ui.ww, grm.ui.wh); |
| 4296 | } |
| 4297 | // #endregion |
| 4298 | |
| 4299 | // * CALLBACKS * // |
| 4300 | // #region CALLBACKS |
| 4301 | /** |
| 4302 | * Handles left mouse button up click events and disables dragging and updates the playback time. |
| 4303 | * @param {number} x - The x-coordinate. |
| 4304 | * @param {number} y - The y-coordinate. |
| 4305 | * @param {number} mask - The mouse mask. |
| 4306 | * @returns {boolean} True or false. |
| 4307 | */ |
| 4308 | on_mouse_lbtn_up(x, y, mask) { |
| 4309 | if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) || |
| 4310 | !this.active || !this.trace(x, y) || !fb.IsPlaying || this.current.length === 0) { |
| 4311 | this.mouseDown = false; |
| 4312 | return false; |
| 4313 | } |
| 4314 | |
| 4315 | this.mouseDown = false; |
| 4316 | |
| 4317 | if (!fb.GetSelection()) return; |
| 4318 | |
| 4319 | const barW = this.w / this.current.length; |
| 4320 | const time = Math.round(fb.PlaybackLength / this.current.length * (x - this.x) / barW); |
| 4321 | fb.PlaybackTime = Clamp(time, 0, fb.PlaybackLength); |
| 4322 | this.throttlePaint(true); |
| 4323 | |
| 4324 | return true; |
| 4325 | } |
| 4326 | |
| 4327 | /** |
| 4328 | * Handles mouse movement events on the waveform bar. |
| 4329 | * @param {number} x - The x-coordinate. |
| 4330 | * @param {number} y - The y-coordinate. |
| 4331 | * @param {number} mask - The mouse mask. |
| 4332 | */ |
| 4333 | on_mouse_move(x, y, mask) { |
| 4334 | if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar)) { |
| 4335 | return; |
| 4336 | } |
| 4337 | |
| 4338 | this.mouseDown = (mask === MouseKey.LButton && this.on_mouse_lbtn_up(x, y, mask)); |
| 4339 | } |
| 4340 | |
| 4341 | /** |
| 4342 | * Handles the mouse wheel event to seek through the playback. |
| 4343 | * @param {number} step - The wheel scroll direction. |
| 4344 | * @returns {boolean} True or false. |
| 4345 | */ |
| 4346 | on_mouse_wheel(step) { |
| 4347 | if (!this.active || !fb.GetSelection() || !fb.IsPlaying || this.current.length === 0) { |
| 4348 | return false; |
| 4349 | } |
| 4350 | |
| 4351 | const seekType = { |
| 4352 | seconds: (scroll) => scroll * this.wheel.seekSpeed, |
| 4353 | percentage: (scroll) => (scroll * this.wheel.seekSpeed) / 100 * fb.PlaybackLength |
| 4354 | }; |
| 4355 | |
| 4356 | const seekTypeFunc = seekType[this.wheel.seekType] || seekType.seconds; |
| 4357 | const newTime = fb.PlaybackTime + seekTypeFunc(step); |
| 4358 | fb.PlaybackTime = Clamp(newTime, 0, fb.PlaybackLength); |
| 4359 | this.throttlePaint(true); |
| 4360 | |
| 4361 | return true; |
| 4362 | } |
| 4363 | |
| 4364 | /** |
| 4365 | * Resets the current waveform and processes new data for the new current playing track. |
| 4366 | * @param {FbMetadbHandle} handle - The handle of the new track. |
| 4367 | * @param {boolean} [isRetry] - The flag indicating whether the method call is a retry attempt. |
| 4368 | * @returns {Promise<void>} The promise that resolves when the processing has finished. |
| 4369 | */ |
| 4370 | async on_playback_new_track(handle = fb.GetNowPlaying(), isRetry = false) { |
| 4371 | if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) || !this.active || !handle) { |
| 4372 | return; |
| 4373 | } |
| 4374 | |
| 4375 | this.reset(); |
| 4376 | this.checkAllowedFile(handle); |
| 4377 | await this.analyzeDataStart(handle, isRetry); |
| 4378 | this.resetAnimation(); |
| 4379 | |
| 4380 | if (this.preset.animate && this.preset.useBPM) this.bpmSteps(handle); |
| 4381 | if (fb.IsPlaying) this.time = fb.PlaybackTime; |
| 4382 | |
| 4383 | this.throttlePaint(); |
| 4384 | } |
| 4385 | |
| 4386 | /** |
| 4387 | * Schedules the `on_playback_new_track` event to be triggered after a specified delay. |
| 4388 | * This is useful for debouncing the event, ensuring it is fired only once after a series of track changes. |
| 4389 | */ |
| 4390 | on_playback_new_track_queue() { |
| 4391 | clearTimeout(this.queueId); |
| 4392 | |
| 4393 | this.queueId = setTimeout(() => { |
| 4394 | this.on_playback_new_track(...arguments); |
| 4395 | }, this.queueMs); |
| 4396 | } |
| 4397 | |
| 4398 | /** |
| 4399 | * Resets the waveform bar on playback stop. |
| 4400 | * @param {number} reason - The type of playback stop. |
| 4401 | */ |
| 4402 | on_playback_stop(reason = -1) { |
| 4403 | if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) || reason !== -1 && !this.active) { |
| 4404 | return; |
| 4405 | } |
| 4406 | |
| 4407 | this.reset(); |
| 4408 | if (reason !== 2) this.throttlePaint(); |
| 4409 | } |
| 4410 | |
| 4411 | /** |
| 4412 | * Updates the waveform bar with throttled repaints. |
| 4413 | * @param {number} time - The current playback time. |
| 4414 | */ |
| 4415 | on_playback_time(time) { |
| 4416 | if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar) || !this.active) { |
| 4417 | return; |
| 4418 | } |
| 4419 | |
| 4420 | this.time = time; |
| 4421 | |
| 4422 | if ((this.preset.paintMode === 'full' || this.preset.indicator || this.analysis.binaryMode === 'visualizer') && |
| 4423 | this.cache === this.current) { |
| 4424 | return; |
| 4425 | } |
| 4426 | |
| 4427 | this.cache = this.current; |
| 4428 | |
| 4429 | if (this.analysis.binaryMode === 'visualizer' || !this.current.length) { |
| 4430 | this.throttlePaint(); |
| 4431 | } |
| 4432 | else if (this.preset.paintMode === 'partial' || this.preset.indicator) { |
| 4433 | const paintRect = this.setPaintRect(this.time); |
| 4434 | this.throttlePaintRect(paintRect.x, paintRect.y, paintRect.width, paintRect.height); |
| 4435 | } |
| 4436 | } |
| 4437 | |
| 4438 | /** |
| 4439 | * Handles the waveform bar state when reloading the theme. |
| 4440 | */ |
| 4441 | on_script_unload() { |
| 4442 | if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar)) return; |
| 4443 | if (this.analysis.autoDelete) this.removeData(); |
| 4444 | } |
| 4445 | |
| 4446 | /** |
| 4447 | * Sets the size and position of the waveform bar and updates them on window resizing. |
| 4448 | * @param {number} w - The width of the waveform bar. |
| 4449 | * @param {number} h - The height of the waveform bar. |
| 4450 | */ |
| 4451 | on_size(w, h) { |
| 4452 | if (['progressbar', 'peakmeterbar'].includes(grSet.seekbar)) return; |
| 4453 | this.x = grm.ui.edgeMargin; |
| 4454 | this.y = 0; |
| 4455 | this.w = w - grm.ui.edgeMarginBoth; |
| 4456 | this.h = grm.ui.seekbarHeight; |
| 4457 | } |
| 4458 | // #endregion |
| 4459 | } |
| 4460 |