Add recently added library section
This commit is contained in:
parent
4fe19f5abd
commit
089fef215c
41
init.ts
41
init.ts
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue