diff --git a/.gitignore b/.gitignore index 96e844b..92ad8c0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json tmp/ library_cache.db +musicroom.db diff --git a/channel.ts b/channel.ts index 3dc4404..8076cf7 100644 --- a/channel.ts +++ b/channel.ts @@ -7,7 +7,7 @@ export interface Track { duration: number; } -export type PlaybackMode = "repeat-all" | "repeat-one" | "shuffle"; +export type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle"; export interface ChannelConfig { id: string; @@ -98,6 +98,15 @@ export class Channel { if (this.queue.length === 0) return; switch (this.playbackMode) { + case "once": + // Play through once, stop at end + if (this.currentIndex < this.queue.length - 1) { + this.currentIndex++; + } else { + // At end of playlist - pause + this.paused = true; + } + break; case "repeat-one": // Stay on same track, just reset timestamp break; diff --git a/config.json b/config.json index 16bf2aa..5902517 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "port": 3001, - "musicDir": "./music", + "musicDir": "D:\\Music\\school_music", "allowGuests": true, "defaultPermissions": { "channel": ["listen", "control"] diff --git a/db.ts b/db.ts index 485ae91..ee6164f 100644 --- a/db.ts +++ b/db.ts @@ -55,19 +55,6 @@ db.run(` ) `); -// Content-addressed tracks table -db.run(` - CREATE TABLE IF NOT EXISTS tracks ( - id TEXT PRIMARY KEY, - title TEXT, - artist TEXT, - album TEXT, - duration REAL NOT NULL, - size INTEGER NOT NULL, - created_at INTEGER DEFAULT (unixepoch()) - ) -`); - // Types export interface User { id: number; @@ -274,26 +261,6 @@ export function getUserSessions(userId: number): Omit[] { // Cleanup expired sessions periodically setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour -// Track functions -export function upsertTrack(track: Omit): void { - db.query(` - INSERT INTO tracks (id, title, artist, album, duration, size) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - title = COALESCE(excluded.title, title), - artist = COALESCE(excluded.artist, artist), - album = COALESCE(excluded.album, album) - `).run(track.id, track.title, track.artist, track.album, track.duration, track.size); -} - -export function getTrack(id: string): Track | null { - return db.query("SELECT * FROM tracks WHERE id = ?").get(id) as Track | null; -} - -export function getAllTracks(): Track[] { - return db.query("SELECT * FROM tracks ORDER BY title").all() as Track[]; -} - // Channel tables db.run(` CREATE TABLE IF NOT EXISTS channels ( diff --git a/library.ts b/library.ts index 0cdad0a..8be8e32 100644 --- a/library.ts +++ b/library.ts @@ -4,7 +4,7 @@ import { watch, type FSWatcher } from "fs"; import { readdir, stat } from "fs/promises"; import { join, relative } from "path"; import { parseFile } from "music-metadata"; -import { upsertTrack, type Track } from "./db"; +import { type Track } from "./db"; const HASH_CHUNK_SIZE = 64 * 1024; // 64KB const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]); @@ -34,10 +34,19 @@ export class Library { private watcher: FSWatcher | null = null; private eventListeners = new Map>(); private pendingFiles = new Map(); // filepath -> debounce timer + + // Scan progress tracking + private _scanProgress = { scanning: false, processed: 0, total: 0 }; + private scanCompleteCallbacks: Set<() => void> = new Set(); + + get scanProgress() { + return { ...this._scanProgress }; + } constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") { this.musicDir = musicDir; this.cacheDb = new Database(cacheDbPath); + this.cacheDb.run("PRAGMA journal_mode = WAL"); this.initCacheDb(); } @@ -52,6 +61,56 @@ export class Library { ) `); this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_file_cache_track ON file_cache(track_id)`); + + // Tracks table - stores metadata for each unique track + this.cacheDb.run(` + CREATE TABLE IF NOT EXISTS tracks ( + id TEXT PRIMARY KEY, + title TEXT, + artist TEXT, + album TEXT, + duration REAL NOT NULL, + size INTEGER NOT NULL, + created_at INTEGER DEFAULT (unixepoch()) + ) + `); + + // Library activity log + this.cacheDb.run(` + CREATE TABLE IF NOT EXISTS library_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER DEFAULT (unixepoch()), + action TEXT NOT NULL, + track_id TEXT, + filename TEXT, + title TEXT, + user_id INTEGER, + username TEXT + ) + `); + this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_library_log_time ON library_log(timestamp DESC)`); + } + + logActivity(action: string, track: { id?: string; filename?: string; title?: string | null }, user?: { id: number; username: string } | null): void { + this.cacheDb.query(` + INSERT INTO library_log (action, track_id, filename, title, user_id, username) + VALUES (?, ?, ?, ?, ?, ?) + `).run(action, track.id || null, track.filename || null, track.title || null, user?.id || null, user?.username || null); + } + + getActivityLog(limit: number = 100): Array<{ id: number; timestamp: number; action: string; track_id: string | null; filename: string | null; title: string | null; user_id: number | null; username: string | null }> { + return this.cacheDb.query(`SELECT * FROM library_log ORDER BY timestamp DESC LIMIT ?`).all(limit) as any; + } + + private upsertTrack(track: { id: string; title: string | null; artist: string | null; album: string | null; duration: number; size: number }): void { + this.cacheDb.query(` + INSERT INTO tracks (id, title, artist, album, duration, size) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = COALESCE(excluded.title, title), + artist = COALESCE(excluded.artist, artist), + album = COALESCE(excluded.album, album) + `).run(track.id, track.title, track.artist, track.album, track.duration, track.size); } async computeTrackId(filePath: string): Promise { @@ -148,8 +207,8 @@ export class Library { created_at: Math.floor(Date.now() / 1000), }; - // Upsert to main database - upsertTrack({ + // Upsert to cache database + this.upsertTrack({ id: trackId, title, artist, @@ -166,60 +225,136 @@ export class Library { } async scan(): Promise { - console.log(`[Library] Scanning ${this.musicDir}...`); + console.log(`[Library] Quick loading from cache...`); + + // Single query joining file_cache and tracks - all in cacheDb now + const cachedTracks = this.cacheDb.query(` + SELECT fc.path, fc.track_id, t.title, t.artist, t.album, t.duration + FROM file_cache fc + LEFT JOIN tracks t ON fc.track_id = t.id + `).all() as Array<{ + path: string; + track_id: string; + title: string | null; + artist: string | null; + album: string | null; + duration: number | null; + }>; + + for (const row of cachedTracks) { + const fullPath = join(this.musicDir, row.path); + const filename = row.path.split(/[/\\]/).pop() || row.path; + + const track: LibraryTrack = { + id: row.track_id, + filename, + filepath: fullPath, + title: row.title || filename.replace(/\.[^.]+$/, ""), + artist: row.artist || null, + album: row.album || null, + duration: row.duration || 0, + available: true, + }; + + this.trackMap.set(track.id, fullPath); + this.trackInfo.set(track.id, track); + } + + console.log(`[Library] Quick loaded ${cachedTracks.length} tracks from cache`); + + // Start background scan for new/changed files + this.startBackgroundScan(); + } + + private async startBackgroundScan(): Promise { + console.log(`[Library] Starting background scan...`); const startTime = Date.now(); let processed = 0; - let cached = 0; - - const scanDir = async (dir: string) => { - const entries = await readdir(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = join(dir, entry.name); - - if (entry.isDirectory()) { - await scanDir(fullPath); - } else if (entry.isFile() && this.isAudioFile(entry.name)) { - const relativePath = relative(this.musicDir, fullPath); - const cacheEntry = this.getCacheEntry(relativePath); - - // Quick check if cache is valid - if (cacheEntry) { - try { - const fileStat = await stat(fullPath); - if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) { - // Cache hit - reuse existing data - const existingTrack = this.trackInfo.get(cacheEntry.track_id); - if (!existingTrack) { - // Need metadata but skip hashing - const track = await this.processFile(fullPath); - if (track) { - this.trackMap.set(track.id, fullPath); - this.trackInfo.set(track.id, track); - } - } else { - this.trackMap.set(cacheEntry.track_id, fullPath); - } - cached++; - continue; - } - } catch {} - } - - const track = await this.processFile(fullPath); - if (track) { - this.trackMap.set(track.id, fullPath); - this.trackInfo.set(track.id, track); - processed++; + let skipped = 0; + const BATCH_SIZE = 10; + const BATCH_DELAY_MS = 50; // Pause between batches to not block + + const filesToProcess: string[] = []; + + // Collect all files first (fast operation) + const collectFiles = async (dir: string) => { + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + await collectFiles(fullPath); + } else if (entry.isFile() && this.isAudioFile(entry.name)) { + filesToProcess.push(fullPath); } } + } catch (e) { + console.error(`[Library] Error reading directory ${dir}:`, e); } }; - - await scanDir(this.musicDir); - + + await collectFiles(this.musicDir); + console.log(`[Library] Found ${filesToProcess.length} audio files to check`); + + // Initialize scan progress + this._scanProgress = { scanning: true, processed: 0, total: filesToProcess.length }; + + // Process in batches with delays + for (let i = 0; i < filesToProcess.length; i += BATCH_SIZE) { + const batch = filesToProcess.slice(i, i + BATCH_SIZE); + + for (const fullPath of batch) { + const relativePath = relative(this.musicDir, fullPath); + const cacheEntry = this.getCacheEntry(relativePath); + + // Check if already loaded and cache is valid + if (cacheEntry && this.trackInfo.has(cacheEntry.track_id)) { + try { + const fileStat = await stat(fullPath); + if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) { + skipped++; + this._scanProgress.processed++; + continue; + } + } catch {} + } + + // Need to process this file + const track = await this.processFile(fullPath); + if (track) { + const isNew = !this.trackInfo.has(track.id); + this.trackMap.set(track.id, fullPath); + this.trackInfo.set(track.id, track); + processed++; + + if (isNew) { + this.emit("added", track); + } + } + this._scanProgress.processed++; + } + + // Yield to other operations + if (i + BATCH_SIZE < filesToProcess.length) { + await new Promise(resolve => setTimeout(resolve, BATCH_DELAY_MS)); + } + + // Progress log every 100 files + if ((i + BATCH_SIZE) % 100 === 0) { + console.log(`[Library] Background scan progress: ${Math.min(i + BATCH_SIZE, filesToProcess.length)}/${filesToProcess.length}`); + } + } + + this._scanProgress = { scanning: false, processed: filesToProcess.length, total: filesToProcess.length }; const elapsed = Date.now() - startTime; - console.log(`[Library] Scan complete: ${this.trackMap.size} tracks (${processed} processed, ${cached} cached) in ${elapsed}ms`); + console.log(`[Library] Background scan complete: ${processed} new/updated, ${skipped} unchanged in ${elapsed}ms`); + + // Notify listeners that scan is complete + this.scanCompleteCallbacks.forEach(cb => cb()); + } + + onScanComplete(callback: () => void): void { + this.scanCompleteCallbacks.add(callback); } startWatching(): void { diff --git a/public/channelSync.js b/public/channelSync.js index ff6d904..ca2bd9a 100644 --- a/public/channelSync.js +++ b/public/channelSync.js @@ -209,6 +209,34 @@ } return; } + // Handle scan progress + if (data.type === "scan_progress") { + const el = M.$("#scan-progress"); + const wasScanning = !el.classList.contains("complete") && !el.classList.contains("hidden"); + + if (data.scanning) { + el.innerHTML = `Scanning: ${data.processed}/${data.total} files`; + el.classList.remove("hidden"); + el.classList.remove("complete"); + } else { + // Show track count when not scanning + const count = M.library.length; + el.innerHTML = `${count} song${count !== 1 ? 's' : ''} in library`; + el.classList.remove("hidden"); + el.classList.add("complete"); + + // Show toast if scan just finished + if (wasScanning) { + M.showToast("Scanning complete!"); + } + } + return; + } + // Handle server-sent toasts + if (data.type === "toast") { + M.showToast(data.message, data.toastType || "info"); + return; + } // Normal channel state update M.handleUpdate(data); }; diff --git a/public/controls.js b/public/controls.js index 5631da0..26d3523 100644 --- a/public/controls.js +++ b/public/controls.js @@ -97,18 +97,19 @@ M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1); // Playback mode button - const modeIcons = { - "repeat-all": "🔁", - "repeat-one": "🔂", - "shuffle": "🔀" + const modeLabels = { + "once": "once", + "repeat-all": "repeat", + "repeat-one": "single", + "shuffle": "shuffle" }; - const modeOrder = ["repeat-all", "repeat-one", "shuffle"]; + const modeOrder = ["once", "repeat-all", "repeat-one", "shuffle"]; M.updateModeButton = function() { const btn = M.$("#btn-mode"); - btn.textContent = modeIcons[M.playbackMode] || "🔁"; + btn.textContent = modeLabels[M.playbackMode] || "repeat"; btn.title = `Playback: ${M.playbackMode}`; - btn.classList.toggle("active", M.playbackMode !== "repeat-all"); + btn.className = "mode-" + M.playbackMode; }; M.$("#btn-mode").onclick = async () => { diff --git a/public/index.html b/public/index.html index 12380f7..b551641 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ NeoRose - +
@@ -40,7 +40,7 @@
@@ -56,14 +56,25 @@
+

