NEW: store recently played tracks
This commit is contained in:
parent
91da9b887d
commit
b53fb36196
6 changed files with 168 additions and 22 deletions
11
index.html
11
index.html
|
|
@ -87,6 +87,12 @@
|
||||||
<span>Library</span>
|
<span>Library</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a href="#settings">
|
<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">
|
<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>
|
</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">
|
<div id="page-album" class="page">
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
<img id="album-detail-image" src="" alt="" class="detail-header-image">
|
<img id="album-detail-image" src="" alt="" class="detail-header-image">
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
trackListSettings.getMode();
|
trackListSettings.getMode();
|
||||||
|
|
||||||
initializeSettings(scrobbler, player, api, ui);
|
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);
|
initializeTrackInteractions(player, api, document.querySelector('.main-content'), document.getElementById('context-menu'), lyricsManager, ui);
|
||||||
initializeUIInteractions(player, api);
|
initializeUIInteractions(player, api);
|
||||||
initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel);
|
initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel);
|
||||||
|
|
|
||||||
68
js/db.js
68
js/db.js
|
|
@ -1,7 +1,7 @@
|
||||||
export class MusicDatabase {
|
export class MusicDatabase {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.dbName = 'MonochromeDB';
|
this.dbName = 'MonochromeDB';
|
||||||
this.version = 2;
|
this.version = 3;
|
||||||
this.db = null;
|
this.db = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +41,12 @@ export class MusicDatabase {
|
||||||
const store = db.createObjectStore('favorites_playlists', { keyPath: 'uuid' });
|
const store = db.createObjectStore('favorites_playlists', { keyPath: 'uuid' });
|
||||||
store.createIndex('addedAt', 'addedAt', { unique: false });
|
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
|
// Favorites API
|
||||||
async toggleFavorite(type, item) {
|
async toggleFavorite(type, item) {
|
||||||
const storeName = `favorites_${type}s`; // tracks, albums, artists
|
const storeName = `favorites_${type}s`; // tracks, albums, artists
|
||||||
|
|
@ -178,12 +239,14 @@ export class MusicDatabase {
|
||||||
const albums = await this.getFavorites('album');
|
const albums = await this.getFavorites('album');
|
||||||
const artists = await this.getFavorites('artist');
|
const artists = await this.getFavorites('artist');
|
||||||
const playlists = await this.getFavorites('playlist');
|
const playlists = await this.getFavorites('playlist');
|
||||||
|
const history = await this.getHistory();
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
favorites_tracks: tracks.map(t => this._minifyItem('track', t)),
|
favorites_tracks: tracks.map(t => this._minifyItem('track', t)),
|
||||||
favorites_albums: albums.map(a => this._minifyItem('album', a)),
|
favorites_albums: albums.map(a => this._minifyItem('album', a)),
|
||||||
favorites_artists: artists.map(a => this._minifyItem('artist', 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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +269,7 @@ export class MusicDatabase {
|
||||||
await importStore('favorites_albums', data.favorites_albums);
|
await importStore('favorites_albums', data.favorites_albums);
|
||||||
await importStore('favorites_artists', data.favorites_artists);
|
await importStore('favorites_artists', data.favorites_artists);
|
||||||
await importStore('favorites_playlists', data.favorites_playlists);
|
await importStore('favorites_playlists', data.favorites_playlists);
|
||||||
|
await importStore('history_tracks', data.history_tracks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
37
js/events.js
37
js/events.js
|
|
@ -1,18 +1,21 @@
|
||||||
//js/events.js
|
//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 { lastFMStorage } from './storage.js';
|
||||||
import { showNotification, downloadTrackWithMetadata } from './downloads.js';
|
import { showNotification, downloadTrackWithMetadata } from './downloads.js';
|
||||||
import { lyricsSettings } from './storage.js';
|
import { lyricsSettings } from './storage.js';
|
||||||
import { updateTabTitle } from './router.js';
|
import { updateTabTitle } from './router.js';
|
||||||
import { db } from './db.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 playPauseBtn = document.querySelector('.play-pause-btn');
|
||||||
const nextBtn = document.getElementById('next-btn');
|
const nextBtn = document.getElementById('next-btn');
|
||||||
const prevBtn = document.getElementById('prev-btn');
|
const prevBtn = document.getElementById('prev-btn');
|
||||||
const shuffleBtn = document.getElementById('shuffle-btn');
|
const shuffleBtn = document.getElementById('shuffle-btn');
|
||||||
const repeatBtn = document.getElementById('repeat-btn');
|
const repeatBtn = document.getElementById('repeat-btn');
|
||||||
|
|
||||||
|
// History tracking
|
||||||
|
let lastLoggedTrackId = null;
|
||||||
|
|
||||||
// Sync UI with player state on load
|
// Sync UI with player state on load
|
||||||
if (player.shuffleActive) {
|
if (player.shuffleActive) {
|
||||||
shuffleBtn.classList.add('active');
|
shuffleBtn.classList.add('active');
|
||||||
|
|
@ -20,7 +23,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler) {
|
||||||
|
|
||||||
if (player.repeatMode !== REPEAT_MODE.OFF) {
|
if (player.repeatMode !== REPEAT_MODE.OFF) {
|
||||||
repeatBtn.classList.add('active');
|
repeatBtn.classList.add('active');
|
||||||
if (player.repeatMode === REPEAT_MODE.ONE) {
|
if (player.repeatMode === REPEAT_MODE.ONE) {
|
||||||
repeatBtn.classList.add('repeat-one');
|
repeatBtn.classList.add('repeat-one');
|
||||||
}
|
}
|
||||||
repeatBtn.title = player.repeatMode === REPEAT_MODE.ALL ? 'Repeat Queue' : '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';
|
repeatBtn.title = 'Repeat';
|
||||||
}
|
}
|
||||||
|
|
||||||
audioPlayer.addEventListener('play', () => {
|
audioPlayer.addEventListener('play', async () => {
|
||||||
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) {
|
if (player.currentTrack) {
|
||||||
scrobbler.updateNowPlaying(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;
|
playPauseBtn.innerHTML = SVG_PAUSE;
|
||||||
player.updateMediaSessionPlaybackState();
|
player.updateMediaSessionPlaybackState();
|
||||||
player.updateMediaSessionPositionState();
|
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) {
|
async function updateContextMenuLikeState(menu, track) {
|
||||||
const likeItem = menu.querySelector('[data-action="toggle-like"]');
|
const likeItem = menu.querySelector('[data-action="toggle-like"]');
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ export function createRouter(ui) {
|
||||||
case 'library':
|
case 'library':
|
||||||
ui.renderLibraryPage();
|
ui.renderLibraryPage();
|
||||||
break;
|
break;
|
||||||
|
case 'recent':
|
||||||
|
ui.renderRecentPage();
|
||||||
|
break;
|
||||||
case 'home':
|
case 'home':
|
||||||
ui.renderHomePage();
|
ui.renderHomePage();
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
69
js/ui.js
69
js/ui.js
|
|
@ -70,13 +70,7 @@ export class UIRenderer {
|
||||||
return '<span class="explicit-badge" title="Explicit">E</span>';
|
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) {
|
adjustTitleFontSize(element, text) {
|
||||||
element.classList.remove('long-title', 'very-long-title');
|
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() {
|
renderApiSettings() {
|
||||||
const container = document.getElementById('api-instance-list');
|
const container = document.getElementById('api-instance-list');
|
||||||
this.api.settings.getInstances().then(instances => {
|
this.api.settings.getInstances().then(instances => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue