diff --git a/build-assets/nsis/uninstall.nsh b/build-assets/nsis/uninstall.nsh new file mode 100644 index 0000000..0d590be --- /dev/null +++ b/build-assets/nsis/uninstall.nsh @@ -0,0 +1,12 @@ +!include LogicLib.nsh + +!macro customUnInstall + MessageBox MB_YESNO|MB_ICONQUESTION "Do you want to delete local files created by Blueberry Music Player?" /SD IDNO IDYES deleteFiles + Goto done + +deleteFiles: + RMDir /r "$LOCALAPPDATA\Blueberry Music Player" + RMDir /r "$APPDATA\Blueberry Music Player" + +done: +!macroend diff --git a/main.js b/main.js index 34e01d8..2e6f320 100644 --- a/main.js +++ b/main.js @@ -2,17 +2,25 @@ const path = require('path') const fs = require('fs') -const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron') +const crypto = require('crypto') +const { pathToFileURL } = require('url') +const { app, BrowserWindow, ipcMain, dialog, shell, nativeImage } = require('electron') const jsmediatags = require('jsmediatags') const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav']) const MAX_AUDIO_FILE_BYTES = Number(process.env.MAX_AUDIO_FILE_BYTES || 100 * 1024 * 1024) +const MAX_METADATA_IMAGE_BYTES = Number(process.env.MAX_METADATA_IMAGE_BYTES || 5 * 1024 * 1024) +const MAX_FOLDER_STAT_WORKERS = Number(process.env.MAX_FOLDER_STAT_WORKERS || 32) +const ARTWORK_THUMBNAIL_SIZE = Number(process.env.ARTWORK_THUMBNAIL_SIZE || 1024) +const ARTWORK_CACHE_VERSION = 'v2' const DEFAULT_VOLUME = 0.7 let settingsStore = null const allowedAudioPaths = new Set() const approvedAudioDirectories = new Set() let memoryLogInterval = null +// if (process.env.ELECTRON_DISABLE_HARDWARE_ACCELERATION === '1') { app.disableHardwareAcceleration() +// } function getAppIconPath() { const iconCandidates = [ @@ -165,7 +173,69 @@ function normalizeImageMime(format) { return `image/${normalized}` } -function normalizeMetadataPicture(picture) { +async function ensureArtworkCacheDirectory() { + const directory = path.join(app.getPath('userData'), 'artwork-cache') + await fs.promises.mkdir(directory, { recursive: true }) + return directory +} + +function getArtworkCacheKey({ filePath, stats, pictureFormat }) { + return crypto + .createHash('sha1') + .update( + [ + filePath, + Number(stats?.mtimeMs || 0), + Number(stats?.size || 0), + pictureFormat || '', + ARTWORK_THUMBNAIL_SIZE, + ARTWORK_CACHE_VERSION, + ].join('|'), + ) + .digest('hex') +} + +async function writeArtworkThumbnail({ buffer, filePath, stats, pictureFormat }) { + if (!buffer?.length || buffer.length > MAX_METADATA_IMAGE_BYTES) { + return null + } + + const cacheDirectory = await ensureArtworkCacheDirectory() + const cachePath = path.join( + cacheDirectory, + `${getArtworkCacheKey({ filePath, stats, pictureFormat })}.png`, + ) + + try { + await fs.promises.access(cachePath, fs.constants.R_OK) + return pathToFileURL(cachePath).href + } catch { + // Cache miss; generate below. + } + + const sourceImage = nativeImage.createFromBuffer(buffer) + if (!sourceImage || sourceImage.isEmpty()) { + return null + } + + const sourceSize = sourceImage.getSize() + const largestSide = Math.max(sourceSize.width || 0, sourceSize.height || 0) + if (!largestSide) { + return null + } + + const scale = Math.min(1, ARTWORK_THUMBNAIL_SIZE / largestSide) + const thumbnail = sourceImage.resize({ + width: Math.max(1, Math.round(sourceSize.width * scale)), + height: Math.max(1, Math.round(sourceSize.height * scale)), + quality: 'best', + }) + + await fs.promises.writeFile(cachePath, thumbnail.toPNG()) + return pathToFileURL(cachePath).href +} + +async function normalizeMetadataPicture(picture, { filePath, stats } = {}) { if (!picture?.data) { return { image: null, @@ -193,14 +263,48 @@ function normalizeMetadataPicture(picture) { const pictureFormat = normalizeImageMime(picture.format) return { - image: `data:${pictureFormat};base64,${buffer.toString('base64')}`, + image: await writeArtworkThumbnail({ + buffer, + filePath, + stats, + pictureFormat, + }), pictureFormat, pictureBytes: buffer.length, } } -function normalizeAudioMetadataTags(tags = {}) { - const picture = normalizeMetadataPicture(tags.picture) +async function mapWithConcurrency(items, concurrency, mapper) { + const results = new Array(items.length) + let nextIndex = 0 + const safeConcurrency = Number.isFinite(Number(concurrency)) + ? Math.floor(Number(concurrency)) + : 1 + const workerCount = Math.min(Math.max(1, safeConcurrency), items.length) + + const workers = Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await mapper(items[index], index) + } + }) + + await Promise.all(workers) + return results +} + +async function normalizeAudioMetadataTags( + tags = {}, + { includeImage = true, filePath = '', stats = null } = {}, +) { + const picture = includeImage + ? await normalizeMetadataPicture(tags.picture, { filePath, stats }) + : { + image: null, + pictureFormat: null, + pictureBytes: 0, + } const metadata = { title: normalizeMetadataTagValue(tags.title), artist: normalizeMetadataTagValue(tags.artist), @@ -216,23 +320,36 @@ function normalizeAudioMetadataTags(tags = {}) { const hasMetadata = Boolean( metadata.title || - metadata.artist || - metadata.album || - metadata.year || - metadata.genre || - metadata.track || - metadata.disc || - metadata.image, + metadata.artist || + metadata.album || + metadata.year || + metadata.genre || + metadata.track || + metadata.disc || + metadata.image, ) return hasMetadata ? metadata : null } -function readAudioMetadata(filePath) { +function readAudioMetadata(filePath, options = {}) { return new Promise((resolve) => { jsmediatags.read(filePath, { - onSuccess: (tag) => { - resolve(normalizeAudioMetadataTags(tag?.tags || {})) + onSuccess: async (tag) => { + try { + resolve( + await normalizeAudioMetadataTags(tag?.tags || {}, { + ...options, + filePath, + }), + ) + } catch (error) { + console.warn('Failed to normalize audio metadata:', { + filePath, + error: String(error?.message || error || 'unknown error'), + }) + resolve(null) + } }, onError: (error) => { console.warn('Failed to read audio metadata:', { @@ -285,7 +402,7 @@ const createWindow = () => { // debug in dev win.loadFile('ui/index.html') - if (app.isPackaged === false) { + if (app.isPackaged === false && process.env.ELECTRON_OPEN_DEVTOOLS !== '0') { win.webContents.openDevTools() } win.on('ready-to-show', () => { @@ -423,15 +540,15 @@ ipcMain.handle('folder:getAudioFiles', async (event, folderPath) => { .filter((entry) => entry.isFile()) .map((entry) => path.join(folderPath, entry.name)) .filter((filePath) => AUDIO_EXTENSIONS.has(path.extname(filePath).toLowerCase())) - const filesWithDates = await Promise.all( - audioPaths.map(async (filePath) => { + const filesWithDates = ( + await mapWithConcurrency(audioPaths, MAX_FOLDER_STAT_WORKERS, async (filePath) => { const stats = await fs.promises.stat(filePath) return { filePath, createdAt: stats.birthtimeMs, } - }), - ) + }) + ).filter(Boolean) // play files in order they were added to the folder const files = filesWithDates @@ -520,6 +637,58 @@ ipcMain.handle( }, ) +ipcMain.handle( + 'playlist:savePlaybackPosition', + async ( + event, + { currentTrackIndex, currentTrackPath, currentTrackOccurrence, playbackPosition } = {}, + ) => { + if (!settingsStore) { + return false + } + + try { + const playlist = settingsStore.get('recentPlaylist', []) + + let approvedCurrentTrackIndex = -1 + + // Prefer resolving by renderer-provided track path (and optional occurrence) + if (currentTrackPath && Array.isArray(playlist)) { + if (Number.isInteger(currentTrackOccurrence) && currentTrackOccurrence >= 1) { + approvedCurrentTrackIndex = findNthOccurrenceIndex( + playlist, + currentTrackPath, + currentTrackOccurrence, + ) + } else { + approvedCurrentTrackIndex = playlist.indexOf(currentTrackPath) + } + } + + // Fall back to numeric index if no path match available + if ( + approvedCurrentTrackIndex < 0 && + Number.isInteger(currentTrackIndex) && + Array.isArray(playlist) && + currentTrackIndex >= 0 && + currentTrackIndex < playlist.length + ) { + approvedCurrentTrackIndex = currentTrackIndex + } + + settingsStore.set('recentPlaylistIndex', approvedCurrentTrackIndex) + settingsStore.set( + 'recentPlaybackPosition', + approvedCurrentTrackIndex >= 0 ? normalizePlaybackPosition(playbackPosition) : 0, + ) + return true + } catch (error) { + console.error('Failed to save playback position:', error) + return false + } + }, +) + ipcMain.handle('playlist:load', async () => { if (!settingsStore) { return { playlist: [], currentTrackIndex: -1, playbackPosition: 0 } @@ -611,13 +780,28 @@ ipcMain.handle('file:approveRecentAudioPath', async (event, filePath) => { } }) -ipcMain.handle('file:readAudioMetadata', async (event, filePath) => { +ipcMain.handle('file:readAudioMetadata', async (event, payload) => { try { - if (!(await getApprovedAudioFileStats(filePath, 'readAudioMetadata'))) { + const filePath = + typeof payload === 'string' + ? payload + : typeof payload?.filePath === 'string' + ? payload.filePath + : '' + const options = + payload && typeof payload === 'object' && typeof payload.options === 'object' + ? payload.options + : {} + + const stats = await getApprovedAudioFileStats(filePath, 'readAudioMetadata') + if (!stats) { return null } - return await readAudioMetadata(filePath) + return await readAudioMetadata(filePath, { + includeImage: options.includeImage !== false, + stats, + }) } catch (error) { console.error('Failed to read audio metadata:', error) return null diff --git a/package.json b/package.json index 4750639..ef7bcd9 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true, - "deleteAppDataOnUninstall": true + "include": "build-assets/nsis/uninstall.nsh" } } } diff --git a/preload.js b/preload.js index 8ba7d02..e16e38f 100644 --- a/preload.js +++ b/preload.js @@ -7,11 +7,24 @@ contextBridge.exposeInMainWorld('electronAPI', { selectImageFile: () => ipcRenderer.invoke('dialog:openImageFile'), openFolder: () => ipcRenderer.invoke('dialog:openFolder'), getAudioFilesInFolder: (folderPath) => ipcRenderer.invoke('folder:getAudioFiles', folderPath), - readAudioMetadata: (filePath) => ipcRenderer.invoke('file:readAudioMetadata', filePath), + readAudioMetadata: (filePath, options = {}) => + ipcRenderer.invoke('file:readAudioMetadata', { filePath, options }), getSavedVolume: () => ipcRenderer.invoke('settings:getVolume'), saveVolume: (volume) => ipcRenderer.invoke('settings:setVolume', volume), savePlaylist: (playlist, currentTrackIndex, playbackPosition = 0) => ipcRenderer.invoke('playlist:save', { playlist, currentTrackIndex, playbackPosition }), + savePlaybackPosition: ( + currentTrackIndex, + playbackPosition = 0, + currentTrackPath, + currentTrackOccurrence, + ) => + ipcRenderer.invoke('playlist:savePlaybackPosition', { + currentTrackIndex, + currentTrackPath, + currentTrackOccurrence, + playbackPosition, + }), loadPlaylist: () => ipcRenderer.invoke('playlist:load'), saveRecentTracks: (tracks) => ipcRenderer.invoke('recent-tracks:save', tracks), loadRecentTracks: () => ipcRenderer.invoke('recent-tracks:load'), diff --git a/ui/assets/cursor-auto.png b/ui/assets/cursor-auto.png new file mode 100644 index 0000000..e412e8b Binary files /dev/null and b/ui/assets/cursor-auto.png differ diff --git a/ui/assets/cursor-drag-clicked.png b/ui/assets/cursor-drag-clicked.png new file mode 100644 index 0000000..eb28371 Binary files /dev/null and b/ui/assets/cursor-drag-clicked.png differ diff --git a/ui/assets/cursor-drag.png b/ui/assets/cursor-drag.png new file mode 100644 index 0000000..fe9b27b Binary files /dev/null and b/ui/assets/cursor-drag.png differ diff --git a/ui/assets/cursor-pointer-clicked.png b/ui/assets/cursor-pointer-clicked.png new file mode 100644 index 0000000..1c57eb0 Binary files /dev/null and b/ui/assets/cursor-pointer-clicked.png differ diff --git a/ui/assets/cursor-text.png b/ui/assets/cursor-text.png new file mode 100644 index 0000000..ea7695e Binary files /dev/null and b/ui/assets/cursor-text.png differ diff --git a/ui/components/bottom-player/player.css b/ui/components/bottom-player/player.css index 43b5bf0..32bef68 100644 --- a/ui/components/bottom-player/player.css +++ b/ui/components/bottom-player/player.css @@ -104,6 +104,15 @@ background: #f5f8fc; } +#bottom-player #trackRepeatBtn.is-active { + color: #2563eb; + background: #e8f0ff; +} + +#bottom-player #trackRepeatBtn.is-active:hover { + background: #dbeafe; +} + #bottom-player #playPauseBtn { width: 40px; height: 40px; @@ -134,6 +143,200 @@ max-width: 120px; } +#bottom-player .trackMenuHost { + position: relative; + display: inline-flex; +} + +#bottom-player .trackMenu { + position: absolute; + right: 0; + bottom: 42px; + min-width: 160px; + border: 1px solid #d9dee7; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 8px 18px rgba(23, 31, 56, 0.16); + padding: 6px; + z-index: 1200; +} + +#bottom-player .trackMenu[hidden] { + display: none; +} + +#bottom-player .trackMenu.is-open { + display: grid; + gap: 6px; +} + +#bottom-player .trackMenuItem { + width: 100%; + height: 34px; + justify-content: flex-start; + border: 1px solid transparent; + border-radius: 8px; + padding: 0 10px; + font: inherit; + font-size: 12px; + font-weight: 600; + color: #273244; + gap: 6px; +} + +#bottom-player .trackMenuItem:hover { + border-color: #d6dbe3; + background: #eef3ff; +} + +.playerModalHost { + display: none; +} + +.playerModalHost.is-open { + display: block; +} + +.playerModalBackdrop { + position: fixed; + inset: 0; + background: rgba(8, 17, 36, 0.38); + z-index: 2500; +} + +.playerPropertiesDialog { + position: fixed; + left: 50%; + top: 50%; + width: min(560px, calc(100vw - 24px)); + max-height: min(680px, calc(100vh - 32px)); + transform: translate(-50%, -50%); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + border: 1px solid #d8dee8; + border-radius: 10px; + background: #ffffff; + box-shadow: 0 12px 32px rgba(18, 28, 47, 0.24); + z-index: 2501; + overflow: hidden; +} + +.playerPropertiesHeader { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + gap: 12px; + align-items: center; + padding: 14px; + border-bottom: 1px solid #edf1f5; +} + +.playerPropertiesArtwork { + width: 64px; + height: 64px; + border-radius: 8px; + object-fit: cover; + background: #edf2f7; +} + +.playerPropertiesHeader h3 { + margin: 0; + font-size: 18px; +} + +.playerPropertiesHeader p { + margin: 4px 0 0; + color: #5f6368; + font-size: 13px; + overflow-wrap: anywhere; +} + +.playerPropertiesContent { + min-height: 0; + overflow-y: auto; + padding: 12px 14px; + display: grid; + gap: 14px; +} + +.playerPropertySection { + display: grid; + gap: 8px; +} + +.playerPropertySection h4 { + margin: 0; + color: #344054; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.playerPropertySection dl { + margin: 0; + display: grid; + gap: 6px; +} + +.playerPropertyRow { + display: grid; + grid-template-columns: minmax(96px, 0.36fr) minmax(0, 1fr); + gap: 10px; + align-items: start; + border-bottom: 1px solid #f1f4f8; + padding-bottom: 6px; +} + +.playerPropertyRow dt, +.playerPropertyRow dd { + margin: 0; + font-size: 13px; +} + +.playerPropertyRow dt { + color: #667085; +} + +.playerPropertyRow dd { + color: #1f2937; + overflow-wrap: anywhere; +} + +.playerPropertiesEmpty, +.playerPropertiesError { + margin: 0; + color: #667085; + font-size: 13px; +} + +.playerPropertiesError { + color: #b42318; +} + +.playerPropertiesActions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 14px; + border-top: 1px solid #edf1f5; +} + +.playerPropertiesCloseBtn { + min-height: 34px; + border: 1px solid #cfd7e2; + border-radius: 8px; + background: #ffffff; + color: #273244; + padding: 0 12px; + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.playerPropertiesCloseBtn:hover { + background: #eef3ff; +} + @media (max-height: 620px) and (min-width: 761px) { :root { --bottom-player-height: 82px; @@ -218,3 +421,19 @@ max-width: 100%; } } + +@media (max-width: 560px) { + .playerPropertiesHeader { + grid-template-columns: 52px minmax(0, 1fr); + } + + .playerPropertiesArtwork { + width: 52px; + height: 52px; + } + + .playerPropertyRow { + grid-template-columns: 1fr; + gap: 3px; + } +} diff --git a/ui/components/bottom-player/player.html b/ui/components/bottom-player/player.html index ba46fd9..3195867 100644 --- a/ui/components/bottom-player/player.html +++ b/ui/components/bottom-player/player.html @@ -55,8 +55,31 @@ - +
+ + + +
diff --git a/ui/components/bottom-player/player.js b/ui/components/bottom-player/player.js index 9f210ca..1dc00dd 100644 --- a/ui/components/bottom-player/player.js +++ b/ui/components/bottom-player/player.js @@ -1,6 +1,19 @@ import { playerState } from '../../state/player-state.js' import { audioService } from '../../services/audio-service.js' import { formatDurationClock as formatTime } from '../../utils/duration.js' +import { bindModalResolve, showModalPrompt } from '../../utils/dom-helpers.js' + +const PLAYER_MODAL_HOST_CLASS = 'playerModalHost' +const METADATA_LABELS = { + title: 'Title', + artist: 'Artist', + album: 'Album', + year: 'Year', + genre: 'Genre', + track: 'Track', + disc: 'Disc', +} +const HIDDEN_METADATA_TAGS = new Set(['image', 'pictureFormat', 'pictureBytes']) export function initializePlayer() { const bottomPlayer = document.getElementById('bottom-player') @@ -16,19 +29,25 @@ export function initializePlayer() { const playPauseBtn = bottomPlayer.querySelector('#playPauseBtn') const prevBtn = bottomPlayer.querySelector('#prevBtn') const nextBtn = bottomPlayer.querySelector('#nextBtn') + const repeatBtn = bottomPlayer.querySelector('#trackRepeatBtn') const progressSlider = bottomPlayer.querySelector('#progressSlider') const currentTimeElement = bottomPlayer.querySelector('#currentTime') const durationElement = bottomPlayer.querySelector('#duration') const volumeSlider = bottomPlayer.querySelector('#volumeSlider') const volumeBtn = bottomPlayer.querySelector('#volumeBtn') + const trackMenuBtn = bottomPlayer.querySelector('#trackMenuBtn') + const trackMenu = bottomPlayer.querySelector('#trackMenu') + const trackPropertiesBtn = bottomPlayer.querySelector('#trackPropertiesBtn') const placeholderCover = audioService?.placeholderCover || './assets/music-placeholder.png' let progressRafId = null + let latestSnapshot = playerState.getState() console.log('Player DOM elements found') - function updatePlayerUI() { + function updatePlayerUI(snapshot = latestSnapshot) { // console.log('updatePlayerUI triggered'); - const { currentTrack, isPlaying, volume } = playerState.getState() + latestSnapshot = snapshot || latestSnapshot + const { currentTrack, isPlaying, volume, loopEnabled } = latestSnapshot const title = currentTrack?.title || 'No song selected' const artist = currentTrack?.artist || 'Unknown artist' const image = currentTrack?.image || placeholderCover @@ -52,6 +71,11 @@ export function initializePlayer() { volumeSlider.value = Number.isFinite(Number(volume)) ? Number(volume) : 0.7 } + if (repeatBtn) { + repeatBtn.classList.toggle('is-active', Boolean(loopEnabled)) + repeatBtn.setAttribute('aria-pressed', String(Boolean(loopEnabled))) + } + const sound = audioService.getCurrentSound() if (sound) { const seek = sound.seek() || 0 @@ -73,6 +97,267 @@ export function initializePlayer() { } } + function getActiveTrackFilePath(snapshot = playerState.getState()) { + const playlist = Array.isArray(snapshot?.playlist) ? snapshot.playlist : [] + const currentTrackIndex = Number.isInteger(snapshot?.currentTrackIndex) + ? snapshot.currentTrackIndex + : -1 + + if (currentTrackIndex >= 0 && currentTrackIndex < playlist.length) { + return playlist[currentTrackIndex] + } + + return typeof snapshot?.currentTrack?.filePath === 'string' + ? snapshot.currentTrack.filePath + : '' + } + + function getFileName(filePath) { + return String(filePath || '') + .split(/[\\/]/) + .filter(Boolean) + .pop() + } + + function formatPropertyValue(value) { + if (value === null || value === undefined || value === '') { + return '' + } + + if (Array.isArray(value)) { + return value.filter(Boolean).join(', ') + } + + if (typeof value === 'object') { + return JSON.stringify(value) + } + + return String(value) + } + + function createMetadataRow(label, value) { + const row = document.createElement('div') + row.className = 'playerPropertyRow' + + const term = document.createElement('dt') + term.textContent = label + + const description = document.createElement('dd') + description.textContent = value || 'Not available' + + row.appendChild(term) + row.appendChild(description) + return row + } + + function appendMetadataSection(container, title, rows) { + const section = document.createElement('section') + section.className = 'playerPropertySection' + + const heading = document.createElement('h4') + heading.textContent = title + section.appendChild(heading) + + if (!rows.length) { + const empty = document.createElement('p') + empty.className = 'playerPropertiesEmpty' + empty.textContent = 'No metadata found.' + section.appendChild(empty) + container.appendChild(section) + return + } + + const list = document.createElement('dl') + rows.forEach(([label, value]) => { + list.appendChild(createMetadataRow(label, value)) + }) + section.appendChild(list) + container.appendChild(section) + } + + function buildRawMetadataRows(rawMetadata) { + if (!rawMetadata || typeof rawMetadata !== 'object') { + return [] + } + + return Object.entries(rawMetadata) + .filter(([key]) => !HIDDEN_METADATA_TAGS.has(key)) + .map(([key, value]) => [METADATA_LABELS[key] || key, formatPropertyValue(value)]) + } + + function buildPropertiesDialog({ filePath, snapshot, rawMetadata, metadataError }) { + const fragment = document.createDocumentFragment() + + const backdrop = document.createElement('div') + backdrop.className = 'playerModalBackdrop' + backdrop.setAttribute('data-close', 'true') + + const dialog = document.createElement('div') + dialog.className = 'playerPropertiesDialog' + dialog.setAttribute('role', 'dialog') + dialog.setAttribute('aria-modal', 'true') + dialog.setAttribute('aria-labelledby', 'playerPropertiesTitle') + + const currentTrack = snapshot?.currentTrack || {} + const displayData = filePath ? audioService.getTrackDisplayData(filePath) : {} + const title = + currentTrack.title || rawMetadata?.title || displayData.title || 'No track selected' + const artist = + currentTrack.artist || rawMetadata?.artist || displayData.artist || 'Unknown artist' + const album = currentTrack.album || rawMetadata?.album || displayData.album || '' + const artwork = + currentTrack.image || rawMetadata?.image || displayData.image || placeholderCover + + const header = document.createElement('div') + header.className = 'playerPropertiesHeader' + + const artworkImage = document.createElement('img') + artworkImage.src = artwork + artworkImage.alt = title + artworkImage.className = 'playerPropertiesArtwork' + artworkImage.addEventListener('error', () => { + artworkImage.src = placeholderCover + }) + + const headingWrap = document.createElement('div') + const heading = document.createElement('h3') + heading.id = 'playerPropertiesTitle' + heading.textContent = 'Track Properties' + + const subtitle = document.createElement('p') + subtitle.textContent = filePath ? getFileName(filePath) || filePath : 'No track selected' + + headingWrap.appendChild(heading) + headingWrap.appendChild(subtitle) + header.appendChild(artworkImage) + header.appendChild(headingWrap) + + const content = document.createElement('div') + content.className = 'playerPropertiesContent' + + const playlist = Array.isArray(snapshot?.playlist) ? snapshot.playlist : [] + const currentTrackIndex = Number.isInteger(snapshot?.currentTrackIndex) + ? snapshot.currentTrackIndex + : -1 + const sound = audioService.getCurrentSound() + const duration = Number(sound?.duration?.() || snapshot?.progress?.duration || 0) + + appendMetadataSection(content, 'File', [ + ['File name', filePath ? getFileName(filePath) || filePath : ''], + ['File path', filePath], + [ + 'Queue position', + currentTrackIndex >= 0 && playlist.length + ? `${currentTrackIndex + 1} of ${playlist.length}` + : '', + ], + ['Duration', duration > 0 ? formatTime(duration) : ''], + ]) + + appendMetadataSection(content, 'Current Display', [ + ['Title', title], + ['Artist', artist], + ['Album', album], + ]) + + appendMetadataSection(content, 'Metadata Tags', buildRawMetadataRows(rawMetadata)) + + if (metadataError) { + const error = document.createElement('p') + error.className = 'playerPropertiesError' + error.textContent = `Could not read embedded metadata: ${metadataError}` + content.appendChild(error) + } + + const actions = document.createElement('div') + actions.className = 'playerPropertiesActions' + + const closeButton = document.createElement('button') + closeButton.type = 'button' + closeButton.className = 'playerPropertiesCloseBtn' + closeButton.textContent = 'Close' + actions.appendChild(closeButton) + + dialog.appendChild(header) + dialog.appendChild(content) + dialog.appendChild(actions) + + fragment.appendChild(backdrop) + fragment.appendChild(dialog) + return fragment + } + + function showPropertiesDialog(props) { + return showModalPrompt({ + scope: document.body, + contentNode: buildPropertiesDialog(props), + hostClass: PLAYER_MODAL_HOST_CLASS, + fallbackValue: undefined, + onBind: ({ modalHost, resolve }) => { + bindModalResolve({ + modalHost, + selector: '.playerPropertiesCloseBtn', + resolve, + value: undefined, + }) + bindModalResolve({ + modalHost, + selector: '.playerModalBackdrop', + resolve, + value: undefined, + }) + }, + }) + } + + function closeTrackMenu() { + if (trackMenu) { + trackMenu.hidden = true + trackMenu.classList.remove('is-open') + } + + if (trackMenuBtn) { + trackMenuBtn.setAttribute('aria-expanded', 'false') + } + } + + function toggleTrackMenu() { + if (!trackMenu || !trackMenuBtn) { + return + } + + const shouldOpen = trackMenu.hidden + trackMenu.hidden = !shouldOpen + trackMenu.classList.toggle('is-open', shouldOpen) + trackMenuBtn.setAttribute('aria-expanded', String(shouldOpen)) + } + + async function showCurrentTrackProperties() { + closeTrackMenu() + + const snapshot = playerState.getState() + const filePath = getActiveTrackFilePath(snapshot) + let rawMetadata = null + let metadataError = '' + + if (filePath && typeof window.electronAPI?.readAudioMetadata === 'function') { + try { + rawMetadata = await window.electronAPI.readAudioMetadata(filePath, { + includeImage: false, + }) + } catch (error) { + metadataError = error?.message || String(error) + } + } + + await showPropertiesDialog({ + filePath, + snapshot, + rawMetadata, + metadataError, + }) + } + function setupEventListeners() { if (playPauseBtn) { playPauseBtn.addEventListener('click', () => { @@ -95,6 +380,12 @@ export function initializePlayer() { }) } + if (repeatBtn) { + repeatBtn.addEventListener('click', () => { + playerState.toggleLoopEnabled() + }) + } + if (progressSlider) { progressSlider.addEventListener('input', (e) => { const sound = audioService.getCurrentSound() @@ -120,6 +411,34 @@ export function initializePlayer() { }) } + if (trackMenuBtn) { + trackMenuBtn.addEventListener('click', (event) => { + event.stopPropagation() + toggleTrackMenu() + }) + } + + if (trackMenu) { + trackMenu.addEventListener('click', (event) => { + event.stopPropagation() + }) + } + + if (trackPropertiesBtn) { + trackPropertiesBtn.addEventListener('click', () => { + showCurrentTrackProperties().catch((error) => { + console.error('Failed to show track properties:', error) + }) + }) + } + + document.addEventListener('click', closeTrackMenu) + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + closeTrackMenu() + } + }) + if (albumArtElement) { albumArtElement.addEventListener('error', () => { albumArtElement.src = placeholderCover @@ -139,7 +458,7 @@ export function initializePlayer() { function updateProgress() { const sound = audioService.getCurrentSound() - const { isPlaying } = playerState.getState() + const isPlaying = Boolean(latestSnapshot?.isPlaying) if (!sound || !isPlaying) { stopProgressLoop() @@ -161,7 +480,7 @@ export function initializePlayer() { } const sound = audioService.getCurrentSound() - const { isPlaying } = playerState.getState() + const isPlaying = Boolean(latestSnapshot?.isPlaying) if (!sound || !isPlaying) { return } @@ -169,11 +488,12 @@ export function initializePlayer() { progressRafId = requestAnimationFrame(updateProgress) } - const unsubscribe = playerState.subscribe(() => { - updatePlayerUI() + const unsubscribe = playerState.subscribe((snapshot) => { + latestSnapshot = snapshot + updatePlayerUI(snapshot) const sound = audioService.getCurrentSound() - const { isPlaying } = playerState.getState() + const isPlaying = Boolean(snapshot?.isPlaying) if (sound && isPlaying) { startProgressLoop() } else { diff --git a/ui/components/loading/loading.css b/ui/components/loading/loading.css index eaa3ca1..0556f23 100644 --- a/ui/components/loading/loading.css +++ b/ui/components/loading/loading.css @@ -8,7 +8,7 @@ background: #0f172a; - display: flex; + /* display: flex; */ justify-content: center; align-items: center; opacity: 0.8; @@ -22,3 +22,7 @@ #loadingText { color: #ffffff; } + +.fade-out { + pointer-events: none; +} diff --git a/ui/components/recent-music/recent-metadata.js b/ui/components/recent-music/recent-metadata.js index 774b134..830de8e 100644 --- a/ui/components/recent-music/recent-metadata.js +++ b/ui/components/recent-music/recent-metadata.js @@ -9,10 +9,20 @@ import { audioService } from '../../services/audio-service.js' const VIEW_SONGS_METADATA_DEBUG_ENABLED = false const VIEW_SONGS_METADATA_WORKERS = 4 +const VIEW_SONGS_METADATA_CACHE_LIMIT = 240 -// Simple in-memory cache for resolved metadata promises keyed by file path. +// Simple in-memory cache for resolved metadata promises keyed by file path+options. const metadataCache = new Map() +function makeMetadataCacheKey(filePath, options) { + const opts = options && typeof options === 'object' ? options : {} + try { + return `${filePath}::${JSON.stringify(opts)}` + } catch { + return `${filePath}::${String(opts)}` + } +} + export function clearMetadataCache() { metadataCache.clear() } @@ -28,6 +38,36 @@ function logViewSongsMetadataDebug(phase, payload = {}) { }) } +function getCachedMetadataPromise(filePath, options) { + const key = makeMetadataCacheKey(filePath, options) + if (!metadataCache.has(key)) { + return null + } + + const cached = metadataCache.get(key) + metadataCache.delete(key) + metadataCache.set(key, cached) + return cached +} + +function setCachedMetadataPromise(filePath, options, fetchPromise) { + const key = makeMetadataCacheKey(filePath, options) + if (metadataCache.has(key)) { + metadataCache.delete(key) + } + + metadataCache.set(key, fetchPromise) + + if (metadataCache.size <= VIEW_SONGS_METADATA_CACHE_LIMIT) { + return + } + + const oldestKey = metadataCache.keys().next().value + if (oldestKey) { + metadataCache.delete(oldestKey) + } +} + function updateViewSongsTrackRow(modalHost, trackIndex, { title, artist }) { if (!modalHost?.isConnected || !Number.isInteger(trackIndex)) { return @@ -115,16 +155,17 @@ export async function hydrateViewSongsMetadata({ playlist, tracks, modalHost, si try { // Reuse cached promise when available so concurrent requests share work - let fetchPromise = metadataCache.get(filePath) + const fetchOptions = { includeImage: false } + let fetchPromise = getCachedMetadataPromise(filePath, fetchOptions) if (!fetchPromise) { fetchPromise = (async () => { try { - return await audioService.resolveTrackMetadata(filePath) + return await audioService.resolveTrackMetadata(filePath, fetchOptions) } catch { return null } })() - metadataCache.set(filePath, fetchPromise) + setCachedMetadataPromise(filePath, fetchOptions, fetchPromise) } const resolvedMetadata = await fetchPromise @@ -217,16 +258,17 @@ export async function resolvePlaylistTracksMetadata(tracks, { signal } = {}) { try { // Reuse cached promise when available so concurrent requests share work - let fetchPromise = metadataCache.get(filePath) + const fetchOptions = { includeImage: true } + let fetchPromise = getCachedMetadataPromise(filePath, fetchOptions) if (!fetchPromise) { fetchPromise = (async () => { try { - return await audioService.resolveTrackMetadata(filePath) + return await audioService.resolveTrackMetadata(filePath, fetchOptions) } catch { return null } })() - metadataCache.set(filePath, fetchPromise) + setCachedMetadataPromise(filePath, fetchOptions, fetchPromise) } const resolvedMetadata = await fetchPromise diff --git a/ui/components/recent-music/recent-music.css b/ui/components/recent-music/recent-music.css index 22e9de9..fcc1f71 100644 --- a/ui/components/recent-music/recent-music.css +++ b/ui/components/recent-music/recent-music.css @@ -371,6 +371,9 @@ border-radius: 8px; padding: 9px 10px; font: inherit; + cursor: + url('../../assets/cursor-text.png') 7 7, + text !important; } .recentModalActions { diff --git a/ui/components/recent-music/recent-music.js b/ui/components/recent-music/recent-music.js index 661a243..5ecdf70 100644 --- a/ui/components/recent-music/recent-music.js +++ b/ui/components/recent-music/recent-music.js @@ -10,6 +10,10 @@ import { import { sessionService } from '../../services/session-service.js' import { audioService } from '../../services/audio-service.js' import { isRouteActive } from '../../utils/route.js' +import { + hydrateImageWithPlaylistArtwork, + hydrateImageWithTrackArtwork, +} from '../../utils/artwork.js' import * as RecentRenderer from './recent-renderer.js' import * as RecentModals from './recent-modals.js' import * as RecentMetadata from './recent-metadata.js' @@ -384,6 +388,47 @@ export function initializeRecentMusic() { }) } + function hydrateRecentArtwork(scope) { + scope.querySelectorAll('.recentTrack').forEach((trackElement) => { + const index = getDataAttributeIndex(trackElement, 'data-track-index') + const imageElement = trackElement.querySelector('.trackCover img') + const track = index === null ? null : recentTracks[index] + if (!track || !imageElement) { + return + } + + hydrateImageWithTrackArtwork({ + imageElement, + track, + audioService, + }) + .then((artwork) => { + if (artwork && recentTracks[index]) { + recentTracks[index] = { + ...recentTracks[index], + image: artwork, + } + } + }) + .catch(() => {}) + }) + + scope.querySelectorAll('.recentPlaylistCard').forEach((playlistElement) => { + const index = getDataAttributeIndex(playlistElement, 'data-playlist-index') + const imageElement = playlistElement.querySelector('.recentPlaylistCover') + const playlist = index === null ? null : recentFolderPlaylists[index] + if (!playlist || !imageElement) { + return + } + + hydrateImageWithPlaylistArtwork({ + imageElement, + playlist, + audioService, + }).catch(() => {}) + }) + } + function renderRecentContent() { const contentContainer = recentMusic.querySelector('.recentTabContent') if (!contentContainer) { @@ -410,6 +455,8 @@ export function initializeRecentMusic() { selector: '.recentPlaylistCover', }) + hydrateRecentArtwork(contentContainer) + window.lucide?.createIcons() cleanupTrackMenuToggles = attachIndexedMenuToggle({ diff --git a/ui/components/skeleton-screen/skeleton.css b/ui/components/skeleton-screen/skeleton.css new file mode 100644 index 0000000..9915dda --- /dev/null +++ b/ui/components/skeleton-screen/skeleton.css @@ -0,0 +1,407 @@ +/* Skeleton screen styles */ +:root { + --skeleton-bg: #e6eef8; + --skeleton-highlight: #f5f7fb; + --skeleton-overlay-bg: rgba(255, 255, 255, 0.85); +} + +#loadingOverlay { + position: fixed; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + background: var(--skeleton-overlay-bg); + + /* display: flex; */ + justify-content: center; + align-items: flex-start; + padding: 40px 16px; + box-sizing: border-box; + + z-index: 9999; + + transition: opacity 0.3s ease; + display: none; +} + +.skeleton-container { + width: 100%; + max-width: 1100px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.skeleton-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 6px; +} + +.skeleton-row { + align-items: center; +} + +.skeleton-thumb { + width: 56px; + height: 56px; + border-radius: 6px; + background: var(--skeleton-bg); + flex: 0 0 auto; +} + +.skeleton-lines { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.skeleton-line { + height: 12px; + border-radius: 4px; + background: var(--skeleton-bg); + width: 100%; + max-width: 80%; +} + +.skeleton-line-short { + max-width: 45%; +} +.skeleton-line-long { + max-width: 100%; +} + +.skeleton-duration { + width: 48px; + height: 12px; + background: var(--skeleton-bg); + border-radius: 4px; + flex: 0 0 auto; +} + +.skeleton-box { + width: 100%; + height: 140px; + border-radius: 8px; + background: var(--skeleton-bg); +} + +.skeleton-block { + background: linear-gradient( + 90deg, + var(--skeleton-bg) 25%, + var(--skeleton-highlight) 50%, + var(--skeleton-bg) 75% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.2s linear infinite; +} + +@keyframes skeleton-shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.fade-out { + opacity: 0; + pointer-events: none; + transition: opacity 300ms ease; +} + +.visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +@media (prefers-reduced-motion: reduce) { + .skeleton-block { + animation: none; + } +} + +#loadingOverlay.loading-overlay-playlist { + inset: var(--skeleton-overlay-top, 0) var(--skeleton-overlay-right, 0) + var(--skeleton-overlay-bottom, var(--bottom-player-height, 92px)) + var(--skeleton-overlay-left, 0); + width: auto; + height: auto; + justify-content: flex-start; + padding: 8px; + overflow-y: auto; +} + +#loadingOverlay.loading-overlay-playlist .skeleton-container.skeleton-table { + max-width: none; + padding: 16px; + box-sizing: border-box; +} + +/* Playlist (table-like) skeleton */ +.skeleton-table { + width: 100%; + display: flex; + flex-direction: column; + gap: 0; +} + +.skeleton-track-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.skeleton-track-table thead { + background: #f8fafc; +} + +.skeleton-track-table th { + padding: 10px; + border-bottom: 1px solid #edf1f5; + text-align: center; + font-size: 13px; + box-sizing: border-box; +} + +.skeleton-track-table td { + height: 60px; + padding: 0 5px; + border-bottom: 1px solid #edf1f5; + text-align: center; + font-size: 13px; + box-sizing: border-box; + overflow: hidden; + vertical-align: middle; +} + +.skeleton-track-table th:first-child, +.skeleton-track-table td.skeleton-col-index { + width: 56px; +} + +.skeleton-track-table th:last-child, +.skeleton-track-table td.skeleton-col-actions { + width: 48px; +} + +.skeleton-track-table td.skeleton-col-actions { + overflow: visible; + position: relative; +} + +.skeleton-track-table th:nth-child(2), +.skeleton-track-table td.skeleton-col-title { + text-align: left; +} + +.skeleton-table-row { + height: 60px; +} + +.skeleton-heading-line, +.skeleton-heading-subline { + height: 13px; + border-radius: 4px; + margin: 0 auto; +} + +.skeleton-heading-subline { + height: 12px; + margin-top: 2px; +} + +.skeleton-track-table th:nth-child(2) .skeleton-heading-line { + margin-left: 0; +} + +.skeleton-index-button { + width: 30px; + height: 30px; + border-radius: 8px; + margin: 0 auto; +} + +.skeleton-track-title-wrap { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.skeleton-track-cover { + width: 38px; + height: 38px; + border-radius: 8px; + background: var(--skeleton-bg); + flex-shrink: 0; +} + +.skeleton-track-title-line { + width: 60%; + max-width: 240px; + height: 13px; + border-radius: 4px; +} + +.skeleton-col-artist .skeleton-line, +.skeleton-col-album .skeleton-line, +.skeleton-col-date .skeleton-line { + width: 70%; + max-width: 140px; + height: 13px; + margin: 0 auto; +} + +.skeleton-col-duration .skeleton-line { + width: 48px; + height: 13px; + margin: 0 auto; +} + +.skeleton-actions-button { + width: 34px; + height: 34px; + border-radius: 8px; + margin: 0 auto; +} + +/* Header skeleton to mirror playlist header */ +.skeleton-header-card { + width: 100%; + border: 1px solid #d8dee8; + border-radius: 12px; + overflow: hidden; + background: #ffffff; + box-sizing: border-box; +} + +.skeleton-header-body { + display: grid; + grid-template-columns: 140px 1fr; + gap: 14px; + padding: 14px; + box-sizing: border-box; +} + +.skeleton-header-image { + width: 140px; + height: 140px; + border-radius: 10px; + background: var(--skeleton-bg); +} + +.skeleton-meta { + display: flex; + flex-direction: column; + gap: 6px; + justify-content: center; +} + +.skeleton-meta .skeleton-line:nth-child(1) { + width: 90px; + height: 12px; +} +.skeleton-meta .skeleton-line:nth-child(2) { + width: 60%; + height: 20px; +} +.skeleton-meta .skeleton-line:nth-child(3) { + width: 45%; + height: 12px; +} + +.skeleton-controls { + margin-top: 12px; + display: flex; + gap: 8px; + align-items: center; +} + +.skeleton-control-play { + width: 40px; + height: 40px; + border-radius: 6px; +} +.skeleton-control-small { + width: 34px; + height: 34px; + border-radius: 6px; +} + +.skeleton-track-section { + margin-top: 14px; + border: 1px solid #d8dee8; + border-radius: 10px; + background: #fff; + overflow: visible; + padding: 0; +} + +@media (max-width: 780px) { + .skeleton-header-body { + grid-template-columns: 1fr; + } + .skeleton-header-image { + width: 100%; + max-width: 180px; + height: 180px; + } +} + +/* Grid and player variants */ +.skeleton-container.skeleton-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + align-items: start; +} + +.skeleton-item.skeleton-tile { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + border-radius: 6px; +} + +.skeleton-item.skeleton-tile .skeleton-box { + height: 120px; +} + +.skeleton-container.skeleton-player { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; +} + +.skeleton-item.skeleton-player-row { + display: flex; + gap: 20px; + align-items: center; + justify-content: center; +} + +.skeleton-player-art { + width: 220px; + height: 220px; + border-radius: 8px; + background: var(--skeleton-bg); +} diff --git a/ui/components/skeleton-screen/skeleton.js b/ui/components/skeleton-screen/skeleton.js new file mode 100644 index 0000000..2134d1f --- /dev/null +++ b/ui/components/skeleton-screen/skeleton.js @@ -0,0 +1,433 @@ +function getOverlay() { + return document.getElementById('loadingOverlay') +} + +function getElementRect(element) { + if (!element || typeof element.getBoundingClientRect !== 'function') { + return null + } + + const rect = element.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) { + return null + } + + return rect +} + +function clearPlaylistOverlayBounds(overlay) { + overlay.style.removeProperty('--skeleton-overlay-top') + overlay.style.removeProperty('--skeleton-overlay-right') + overlay.style.removeProperty('--skeleton-overlay-bottom') + overlay.style.removeProperty('--skeleton-overlay-left') +} + +function applyPlaylistOverlayBounds(overlay) { + const appScroll = window.appScrollElement || document.getElementById('app-scroll') + const routeHost = document.getElementById('recent-music') + const sidebar = + document.querySelector('#sidebar .sideBar') || document.getElementById('sidebar') + + const scrollRect = getElementRect(appScroll) + const routeRect = getElementRect(routeHost) + const sidebarRect = getElementRect(sidebar) + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0 + const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0 + + let top = routeRect?.top ?? scrollRect?.top ?? 0 + let left = routeRect?.left ?? scrollRect?.left ?? 0 + let rightEdge = routeRect?.right ?? scrollRect?.right ?? viewportWidth + const bottomEdge = scrollRect?.bottom ?? viewportHeight + + if (sidebarRect) { + const sidebarLooksHorizontal = sidebarRect.width >= viewportWidth * 0.4 + const sidebarLooksVertical = sidebarRect.height >= viewportHeight * 0.4 + + if (sidebarLooksHorizontal && sidebarRect.top <= top && sidebarRect.bottom > top) { + top = Math.max(top, sidebarRect.bottom) + } + + if (sidebarLooksVertical && sidebarRect.left <= left && sidebarRect.right > left) { + left = Math.max(left, sidebarRect.right) + } + } + + rightEdge = Math.max(rightEdge, left) + + overlay.style.setProperty('--skeleton-overlay-top', `${Math.max(0, Math.round(top))}px`) + overlay.style.setProperty( + '--skeleton-overlay-right', + `${Math.max(0, Math.round(viewportWidth - rightEdge))}px`, + ) + overlay.style.setProperty( + '--skeleton-overlay-bottom', + `${Math.max(0, Math.round(viewportHeight - bottomEdge))}px`, + ) + overlay.style.setProperty('--skeleton-overlay-left', `${Math.max(0, Math.round(left))}px`) +} + +function ensureOverlayElements() { + let overlay = getOverlay() + + if (!overlay) { + overlay = document.createElement('div') + overlay.id = 'loadingOverlay' + overlay.style.display = 'none' + document.body.appendChild(overlay) + } + + let container = overlay.querySelector('.skeleton-container') + if (!container) { + container = document.createElement('div') + container.className = 'skeleton-container' + overlay.appendChild(container) + } + + let status = overlay.querySelector('.loadingStatus') + if (!status) { + status = document.createElement('div') + status.className = 'loadingStatus visually-hidden' + status.setAttribute('aria-live', 'polite') + overlay.appendChild(status) + } + + return { + overlay, + container, + status, + } +} + +function renderSkeletonItems(container, count = 6, variant = 'row') { + container.innerHTML = '' + container.classList.remove( + 'skeleton-grid', + 'skeleton-player', + 'skeleton-rows', + 'skeleton-table', + ) + + if (variant === 'grid') { + container.classList.add('skeleton-grid') + for (let i = 0; i < count; i++) { + const tile = document.createElement('div') + tile.className = 'skeleton-item skeleton-tile' + + const box = document.createElement('div') + box.className = 'skeleton-box skeleton-block' + + const line = document.createElement('div') + line.className = 'skeleton-line skeleton-line-long skeleton-block' + + tile.appendChild(box) + tile.appendChild(line) + container.appendChild(tile) + } + + return + } + + if (variant === 'playlist') { + container.classList.add('skeleton-table') + + // Header card mirrors .playlistHeader and .playlistHeaderBody. + const headerCard = document.createElement('div') + headerCard.className = 'skeleton-header-card' + + const headerBody = document.createElement('div') + headerBody.className = 'skeleton-header-body' + + const image = document.createElement('div') + image.className = 'skeleton-header-image skeleton-block' + + const meta = document.createElement('div') + meta.className = 'skeleton-meta' + const metaLabel = document.createElement('div') + metaLabel.className = 'skeleton-line skeleton-block' + const metaTitle = document.createElement('div') + metaTitle.className = 'skeleton-line skeleton-block' + const metaSub = document.createElement('div') + metaSub.className = 'skeleton-line skeleton-block' + + meta.appendChild(metaLabel) + meta.appendChild(metaTitle) + meta.appendChild(metaSub) + + headerBody.appendChild(image) + headerBody.appendChild(meta) + headerCard.appendChild(headerBody) + + container.appendChild(headerCard) + + // controls + const controls = document.createElement('div') + controls.className = 'skeleton-controls' + const play = document.createElement('div') + play.className = 'skeleton-control-play skeleton-block' + const shuffle = document.createElement('div') + shuffle.className = 'skeleton-control-small skeleton-block' + const advance = document.createElement('div') + advance.className = 'skeleton-control-small skeleton-block' + controls.appendChild(play) + controls.appendChild(shuffle) + controls.appendChild(advance) + container.appendChild(controls) + + // Track section mirrors .playlistTrackSection > .playlistTrackTable. + const trackSection = document.createElement('div') + trackSection.className = 'skeleton-track-section' + + const table = document.createElement('table') + table.className = 'skeleton-track-table' + + const thead = document.createElement('thead') + const headerRow = document.createElement('tr') + const headerCells = [ + { className: 'skeleton-col-index', width: '24px' }, + { className: 'skeleton-col-title', width: '48px' }, + { className: 'skeleton-col-artist', width: '48px' }, + { className: 'skeleton-col-album', width: '48px' }, + { className: 'skeleton-col-date', width: '78px', subWidth: '64px' }, + { className: 'skeleton-col-duration', width: '58px' }, + { className: 'skeleton-col-actions', width: '1px' }, + ] + + headerCells.forEach((cell) => { + const th = document.createElement('th') + th.className = cell.className + + if (cell.width !== '1px') { + const line = document.createElement('div') + line.className = 'skeleton-heading-line skeleton-block' + line.style.width = cell.width + th.appendChild(line) + } + + if (cell.subWidth) { + const subline = document.createElement('div') + subline.className = 'skeleton-heading-subline skeleton-block' + subline.style.width = cell.subWidth + th.appendChild(subline) + } + + headerRow.appendChild(th) + }) + + thead.appendChild(headerRow) + table.appendChild(thead) + + const tbody = document.createElement('tbody') + + for (let i = 0; i < Math.max(1, count); i++) { + const row = document.createElement('tr') + row.className = 'skeleton-table-row' + + // index + const colIndex = document.createElement('td') + colIndex.className = 'skeleton-cell skeleton-col-index' + const idxLine = document.createElement('div') + idxLine.className = 'skeleton-index-button skeleton-block' + colIndex.appendChild(idxLine) + + // title (cover + lines) + const colTitle = document.createElement('td') + colTitle.className = 'skeleton-cell skeleton-col-title' + const titleWrap = document.createElement('div') + titleWrap.className = 'skeleton-track-title-wrap' + const cover = document.createElement('div') + cover.className = 'skeleton-track-cover skeleton-block' + const titleLine = document.createElement('div') + titleLine.className = 'skeleton-track-title-line skeleton-block' + titleWrap.appendChild(cover) + titleWrap.appendChild(titleLine) + colTitle.appendChild(titleWrap) + + // artist + const colArtist = document.createElement('td') + colArtist.className = 'skeleton-cell skeleton-col-artist' + const artistLine = document.createElement('div') + artistLine.className = 'skeleton-line skeleton-block' + colArtist.appendChild(artistLine) + + // album + const colAlbum = document.createElement('td') + colAlbum.className = 'skeleton-cell skeleton-col-album' + const albumLine = document.createElement('div') + albumLine.className = 'skeleton-line skeleton-block' + colAlbum.appendChild(albumLine) + + // date + const colDate = document.createElement('td') + colDate.className = 'skeleton-cell skeleton-col-date' + const dateLine = document.createElement('div') + dateLine.className = 'skeleton-line skeleton-block' + colDate.appendChild(dateLine) + + // duration + const colDuration = document.createElement('td') + colDuration.className = 'skeleton-cell skeleton-col-duration' + const durLine = document.createElement('div') + durLine.className = 'skeleton-line skeleton-block' + colDuration.appendChild(durLine) + + // actions + const colActions = document.createElement('td') + colActions.className = 'skeleton-cell skeleton-col-actions' + const actLine = document.createElement('div') + actLine.className = 'skeleton-actions-button skeleton-block' + colActions.appendChild(actLine) + + row.appendChild(colIndex) + row.appendChild(colTitle) + row.appendChild(colArtist) + row.appendChild(colAlbum) + row.appendChild(colDate) + row.appendChild(colDuration) + row.appendChild(colActions) + + tbody.appendChild(row) + } + + table.appendChild(tbody) + trackSection.appendChild(table) + container.appendChild(trackSection) + + return + } + + if (variant === 'player') { + container.classList.add('skeleton-player') + for (let i = 0; i < Math.max(1, count); i++) { + const item = document.createElement('div') + item.className = 'skeleton-item skeleton-player-row' + + const art = document.createElement('div') + art.className = 'skeleton-player-art skeleton-block' + + const lines = document.createElement('div') + lines.className = 'skeleton-lines' + + const title = document.createElement('div') + title.className = 'skeleton-line skeleton-line-long skeleton-block' + title.style.height = '18px' + + const subtitle = document.createElement('div') + subtitle.className = 'skeleton-line skeleton-line-short skeleton-block' + subtitle.style.height = '14px' + + lines.appendChild(title) + lines.appendChild(subtitle) + + item.appendChild(art) + item.appendChild(lines) + container.appendChild(item) + } + + return + } + + // default: row + container.classList.add('skeleton-rows') + for (let i = 0; i < count; i++) { + const item = document.createElement('div') + item.className = 'skeleton-item skeleton-row' + + const thumb = document.createElement('div') + thumb.className = 'skeleton-thumb skeleton-block' + + const lines = document.createElement('div') + lines.className = 'skeleton-lines' + + const line1 = document.createElement('div') + line1.className = 'skeleton-line skeleton-line-long skeleton-block' + + const line2 = document.createElement('div') + line2.className = 'skeleton-line skeleton-line-short skeleton-block' + + lines.appendChild(line1) + lines.appendChild(line2) + + const dur = document.createElement('div') + dur.className = 'skeleton-duration skeleton-block' + + item.appendChild(thumb) + item.appendChild(lines) + item.appendChild(dur) + + container.appendChild(item) + } +} + +export function initializeLoadingScreen() { + ensureOverlayElements() + + let hideTimeout = null + + window.loader = { + setMessage(text) { + const ensured = ensureOverlayElements() + if (ensured && ensured.status) { + ensured.status.textContent = text + } + }, + + show(opts = {}) { + let text = 'Loading...' + let count = 6 + let variant = 'row' + + if (typeof opts === 'string') { + text = opts + } else if (opts && typeof opts === 'object') { + if (opts.text) text = opts.text + if (Number.isFinite(opts.count)) count = opts.count + if (opts.variant && typeof opts.variant === 'string') variant = opts.variant + } + + if (hideTimeout) { + clearTimeout(hideTimeout) + hideTimeout = null + } + + const ensured = ensureOverlayElements() + const overlay = ensured?.overlay + const container = ensured?.container + const status = ensured?.status + + if (!overlay || !container) return + + if (status && text) status.textContent = text + + const isPlaylistVariant = variant === 'playlist' + overlay.classList.toggle('loading-overlay-playlist', isPlaylistVariant) + if (isPlaylistVariant) { + applyPlaylistOverlayBounds(overlay) + } else { + clearPlaylistOverlayBounds(overlay) + } + renderSkeletonItems(container, count, variant) + + overlay.style.display = 'flex' + overlay.classList.remove('fade-out') + }, + + hide(minDuration = 1000) { + const overlay = getOverlay() + + if (!overlay) return + + overlay.classList.add('fade-out') + + hideTimeout = setTimeout(() => { + overlay.style.display = 'none' + overlay.classList.remove('loading-overlay-playlist') + clearPlaylistOverlayBounds(overlay) + const container = overlay.querySelector('.skeleton-container') + if (container) container.innerHTML = '' + hideTimeout = null + }, minDuration) + }, + } +} + +window.initializeLoadingScreen = initializeLoadingScreen diff --git a/ui/index.html b/ui/index.html index 107cabf..c451539 100644 --- a/ui/index.html +++ b/ui/index.html @@ -11,7 +11,7 @@ - +