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> {
// Initialize yt-dlp if configured
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
@ -352,22 +367,10 @@ export async function init(): Promise<void> {
// 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,
duration: t.duration,
replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak,
}));
const allTracks = library.getAllTracks().map(serializeLibraryTrack);
broadcastToAll({
type: "track_added",
track: {
id: track.id,
title: track.title,
duration: track.duration,
replayGainDb: track.replayGainDb,
replayPeak: track.replayPeak,
},
track: serializeLibraryTrack(track),
library: allTracks
});
});
@ -375,16 +378,10 @@ export async function init(): Promise<void> {
library.on("removed", (track) => {
console.log(`Track removed: ${track.title}`);
removeTrackFromQueues(track.id);
const allTracks = library.getAllTracks().map(t => ({
id: t.id,
title: t.title,
duration: t.duration,
replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak,
}));
const allTracks = library.getAllTracks().map(serializeLibraryTrack);
broadcastToAll({
type: "track_removed",
track: { id: track.id, title: track.title },
track: serializeLibraryTrack(track),
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">
</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="add-panel" class="add-panel hidden">
<button id="btn-add-close" class="add-panel-close">Close</button>

View File

@ -12,9 +12,12 @@
// Container instances
let queueContainer = null;
let libraryContainer = null;
let recentLibraryContainer = null;
// Library search state
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)
async function downloadTrack(trackId, filename) {
@ -190,9 +193,70 @@
};
// 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() {
const queueEl = M.$("#queue");
const libraryEl = M.$("#library");
const recentLibraryEl = M.$("#recent-library");
if (queueEl && !queueContainer) {
queueContainer = M.trackContainer.createContainer({
@ -209,18 +273,18 @@
type: 'library',
element: libraryEl,
getTracks: () => M.library,
getFilteredTracks: () => {
const query = M.librarySearchQuery.toLowerCase();
if (!query) {
return M.library.map((track, i) => ({ track, originalIndex: i }));
}
return M.library
.map((track, i) => ({ track, originalIndex: i }))
.filter(({ track }) => {
const title = track.title?.trim() || track.filename || '';
return title.toLowerCase().includes(query);
});
}
getFilteredTracks: getLibraryRows,
emptyMessage: "No library matches"
});
}
if (recentLibraryEl && !recentLibraryContainer) {
recentLibraryContainer = M.trackContainer.createContainer({
type: 'library',
element: recentLibraryEl,
getTracks: () => M.library,
getFilteredTracks: getRecentLibraryRows,
emptyMessage: "No songs added in the last 7 days"
});
}
}
@ -254,6 +318,10 @@
M.renderLibrary = function() {
initContainers();
updateLibrarySectionLabels();
if (recentLibraryContainer) {
recentLibraryContainer.render();
}
if (libraryContainer) {
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; }
.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; 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 .track[title], #queue .track[title], #playlist-tracks .track[title] { cursor: pointer; }
#library .track:hover, #queue .track:hover, #playlist-tracks .track:hover { background: #222; }
#library, #queue, #recent-library { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
.library-section { flex: 0 0 auto; min-height: 0; display: flex; flex-direction: column; margin-bottom: 0.45rem; }
.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-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; }
.cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; }
.track.cached .cache-indicator { background: #4e8; }
@ -520,6 +526,10 @@ button:hover { background: #333; }
overflow-y: auto;
width: 100%;
}
#recent-library {
max-height: 28vh;
min-height: 0;
}
.add-btn {
flex-shrink: 0;
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"] #library .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"] #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"] #library,
body[data-ui-theme="spreadsheet"] #recent-library,
body[data-ui-theme="spreadsheet"] #queue { overflow-x: scroll; }
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.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder)
* @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
*/
function createContainer(config) {
@ -52,6 +53,7 @@
canReorder = false,
isPlaylistOwner = false,
playlistId = null,
emptyMessage = null,
onRender
} = config;
@ -85,9 +87,9 @@
}
if (currentTracks.length === 0) {
const emptyMsg = type === 'queue' ? 'Queue empty - drag tracks here'
: type === 'library' ? 'No tracks'
: 'No tracks - drag here to add';
const emptyMsg = emptyMessage || (type === 'queue' ? 'Queue empty - drag tracks here'
: type === 'library' ? 'No tracks'
: 'No tracks - drag here to add');
element.innerHTML = `<div class="empty">${emptyMsg}</div>`;
if (onRender) onRender();
return;

View File

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