NEW: store recently played tracks

This commit is contained in:
Julien Maille 2025-12-27 21:24:04 +01:00
parent 91da9b887d
commit b53fb36196
6 changed files with 168 additions and 22 deletions

View file

@ -87,6 +87,12 @@
<span>Library</span>
</a>
</li>
<li class="nav-item">
<a href="#recent">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
<span>Recent</span>
</a>
</li>
<li class="nav-item">
<a href="#settings">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -191,6 +197,11 @@
</div>
</div>
<div id="page-recent" class="page">
<h2 class="section-title">Recently played</h2>
<div class="track-list" id="recent-tracks-container"></div>
</div>
<div id="page-album" class="page">
<header class="detail-header">
<img id="album-detail-image" src="" alt="" class="detail-header-image">

View file

@ -199,7 +199,7 @@ document.addEventListener('DOMContentLoaded', async () => {
trackListSettings.getMode();
initializeSettings(scrobbler, player, api, ui);
initializePlayerEvents(player, audioPlayer, scrobbler);
initializePlayerEvents(player, audioPlayer, scrobbler, ui);
initializeTrackInteractions(player, api, document.querySelector('.main-content'), document.getElementById('context-menu'), lyricsManager, ui);
initializeUIInteractions(player, api);
initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel);

View file

@ -1,7 +1,7 @@
export class MusicDatabase {
constructor() {
this.dbName = 'MonochromeDB';
this.version = 2;
this.version = 3;
this.db = null;
}
@ -41,6 +41,12 @@ export class MusicDatabase {
const store = db.createObjectStore('favorites_playlists', { keyPath: 'uuid' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
// History store
if (!db.objectStoreNames.contains('history_tracks')) {
const store = db.createObjectStore('history_tracks', { keyPath: 'timestamp' });
store.createIndex('timestamp', 'timestamp', { unique: true });
}
};
});
}
@ -62,6 +68,61 @@ export class MusicDatabase {
});
}
// History API
async addToHistory(track) {
const storeName = 'history_tracks';
const minified = this._minifyItem('track', track);
// Use a unique timestamp even if called rapidly
// (though unlikely to be <1ms for playback start)
const entry = { ...minified, timestamp: Date.now() };
const db = await this.open();
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
// Add new entry
store.put(entry);
// Trim to 100
const index = store.index('timestamp');
const countRequest = index.count();
countRequest.onsuccess = () => {
if (countRequest.result > 100) {
// Get oldest keys
const cursorRequest = index.openCursor();
let deleted = 0;
const toDelete = countRequest.result - 100;
cursorRequest.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor && deleted < toDelete) {
cursor.delete();
deleted++;
cursor.continue();
}
};
}
};
}
async getHistory() {
const storeName = 'history_tracks';
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const index = store.index('timestamp');
const request = index.getAll();
request.onsuccess = () => {
// Return reversed (newest first)
resolve(request.result.reverse());
};
request.onerror = () => reject(request.error);
});
}
// Favorites API
async toggleFavorite(type, item) {
const storeName = `favorites_${type}s`; // tracks, albums, artists
@ -178,12 +239,14 @@ export class MusicDatabase {
const albums = await this.getFavorites('album');
const artists = await this.getFavorites('artist');
const playlists = await this.getFavorites('playlist');
const history = await this.getHistory();
const data = {
favorites_tracks: tracks.map(t => this._minifyItem('track', t)),
favorites_albums: albums.map(a => this._minifyItem('album', a)),
favorites_artists: artists.map(a => this._minifyItem('artist', a)),
favorites_playlists: playlists.map(p => this._minifyItem('playlist', p))
favorites_playlists: playlists.map(p => this._minifyItem('playlist', p)),
history_tracks: history.map(t => this._minifyItem('track', t))
};
return data;
}
@ -206,6 +269,7 @@ export class MusicDatabase {
await importStore('favorites_albums', data.favorites_albums);
await importStore('favorites_artists', data.favorites_artists);
await importStore('favorites_playlists', data.favorites_playlists);
await importStore('history_tracks', data.history_tracks);
}
}

View file

