Add recently added library section
This commit is contained in:
parent
e69e955ff9
commit
ee63f61e01
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> {
|
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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
.map((track, i) => ({ track, originalIndex: i }))
|
|
||||||
.filter(({ track }) => {
|
|
||||||
const title = track.title?.trim() || track.filename || '';
|
|
||||||
return title.toLowerCase().includes(query);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
M.renderLibrary = function() {
|
||||||
initContainers();
|
initContainers();
|
||||||
|
updateLibrarySectionLabels();
|
||||||
|
if (recentLibraryContainer) {
|
||||||
|
recentLibraryContainer.render();
|
||||||
|
}
|
||||||
if (libraryContainer) {
|
if (libraryContainer) {
|
||||||
libraryContainer.render();
|
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; }
|
.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"] {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue