//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: 'DOLBY_ATMOS', HI_RES_LOSSLESS: 'HI_RES_LOSSLESS', LOSSLESS: 'LOSSLESS', HIGH: 'HIGH', LOW: 'LOW', }; export const QUALITY_PRIORITY = ['DOLBY_ATMOS', 'HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW']; export const QUALITY_TOKENS = { DOLBY_ATMOS: ['DOLBY_ATMOS', 'ATMOS'], HI_RES_LOSSLESS: [ 'HI_RES_LOSSLESS', 'HIRES_LOSSLESS', 'HIRESLOSSLESS', 'HIFI_PLUS', 'HI_RES_FLAC', 'HI_RES', 'HIRES', 'MASTER', 'MASTER_QUALITY', 'MQA', ], LOSSLESS: ['LOSSLESS', 'HIFI'], HIGH: ['HIGH', 'HIGH_QUALITY'], LOW: ['LOW', 'LOW_QUALITY'], }; 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 `
${text}
`; }; 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} - 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': return 'm4a'; default: return 'flac'; } }; export const buildTrackFilename = (track, quality, extension = 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: 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 createQualityBadgeHTML = (track) => { if (!qualityBadgeSettings.isEnabled()) return ''; const quality = deriveTrackQuality(track); if (quality === 'DOLBY_ATMOS') { return `${SVG_ATMOS(20)}`; } else if (quality === 'HI_RES_LOSSLESS') { return 'HD'; } 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 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; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }; export const escapeHtml = (unsafe) => { if (typeof unsafe !== 'string') return unsafe; return unsafe .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 `${escapedName}`; } // For normal tracks, use the artist ID return `${escapedName}`; }) .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} - 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} fn Function to execute * @param {(error: unknown) => T | Promise} onError Error handler * @returns {T | Promise} */ 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} 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 = ` `; 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 }; }