Library

+
+ + +

Queue

+
@@ -81,7 +92,7 @@ - 🔁 + repeat
0:00/0:00
@@ -96,6 +107,14 @@
+ + @@ -106,6 +125,7 @@ + diff --git a/public/init.js b/public/init.js index b58d400..0170ae8 100644 --- a/public/init.js +++ b/public/init.js @@ -23,6 +23,19 @@ console.log(`TrackStorage: ${M.cachedTracks.size} tracks cached`); } + // Setup history panel handlers + document.addEventListener("DOMContentLoaded", () => { + const btnHistory = M.$("#btn-history"); + const btnClose = M.$("#btn-close-history"); + + if (btnHistory) { + btnHistory.onclick = () => M.toggleToastHistory(); + } + if (btnClose) { + btnClose.onclick = () => M.toggleToastHistory(); + } + }); + // Initialize the application Promise.all([initStorage(), loadServerStatus()]).then(async () => { await M.loadLibrary(); diff --git a/public/queue.js b/public/queue.js index 646c0b8..1d7b647 100644 --- a/public/queue.js +++ b/public/queue.js @@ -313,6 +313,7 @@ if (M.queue.length === 0) { container.innerHTML = '
Queue empty - drag tracks here
'; + M.updateNowPlayingBar(); return; } @@ -333,6 +334,7 @@ div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); div.dataset.index = i; const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); + div.title = title; // Tooltip for full name const checkmark = isSelected ? `` : ''; const trackNum = `${i + 1}.`; @@ -508,8 +510,46 @@ container.appendChild(div); }); + + M.updateNowPlayingBar(); }; + // Update the now-playing bar above the queue + M.updateNowPlayingBar = function() { + const bar = M.$("#now-playing-bar"); + if (!bar) return; + + const track = M.queue[M.currentIndex]; + if (!track) { + bar.classList.add("hidden"); + return; + } + + const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); + bar.innerHTML = `Now playing: ${title}`; + bar.title = title; + bar.classList.remove("hidden"); + }; + + // Scroll queue to current track + M.scrollToCurrentTrack = function() { + const container = M.$("#queue"); + if (!container) return; + + const activeTrack = container.querySelector(".track.active"); + if (activeTrack) { + activeTrack.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }; + + // Setup now-playing bar click handler + document.addEventListener("DOMContentLoaded", () => { + const bar = M.$("#now-playing-bar"); + if (bar) { + bar.onclick = () => M.scrollToCurrentTrack(); + } + }); + // Library search state M.librarySearchQuery = ""; @@ -545,6 +585,7 @@ const isSelected = M.selectedLibraryIds.has(track.id); div.className = "track" + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); + div.title = title; // Tooltip for full name const checkmark = isSelected ? `` : ''; div.innerHTML = `${checkmark}${title}${M.fmt(track.duration)}`; diff --git a/public/styles.css b/public/styles.css index a6a6193..7e45abd 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,6 +1,6 @@ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; min-height: 100vh; } -#app { width: 100%; max-width: 1200px; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; } +#app { width: 100%; max-width: 1700px; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; } h1 { font-size: 1rem; color: #888; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; } h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.05em; } #sync-indicator { width: 8px; height: 8px; border-radius: 50%; background: #4e8; display: none; flex-shrink: 0; } @@ -20,8 +20,8 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe #stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.85rem; } /* Main content - library and queue */ -#main-content { display: flex; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.5rem; } -#channels-panel { flex: 0 0 140px; background: #1a1a1a; border-radius: 6px; padding: 0.4rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; } +#main-content { display: flex; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.5rem; max-width: 1600px; margin-left: auto; margin-right: auto; } +#channels-panel { flex: 0 0 160px; background: #1a1a1a; border-radius: 6px; padding: 0.4rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; } #channels-list { flex: 1; overflow-y: auto; } #channels-list .channel-item { padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; display: flex; flex-direction: column; gap: 0.1rem; } #channels-list .channel-item.active { background: #2a4a3a; color: #4e8; } @@ -33,8 +33,26 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe #channels-list .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 0; position: relative; } #channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; } #channels-list .listener-mult { color: #666; font-size: 0.55rem; } -#library-panel, #queue-panel { flex: 2; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; } +#library-panel, #queue-panel { flex: 0 0 700px; min-width: 0; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; } +.scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; } +.scan-progress.hidden { display: none; } +.scan-progress.complete { color: #4e8; background: #1a2a1a; } +.scan-progress .spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid #ea4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.upload-btn { width: 100%; padding: 0.5rem; background: #2a3a2a; border: 1px dashed #4e8; border-radius: 4px; color: #4e8; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; flex-shrink: 0; } +.upload-btn:hover { background: #3a4a3a; } +.upload-progress { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; background: #2a2a1a; border-radius: 4px; margin-top: 0.3rem; flex-shrink: 0; position: relative; overflow: hidden; } +.upload-progress.hidden { display: none; } +.upload-progress-bar { position: absolute; left: 0; top: 0; bottom: 0; background: #4a6a4a; transition: width 0.2s; } +.upload-progress-text { position: relative; z-index: 1; font-size: 0.75rem; color: #4e8; } +.upload-dropzone { position: absolute; inset: 0; background: rgba(42, 74, 58, 0.95); display: flex; align-items: center; justify-content: center; border-radius: 6px; border: 2px dashed #4e8; z-index: 10; } +.upload-dropzone.hidden { display: none; } +.dropzone-content { color: #4e8; font-size: 1.2rem; font-weight: 600; } #queue-title { margin: 0 0 0.3rem 0; } +.now-playing-bar { font-size: 0.75rem; color: #4e8; padding: 0.3rem 0.5rem; background: #1a2a1a; border: 1px solid #2a4a3a; border-radius: 4px; margin-bottom: 0.3rem; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.now-playing-bar:hover { background: #2a3a2a; } +.now-playing-bar.hidden { display: none; } +.now-playing-bar .label { color: #666; } .panel-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; } .panel-header h3 { margin: 0; flex-shrink: 0; } .panel-header select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } @@ -45,15 +63,16 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe .btn-submit-channel:hover { background: #3a5a4a; } .search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } .search-input::placeholder { color: #666; } -#library, #queue { flex: 1; overflow-y: auto; } -#library .track, #queue .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; } +#library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; } +#library .track, #queue .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; } +#library .track[title], #queue .track[title] { cursor: pointer; } #library .track:hover, #queue .track:hover { background: #222; } #queue .track.active { background: #2a4a3a; color: #4e8; } .cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; } .track.cached .cache-indicator { background: #4e8; } .track.not-cached .cache-indicator { background: #ea4; } .track-number { color: #555; font-size: 0.7rem; min-width: 1.3rem; margin-right: 0.2rem; } -.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } .track-actions .duration { color: #666; font-size: 0.75rem; } .track-actions .track-add, .track-actions .track-remove { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.85rem; color: #ccc; cursor: pointer; opacity: 0; transition: opacity 0.2s; } @@ -96,9 +115,12 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe #btn-sync.synced.connected { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; } #btn-prev, #btn-next { font-size: 0.75rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; } #btn-prev:hover, #btn-next:hover { opacity: 1; } -#btn-mode { font-size: 0.85rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; } -#btn-mode:hover { opacity: 1; } -#btn-mode.active { opacity: 1; color: #4e8; } +#btn-mode { font-size: 0.7rem; cursor: pointer; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; padding: 0.1rem 0.3rem; border-radius: 3px; } +#btn-mode.mode-once { color: #888; text-shadow: none; } +#btn-mode.mode-repeat-all { color: #4e8; text-shadow: 0 0 6px #4e8; } +#btn-mode.mode-repeat-one { color: #eb0; text-shadow: 0 0 6px #eb0; } +#btn-mode.mode-shuffle { color: #c4f; text-shadow: 0 0 6px #c4f; } +#btn-mode:hover { opacity: 0.8; } #status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; } #progress-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; } #progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; } @@ -116,7 +138,7 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe #volume-controls { display: flex; gap: 0.4rem; align-items: center; } #btn-mute { font-size: 1rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; } #btn-mute:hover { opacity: 1; } -#volume { width: 70px; accent-color: #4e8; } +#volume { width: 120px; accent-color: #4e8; } /* Common */ button { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; } @@ -153,8 +175,24 @@ button:hover { background: #333; } ::-webkit-scrollbar-thumb:hover { background-color: #555; } /* Toast notifications */ -#toast-container { position: fixed; top: 0.5rem; left: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; z-index: 1000; pointer-events: none; } +#toast-container { position: fixed; top: 0.5rem; left: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; z-index: 1000; pointer-events: none; max-height: 80vh; overflow-y: auto; } .toast { background: #1a3a2a; color: #4e8; padding: 0.5rem 0.75rem; border-radius: 5px; border: 1px solid #4e8; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font-size: 0.8rem; animation: toast-in 0.3s ease-out; max-width: 280px; } +.toast.toast-warning { background: #3a3a1a; color: #ea4; border-color: #ea4; } +.toast.toast-error { background: #3a1a1a; color: #e44; border-color: #e44; } .toast.fade-out { animation: toast-out 0.3s ease-in forwards; } @keyframes toast-in { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } } @keyframes toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-20px); } } + +/* Toast history panel */ +#btn-history { position: fixed; bottom: 0.5rem; left: 0.5rem; background: #222; border: 1px solid #333; color: #888; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.7rem; z-index: 999; } +#btn-history:hover { background: #333; color: #ccc; } +#toast-history { position: fixed; bottom: 3rem; left: 0.5rem; width: 320px; max-height: 400px; background: #1a1a1a; border: 1px solid #333; border-radius: 6px; z-index: 1001; display: flex; flex-direction: column; } +#toast-history.hidden { display: none; } +.history-header { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px solid #333; color: #888; font-size: 0.8rem; } +.history-header button { background: none; border: none; color: #888; cursor: pointer; font-size: 1rem; padding: 0; width: 20px; height: 20px; } +.history-header button:hover { color: #ccc; } +#toast-history-list { flex: 1; overflow-y: auto; padding: 0.3rem; max-height: 350px; } +.history-item { padding: 0.3rem 0.5rem; font-size: 0.75rem; border-radius: 3px; margin-bottom: 0.2rem; color: #4e8; background: #1a2a1a; } +.history-item.history-warning { color: #ea4; background: #2a2a1a; } +.history-item.history-error { color: #e44; background: #2a1a1a; } +.history-time { color: #666; margin-right: 0.4rem; } diff --git a/public/utils.js b/public/utils.js index 7cfc81a..68495cb 100644 --- a/public/utils.js +++ b/public/utils.js @@ -15,17 +15,56 @@ return m + ":" + String(s).padStart(2, "0"); }; - // Toast notifications - M.showToast = function(message, duration = 4000) { + // Toast history + M.toastHistory = []; + + // Toast notifications (log style - multiple visible) + M.showToast = function(message, type = "info", duration = 5000) { const container = M.$("#toast-container"); const toast = document.createElement("div"); - toast.className = "toast"; + toast.className = "toast toast-" + type; toast.textContent = message; container.appendChild(toast); setTimeout(() => { toast.classList.add("fade-out"); setTimeout(() => toast.remove(), 300); }, duration); + + // Add to history + M.toastHistory.push({ + message, + type, + time: new Date() + }); + M.updateToastHistory(); + }; + + // Update toast history panel + M.updateToastHistory = function() { + const list = M.$("#toast-history-list"); + if (!list) return; + + list.innerHTML = ""; + // Show newest first + const items = [...M.toastHistory].reverse().slice(0, 50); + for (const item of items) { + const div = document.createElement("div"); + div.className = "history-item history-" + item.type; + const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + div.innerHTML = `${time} ${item.message}`; + list.appendChild(div); + } + }; + + // Toggle toast history panel + M.toggleToastHistory = function() { + const panel = M.$("#toast-history"); + if (panel) { + panel.classList.toggle("hidden"); + if (!panel.classList.contains("hidden")) { + M.updateToastHistory(); + } + } }; // Flash permission denied animation @@ -40,23 +79,25 @@ // Set track title (UI and document title) M.setTrackTitle = function(title) { M.currentTitle = title; - const titleEl = M.$("#track-title"); const containerEl = M.$("#track-name"); - const marqueeEl = containerEl.querySelector(".marquee-inner"); + const marqueeEl = containerEl?.querySelector(".marquee-inner"); + + if (!containerEl || !marqueeEl) return; - titleEl.textContent = title; document.title = title ? `${title} - MusicRoom` : "MusicRoom"; + // First set simple content to measure + marqueeEl.innerHTML = `${title}`; + // Check if title overflows and needs scrolling requestAnimationFrame(() => { - const needsScroll = titleEl.scrollWidth > containerEl.clientWidth; + const titleEl = M.$("#track-title"); + const needsScroll = titleEl && titleEl.scrollWidth > containerEl.clientWidth; containerEl.classList.toggle("scrolling", needsScroll); // Duplicate text for seamless wrap-around scrolling if (needsScroll) { marqueeEl.innerHTML = `${title}   •   ${title}   •   `; - } else { - marqueeEl.innerHTML = `${title}`; } }); }; diff --git a/server.ts b/server.ts index 6742d14..ac5acfd 100644 --- a/server.ts +++ b/server.ts @@ -124,6 +124,21 @@ async function init(): Promise { // Scan library first await library.scan(); library.startWatching(); + + // Broadcast when scan completes + library.onScanComplete(() => { + broadcastToAll({ type: "scan_progress", scanning: false }); + }); + + // Broadcast when tracks are added/updated + library.on("added", (track) => { + broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" }); + library.logActivity("scan_added", { id: track.id, filename: track.filename, title: track.title }); + }); + library.on("changed", (track) => { + broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" }); + library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title }); + }); // Load channels from database const savedChannels = loadAllChannels(); @@ -222,6 +237,7 @@ function broadcastChannelList() { // Listen for library changes and notify clients library.on("added", (track) => { console.log(`New track detected: ${track.title}`); + const allTracks = library.getAllTracks().map(t => ({ id: t.id, title: t.title, @@ -266,6 +282,20 @@ setInterval(() => { channel.broadcast(); } } + + // Broadcast scan progress every 2 seconds while scanning + const scanProgress = library.scanProgress; + if (scanProgress.scanning && tickCount % 2 === 0) { + broadcastToAll({ + type: "scan_progress", + scanning: true, + processed: scanProgress.processed, + total: scanProgress.total + }); + } else if (!scanProgress.scanning && tickCount % 30 === 0) { + // Periodically send "not scanning" to clear any stale UI + broadcastToAll({ type: "scan_progress", scanning: false }); + } }, 1000); // Helper to get or create guest session @@ -467,6 +497,55 @@ serve({ return Response.json(tracks, { headers }); } + // API: upload audio file + if (path === "/api/upload" && req.method === "POST") { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + + try { + const formData = await req.formData(); + const uploadedFile = formData.get("file"); + + if (!uploadedFile || !(uploadedFile instanceof File)) { + library.logActivity("upload_failed", { filename: "unknown" }, { id: user.id, username: user.username }); + return Response.json({ error: "No file provided" }, { status: 400 }); + } + + // Validate extension + const validExts = [".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]; + const ext = uploadedFile.name.toLowerCase().match(/\.[^.]+$/)?.[0]; + if (!ext || !validExts.includes(ext)) { + library.logActivity("upload_rejected", { filename: uploadedFile.name }, { id: user.id, username: user.username }); + return Response.json({ error: "Invalid audio format" }, { status: 400 }); + } + + // Sanitize filename + const safeName = uploadedFile.name.replace(/[^a-zA-Z0-9._-]/g, "_"); + const destPath = join(config.musicDir, safeName); + + // Check if file already exists + const existingFile = Bun.file(destPath); + if (await existingFile.exists()) { + library.logActivity("upload_duplicate", { filename: safeName }, { id: user.id, username: user.username }); + return Response.json({ error: "File already exists" }, { status: 409 }); + } + + // Write file + const arrayBuffer = await uploadedFile.arrayBuffer(); + await Bun.write(destPath, arrayBuffer); + + console.log(`[Upload] ${user.username} uploaded: ${safeName}`); + library.logActivity("upload", { filename: safeName }, { id: user.id, username: user.username }); + return Response.json({ success: true, filename: safeName }, { headers }); + } catch (e) { + console.error("[Upload] Error:", e); + library.logActivity("upload_error", { filename: "unknown" }, { id: user.id, username: user.username }); + return Response.json({ error: "Upload failed" }, { status: 500 }); + } + } + // Auth: signup if (path === "/api/auth/signup" && req.method === "POST") { try { @@ -489,6 +568,7 @@ serve({ ?? undefined; const token = createSession(user.id, userAgent, ipAddress); console.log(`[AUTH] Signup: user="${username}" id=${user.id} admin=${user.is_admin} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`); + library.logActivity("account_created", { title: user.is_admin ? "admin" : "user" }, { id: user.id, username: user.username }); return Response.json( { user: { id: user.id, username: user.username, isAdmin: user.is_admin } }, { headers: { "Set-Cookie": setSessionCookie(token) } }