Add recently added library section

This commit is contained in:
peterino2 2026-07-05 10:11:27 -07:00
parent 4fe19f5abd
commit 089fef215c
6 changed files with 132 additions and 41 deletions

41
init.ts
View File

@ -85,6 +85,21 @@ export function getAllLibraryTracks(lib: Library): Track[] {
})); }));
} }
function serializeLibraryTrack(track: ReturnType<Library["getAllTracks"]>[number]) {
return {
id: track.id,
filename: track.filename,
title: track.title,
artist: track.artist,
album: track.album,
duration: track.duration,
replayGainDb: track.replayGainDb,
replayPeak: track.replayPeak,
createdAt: track.created_at,
available: track.available,
};
}
export async function init(): Promise<void> { export async function init(): Promise<void> {
// Initialize yt-dlp if configured // Initialize yt-dlp if configured
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!; const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
@ -352,22 +367,10 @@ export async function init(): Promise<void> {
// 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(serializeLibraryTrack);
id: t.id,
title: t.title,
duration: t.duration,
replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak,
}));
broadcastToAll({ broadcastToAll({
type: "track_added", type: "track_added",
track: { track: serializeLibraryTrack(track),
id: track.id,
title: track.title,
duration: track.duration,
replayGainDb: track.replayGainDb,
replayPeak: track.replayPeak,
},
library: allTracks library: allTracks
}); });
}); });
@ -375,16 +378,10 @@ export async function init(): Promise<void> {
library.on("removed", (track) => { library.on("removed", (track) => {
console.log(`Track removed: ${track.title}`); console.log(`Track removed: ${track.title}`);
removeTrackFromQueues(track.id); removeTrackFromQueues(track.id);
const allTracks = library.getAllTracks().map(t => ({ const allTracks = library.getAllTracks().map(serializeLibraryTrack);
id: t.id,
title: t.title,
duration: t.duration,
replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak,
}));
broadcastToAll({ broadcastToAll({
type: "track_removed", type: "track_removed",
track: { id: track.id, title: track.title }, track: serializeLibraryTrack(track),
library: allTracks library: allTracks
}); });
}); });

View File

@ -108,6 +108,17 @@
<input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none"> <input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none">
</div> </div>
<div id="scan-progress" class="scan-progress hidden"></div> <div id="scan-progress" class="scan-progress hidden"></div>
<div id="recent-library-section" class="library-section">
<div class="library-section-header">
<h4>Recently added</h4>
<span id="recent-library-count"></span>
</div>
<div id="recent-library"></div>
</div>
<div class="library-section-header library-all-header">
<h4>All songs</h4>
<span id="library-count"></span>
</div>
<div id="library"></div> <div id="library"></div>
<div id="add-panel" class="add-panel hidden"> <div id="add-panel" class="add-panel hidden">
<button id="btn-add-close" class="add-panel-close">Close</button> <button id="btn-add-close" class="add-panel-close">Close</button>

View File

@ -12,9 +12,12 @@
// Container instances // Container instances
let queueContainer = null; let queueContainer = null;
let libraryContainer = null; let libraryContainer = null;
let recentLibraryContainer = null;
// Library search state // Library search state
M.librarySearchQuery = ""; M.librarySearchQuery = "";
const RECENT_LIBRARY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
const RECENT_LIBRARY_LIMIT = 50;
// Download a track to user's device (uses cache if available) // Download a track to user's device (uses cache if available)
async function downloadTrack(trackId, filename) { async function downloadTrack(trackId, filename) {
@ -190,9 +193,70 @@
}; };
// Initialize containers // Initialize containers
function trackMatchesLibrarySearch(track) {
const query = M.librarySearchQuery.toLowerCase();
if (!query) return true;
const title = track.title?.trim() || track.filename || "";
const artist = track.artist || "";
const album = track.album || "";
return [title, artist, album, track.filename || ""]
.some(value => value.toLowerCase().includes(query));
}
function getTrackCreatedMs(track) {
const value = Number(track.createdAt ?? track.created_at ?? 0);
if (!value || !Number.isFinite(value)) return 0;
return value > 1000000000000 ? value : value * 1000;
}
function isRecentlyAdded(track) {
const createdMs = getTrackCreatedMs(track);
if (!createdMs) return false;
const now = Date.now();
return createdMs >= now - RECENT_LIBRARY_WINDOW_MS && createdMs <= now + 5 * 60 * 1000;
}
function getLibraryRows() {
return M.library
.map((track, i) => ({ track, originalIndex: i }))
.filter(({ track }) => trackMatchesLibrarySearch(track));
}
function getRecentLibraryRows(limit = true) {
const rows = M.library
.map((track, i) => ({ track, originalIndex: i }))
.filter(({ track }) => isRecentlyAdded(track) && trackMatchesLibrarySearch(track))
.sort((a, b) => getTrackCreatedMs(b.track) - getTrackCreatedMs(a.track));
return limit ? rows.slice(0, RECENT_LIBRARY_LIMIT) : rows;
}
function updateLibrarySectionLabels() {
const recentCount = getRecentLibraryRows(false).length;
const visibleRecentCount = Math.min(recentCount, RECENT_LIBRARY_LIMIT);
const libraryCount = getLibraryRows().length;
const recentCountEl = M.$("#recent-library-count");
const libraryCountEl = M.$("#library-count");
const hasQuery = M.librarySearchQuery.trim().length > 0;
if (recentCountEl) {
const baseText = hasQuery
? `${recentCount} match${recentCount === 1 ? "" : "es"} from 7 days`
: `${recentCount} from 7 days`;
recentCountEl.textContent = recentCount > visibleRecentCount
? `${baseText} · newest ${visibleRecentCount}`
: baseText;
}
if (libraryCountEl) {
libraryCountEl.textContent = hasQuery
? `${libraryCount} match${libraryCount === 1 ? "" : "es"}`
: `${libraryCount} total`;
}
}
function initContainers() { function initContainers() {
const queueEl = M.$("#queue"); const queueEl = M.$("#queue");
const libraryEl = M.$("#library"); const libraryEl = M.$("#library");
const recentLibraryEl = M.$("#recent-library");
if (queueEl && !queueContainer) { if (queueEl && !queueContainer) {
queueContainer = M.trackContainer.createContainer({ queueContainer = M.trackContainer.createContainer({
@ -209,18 +273,18 @@
type: 'library', type: 'library',
element: libraryEl, element: libraryEl,
getTracks: () => M.library, getTracks: () => M.library,
getFilteredTracks: () => { getFilteredTracks: getLibraryRows,
const query = M.librarySearchQuery.toLowerCase(); emptyMessage: "No library matches"
if (!query) { });
return M.library.map((track, i) => ({ track, originalIndex: i })); }
}
return M.library if (recentLibraryEl && !recentLibraryContainer) {
.map((track, i) => ({ track, originalIndex: i })) recentLibraryContainer = M.trackContainer.createContainer({
.filter(({ track }) => { type: 'library',
const title = track.title?.trim() || track.filename || ''; element: recentLibraryEl,
return title.toLowerCase().includes(query); getTracks: () => M.library,
}); getFilteredTracks: getRecentLibraryRows,
} emptyMessage: "No songs added in the last 7 days"
}); });
} }
} }
@ -254,6 +318,10 @@
M.renderLibrary = function() { M.renderLibrary = function() {
initContainers(); initContainers();
updateLibrarySectionLabels();
if (recentLibraryContainer) {
recentLibraryContainer.render();
}
if (libraryContainer) { if (libraryContainer) {
libraryContainer.render(); libraryContainer.render();
} }

View File

@ -147,10 +147,16 @@ h3 { font-size: 0.8rem; color: #999; 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; overflow-x: hidden; min-width: 0; } #library, #queue, #recent-library { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
#library .track, #queue .track, #playlist-tracks .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-section { flex: 0 0 auto; min-height: 0; display: flex; flex-direction: column; margin-bottom: 0.45rem; }
#library .track[title], #queue .track[title], #playlist-tracks .track[title] { cursor: pointer; } .library-section-header { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin: 0.15rem 0 0.25rem; color: #888; flex-shrink: 0; }
#library .track:hover, #queue .track:hover, #playlist-tracks .track:hover { background: #222; } .library-section-header h4 { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: #aaa; margin: 0; }
.library-section-header span { color: #666; font-size: 0.68rem; white-space: nowrap; }
.library-all-header { margin-top: 0.1rem; }
#recent-library { flex: 0 1 auto; max-height: min(34vh, 220px); border-bottom: 1px solid #262626; padding-bottom: 0.35rem; }
#library .track, #queue .track, #recent-library .track, #playlist-tracks .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], #recent-library .track[title], #playlist-tracks .track[title] { cursor: pointer; }
#library .track:hover, #queue .track:hover, #recent-library .track:hover, #playlist-tracks .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; }
@ -520,6 +526,10 @@ button:hover { background: #333; }
overflow-y: auto; overflow-y: auto;
width: 100%; width: 100%;
} }
#recent-library {
max-height: 28vh;
min-height: 0;
}
.add-btn { .add-btn {
flex-shrink: 0; flex-shrink: 0;
width: auto; width: auto;
@ -970,6 +980,7 @@ body[data-ui-theme="compact"] #library-panel,
body[data-ui-theme="compact"] #queue-panel { padding: 0.25rem; max-height: 66vh; } body[data-ui-theme="compact"] #queue-panel { padding: 0.25rem; max-height: 66vh; }
body[data-ui-theme="compact"] #library .track, body[data-ui-theme="compact"] #library .track,
body[data-ui-theme="compact"] #queue .track, body[data-ui-theme="compact"] #queue .track,
body[data-ui-theme="compact"] #recent-library .track,
body[data-ui-theme="compact"] #playlist-tracks .track { padding: 0.16rem 0.35rem; font-size: 0.78rem; } body[data-ui-theme="compact"] #playlist-tracks .track { padding: 0.16rem 0.35rem; font-size: 0.78rem; }
body[data-ui-theme="compact"] #player-bar { padding: 0.35rem 0.5rem; } body[data-ui-theme="compact"] #player-bar { padding: 0.35rem 0.5rem; }
@ -1153,6 +1164,7 @@ body[data-ui-theme="spreadsheet"] #player-bar {
} }
body[data-ui-theme="spreadsheet"] .track { border-bottom: 1px solid #999; border-radius: 0; } body[data-ui-theme="spreadsheet"] .track { border-bottom: 1px solid #999; border-radius: 0; }
body[data-ui-theme="spreadsheet"] #library, body[data-ui-theme="spreadsheet"] #library,
body[data-ui-theme="spreadsheet"] #recent-library,
body[data-ui-theme="spreadsheet"] #queue { overflow-x: scroll; } body[data-ui-theme="spreadsheet"] #queue { overflow-x: scroll; }
body[data-ui-theme="myspace"] { body[data-ui-theme="myspace"] {

View File

@ -41,6 +41,7 @@
* @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only) * @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only)
* @param {boolean} [config.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder) * @param {boolean} [config.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder)
* @param {string} [config.playlistId] - Playlist ID (for playlist type) * @param {string} [config.playlistId] - Playlist ID (for playlist type)
* @param {string} [config.emptyMessage] - Message when the container has no tracks
* @param {Function} [config.onRender] - Callback after render * @param {Function} [config.onRender] - Callback after render
*/ */
function createContainer(config) { function createContainer(config) {
@ -52,6 +53,7 @@
canReorder = false, canReorder = false,
isPlaylistOwner = false, isPlaylistOwner = false,
playlistId = null, playlistId = null,
emptyMessage = null,
onRender onRender
} = config; } = config;
@ -85,9 +87,9 @@
} }
if (currentTracks.length === 0) { if (currentTracks.length === 0) {
const emptyMsg = type === 'queue' ? 'Queue empty - drag tracks here' const emptyMsg = emptyMessage || (type === 'queue' ? 'Queue empty - drag tracks here'
: type === 'library' ? 'No tracks' : type === 'library' ? 'No tracks'
: 'No tracks - drag here to add'; : 'No tracks - drag here to add');
element.innerHTML = `<div class="empty">${emptyMsg}</div>`; element.innerHTML = `<div class="empty">${emptyMsg}</div>`;
if (onRender) onRender(); if (onRender) onRender();
return; return;

View File

@ -19,6 +19,7 @@ export function handleGetLibrary(req: Request, server: any): Response {
duration: t.duration, duration: t.duration,
replayGainDb: t.replayGainDb, replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak, replayPeak: t.replayPeak,
createdAt: t.created_at,
available: t.available, available: t.available,
})); }));
return Response.json(tracks, { headers }); return Response.json(tracks, { headers });