saving
This commit is contained in:
parent
55e4dd3947
commit
d9ece80418
|
|
@ -36,3 +36,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
tmp/
|
tmp/
|
||||||
library_cache.db
|
library_cache.db
|
||||||
|
|
||||||
|
musicroom.db
|
||||||
|
|
|
||||||
11
channel.ts
11
channel.ts
|
|
@ -7,7 +7,7 @@ export interface Track {
|
||||||
duration: number;
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlaybackMode = "repeat-all" | "repeat-one" | "shuffle";
|
export type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle";
|
||||||
|
|
||||||
export interface ChannelConfig {
|
export interface ChannelConfig {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -98,6 +98,15 @@ export class Channel {
|
||||||
if (this.queue.length === 0) return;
|
if (this.queue.length === 0) return;
|
||||||
|
|
||||||
switch (this.playbackMode) {
|
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":
|
case "repeat-one":
|
||||||
// Stay on same track, just reset timestamp
|
// Stay on same track, just reset timestamp
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"port": 3001,
|
"port": 3001,
|
||||||
"musicDir": "./music",
|
"musicDir": "D:\\Music\\school_music",
|
||||||
"allowGuests": true,
|
"allowGuests": true,
|
||||||
"defaultPermissions": {
|
"defaultPermissions": {
|
||||||
"channel": ["listen", "control"]
|
"channel": ["listen", "control"]
|
||||||
|
|
|
||||||
33
db.ts
33
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
|
// Types
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -274,26 +261,6 @@ export function getUserSessions(userId: number): Omit<Session, 'token'>[] {
|
||||||
// Cleanup expired sessions periodically
|
// Cleanup expired sessions periodically
|
||||||
setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour
|
setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour
|
||||||
|
|
||||||
// Track functions
|
|
||||||
export function upsertTrack(track: Omit<Track, "created_at">): 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
|
// Channel tables
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS channels (
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
|
|
||||||
235
library.ts
235
library.ts
|
|
@ -4,7 +4,7 @@ import { watch, type FSWatcher } from "fs";
|
||||||
import { readdir, stat } from "fs/promises";
|
import { readdir, stat } from "fs/promises";
|
||||||
import { join, relative } from "path";
|
import { join, relative } from "path";
|
||||||
import { parseFile } from "music-metadata";
|
import { parseFile } from "music-metadata";
|
||||||
import { upsertTrack, type Track } from "./db";
|
import { type Track } from "./db";
|
||||||
|
|
||||||
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
|
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
|
||||||
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]);
|
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 watcher: FSWatcher | null = null;
|
||||||
private eventListeners = new Map<LibraryEventType, Set<LibraryEventCallback>>();
|
private eventListeners = new Map<LibraryEventType, Set<LibraryEventCallback>>();
|
||||||
private pendingFiles = new Map<string, NodeJS.Timeout>(); // filepath -> debounce timer
|
private pendingFiles = new Map<string, NodeJS.Timeout>(); // 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") {
|
constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") {
|
||||||
this.musicDir = musicDir;
|
this.musicDir = musicDir;
|
||||||
this.cacheDb = new Database(cacheDbPath);
|
this.cacheDb = new Database(cacheDbPath);
|
||||||
|
this.cacheDb.run("PRAGMA journal_mode = WAL");
|
||||||
this.initCacheDb();
|
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)`);
|
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<string> {
|
async computeTrackId(filePath: string): Promise<string> {
|
||||||
|
|
@ -148,8 +207,8 @@ export class Library {
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Upsert to main database
|
// Upsert to cache database
|
||||||
upsertTrack({
|
this.upsertTrack({
|
||||||
id: trackId,
|
id: trackId,
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
|
|
@ -166,60 +225,136 @@ export class Library {
|
||||||
}
|
}
|
||||||
|
|
||||||
async scan(): Promise<void> {
|
async scan(): Promise<void> {
|
||||||
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<void> {
|
||||||
|
console.log(`[Library] Starting background scan...`);
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
let cached = 0;
|
let skipped = 0;
|
||||||
|
const BATCH_SIZE = 10;
|
||||||
const scanDir = async (dir: string) => {
|
const BATCH_DELAY_MS = 50; // Pause between batches to not block
|
||||||
const entries = await readdir(dir, { withFileTypes: true });
|
|
||||||
|
const filesToProcess: string[] = [];
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = join(dir, entry.name);
|
// Collect all files first (fast operation)
|
||||||
|
const collectFiles = async (dir: string) => {
|
||||||
if (entry.isDirectory()) {
|
try {
|
||||||
await scanDir(fullPath);
|
const entries = await readdir(dir, { withFileTypes: true });
|
||||||
} else if (entry.isFile() && this.isAudioFile(entry.name)) {
|
for (const entry of entries) {
|
||||||
const relativePath = relative(this.musicDir, fullPath);
|
const fullPath = join(dir, entry.name);
|
||||||
const cacheEntry = this.getCacheEntry(relativePath);
|
if (entry.isDirectory()) {
|
||||||
|
await collectFiles(fullPath);
|
||||||
// Quick check if cache is valid
|
} else if (entry.isFile() && this.isAudioFile(entry.name)) {
|
||||||
if (cacheEntry) {
|
filesToProcess.push(fullPath);
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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;
|
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 {
|
startWatching(): void {
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,34 @@
|
||||||
}
|
}
|
||||||
return;
|
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 = `<span class="spinner"></span>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
|
// Normal channel state update
|
||||||
M.handleUpdate(data);
|
M.handleUpdate(data);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -97,18 +97,19 @@
|
||||||
M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1);
|
M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1);
|
||||||
|
|
||||||
// Playback mode button
|
// Playback mode button
|
||||||
const modeIcons = {
|
const modeLabels = {
|
||||||
"repeat-all": "🔁",
|
"once": "once",
|
||||||
"repeat-one": "🔂",
|
"repeat-all": "repeat",
|
||||||
"shuffle": "🔀"
|
"repeat-one": "single",
|
||||||
|
"shuffle": "shuffle"
|
||||||
};
|
};
|
||||||
const modeOrder = ["repeat-all", "repeat-one", "shuffle"];
|
const modeOrder = ["once", "repeat-all", "repeat-one", "shuffle"];
|
||||||
|
|
||||||
M.updateModeButton = function() {
|
M.updateModeButton = function() {
|
||||||
const btn = M.$("#btn-mode");
|
const btn = M.$("#btn-mode");
|
||||||
btn.textContent = modeIcons[M.playbackMode] || "🔁";
|
btn.textContent = modeLabels[M.playbackMode] || "repeat";
|
||||||
btn.title = `Playback: ${M.playbackMode}`;
|
btn.title = `Playback: ${M.playbackMode}`;
|
||||||
btn.classList.toggle("active", M.playbackMode !== "repeat-all");
|
btn.className = "mode-" + M.playbackMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
M.$("#btn-mode").onclick = async () => {
|
M.$("#btn-mode").onclick = async () => {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NeoRose</title>
|
<title>NeoRose</title>
|
||||||
<link rel="stylesheet" href="styles.css?v=4">
|
<link rel="stylesheet" href="styles.css?v=19">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="username" id="current-username"></span>
|
<span class="username" id="current-username"></span>
|
||||||
<span class="admin-badge" id="admin-badge" style="display:none;">Admin</span>
|
<span class="admin-badge" id="admin-badge" style="display:none;">Admin</span>
|
||||||
<button id="btn-kick-others" title="Disconnect all other devices">kick others</button>
|
<button id="btn-kick-others" title="Disconnect all other devices">Disconnect all my devices</button>
|
||||||
<button id="btn-logout">logout</button>
|
<button id="btn-logout">logout</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -56,14 +56,25 @@
|
||||||
<div id="channels-list"></div>
|
<div id="channels-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="library-panel">
|
<div id="library-panel">
|
||||||
|
<div id="scan-progress" class="scan-progress hidden"></div>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3>Library</h3>
|
<h3>Library</h3>
|
||||||
<input type="text" id="library-search" placeholder="Search..." class="search-input">
|
<input type="text" id="library-search" placeholder="Search..." class="search-input">
|
||||||
|
<input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none">
|
||||||
</div>
|
</div>
|
||||||
<div id="library"></div>
|
<div id="library"></div>
|
||||||
|
<div id="upload-progress" class="upload-progress hidden">
|
||||||
|
<div class="upload-progress-bar"></div>
|
||||||
|
<span class="upload-progress-text"></span>
|
||||||
|
</div>
|
||||||
|
<button id="btn-upload" class="upload-btn">Upload files...</button>
|
||||||
|
<div id="upload-dropzone" class="upload-dropzone hidden">
|
||||||
|
<div class="dropzone-content">Drop audio files here</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="queue-panel">
|
<div id="queue-panel">
|
||||||
<h3 id="queue-title">Queue</h3>
|
<h3 id="queue-title">Queue</h3>
|
||||||
|
<div id="now-playing-bar" class="now-playing-bar hidden" title="Click to scroll to current track"></div>
|
||||||
<div id="queue"></div>
|
<div id="queue"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,7 +92,7 @@
|
||||||
<span id="btn-prev" title="Previous track">⏮</span>
|
<span id="btn-prev" title="Previous track">⏮</span>
|
||||||
<span id="status-icon">▶</span>
|
<span id="status-icon">▶</span>
|
||||||
<span id="btn-next" title="Next track">⏭</span>
|
<span id="btn-next" title="Next track">⏭</span>
|
||||||
<span id="btn-mode" title="Playback mode">🔁</span>
|
<span id="btn-mode" class="mode-repeat-all" title="Playback mode">repeat</span>
|
||||||
<div id="progress-container"><div id="progress-bar"></div><div id="seek-tooltip"></div></div>
|
<div id="progress-container"><div id="progress-bar"></div><div id="seek-tooltip"></div></div>
|
||||||
<div id="time"><span id="time-current">0:00</span>/<span id="time-total">0:00</span></div>
|
<div id="time"><span id="time-current">0:00</span>/<span id="time-total">0:00</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,6 +107,14 @@
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container"></div>
|
||||||
|
<button id="btn-history" title="Show notification history">recent notifications</button>
|
||||||
|
<div id="toast-history" class="hidden">
|
||||||
|
<div class="history-header">
|
||||||
|
<span>Notification History</span>
|
||||||
|
<button id="btn-close-history">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="toast-history-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="trackStorage.js"></script>
|
<script src="trackStorage.js"></script>
|
||||||
<script src="core.js"></script>
|
<script src="core.js"></script>
|
||||||
|
|
@ -106,6 +125,7 @@
|
||||||
<script src="queue.js"></script>
|
<script src="queue.js"></script>
|
||||||
<script src="controls.js"></script>
|
<script src="controls.js"></script>
|
||||||
<script src="auth.js"></script>
|
<script src="auth.js"></script>
|
||||||
|
<script src="upload.js"></script>
|
||||||
<script src="init.js"></script>
|
<script src="init.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,19 @@
|
||||||
console.log(`TrackStorage: ${M.cachedTracks.size} tracks cached`);
|
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
|
// Initialize the application
|
||||||
Promise.all([initStorage(), loadServerStatus()]).then(async () => {
|
Promise.all([initStorage(), loadServerStatus()]).then(async () => {
|
||||||
await M.loadLibrary();
|
await M.loadLibrary();
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,7 @@
|
||||||
|
|
||||||
if (M.queue.length === 0) {
|
if (M.queue.length === 0) {
|
||||||
container.innerHTML = '<div class="empty drop-zone">Queue empty - drag tracks here</div>';
|
container.innerHTML = '<div class="empty drop-zone">Queue empty - drag tracks here</div>';
|
||||||
|
M.updateNowPlayingBar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,6 +334,7 @@
|
||||||
div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : "");
|
div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : "");
|
||||||
div.dataset.index = i;
|
div.dataset.index = i;
|
||||||
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
|
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
|
||||||
|
div.title = title; // Tooltip for full name
|
||||||
|
|
||||||
const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
|
const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
|
||||||
const trackNum = `<span class="track-number">${i + 1}.</span>`;
|
const trackNum = `<span class="track-number">${i + 1}.</span>`;
|
||||||
|
|
@ -508,8 +510,46 @@
|
||||||
|
|
||||||
container.appendChild(div);
|
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 = `<span class="label">Now playing:</span> ${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
|
// Library search state
|
||||||
M.librarySearchQuery = "";
|
M.librarySearchQuery = "";
|
||||||
|
|
||||||
|
|
@ -545,6 +585,7 @@
|
||||||
const isSelected = M.selectedLibraryIds.has(track.id);
|
const isSelected = M.selectedLibraryIds.has(track.id);
|
||||||
div.className = "track" + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : "");
|
div.className = "track" + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : "");
|
||||||
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
|
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
|
||||||
|
div.title = title; // Tooltip for full name
|
||||||
|
|
||||||
const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
|
const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
|
||||||
div.innerHTML = `${checkmark}<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
|
div.innerHTML = `${checkmark}<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { 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; }
|
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; }
|
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; }
|
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; }
|
#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; }
|
#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 - library and queue */
|
||||||
#main-content { display: flex; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.5rem; }
|
#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 140px; background: #1a1a1a; border-radius: 6px; padding: 0.4rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; }
|
#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 { 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 { 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; }
|
#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 { 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::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; }
|
#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; }
|
#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 { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
|
||||||
.panel-header h3 { margin: 0; flex-shrink: 0; }
|
.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; }
|
.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; }
|
.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 { 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; }
|
.search-input::placeholder { color: #666; }
|
||||||
#library, #queue { flex: 1; overflow-y: auto; }
|
#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; }
|
#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; }
|
#library .track:hover, #queue .track:hover { background: #222; }
|
||||||
#queue .track.active { background: #2a4a3a; color: #4e8; }
|
#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; }
|
.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.cached .cache-indicator { background: #4e8; }
|
||||||
.track.not-cached .cache-indicator { background: #ea4; }
|
.track.not-cached .cache-indicator { background: #ea4; }
|
||||||
.track-number { color: #555; font-size: 0.7rem; min-width: 1.3rem; margin-right: 0.2rem; }
|
.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 { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
||||||
.track-actions .duration { color: #666; font-size: 0.75rem; }
|
.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; }
|
.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-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, #btn-next { font-size: 0.75rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }
|
||||||
#btn-prev:hover, #btn-next:hover { opacity: 1; }
|
#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 { 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:hover { opacity: 1; }
|
#btn-mode.mode-once { color: #888; text-shadow: none; }
|
||||||
#btn-mode.active { opacity: 1; color: #4e8; }
|
#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; }
|
#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-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; }
|
#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; }
|
#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 { font-size: 1rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; }
|
||||||
#btn-mute:hover { opacity: 1; }
|
#btn-mute:hover { opacity: 1; }
|
||||||
#volume { width: 70px; accent-color: #4e8; }
|
#volume { width: 120px; accent-color: #4e8; }
|
||||||
|
|
||||||
/* Common */
|
/* Common */
|
||||||
button { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
|
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; }
|
::-webkit-scrollbar-thumb:hover { background-color: #555; }
|
||||||
|
|
||||||
/* Toast notifications */
|
/* 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 { 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; }
|
.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-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); } }
|
@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; }
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,56 @@
|
||||||
return m + ":" + String(s).padStart(2, "0");
|
return m + ":" + String(s).padStart(2, "0");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toast notifications
|
// Toast history
|
||||||
M.showToast = function(message, duration = 4000) {
|
M.toastHistory = [];
|
||||||
|
|
||||||
|
// Toast notifications (log style - multiple visible)
|
||||||
|
M.showToast = function(message, type = "info", duration = 5000) {
|
||||||
const container = M.$("#toast-container");
|
const container = M.$("#toast-container");
|
||||||
const toast = document.createElement("div");
|
const toast = document.createElement("div");
|
||||||
toast.className = "toast";
|
toast.className = "toast toast-" + type;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.classList.add("fade-out");
|
toast.classList.add("fade-out");
|
||||||
setTimeout(() => toast.remove(), 300);
|
setTimeout(() => toast.remove(), 300);
|
||||||
}, duration);
|
}, 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 = `<span class="history-time">${time}</span> ${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
|
// Flash permission denied animation
|
||||||
|
|
@ -40,23 +79,25 @@
|
||||||
// Set track title (UI and document title)
|
// Set track title (UI and document title)
|
||||||
M.setTrackTitle = function(title) {
|
M.setTrackTitle = function(title) {
|
||||||
M.currentTitle = title;
|
M.currentTitle = title;
|
||||||
const titleEl = M.$("#track-title");
|
|
||||||
const containerEl = M.$("#track-name");
|
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";
|
document.title = title ? `${title} - MusicRoom` : "MusicRoom";
|
||||||
|
|
||||||
|
// First set simple content to measure
|
||||||
|
marqueeEl.innerHTML = `<span id="track-title">${title}</span>`;
|
||||||
|
|
||||||
// Check if title overflows and needs scrolling
|
// Check if title overflows and needs scrolling
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const needsScroll = titleEl.scrollWidth > containerEl.clientWidth;
|
const titleEl = M.$("#track-title");
|
||||||
|
const needsScroll = titleEl && titleEl.scrollWidth > containerEl.clientWidth;
|
||||||
containerEl.classList.toggle("scrolling", needsScroll);
|
containerEl.classList.toggle("scrolling", needsScroll);
|
||||||
|
|
||||||
// Duplicate text for seamless wrap-around scrolling
|
// Duplicate text for seamless wrap-around scrolling
|
||||||
if (needsScroll) {
|
if (needsScroll) {
|
||||||
marqueeEl.innerHTML = `<span id="track-title">${title}</span><span class="marquee-spacer"> • </span><span>${title}</span><span class="marquee-spacer"> • </span>`;
|
marqueeEl.innerHTML = `<span id="track-title">${title}</span><span class="marquee-spacer"> • </span><span>${title}</span><span class="marquee-spacer"> • </span>`;
|
||||||
} else {
|
|
||||||
marqueeEl.innerHTML = `<span id="track-title">${title}</span>`;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
80
server.ts
80
server.ts
|
|
@ -124,6 +124,21 @@ async function init(): Promise<void> {
|
||||||
// Scan library first
|
// Scan library first
|
||||||
await library.scan();
|
await library.scan();
|
||||||
library.startWatching();
|
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
|
// Load channels from database
|
||||||
const savedChannels = loadAllChannels();
|
const savedChannels = loadAllChannels();
|
||||||
|
|
@ -222,6 +237,7 @@ function broadcastChannelList() {
|
||||||
// Listen for library changes and notify clients
|
// Listen for library changes and notify clients
|
||||||
library.on("added", (track) => {
|
library.on("added", (track) => {
|
||||||
console.log(`New track detected: ${track.title}`);
|
console.log(`New track detected: ${track.title}`);
|
||||||
|
|
||||||
const allTracks = library.getAllTracks().map(t => ({
|
const allTracks = library.getAllTracks().map(t => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
|
|
@ -266,6 +282,20 @@ setInterval(() => {
|
||||||
channel.broadcast();
|
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);
|
}, 1000);
|
||||||
|
|
||||||
// Helper to get or create guest session
|
// Helper to get or create guest session
|
||||||
|
|
@ -467,6 +497,55 @@ serve({
|
||||||
return Response.json(tracks, { headers });
|
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
|
// Auth: signup
|
||||||
if (path === "/api/auth/signup" && req.method === "POST") {
|
if (path === "/api/auth/signup" && req.method === "POST") {
|
||||||
try {
|
try {
|
||||||
|
|
@ -489,6 +568,7 @@ serve({
|
||||||
?? undefined;
|
?? undefined;
|
||||||
const token = createSession(user.id, userAgent, ipAddress);
|
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)}..."`);
|
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(
|
return Response.json(
|
||||||
{ user: { id: user.id, username: user.username, isAdmin: user.is_admin } },
|
{ user: { id: user.id, username: user.username, isAdmin: user.is_admin } },
|
||||||
{ headers: { "Set-Cookie": setSessionCookie(token) } }
|
{ headers: { "Set-Cookie": setSessionCookie(token) } }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue