mirror of
https://github.com/monochrome-music/monochrome.git
synced 2026-10-23 03:25:02 -05:00
1053 lines
36 KiB
JavaScript
1053 lines
36 KiB
JavaScript
//js/utils.js
|
|
import { modernSettings } from './ModernSettings.js';
|
|
import { SVG_ATMOS } from './icons.js';
|
|
import { qualityBadgeSettings, coverArtSizeSettings, trackDateSettings } from './storage.js';
|
|
|
|
export const QUALITY = 'LOSSLESS';
|
|
|
|
export const REPEAT_MODE = {
|
|
OFF: 0,
|
|
ALL: 1,
|
|
ONE: 2,
|
|
};
|
|
|
|
export const AUDIO_QUALITIES = {
|
|
DOLBY_ATMOS_EAC3_HIGH: 'DOLBY_ATMOS_EAC3_HIGH',
|
|
DOLBY_ATMOS_EAC3_LOW: 'DOLBY_ATMOS_EAC3_LOW',
|
|
DOLBY_ATMOS_AC4_HIGH: 'DOLBY_ATMOS_AC4_HIGH',
|
|
DOLBY_ATMOS_AC4_LOW: 'DOLBY_ATMOS_AC4_LOW',
|
|
HI_RES_LOSSLESS: 'HI_RES_LOSSLESS',
|
|
LOSSLESS: 'LOSSLESS',
|
|
HIGH: 'HIGH',
|
|
LOW: 'LOW',
|
|
};
|
|
|
|
export const ATMOS_QUALITIES = [
|
|
'DOLBY_ATMOS_EAC3_HIGH',
|
|
'DOLBY_ATMOS_EAC3_LOW',
|
|
'DOLBY_ATMOS_AC4_HIGH',
|
|
'DOLBY_ATMOS_AC4_LOW',
|
|
];
|
|
|
|
export const QUALITY_PRIORITY = [...ATMOS_QUALITIES, 'HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'];
|
|
|
|
export const QUALITY_TOKENS = {
|
|
DOLBY_ATMOS_EAC3_HIGH: ['DOLBY_ATMOS_EAC3_HIGH', 'DOLBY_ATMOS', 'ATMOS', 'EAC3_JOC'],
|
|
DOLBY_ATMOS_EAC3_LOW: ['DOLBY_ATMOS_EAC3_LOW'],
|
|
DOLBY_ATMOS_AC4_HIGH: ['DOLBY_ATMOS_AC4_HIGH'],
|
|
DOLBY_ATMOS_AC4_LOW: ['DOLBY_ATMOS_AC4_LOW'],
|
|
HI_RES_LOSSLESS: [
|
|
'HI_RES_LOSSLESS',
|
|
'HIRES_LOSSLESS',
|
|
'HIRESLOSSLESS',
|
|
'HIFI_PLUS',
|
|
'HI_RES_FLAC',
|
|
'HI_RES',
|
|
'HIRES',
|
|
'MASTER',
|
|
'MASTER_QUALITY',
|
|
'MQA',
|
|
'UHD',
|
|
'ULTRAHD',
|
|
],
|
|
LOSSLESS: ['LOSSLESS', 'HIFI', 'HD'],
|
|
HIGH: ['HIGH', 'HIGH_QUALITY', 'SD', 'SD_HIGH', 'SD_MEDIUM'],
|
|
LOW: ['LOW', 'LOW_QUALITY', 'LD', 'SD_LOW'],
|
|
};
|
|
|
|
export const RATE_LIMIT_ERROR_MESSAGE = 'Too Many Requests. Please wait a moment and try again.';
|
|
|
|
export const formatTime = (seconds) => {
|
|
if (isNaN(seconds)) return '0:00';
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
if (h > 0) {
|
|
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
};
|
|
|
|
export const getTrackYearDisplay = (track) => {
|
|
const useAlbumYear = trackDateSettings.useAlbumYear();
|
|
const releaseDate = useAlbumYear
|
|
? track?.album?.releaseDate || track?.streamStartDate
|
|
: track?.streamStartDate || track?.album?.releaseDate;
|
|
if (!releaseDate) return '';
|
|
const date = new Date(releaseDate);
|
|
return isNaN(date.getTime()) ? '' : ` • ${date.getFullYear()}`;
|
|
};
|
|
|
|
export const createPlaceholder = (text, isLoading = false) => {
|
|
return `<div class="placeholder-text ${isLoading ? 'loading' : ''}">${text}</div>`;
|
|
};
|
|
|
|
export const trackDataStore = new WeakMap();
|
|
|
|
export const sanitizeForFilename = (value) => {
|
|
if (!value) return 'Unknown';
|
|
return value
|
|
.replace(/[\\/:*?"<>|]/g, '_')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
};
|
|
|
|
/**
|
|
* Sanitizes a single path component (no slashes allowed in the output).
|
|
* Invalid filesystem characters are replaced with underscores.
|
|
*/
|
|
export const sanitizeForPathComponent = (value) => {
|
|
if (!value) return 'Unknown';
|
|
return value
|
|
.replace(/[\\/:*?"<>|]/g, '_')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
};
|
|
|
|
/**
|
|
* Like {@link formatTemplate} but allows `/` in the template for nested
|
|
* directory structures. Each path component has invalid characters replaced,
|
|
* the path is normalised to forward-slash separators, and empty components,
|
|
* `.`, and `..` segments are stripped.
|
|
*/
|
|
export const formatPathTemplate = (template, data) => {
|
|
let result = replaceTokens(template, {
|
|
discNumber: String(Number(data.discNumber || 1)),
|
|
trackNumber: data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00',
|
|
artist: sanitizeForPathComponent(data.artist || 'Unknown Artist'),
|
|
title: sanitizeForPathComponent(data.title || 'Unknown Title'),
|
|
album: sanitizeForPathComponent(data.album || 'Unknown Album'),
|
|
albumArtist: sanitizeForPathComponent(data.albumArtist || 'Unknown Artist'),
|
|
albumTitle: sanitizeForPathComponent(data.albumTitle || 'Unknown Album'),
|
|
year: sanitizeForPathComponent(String(data.year || 'Unknown')),
|
|
});
|
|
|
|
// Normalise separators, collapse duplicates, strip . and ..
|
|
return result
|
|
.replace(/\\/g, '/')
|
|
.split('/')
|
|
.map((p) => p.trim())
|
|
.filter((p) => p !== '' && p !== '.' && p !== '..')
|
|
.join('/');
|
|
};
|
|
|
|
/**
|
|
* Detects audio format from DataView of first bytes
|
|
* @param {DataView} view - DataView of first 12 bytes of audio file
|
|
* @param {string} mimeType - MIME type from blob
|
|
* @returns {string|null} - Format: 'flac', 'mp4', 'mp3', or null
|
|
*/
|
|
export const detectAudioFormat = (view, mimeType = '') => {
|
|
// Check for FLAC signature: "fLaC" (0x66 0x4C 0x61 0x43)
|
|
if (
|
|
view.byteLength >= 4 &&
|
|
view.getUint8(0) === 0x66 && // f
|
|
view.getUint8(1) === 0x4c && // L
|
|
view.getUint8(2) === 0x61 && // a
|
|
view.getUint8(3) === 0x43 // C
|
|
) {
|
|
return 'flac';
|
|
}
|
|
|
|
// Check for OGG signature: "OggS" (0x4F 0x67 0x67 0x53)
|
|
if (
|
|
view.byteLength >= 4 &&
|
|
view.getUint8(0) === 0x4f && // O
|
|
view.getUint8(1) === 0x67 && // g
|
|
view.getUint8(2) === 0x67 && // g
|
|
view.getUint8(3) === 0x53 // S
|
|
) {
|
|
return 'ogg';
|
|
}
|
|
|
|
// Check for MP4/M4A signature: "ftyp" at offset 4
|
|
if (
|
|
view.byteLength >= 8 &&
|
|
view.getUint8(4) === 0x66 && // f
|
|
view.getUint8(5) === 0x74 && // t
|
|
view.getUint8(6) === 0x79 && // y
|
|
view.getUint8(7) === 0x70 // p
|
|
) {
|
|
return 'mp4';
|
|
}
|
|
|
|
// Check for MP3 signature: ID3 tag or MPEG frame sync
|
|
if (
|
|
view.byteLength >= 3 &&
|
|
view.getUint8(0) === 0x49 && // I
|
|
view.getUint8(1) === 0x44 && // D
|
|
view.getUint8(2) === 0x33 // 3
|
|
) {
|
|
return 'mp3';
|
|
}
|
|
|
|
// Detect RIFF/WAVE by "RIFF" at offset 0 and "WAVE" at offset 8 (only in dev mode)
|
|
if (
|
|
import.meta.env.DEV &&
|
|
view.byteLength >= 12 &&
|
|
view.getUint8(0) === 0x52 && // R
|
|
view.getUint8(1) === 0x49 && // I
|
|
view.getUint8(2) === 0x46 && // F
|
|
view.getUint8(3) === 0x46 && // F
|
|
view.getUint8(8) === 0x57 && // W
|
|
view.getUint8(9) === 0x41 && // A
|
|
view.getUint8(10) === 0x56 && // V
|
|
view.getUint8(11) === 0x45 // E
|
|
) {
|
|
return 'wav';
|
|
}
|
|
|
|
// Check for MPEG frame sync (0xFF 0xFB or 0xFF 0xFA)
|
|
if (view.byteLength >= 2 && view.getUint8(0) === 0xff && (view.getUint8(1) & 0xe0) === 0xe0) {
|
|
return 'mp3';
|
|
}
|
|
|
|
if (
|
|
view.byteLength >= 7 &&
|
|
view.getUint8(0) === 0x23 &&
|
|
view.getUint8(1) === 0x45 &&
|
|
view.getUint8(2) === 0x58 &&
|
|
view.getUint8(3) === 0x54 &&
|
|
view.getUint8(4) === 0x4d &&
|
|
view.getUint8(5) === 0x33 &&
|
|
view.getUint8(6) === 0x55
|
|
) {
|
|
return 'm3u8';
|
|
}
|
|
|
|
if (view.byteLength >= 188 && view.getUint8(0) === 0x47 && view.getUint8(188) === 0x47) {
|
|
return 'ts';
|
|
}
|
|
|
|
// Fallback to MIME type
|
|
if (mimeType === 'audio/flac') return 'flac';
|
|
if (mimeType === 'audio/ogg') return 'ogg';
|
|
if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
|
|
if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3';
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Detects actual audio format from blob signature
|
|
* @param {Blob} blob - Audio blob to analyze
|
|
* @returns {Promise<string>} - Extension: 'flac', 'm4a', 'mp3', or fallback based on mime
|
|
*/
|
|
export const getExtensionFromBlob = async (blob) => {
|
|
const buffer = await blob.slice(0, 12).arrayBuffer();
|
|
const view = new DataView(buffer);
|
|
|
|
const format = detectAudioFormat(view, blob.type);
|
|
|
|
if (format === 'mp4') {
|
|
if (blob.type.includes('video')) return 'mp4';
|
|
return 'm4a';
|
|
}
|
|
if (format) return format;
|
|
|
|
if (blob.type.includes('video')) return 'mp4';
|
|
if (blob.type === 'audio/flac') return 'flac';
|
|
if (blob.type === 'audio/ogg') return 'ogg';
|
|
if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') return 'mp4';
|
|
if (blob.type === 'audio/mp3' || blob.type === 'audio/mpeg') return 'mp3';
|
|
|
|
return 'flac';
|
|
};
|
|
|
|
export const getExtensionForQuality = (quality) => {
|
|
switch (quality) {
|
|
case 'LOW':
|
|
case 'HIGH':
|
|
case 'DOLBY_ATMOS_EAC3_HIGH':
|
|
case 'DOLBY_ATMOS_EAC3_LOW':
|
|
case 'DOLBY_ATMOS_AC4_HIGH':
|
|
case 'DOLBY_ATMOS_AC4_LOW':
|
|
return 'm4a';
|
|
default:
|
|
return 'flac';
|
|
}
|
|
};
|
|
|
|
export const buildTrackFilename = (track, quality, extension = null, overrideNumber = null) => {
|
|
const template = modernSettings.filenameTemplate;
|
|
const ext = extension || getExtensionForQuality(quality);
|
|
|
|
const artistName = track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist';
|
|
|
|
const data = {
|
|
discNumber: getTrackDiscNumber(track) || 1,
|
|
trackNumber: overrideNumber ?? track.trackNumber,
|
|
artist: artistName,
|
|
title: getTrackTitle(track),
|
|
album: track.album?.title,
|
|
};
|
|
|
|
return formatTemplate(template, data) + '.' + ext;
|
|
};
|
|
|
|
const sanitizeToken = (value) => {
|
|
if (!value) return '';
|
|
return value
|
|
.trim()
|
|
.toUpperCase()
|
|
.replace(/[^A-Z0-9]+/g, '_');
|
|
};
|
|
|
|
export const normalizeQualityToken = (value) => {
|
|
if (!value) return null;
|
|
|
|
const token = sanitizeToken(value);
|
|
|
|
for (const [quality, aliases] of Object.entries(QUALITY_TOKENS)) {
|
|
if (aliases.includes(token)) {
|
|
return quality;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
export const isAtmosQuality = (value) => {
|
|
const quality = normalizeQualityToken(value) || sanitizeToken(value);
|
|
return ATMOS_QUALITIES.includes(quality);
|
|
};
|
|
|
|
export const isAc4AtmosQuality = (value) => {
|
|
const quality = normalizeQualityToken(value) || sanitizeToken(value);
|
|
return quality === 'DOLBY_ATMOS_AC4_HIGH' || quality === 'DOLBY_ATMOS_AC4_LOW';
|
|
};
|
|
|
|
export const formatAtmosPlaybackDetails = (playbackInfo = {}) => {
|
|
const rawCodec = String(playbackInfo.codec || '').toLowerCase();
|
|
const quality = normalizeQualityToken(playbackInfo.quality) || sanitizeToken(playbackInfo.quality);
|
|
const codec = rawCodec.includes('ac-4') || rawCodec === 'ac4' || quality?.includes('_AC4_') ? 'AC-4' : 'E-AC-3 JOC';
|
|
const bitrateKbps = Number(playbackInfo.bitrateKbps ?? playbackInfo.bitrate_kbps) || null;
|
|
const sampleRateHz =
|
|
Number(
|
|
playbackInfo.sampleRateHz ??
|
|
playbackInfo.sample_rate_hz ??
|
|
playbackInfo.sampleRate ??
|
|
playbackInfo.sample_rate
|
|
) || null;
|
|
const channelLayout = playbackInfo.channelLayout || playbackInfo.channel_layout || null;
|
|
const channels = Number(playbackInfo.channels) || null;
|
|
const details = ['Dolby Atmos', codec];
|
|
if (bitrateKbps) details.push(`${bitrateKbps} kbps`);
|
|
if (sampleRateHz) {
|
|
const sampleRateKHz = sampleRateHz >= 1000 ? sampleRateHz / 1000 : sampleRateHz;
|
|
details.push(`${Number.isInteger(sampleRateKHz) ? sampleRateKHz : sampleRateKHz.toFixed(1)} kHz`);
|
|
}
|
|
if (channelLayout) details.push(channelLayout);
|
|
else if (channels) details.push(`${channels} channels`);
|
|
return details.join(' · ');
|
|
};
|
|
|
|
export const createQualityBadgeHTML = (track) => {
|
|
if (!track || !qualityBadgeSettings.isEnabled()) return '';
|
|
|
|
const playbackInfo = track.playbackQualityInfo || track;
|
|
const derivedQuality = deriveTrackQuality(track);
|
|
const isAtmos =
|
|
isAtmosQuality(playbackInfo.quality) ||
|
|
isAtmosQuality(derivedQuality) ||
|
|
track?.audioQuality === 'DOLBY_ATMOS' ||
|
|
track?.quality === 'DOLBY_ATMOS' ||
|
|
track?.audioModes?.includes('DOLBY_ATMOS');
|
|
|
|
if (isAtmos) {
|
|
const title = formatAtmosPlaybackDetails(playbackInfo);
|
|
return `<span class="quality-badge quality-atmos" title="${escapeHtml(title)}">${SVG_ATMOS(20)}</span>`;
|
|
}
|
|
|
|
const hasDetailedLosslessInfo =
|
|
(playbackInfo.bitDepth || playbackInfo.bit_depth) &&
|
|
(playbackInfo.sampleRateHz ||
|
|
playbackInfo.sample_rate_hz ||
|
|
playbackInfo.sampleRate ||
|
|
playbackInfo.sample_rate);
|
|
if (hasDetailedLosslessInfo) {
|
|
const badgeText = formatQualityBadgeText(playbackInfo, null, deriveTrackQuality(track));
|
|
if (badgeText) {
|
|
return `<span class="quality-badge quality-hires" title="${escapeHtml(badgeText)}">${escapeHtml(badgeText)}</span>`;
|
|
}
|
|
}
|
|
|
|
if (derivedQuality === 'HI_RES_LOSSLESS') {
|
|
return '<span class="quality-badge quality-hires" title="HD FLAC">HD FLAC</span>';
|
|
}
|
|
|
|
if (
|
|
derivedQuality === 'LOSSLESS' ||
|
|
track?.audioQuality === 'LOSSLESS' ||
|
|
track?.mediaMetadata?.tags?.includes('LOSSLESS') ||
|
|
track?.album?.mediaMetadata?.tags?.includes('LOSSLESS')
|
|
) {
|
|
return '<span class="quality-badge quality-hires" title="FLAC">FLAC</span>';
|
|
}
|
|
|
|
if (derivedQuality === 'HIGH' || derivedQuality === 'LOW') {
|
|
return '<span class="quality-badge quality-hires" title="AAC">AAC</span>';
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
export const deriveQualityFromTags = (rawTags) => {
|
|
if (!Array.isArray(rawTags)) return null;
|
|
|
|
const candidates = [];
|
|
for (const tag of rawTags) {
|
|
if (typeof tag !== 'string') continue;
|
|
const normalized = normalizeQualityToken(tag);
|
|
if (normalized && !candidates.includes(normalized)) {
|
|
candidates.push(normalized);
|
|
}
|
|
}
|
|
|
|
return pickBestQuality(candidates);
|
|
};
|
|
|
|
export const pickBestQuality = (candidates) => {
|
|
let best = null;
|
|
let bestRank = Infinity;
|
|
|
|
for (const candidate of candidates) {
|
|
if (!candidate) continue;
|
|
const rank = QUALITY_PRIORITY.indexOf(candidate);
|
|
const currentRank = rank === -1 ? Infinity : rank;
|
|
|
|
if (currentRank < bestRank) {
|
|
best = candidate;
|
|
bestRank = currentRank;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
};
|
|
|
|
export const deriveTrackQuality = (track) => {
|
|
if (!track) return null;
|
|
|
|
const candidates = [
|
|
deriveQualityFromTags(track.mediaMetadata?.tags),
|
|
deriveQualityFromTags(track.album?.mediaMetadata?.tags),
|
|
deriveQualityFromTags(track.mediaTags),
|
|
deriveQualityFromTags(track.album?.mediaTags),
|
|
normalizeQualityToken(track.audioQuality),
|
|
];
|
|
|
|
return pickBestQuality(candidates);
|
|
};
|
|
|
|
export const formatQualityBadgeText = (streamInfo, activeVariant, fallbackQuality = null) => {
|
|
const bitDepth = Number(streamInfo?.bitDepth ?? streamInfo?.bit_depth) || null;
|
|
const sampleRateHz =
|
|
Number(
|
|
streamInfo?.sampleRateHz ?? streamInfo?.sample_rate_hz ?? streamInfo?.sampleRate ?? streamInfo?.sample_rate
|
|
) || null;
|
|
const bitrateKbps =
|
|
streamInfo?.bitrateKbps ||
|
|
(activeVariant?.audioBandwidth ? Math.round(activeVariant.audioBandwidth / 1000) : null);
|
|
const rawCodec = (streamInfo?.codec || activeVariant?.audioCodec || '').toLowerCase();
|
|
const quality = String(streamInfo?.quality || fallbackQuality || '').toUpperCase();
|
|
|
|
const isExplicitLossy =
|
|
streamInfo?.lossless === false ||
|
|
rawCodec.includes('mp3') ||
|
|
rawCodec.includes('aac') ||
|
|
rawCodec.includes('mp4a') ||
|
|
rawCodec.includes('opus');
|
|
|
|
const isLossless =
|
|
!isExplicitLossy &&
|
|
(streamInfo?.lossless === true ||
|
|
rawCodec.includes('flac') ||
|
|
quality === 'HI_RES_LOSSLESS' ||
|
|
quality === 'LOSSLESS' ||
|
|
quality === 'UHD' ||
|
|
quality === 'HD');
|
|
|
|
if (
|
|
isLossless ||
|
|
rawCodec.includes('flac') ||
|
|
(!rawCodec && !isExplicitLossy && (quality === 'HI_RES_LOSSLESS' || quality === 'LOSSLESS'))
|
|
) {
|
|
if (bitDepth && sampleRateHz) {
|
|
const sampleRateKHz =
|
|
sampleRateHz >= 1000
|
|
? sampleRateHz % 1000 === 0
|
|
? (sampleRateHz / 1000).toString()
|
|
: (sampleRateHz / 1000).toFixed(1)
|
|
: sampleRateHz.toString();
|
|
const isHiRes = bitDepth > 16 || sampleRateHz > 48000;
|
|
return `${isHiRes ? 'HD' : 'FLAC'} ${bitDepth}/${sampleRateKHz}`;
|
|
}
|
|
|
|
if (quality === 'HI_RES_LOSSLESS' || quality === 'UHD' || quality === 'ULTRAHD') {
|
|
return 'HD FLAC';
|
|
}
|
|
return 'FLAC';
|
|
}
|
|
|
|
// Non-lossless (MP3, AAC, Opus, etc.)
|
|
let codecName = 'AAC';
|
|
if (rawCodec.includes('mp3')) {
|
|
codecName = 'MP3';
|
|
} else if (rawCodec.includes('opus')) {
|
|
codecName = 'Opus';
|
|
} else if (rawCodec.includes('aac') || rawCodec.includes('mp4a')) {
|
|
codecName = 'AAC';
|
|
} else if (rawCodec) {
|
|
codecName = rawCodec.toUpperCase();
|
|
}
|
|
|
|
if (bitrateKbps) {
|
|
return `${codecName} ${bitrateKbps}k`;
|
|
}
|
|
return codecName;
|
|
};
|
|
|
|
export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
export const hasExplicitContent = (item) => {
|
|
return item?.explicit === true || item?.explicitLyrics === true;
|
|
};
|
|
|
|
export const isTrackUnavailable = (track) => {
|
|
if (!track) return true;
|
|
if (track.isLocal) return false;
|
|
// AllowStreaming false or StreamReady false usually mean unavailable
|
|
// title === 'Unavailable' is also a strong indicator from the user's example
|
|
return track.allowStreaming === false || track.streamReady === false || track.title === 'Unavailable';
|
|
};
|
|
|
|
export const debounce = (func, wait) => {
|
|
let timeout;
|
|
function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
}
|
|
executedFunction.cancel = () => {
|
|
clearTimeout(timeout);
|
|
timeout = undefined;
|
|
};
|
|
return executedFunction;
|
|
};
|
|
|
|
const REPORT_SENSITIVE_KEY = /(authorization|cookie|password|secret|token|jwt|api.?key|decryption|manifest|url)/i;
|
|
|
|
export function sanitizeErrorReportDetails(value, seen = new WeakSet()) {
|
|
if (value instanceof Error) {
|
|
return { name: value.name, message: value.message };
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value
|
|
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
|
|
.replace(/([?&](?:token|key|signature|jwt|authorization)=)[^&\s]+/gi, '$1[redacted]');
|
|
}
|
|
if (!value || typeof value !== 'object') return value;
|
|
if (seen.has(value)) return '[circular]';
|
|
seen.add(value);
|
|
|
|
if (Array.isArray(value)) return value.map((item) => sanitizeErrorReportDetails(item, seen));
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, item]) => [
|
|
key,
|
|
REPORT_SENSITIVE_KEY.test(key) ? '[redacted]' : sanitizeErrorReportDetails(item, seen),
|
|
])
|
|
);
|
|
}
|
|
|
|
export function buildErrorReportUrl(message, details = null) {
|
|
const title = `[Bug] ${String(message || 'Unexpected error')}`.slice(0, 120);
|
|
const context = {
|
|
page:
|
|
typeof location !== 'undefined'
|
|
? `${location.origin}${location.pathname}${location.hash || ''}`
|
|
: 'unknown',
|
|
browser: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
|
|
platform: typeof navigator !== 'undefined' ? navigator.platform || 'unknown' : 'unknown',
|
|
language: typeof navigator !== 'undefined' ? navigator.language || 'unknown' : 'unknown',
|
|
viewport: typeof window !== 'undefined' ? `${window.innerWidth || 0}x${window.innerHeight || 0}` : 'unknown',
|
|
// eslint-disable-next-line no-undef
|
|
commit: typeof __COMMIT_HASH__ !== 'undefined' ? __COMMIT_HASH__ : 'dev',
|
|
};
|
|
const sanitized = details ? sanitizeErrorReportDetails(details) : null;
|
|
let diagnosticJson = sanitized ? JSON.stringify(sanitized, null, 2) : 'No additional diagnostic data captured.';
|
|
if (diagnosticJson.length > 6000) diagnosticJson = `${diagnosticJson.slice(0, 6000)}\n… (truncated)`;
|
|
const body = [
|
|
'### Error',
|
|
String(message || 'Unexpected error'),
|
|
'',
|
|
'### Environment',
|
|
...Object.entries(context).map(([key, value]) => `- ${key}: ${value}`),
|
|
'',
|
|
'### Diagnostic details',
|
|
'```json',
|
|
diagnosticJson,
|
|
'```',
|
|
'',
|
|
'### Steps to reproduce',
|
|
'1. ',
|
|
].join('\n');
|
|
const url = new URL('https://github.com/monochrome-music/monochrome/issues/new');
|
|
url.searchParams.set('title', title);
|
|
url.searchParams.set('body', body);
|
|
return url.toString();
|
|
}
|
|
|
|
export const escapeHtml = (unsafe) => {
|
|
if (typeof unsafe !== 'string') return unsafe;
|
|
return unsafe
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
};
|
|
|
|
export const decodeHtml = (html) => {
|
|
if (!html) return '';
|
|
const div = document.createElement('div');
|
|
div.innerHTML = html;
|
|
return div.textContent;
|
|
};
|
|
|
|
export const getTrackTitle = (track, { fallback = 'Unknown Title' } = {}) => {
|
|
if (!track?.title) return fallback;
|
|
return track?.version ? `${track.title} (${track.version})` : track.title;
|
|
};
|
|
|
|
export const getTrackArtists = (track = {}, { fallback = 'Unknown Artist' } = {}) => {
|
|
if (track?.artists?.length) {
|
|
return track.artists.map((artist) => artist?.name).join(', ');
|
|
}
|
|
|
|
return fallback;
|
|
};
|
|
|
|
export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } = {}) => {
|
|
if (track?.artists?.length) {
|
|
return track.artists
|
|
.map((artist) => {
|
|
const escapedName = escapeHtml(artist.name || 'Unknown Artist');
|
|
const escapedId = escapeHtml(artist.id || '');
|
|
// Check if this is a tracker/unreleased track
|
|
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
|
if (isTracker && track.trackerInfo?.sheetId) {
|
|
const escapedSheetId = escapeHtml(track.trackerInfo.sheetId);
|
|
// For tracker tracks, link to the tracker artist page
|
|
return `<span class="artist-link tracker-artist-link" data-tracker-sheet-id="${escapedSheetId}">${escapedName}</span>`;
|
|
}
|
|
// For normal tracks, use the artist ID
|
|
return `<span class="artist-link" data-artist-id="${escapedId}">${escapedName}</span>`;
|
|
})
|
|
.join(', ');
|
|
}
|
|
|
|
return fallback;
|
|
};
|
|
|
|
export const formatTemplate = (template, data) =>
|
|
replaceTokens(template, {
|
|
discNumber: String(Number(data.discNumber || 1)),
|
|
trackNumber: data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00',
|
|
artist: sanitizeForFilename(data.artist || 'Unknown Artist'),
|
|
title: sanitizeForFilename(data.title || 'Unknown Title'),
|
|
album: sanitizeForFilename(data.album || 'Unknown Album'),
|
|
albumArtist: sanitizeForFilename(data.albumArtist || 'Unknown Artist'),
|
|
albumTitle: sanitizeForFilename(data.albumTitle || 'Unknown Album'),
|
|
year: data.year || 'Unknown',
|
|
});
|
|
|
|
export const calculateTotalDuration = (tracks) => {
|
|
if (!Array.isArray(tracks) || tracks.length === 0) return 0;
|
|
return tracks.reduce((total, track) => total + (track.duration || 0), 0);
|
|
};
|
|
|
|
export const formatDuration = (seconds) => {
|
|
if (!seconds || isNaN(seconds)) return '0 min';
|
|
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours} hr ${minutes} min`;
|
|
}
|
|
return `${minutes} min`;
|
|
};
|
|
|
|
const coverCache = new Map();
|
|
|
|
function resizeImageBlob(blob, size) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
const url = URL.createObjectURL(blob);
|
|
img.onload = () => {
|
|
URL.revokeObjectURL(url);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
ctx.drawImage(img, 0, 0, size, size);
|
|
canvas.toBlob(
|
|
(resizedBlob) => {
|
|
if (resizedBlob) resolve(resizedBlob);
|
|
else reject(new Error('Canvas toBlob failed'));
|
|
},
|
|
blob.type || 'image/jpeg',
|
|
0.9
|
|
);
|
|
};
|
|
img.onerror = (e) => {
|
|
URL.revokeObjectURL(url);
|
|
reject(e);
|
|
};
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetches and caches cover art as a Blob
|
|
* @param {Object} api - API instance with getCoverUrl method
|
|
* @param {string} coverId - ID of the cover art to fetch
|
|
* @returns {Promise<Blob|null>} - Cover art blob or null if not available
|
|
*/
|
|
export async function getCoverBlob(api, coverId) {
|
|
if (!coverId) return null;
|
|
|
|
let sizeStr = coverArtSizeSettings.getSize();
|
|
|
|
if (sizeStr.includes('x')) {
|
|
sizeStr = sizeStr.split('x')[0];
|
|
}
|
|
|
|
let requestedSize = parseInt(sizeStr, 10);
|
|
if (isNaN(requestedSize) || requestedSize <= 0) requestedSize = 1280;
|
|
|
|
const cacheKey = `${coverId}-${requestedSize}`;
|
|
if (coverCache.has(cacheKey)) return coverCache.get(cacheKey);
|
|
|
|
// Tidal seems to only support these soooo
|
|
const supportedSizes = [80, 160, 320, 640, 1280];
|
|
let fetchSize = 1280;
|
|
|
|
const bestSize = supportedSizes.find((s) => s >= requestedSize);
|
|
if (bestSize) {
|
|
fetchSize = bestSize;
|
|
}
|
|
|
|
const fetchWithProxy = async (url) => {
|
|
try {
|
|
const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(url)}`;
|
|
const response = await fetch(proxyUrl);
|
|
if (response.ok) return await response.blob();
|
|
} catch (e) {
|
|
console.warn('Proxy fetch failed:', e);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
let blob = null;
|
|
try {
|
|
const url = api.getCoverUrl(coverId, fetchSize.toString());
|
|
// Try direct fetch first
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
blob = await response.blob();
|
|
} else {
|
|
// If direct fetch fails (e.g. 404 from SW due to CORS), try proxy
|
|
blob = await fetchWithProxy(url);
|
|
}
|
|
} catch {
|
|
// Network error (CORS rejection not handled by SW), try proxy
|
|
const url = api.getCoverUrl(coverId, fetchSize.toString());
|
|
blob = await fetchWithProxy(url);
|
|
}
|
|
|
|
if (blob) {
|
|
if (fetchSize !== requestedSize) {
|
|
try {
|
|
blob = await resizeImageBlob(blob, requestedSize);
|
|
} catch (e) {
|
|
console.warn('Failed to resize cover art, using original size:', e);
|
|
}
|
|
}
|
|
coverCache.set(cacheKey, blob);
|
|
return blob;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Positions a menu element relative to a point or an anchor rectangle,
|
|
* ensuring it stays within the viewport and becomes scrollable if too tall.
|
|
* @param {HTMLElement} menu - The menu element to position
|
|
* @param {number} x - X coordinate (clientX)
|
|
* @param {number} y - Y coordinate (clientY)
|
|
* @param {DOMRect} [anchorRect] - Optional anchor element rectangle
|
|
*/
|
|
export function positionMenu(menu, x, y, anchorRect = null) {
|
|
// Temporarily show to measure dimensions
|
|
menu.style.visibility = 'hidden';
|
|
menu.style.display = 'block';
|
|
menu.style.maxHeight = '';
|
|
menu.style.overflowY = '';
|
|
|
|
const menuWidth = menu.offsetWidth;
|
|
const menuHeight = menu.offsetHeight;
|
|
const windowWidth = window.innerWidth;
|
|
const windowHeight = window.innerHeight;
|
|
|
|
let left = x;
|
|
let top = y;
|
|
|
|
if (anchorRect) {
|
|
// Adjust horizontal position if it overflows right
|
|
if (left + menuWidth > windowWidth - 10) {
|
|
left = Math.max(10, anchorRect.right - menuWidth);
|
|
}
|
|
// Adjust vertical position if it overflows bottom
|
|
if (top + menuHeight > windowHeight - 10) {
|
|
top = Math.max(10, anchorRect.top - menuHeight - 5);
|
|
}
|
|
} else {
|
|
// Adjust horizontal position if it overflows right
|
|
if (left + menuWidth > windowWidth - 10) {
|
|
left = Math.max(10, windowWidth - menuWidth - 10);
|
|
}
|
|
// Adjust vertical position if it overflows bottom
|
|
if (top + menuHeight > windowHeight - 10) {
|
|
top = Math.max(10, y - menuHeight);
|
|
}
|
|
}
|
|
|
|
// Final checks to ensure it's not off-screen at the top or left
|
|
if (left < 10) left = 10;
|
|
if (top < 10) top = 10;
|
|
|
|
// If it's still too tall for the viewport, make it scrollable
|
|
// We measure again because max-height might be needed
|
|
const currentMenuHeight = menu.offsetHeight;
|
|
if (top + currentMenuHeight > windowHeight - 10) {
|
|
menu.style.maxHeight = `${windowHeight - top - 10}px`;
|
|
menu.style.overflowY = 'auto';
|
|
}
|
|
|
|
menu.style.top = `${top}px`;
|
|
menu.style.left = `${left}px`;
|
|
menu.style.visibility = 'visible';
|
|
}
|
|
|
|
export const getShareUrl = (path) => {
|
|
const baseUrl = window.NL_MODE ? 'https://monochrome.tf' : window.location.origin;
|
|
const safePath = path.startsWith('/') ? path : `/${path}`;
|
|
return `${baseUrl}${safePath}`;
|
|
};
|
|
|
|
/**
|
|
* Builds a full artist array by combining the track's listed artists
|
|
* with any featured artists parsed from the title (feat./with).
|
|
*/
|
|
export function getFullArtistArray(track) {
|
|
const knownArtists =
|
|
Array.isArray(track.artists) && track.artists.length > 0
|
|
? track.artists.map((a) => (typeof a === 'string' ? a : a.name) || '').filter(Boolean)
|
|
: track.artist?.name
|
|
? [track.artist.name]
|
|
: [];
|
|
|
|
// Parse featured artists from title, e.g. "Song (feat. A, B & C)" or "(with X & Y)"
|
|
// Note: splitting on '&' may incorrectly fragment compound artist names like "Simon & Garfunkel".
|
|
const featPattern = /\(\s*(?:feat\.?|ft\.?|with)\s+(.+?)\s*\)/gi;
|
|
const allFeatArtists = [...(track.title?.matchAll(featPattern) ?? [])].flatMap((m) =>
|
|
m[1]
|
|
.split(/\s*[,&]\s*/)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
);
|
|
if (allFeatArtists.length > 0) {
|
|
const knownLower = new Set(knownArtists.map((n) => n.toLowerCase()));
|
|
for (const feat of allFeatArtists) {
|
|
if (!knownLower.has(feat.toLowerCase())) {
|
|
knownArtists.push(feat);
|
|
knownLower.add(feat.toLowerCase());
|
|
}
|
|
}
|
|
}
|
|
|
|
return knownArtists;
|
|
}
|
|
|
|
/**
|
|
* Builds a full artist string by combining the track's listed artists
|
|
* with any featured artists parsed from the title (feat./with).
|
|
*/
|
|
export function getFullArtistString(track) {
|
|
const knownArtists = getFullArtistArray(track);
|
|
|
|
return knownArtists.join('; ') || null;
|
|
}
|
|
|
|
export function fetchBlob(url) {
|
|
return fetch(url).then((d) => d.blob());
|
|
}
|
|
|
|
export async function fetchBlobURL(url) {
|
|
return URL.createObjectURL(await fetchBlob(url));
|
|
}
|
|
|
|
export function getMimeType(data) {
|
|
if (data.length >= 2 && data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg';
|
|
if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
|
|
return 'image/png';
|
|
return 'image/jpeg';
|
|
}
|
|
|
|
/**
|
|
* Retrieves the cover ID or image URL for a track
|
|
* @param {Object} track - The track object
|
|
* @param {Object} [track.album] - The album object associated with the track
|
|
* @param {string} [track.album.cover] - The album cover ID or URL
|
|
* @param {string} [track.album.coverId] - The album cover ID
|
|
* @param {string} [track.album.image] - The album image URL
|
|
* @param {string} [track.cover] - The track cover ID or URL
|
|
* @param {string} [track.coverId] - The track cover ID
|
|
* @param {string} [track.image] - The track image URL
|
|
* @returns {string|null} The cover ID or image URL, or null if none is available
|
|
*/
|
|
export function getTrackCoverId(track) {
|
|
return (
|
|
track.album?.cover ||
|
|
track.cover ||
|
|
track.image ||
|
|
track.album?.coverId ||
|
|
track.coverId ||
|
|
track.album?.image ||
|
|
null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Converts a value to a positive integer.
|
|
* @param {*} value - The value to convert to a positive integer.
|
|
* @returns {number|null} The parsed positive integer, or null if the value is not a finite positive number.
|
|
*/
|
|
export function toPositiveInt(value) {
|
|
const parsed = parseInt(value, 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
}
|
|
|
|
/**
|
|
* Extracts the disc number from a track object by checking multiple possible property names.
|
|
* @param {Object} track - The track object to extract the disc number from.
|
|
* @returns {number|null} The disc number as a positive integer, or null if no valid disc number is found.
|
|
*/
|
|
export function getTrackDiscNumber(track) {
|
|
const candidates = [
|
|
track?.volumeNumber,
|
|
track?.discNumber,
|
|
track?.mediaNumber,
|
|
track?.media_number,
|
|
track?.volume,
|
|
track?.disc,
|
|
track?.volume?.number,
|
|
track?.disc?.number,
|
|
track?.media?.number,
|
|
track?.disc,
|
|
track?.disc_no,
|
|
track?.discNo,
|
|
track?.disc_number,
|
|
track?.mediaMetadata?.discNumber,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const parsed = toPositiveInt(candidate);
|
|
if (parsed) return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Executes a function with a fallback error handler.
|
|
* Works with both synchronous and asynchronous callbacks.
|
|
*
|
|
* If the callback returns a Promise, the result will also be a Promise.
|
|
*
|
|
* @template T
|
|
* @param {() => T | Promise<T>} fn Function to execute
|
|
* @param {(error: unknown) => T | Promise<T>} onError Error handler
|
|
* @returns {T | Promise<T>}
|
|
*/
|
|
export function tryCatch(fn, onError) {
|
|
try {
|
|
const result = fn();
|
|
|
|
if (result instanceof Promise) {
|
|
return result.catch(onError);
|
|
}
|
|
|
|
return result;
|
|
} catch (err) {
|
|
return onError(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace `{token}` placeholders in a template string.
|
|
*
|
|
* Replacement values are inserted verbatim and are NOT reprocessed,
|
|
* preventing cascading replacements if values contain token patterns.
|
|
*
|
|
* @param {string} template The input string containing tokens like `{tokenName}`
|
|
* @param {Record<string, string>} tokens An object of tokens to replace and the replacement values.
|
|
* @returns {string} The string with valid tokens replaced
|
|
*/
|
|
export function replaceTokens(template, tokens) {
|
|
return template.replace(/{([^{}]+)}/g, (match, key) => {
|
|
return key in tokens ? tokens[key] : match;
|
|
});
|
|
}
|
|
|
|
export function createModal({ title, content, className = '', onClose }) {
|
|
const modal = document.createElement('div');
|
|
modal.className = 'modal active';
|
|
modal.style.zIndex = '10000';
|
|
|
|
modal.innerHTML = `
|
|
<div class="modal-overlay"></div>
|
|
<div class="modal-content ${className}" style="display: flex; flex-direction: column;">
|
|
<div class="modal-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem;">
|
|
<h3 style="margin: 0;">${title}</h3>
|
|
<button class="btn-close" style="background: none; border: none; font-size: 2rem; cursor: pointer; color: var(--foreground); padding: 0.2rem 0.5rem; line-height: 1;">×</button>
|
|
</div>
|
|
<div class="modal-body" style="max-height: 70vh; overflow-y: auto; padding-right: 0.5rem;"></div>
|
|
</div>
|
|
`;
|
|
|
|
const body = modal.querySelector('.modal-body');
|
|
if (typeof content === 'string') {
|
|
body.innerHTML = content;
|
|
} else if (content instanceof HTMLElement) {
|
|
body.appendChild(content);
|
|
}
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
const close = () => {
|
|
modal.remove();
|
|
if (onClose) onClose();
|
|
};
|
|
|
|
modal.querySelector('.modal-overlay').onclick = close;
|
|
modal.querySelector('.btn-close').onclick = close;
|
|
|
|
return { modal, close };
|
|
}
|