@ -1,18 +1,21 @@
//js/events.js
import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename, getTrackTitle } from './utils.js';
import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename, getTrackTitle, formatTime } from './utils.js';
import { lastFMStorage } from './storage.js';
import { showNotification, downloadTrackWithMetadata } from './downloads.js';
import { lyricsSettings } from './storage.js';
import { updateTabTitle } from './router.js';
import { db } from './db.js';
export function initializePlayerEvents(player, audioPlayer, scrobbler) {
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const playPauseBtn = document.querySelector('.play-pause-btn');
const nextBtn = document.getElementById('next-btn');
const prevBtn = document.getElementById('prev-btn');
const shuffleBtn = document.getElementById('shuffle-btn');
const repeatBtn = document.getElementById('repeat-btn');
// History tracking
let lastLoggedTrackId = null;
// Sync UI with player state on load
if (player.shuffleActive) {
shuffleBtn.classList.add('active');
@ -20,7 +23,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler) {
if (player.repeatMode !== REPEAT_MODE.OFF) {
repeatBtn.classList.add('active');
if (player.repeatMode === REPEAT_MODE.ONE) {
if (player.repeatMode === REPEAT_MODE.ONE) {
repeatBtn.classList.add('repeat-one');
}
repeatBtn.title = player.repeatMode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One';
@ -28,10 +31,25 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler) {
repeatBtn.title = 'Repeat';
}
audioPlayer.addEventListener('play', () => {
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) {
scrobbler.updateNowPlaying(player.currentTrack);
audioPlayer.addEventListener('play', async () => {
if (player.currentTrack) {
// Scrobble
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled()) {
scrobbler.updateNowPlaying(player.currentTrack);
}
// Log to local history if it's a new track session
if (player.currentTrack.id !== lastLoggedTrackId) {
await db.addToHistory(player.currentTrack);
lastLoggedTrackId = player.currentTrack.id;
// Update Recent Page if active
if (window.location.hash === '#recent') {
ui.renderRecentPage();
}
}
}
playPauseBtn.innerHTML = SVG_PAUSE;
player.updateMediaSessionPlaybackState();
player.updateMediaSessionPositionState();
@ -532,12 +550,7 @@ function renderQueue(player) {
}
}
function formatTime(seconds) {
if (isNaN(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
async function updateContextMenuLikeState(menu, track) {
const likeItem = menu.querySelector('[data-action="toggle-like"]');

View file

@ -20,6 +20,9 @@ export function createRouter(ui) {
case 'library':
ui.renderLibraryPage();
break;
case 'recent':
ui.renderRecentPage();
break;
case 'home':
ui.renderHomePage();
break;

View file

@ -70,13 +70,7 @@ export class UIRenderer {
return '<span class="explicit-badge" title="Explicit">E</span>';
}
createTrackMenuButton() {
return `
<button class="track-menu-btn" onclick="event.stopPropagation();" title="More options">
${SVG_MENU}
</button>
`;
}
adjustTitleFontSize(element, text) {
element.classList.remove('long-title', 'very-long-title');
@ -1000,6 +994,67 @@ export class UIRenderer {
}
}
async renderRecentPage() {
this.showPage('recent');
const container = document.getElementById('recent-tracks-container');
container.innerHTML = this.createSkeletonTracks(10, true);
try {
const history = await db.getHistory();
if (history.length === 0) {
container.innerHTML = createPlaceholder("You haven't played any tracks yet.");
return;
}
// Group by date
const groups = {};
const today = new Date().setHours(0, 0, 0, 0);
const yesterday = new Date(today - 86400000).setHours(0, 0, 0, 0);
history.forEach(item => {
const date = new Date(item.timestamp);
const dayStart = new Date(date).setHours(0, 0, 0, 0);
let label;
if (dayStart === today) label = 'Today';
else if (dayStart === yesterday) label = 'Yesterday';
else label = date.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
if (!groups[label]) groups[label] = [];
groups[label].push(item);
});
container.innerHTML = '';
for (const [label, tracks] of Object.entries(groups)) {
const header = document.createElement('h3');
header.className = 'track-list-header-group';
header.textContent = label;
header.style.margin = '1.5rem 0 0.5rem 0';
header.style.fontSize = '1.1rem';
header.style.fontWeight = '600';
header.style.color = 'var(--foreground)';
header.style.paddingLeft = '0.5rem';
container.appendChild(header);
// Use a temporary container to render tracks and then move them
const tempContainer = document.createElement('div');
this.renderListWithTracks(tempContainer, tracks, true);
// Move children to main container
while (tempContainer.firstChild) {
container.appendChild(tempContainer.firstChild);
}
}
} catch (error) {
console.error('Failed to load history:', error);
container.innerHTML = createPlaceholder('Failed to load history.');
}
}
renderApiSettings() {
const container = document.getElementById('api-instance-list');
this.api.settings.getInstances().then(instances => {