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 @@
-
+