Merge pull request #28 from JulienMaille/recent
store recently played tracks
This commit is contained in:
commit
a9cfa13fb7
10 changed files with 218 additions and 40 deletions
15
index.html
15
index.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
68
js/db.js
68
js/db.js
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
49
js/events.js
49
js/events.js
|
|
@ -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"]');
|
||||
|
|
|
|||
10
js/player.js
10
js/player.js
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ export function createRouter(ui) {
|
|||
case 'library':
|
||||
ui.renderLibraryPage();
|
||||
break;
|
||||
case 'recent':
|
||||
ui.renderRecentPage();
|
||||
break;
|
||||
case 'home':
|
||||
ui.renderHomePage();
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
69
js/ui.js
69
js/ui.js
|
|
@ -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 => {
|
||||
|
|
|
|||
10
js/utils.js
10
js/utils.js
|
|
@ -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');
|
||||
|
|
|
|||
19
styles.css
19
styles.css
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue