Merge pull request #28 from JulienMaille/recent

store recently played tracks
This commit is contained in:
Julien 2025-12-27 23:22:51 +01:00 committed by GitHub
commit a9cfa13fb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 218 additions and 40 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">
@ -304,7 +315,7 @@
<div class="setting-item">
<div class="info">
<span class="label">Album Cover Background</span>
<span class="description">Use the album cover as a blurred background on album pages</span>
<span class="description">Use the album cover as a blurred background on album pages and as primary color</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="album-background-toggle">
@ -415,7 +426,7 @@
<div class="setting-item">
<div class="info">
<span class="label">Backup & Restore</span>
<span class="description">Export or import your library and playlists as JSON</span>
<span class="description">Export or import your library and history as JSON</span>
</div>
<div style="display: flex; gap: 0.5rem;">
<button id="export-library-btn" class="btn-secondary">Export</button>

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 historyLoggedTrackId = 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';
@ -29,9 +32,13 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler) {
}
audioPlayer.addEventListener('play', () => {
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) {
scrobbler.updateNowPlaying(player.currentTrack);
if (player.currentTrack) {
// Scrobble
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled()) {
scrobbler.updateNowPlaying(player.currentTrack);
}
}
playPauseBtn.innerHTML = SVG_PAUSE;
player.updateMediaSessionPlaybackState();
player.updateMediaSessionPositionState();
@ -53,13 +60,23 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler) {
player.playNext();
});
audioPlayer.addEventListener('timeupdate', () => {
audioPlayer.addEventListener('timeupdate', async () => {
const { currentTime, duration } = audioPlayer;
if (duration) {
const progressFill = document.getElementById('progress-fill');
const currentTimeEl = document.getElementById('current-time');
progressFill.style.width = `${(currentTime / duration) * 100}%`;
currentTimeEl.textContent = formatTime(currentTime);
// Log to history after 10 seconds of playback
if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) {
historyLoggedTrackId = player.currentTrack.id;
await db.addToHistory(player.currentTrack);
if (window.location.hash === '#recent') {
ui.renderRecentPage();
}
}
}
});
@ -507,7 +524,18 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
});
document.querySelector('.now-playing-bar .artist').addEventListener('click', () => {
document.querySelector('.now-playing-bar .artist').addEventListener('click', (e) => {
const link = e.target.closest('.artist-link');
if (link) {
e.stopPropagation();
const artistId = link.dataset.artistId;
if (artistId) {
window.location.hash = `#artist/${artistId}`;
}
return;
}
// Fallback for non-link clicks (e.g. separators) or single artist legacy
const track = player.currentTrack;
if (track?.artist?.id) {
window.location.hash = `#artist/${track.artist.id}`;
@ -532,12 +560,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

@ -1,5 +1,5 @@
//js/player.js
import { REPEAT_MODE, formatTime, getTrackArtists, getTrackTitle} from './utils.js';
import { REPEAT_MODE, formatTime, getTrackArtists, getTrackTitle, getTrackArtistsHTML } from './utils.js';
import { queueManager } from './storage.js';
export class Player {
@ -43,7 +43,7 @@ export class Player {
// Restore UI
const track = this.currentTrack;
const trackTitle = getTrackTitle(track);
const trackArtists = getTrackArtists(track);
const trackArtistsHTML = getTrackArtistsHTML(track);
const coverEl = document.querySelector('.now-playing-bar .cover');
const titleEl = document.querySelector('.now-playing-bar .title');
@ -51,7 +51,7 @@ export class Player {
if (coverEl) coverEl.src = this.api.getCoverUrl(track.album?.cover, '1280');
if (titleEl) titleEl.textContent = trackTitle;
if (artistEl) artistEl.textContent = trackArtists;
if (artistEl) artistEl.innerHTML = trackArtistsHTML;
const totalDurationEl = document.getElementById('total-duration');
if (totalDurationEl) totalDurationEl.textContent = formatTime(track.duration);
document.title = `${trackTitle}${track.artist?.name || 'Unknown'}`;
@ -168,12 +168,12 @@ export class Player {
this.currentTrack = track;
const trackTitle = getTrackTitle(track);
const trackArtists = getTrackArtists(track);
const trackArtistsHTML = getTrackArtistsHTML(track);
document.querySelector('.now-playing-bar .cover').src =
this.api.getCoverUrl(track.album?.cover, '1280');
document.querySelector('.now-playing-bar .title').textContent = trackTitle;
document.querySelector('.now-playing-bar .artist').textContent = trackArtists;
document.querySelector('.now-playing-bar .artist').innerHTML = trackArtistsHTML;
document.title = `${trackTitle}${track.artist?.name || 'Unknown'}`;
this.updatePlayingTrackIndicator();

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

@ -1,7 +1,7 @@
//storage.js
export const apiSettings = {
STORAGE_KEY: 'monochrome-api-instances',
INSTANCES_URL: "https://raw.githubusercontent.com/SamidyFR/monochrome/refs/heads/main/instances.json",
INSTANCES_URL: "../instances.json",
SPEED_TEST_CACHE_KEY: 'monochrome-instance-speeds',
SPEED_TEST_CACHE_DURATION: 1000 * 60 * 60,
defaultInstances: [],
@ -19,9 +19,14 @@ export const apiSettings = {
const data = await response.json();
const allInstances = [];
for (const [provider, config] of Object.entries(data.api)) {
if (config.cors === false && Array.isArray(config.urls)) {
allInstances.push(...config.urls);
if (Array.isArray(data)) {
allInstances.push(...data);
} else if (data.api) {
// Legacy support or if structure changes back
for (const [provider, config] of Object.entries(data.api)) {
if (config.cors === false && Array.isArray(config.urls)) {
allInstances.push(...config.urls);
}
}
}

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 => {

View file

@ -176,6 +176,16 @@ export const getTrackArtists = (track = {}, { fallback = 'Unknown Artist' } = {}
return fallback;
};
export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } = {}) => {
if (track?.artists?.length) {
return track.artists.map(artist =>
`<span class="artist-link" data-artist-id="${artist.id}">${artist.name}</span>`
).join(', ');
}
return fallback;
};
export const formatTemplate = (template, data) => {
let result = template;
result = result.replace(/\{trackNumber\}/g, data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00');

View file

@ -53,7 +53,7 @@
--highlight: #3b82f6;
--highlight-rgb: 59, 130, 246;
--active-highlight: #3b82f6;
--explicit-badge: #ef4444;
--explicit-badge: #750a0a;
}
:root[data-theme="ocean"] {
@ -133,8 +133,7 @@
--highlight: #2563eb;
--highlight-rgb: 37, 99, 235;
--active-highlight: var(--highlight);
--explicit-badge: #ef4444;
--explicit-badge-foreground: #ffffff;
--explicit-badge: #f58a8a;
--cover-filter: blur(30px) brightness(1.6) opacity(0.85);
}
@ -622,7 +621,7 @@ body.has-page-background .track-item:hover {
align-items: center;
justify-content: center;
background-color: var(--explicit-badge);
color: var(--explicit-badge-foreground, #000);
color: var(--background);
font-size: 0.65rem;
font-weight: 700;
padding: 0.15rem 0.35rem;
@ -1464,14 +1463,14 @@ input:checked + .slider::before {
#fullscreen-track-artist {
font-size: 1.25rem;
color: var(--primary);
color: var(--muted-foreground);
font-weight: 500;
}
#fullscreen-next-track {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--primary);
color: var(--muted-foreground);
display: flex;
flex-direction: column;
gap: 0.2rem;
@ -3207,6 +3206,14 @@ input:checked + .slider::before {
}
}
.now-playing-bar .artist .artist-link {
cursor: pointer;
transition: color var(--transition);
}
.now-playing-bar .artist .artist-link:hover {
color: var(--highlight);
text-decoration: underline;
}
/* Updated Volume Controls Layout */
.volume-controls {