Merge branch 'main' of github.com:monochrome-music/monochrome
This commit is contained in:
commit
895c41c1c3
13 changed files with 762 additions and 29 deletions
BIN
bun.lockb
Executable file
BIN
bun.lockb
Executable file
Binary file not shown.
14
index.html
14
index.html
|
|
@ -75,6 +75,8 @@
|
|||
<!-- Preconnect to critical third-party origins -->
|
||||
<link rel="preconnect" href="https://api.fonts.coollabs.io" crossorigin />
|
||||
<link rel="preconnect" href="https://resources.tidal.com" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
<link rel="apple-touch-icon" href="/assets/logo.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
|
@ -96,6 +98,18 @@
|
|||
rel="stylesheet"
|
||||
/>
|
||||
</noscript>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&family=Noto+Sans+HK:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;600;700&family=Noto+Sans+KR:wght@400;500;600;700&family=Noto+Sans+Hebrew:wght@400;500;600;700&family=Noto+Sans+Arabic:wght@400;500;600;700&family=Noto+Sans+Devanagari:wght@400;500;600;700&family=Noto+Sans+Bengali:wght@400;500;600;700&family=Noto+Sans+Thai:wght@400;500;600;700&family=Noto+Sans+Tamil:wght@400;500;600;700&family=Noto+Sans+Telugu:wght@400;500;600;700&family=Noto+Sans+Gujarati:wght@400;500;600;700&family=Noto+Sans+Kannada:wght@400;500;600;700&family=Noto+Sans+Malayalam:wght@400;500;600;700&family=Noto+Sans+Sinhala:wght@400;500;600;700&family=Noto+Sans+Khmer:wght@400;500;600;700&family=Noto+Sans+Lao:wght@400;500;600;700&family=Noto+Sans+Myanmar:wght@400;500;600;700&family=Noto+Sans+Georgian:wght@400;500;600;700&family=Noto+Sans+Armenian:wght@400;500;600;700&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap"
|
||||
media="print"
|
||||
onload="this.media = 'all'"
|
||||
/>
|
||||
<noscript>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&family=Noto+Sans+HK:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;600;700&family=Noto+Sans+KR:wght@400;500;600;700&family=Noto+Sans+Hebrew:wght@400;500;600;700&family=Noto+Sans+Arabic:wght@400;500;600;700&family=Noto+Sans+Devanagari:wght@400;500;600;700&family=Noto+Sans+Bengali:wght@400;500;600;700&family=Noto+Sans+Thai:wght@400;500;600;700&family=Noto+Sans+Tamil:wght@400;500;600;700&family=Noto+Sans+Telugu:wght@400;500;600;700&family=Noto+Sans+Gujarati:wght@400;500;600;700&family=Noto+Sans+Kannada:wght@400;500;600;700&family=Noto+Sans+Malayalam:wght@400;500;600;700&family=Noto+Sans+Sinhala:wght@400;500;600;700&family=Noto+Sans+Khmer:wght@400;500;600;700&family=Noto+Sans+Lao:wght@400;500;600;700&family=Noto+Sans+Myanmar:wght@400;500;600;700&family=Noto+Sans+Georgian:wght@400;500;600;700&family=Noto+Sans+Armenian:wght@400;500;600;700&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap"
|
||||
/>
|
||||
</noscript>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
|
||||
|
|
|
|||
|
|
@ -660,7 +660,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
});
|
||||
});
|
||||
|
||||
initializePlayerEvents(Player.instance, audioPlayer, scrobbler, UIRenderer.instance);
|
||||
await initializePlayerEvents(Player.instance, audioPlayer, scrobbler, UIRenderer.instance);
|
||||
initializeTrackInteractions(
|
||||
Player.instance,
|
||||
MusicAPI.instance,
|
||||
|
|
@ -1087,6 +1087,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
});
|
||||
|
||||
Player.instance.setQueue(sortedTracks, 0);
|
||||
Player.instance.enableAutoplay();
|
||||
const shuffleBtn = document.getElementById('shuffle-btn');
|
||||
if (shuffleBtn) shuffleBtn.classList.remove('active');
|
||||
Player.instance.shuffleActive = false;
|
||||
|
|
@ -1118,6 +1119,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
if (tracks && tracks.length > 0) {
|
||||
const shuffledTracks = [...tracks].sort(() => Math.random() - 0.5);
|
||||
Player.instance.setQueue(shuffledTracks, 0);
|
||||
Player.instance.enableAutoplay();
|
||||
const shuffleBtn = document.getElementById('shuffle-btn');
|
||||
if (shuffleBtn) shuffleBtn.classList.remove('active');
|
||||
Player.instance.shuffleActive = false;
|
||||
|
|
@ -1186,6 +1188,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
const shuffledTracks = [...allTracks].sort(() => Math.random() - 0.5);
|
||||
Player.instance.setQueue(shuffledTracks, 0);
|
||||
Player.instance.enableAutoplay();
|
||||
const shuffleBtn = document.getElementById('shuffle-btn');
|
||||
if (shuffleBtn) shuffleBtn.classList.remove('active');
|
||||
Player.instance.shuffleActive = false;
|
||||
|
|
|
|||
|
|
@ -826,6 +826,7 @@ class AudioContextManager {
|
|||
if (this.volumeNode && this.audioContext) {
|
||||
const now = this.audioContext.currentTime;
|
||||
this.volumeNode.gain.setTargetAtTime(this.currentVolume, now, 0.01);
|
||||
window.dispatchEvent(new CustomEvent('volume-change'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
69
js/events.js
69
js/events.js
|
|
@ -375,7 +375,7 @@ async function handleSelectionAction(action) {
|
|||
}
|
||||
}
|
||||
|
||||
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||
export async function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||
if (homeStartRadioBtn) {
|
||||
homeStartRadioBtn.addEventListener('click', async () => {
|
||||
await player.enableRadio();
|
||||
|
|
@ -384,9 +384,13 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
|
||||
const sleepTimerBtnMobile = document.getElementById('sleep-timer-btn');
|
||||
|
||||
// History tracking
|
||||
let historyLoggedTrackId = null;
|
||||
|
||||
const { listeningTracker } = await import('./listening-tracker.js');
|
||||
|
||||
let _previousTrackId = null;
|
||||
let _trackPlayStartTime = null;
|
||||
|
||||
const setupMediaListeners = (element) => {
|
||||
element.addEventListener('loadstart', () => {
|
||||
if (player.activeElement === element) {
|
||||
|
|
@ -397,14 +401,32 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
element.addEventListener('play', async () => {
|
||||
if (player.activeElement !== element) return;
|
||||
|
||||
// Initialize audio context manager for EQ (only once)
|
||||
if (!audioContextManager.isReady()) {
|
||||
audioContextManager.init(element);
|
||||
}
|
||||
await audioContextManager.resume();
|
||||
|
||||
if (player.currentTrack) {
|
||||
// Scrobble
|
||||
const currentId = player.currentTrack.id;
|
||||
if (currentId !== _previousTrackId) {
|
||||
if (_previousTrackId !== null) {
|
||||
const prevSignal = listeningTracker.getSessionSignals();
|
||||
const prevPlayTime = prevSignal.accumulatedPlayTime || 0;
|
||||
const prevDuration = prevSignal.trackDuration || 0;
|
||||
listeningTracker.onSkip();
|
||||
const prevTrack =
|
||||
player.getCurrentQueue()[player.currentQueueIndex - 1] ||
|
||||
player.getCurrentQueue().find((t) => t.id === _previousTrackId);
|
||||
if (prevTrack && prevPlayTime > 0) {
|
||||
listeningTracker.updateArtistAffinity(prevTrack, prevPlayTime, prevDuration, true);
|
||||
}
|
||||
listeningTracker.forceFlush();
|
||||
}
|
||||
_previousTrackId = currentId;
|
||||
listeningTracker.onTrackStart(player.currentTrack);
|
||||
_trackPlayStartTime = Date.now();
|
||||
}
|
||||
|
||||
if (scrobbler.isAuthenticated()) {
|
||||
scrobbler.updateNowPlaying(player.currentTrack);
|
||||
}
|
||||
|
|
@ -433,6 +455,15 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
|
||||
element.addEventListener('ended', () => {
|
||||
if (player.activeElement !== element) return;
|
||||
const elapsedPlayTime = listeningTracker.getSessionSignals().accumulatedPlayTime || 0;
|
||||
const trackDur = listeningTracker.getSessionSignals().trackDuration || 0;
|
||||
listeningTracker.onTrackEnd();
|
||||
if (player.currentTrack) {
|
||||
const effectivePlayTime = elapsedPlayTime || (Date.now() - _trackPlayStartTime) / 1000;
|
||||
listeningTracker.updateArtistAffinity(player.currentTrack, effectivePlayTime, trackDur, false);
|
||||
}
|
||||
listeningTracker.forceFlush();
|
||||
_previousTrackId = null;
|
||||
player.playNext();
|
||||
});
|
||||
|
||||
|
|
@ -446,7 +477,8 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
progressFill.style.width = `${(currentTime / duration) * 100}%`;
|
||||
currentTimeEl.textContent = formatTime(currentTime);
|
||||
|
||||
// Log to history after 10 seconds of playback
|
||||
listeningTracker.onTimeUpdate(currentTime, duration);
|
||||
|
||||
if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) {
|
||||
historyLoggedTrackId = player.currentTrack.id;
|
||||
const historyEntry = await db.addToHistory(player.currentTrack);
|
||||
|
|
@ -518,6 +550,8 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
});
|
||||
};
|
||||
|
||||
window.addEventListener('volume-change', updateVolumeUI);
|
||||
|
||||
setupMediaListeners(audioPlayer);
|
||||
if (player.video) {
|
||||
setupMediaListeners(player.video);
|
||||
|
|
@ -2145,10 +2179,25 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
player.playVideo(clickedTrack);
|
||||
} else {
|
||||
player.setQueue([clickedTrack], 0);
|
||||
player.enableAutoplay();
|
||||
document.getElementById('shuffle-btn').classList.remove('active');
|
||||
player.playTrackFromQueue();
|
||||
|
||||
api.getTrackRecommendations(clickedTrack.id).then((recs) => {
|
||||
const { autoplaySettings } = await import('./storage.js');
|
||||
const fetchRecs = autoplaySettings.isSmartRecsEnabled()
|
||||
? (async () => {
|
||||
const { smartRecommendations } = await import('./smart-recommendations.js');
|
||||
const recs = await api.getTrackRecommendations(clickedTrack.id);
|
||||
if (recs && recs.length > 0) {
|
||||
const filtered = smartRecommendations.filterRecommendations(recs);
|
||||
const ranked = smartRecommendations.rankRecommendations(filtered);
|
||||
return ranked;
|
||||
}
|
||||
return [];
|
||||
})()
|
||||
: api.getTrackRecommendations(clickedTrack.id);
|
||||
|
||||
fetchRecs.then((recs) => {
|
||||
if (recs && recs.length > 0) {
|
||||
player.addToQueue(recs);
|
||||
}
|
||||
|
|
@ -2164,13 +2213,8 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
const startIndex = trackList.findIndex((t) => t.id == clickedTrackId);
|
||||
|
||||
player.setQueue(trackList, startIndex);
|
||||
player.enableAutoplay();
|
||||
|
||||
// Set artist popular tracks context if on artist page
|
||||
console.log('[Events] Setting context:', {
|
||||
page: ui.currentPage,
|
||||
artistId: ui.currentArtistId,
|
||||
trackCount: trackList.length,
|
||||
});
|
||||
if (ui.currentPage === 'artist' && ui.currentArtistId) {
|
||||
player.setArtistPopularTracksContext(ui.currentArtistId, trackList, trackList.length, true);
|
||||
}
|
||||
|
|
@ -2220,6 +2264,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
if (trackList.length === 0) return;
|
||||
const startIndex = trackList.findIndex((t) => t.id == clickedTrackId);
|
||||
player.setQueue(trackList, startIndex);
|
||||
player.enableAutoplay();
|
||||
if (ui.currentPage === 'artist' && ui.currentArtistId) {
|
||||
player.setArtistPopularTracksContext(ui.currentArtistId, trackList, trackList.length, true);
|
||||
}
|
||||
|
|
|
|||
282
js/listening-tracker.js
Normal file
282
js/listening-tracker.js
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
const STORAGE_KEY = 'monochrome-listening-data';
|
||||
const MAX_TRACKS = 2000;
|
||||
const MAX_ARTISTS = 500;
|
||||
const SKIP_THRESHOLD_S = 5;
|
||||
const COMPLETION_RATIO_THRESHOLD = 0.3;
|
||||
|
||||
class ListeningTracker {
|
||||
constructor() {
|
||||
this._data = null;
|
||||
this._currentTrackId = null;
|
||||
this._playStartTime = null;
|
||||
this._lastTimeUpdate = 0;
|
||||
this._accumulatedPlayTime = 0;
|
||||
this._trackDuration = 0;
|
||||
this._flushTimer = null;
|
||||
}
|
||||
|
||||
_load() {
|
||||
if (this._data) return this._data;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
this._data = raw ? JSON.parse(raw) : this._empty();
|
||||
} catch {
|
||||
this._data = this._empty();
|
||||
}
|
||||
return this._data;
|
||||
}
|
||||
|
||||
_empty() {
|
||||
return { tracks: {}, artists: {}, version: 1 };
|
||||
}
|
||||
|
||||
_save() {
|
||||
try {
|
||||
const d = this._data || this._load();
|
||||
const trackEntries = Object.entries(d.tracks);
|
||||
if (trackEntries.length > MAX_TRACKS) {
|
||||
trackEntries.sort((a, b) => (b[1].lastPlayed || 0) - (a[1].lastPlayed || 0));
|
||||
d.tracks = Object.fromEntries(trackEntries.slice(0, MAX_TRACKS));
|
||||
}
|
||||
const artistEntries = Object.entries(d.artists);
|
||||
if (artistEntries.length > MAX_ARTISTS) {
|
||||
artistEntries.sort((a, b) => (b[1].affinity || 0) - (a[1].affinity || 0));
|
||||
d.artists = Object.fromEntries(artistEntries.slice(0, MAX_ARTISTS));
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(d));
|
||||
} catch (e) {
|
||||
console.warn('ListeningTracker: save failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
_flush() {
|
||||
if (this._flushTimer) return;
|
||||
this._flushTimer = setTimeout(() => {
|
||||
this._save();
|
||||
this._flushTimer = null;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
onTrackStart(track) {
|
||||
if (!track || !track.id) return;
|
||||
this._finalizeCurrent();
|
||||
this._currentTrackId = track.id;
|
||||
this._playStartTime = Date.now();
|
||||
this._lastTimeUpdate = 0;
|
||||
this._accumulatedPlayTime = 0;
|
||||
this._trackDuration = (track.duration || 0) / 1000;
|
||||
}
|
||||
|
||||
onTimeUpdate(currentTime, duration) {
|
||||
if (!this._currentTrackId || this._playStartTime === null) return;
|
||||
if (duration > 0) this._trackDuration = duration;
|
||||
if (this._lastTimeUpdate > 0 && currentTime > this._lastTimeUpdate) {
|
||||
const delta = currentTime - this._lastTimeUpdate;
|
||||
if (delta < 5) {
|
||||
this._accumulatedPlayTime += delta;
|
||||
}
|
||||
}
|
||||
this._lastTimeUpdate = currentTime;
|
||||
}
|
||||
|
||||
onTrackEnd() {
|
||||
this._finalizeCurrent();
|
||||
}
|
||||
|
||||
onSkip() {
|
||||
if (!this._currentTrackId || this._playStartTime === null) return;
|
||||
const elapsed = this._accumulatedPlayTime;
|
||||
this._recordTrackSignal(this._currentTrackId, elapsed, this._trackDuration, true);
|
||||
if (this._currentTrackId) {
|
||||
const currentData = this._load();
|
||||
const trackMeta = this._findTrackMeta(this._currentTrackId);
|
||||
if (trackMeta) {
|
||||
this._updateArtistAffinityFromData(currentData, trackMeta, elapsed, this._trackDuration, true);
|
||||
}
|
||||
}
|
||||
this._currentTrackId = null;
|
||||
this._playStartTime = null;
|
||||
this._accumulatedPlayTime = 0;
|
||||
this._lastTimeUpdate = 0;
|
||||
this._flush();
|
||||
}
|
||||
|
||||
_finalizeCurrent() {
|
||||
if (!this._currentTrackId || this._playStartTime === null) return;
|
||||
const elapsed = this._accumulatedPlayTime;
|
||||
this._recordTrackSignal(this._currentTrackId, elapsed, this._trackDuration, false);
|
||||
if (this._currentTrackId) {
|
||||
const currentData = this._load();
|
||||
const trackMeta = this._findTrackMeta(this._currentTrackId);
|
||||
if (trackMeta) {
|
||||
this._updateArtistAffinityFromData(currentData, trackMeta, elapsed, this._trackDuration, false);
|
||||
}
|
||||
}
|
||||
this._currentTrackId = null;
|
||||
this._playStartTime = null;
|
||||
this._accumulatedPlayTime = 0;
|
||||
this._lastTimeUpdate = 0;
|
||||
this._flush();
|
||||
}
|
||||
|
||||
_findTrackMeta(_trackId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
_recordTrackSignal(trackId, playTimeS, durationS, wasSkipped) {
|
||||
const d = this._load();
|
||||
if (!d.tracks[trackId]) {
|
||||
d.tracks[trackId] = {
|
||||
playCount: 0,
|
||||
skipCount: 0,
|
||||
totalPlayTime: 0,
|
||||
completionCount: 0,
|
||||
lastPlayed: 0,
|
||||
avgCompletionRatio: 0,
|
||||
};
|
||||
}
|
||||
const t = d.tracks[trackId];
|
||||
t.playCount++;
|
||||
t.totalPlayTime += playTimeS;
|
||||
t.lastPlayed = Date.now();
|
||||
|
||||
const completionRatio = durationS > 0 ? Math.min(playTimeS / durationS, 1) : 0;
|
||||
t.avgCompletionRatio =
|
||||
t.avgCompletionRatio === 0 ? completionRatio : t.avgCompletionRatio * 0.8 + completionRatio * 0.2;
|
||||
|
||||
if (wasSkipped || playTimeS < SKIP_THRESHOLD_S) {
|
||||
t.skipCount++;
|
||||
} else if (playTimeS >= durationS * 0.9 || completionRatio >= 0.9) {
|
||||
t.completionCount++;
|
||||
}
|
||||
}
|
||||
|
||||
updateArtistAffinity(track, playTimeS, durationS, wasSkipped) {
|
||||
if (!track) return;
|
||||
const d = this._load();
|
||||
this._updateArtistAffinityFromData(d, track, playTimeS, durationS, wasSkipped);
|
||||
this._flush();
|
||||
}
|
||||
|
||||
_updateArtistAffinityFromData(d, track, playTimeS, durationS, wasSkipped) {
|
||||
const artistIds = [];
|
||||
if (track.artist && track.artist.id) artistIds.push(track.artist.id);
|
||||
if (track.artists && Array.isArray(track.artists)) {
|
||||
for (const a of track.artists) {
|
||||
if (a.id) artistIds.push(a.id);
|
||||
}
|
||||
}
|
||||
if (artistIds.length === 0) return;
|
||||
|
||||
const completionRatio = durationS > 0 ? Math.min(playTimeS / durationS, 1) : 0;
|
||||
const weight = wasSkipped
|
||||
? -0.5
|
||||
: completionRatio > 0.8
|
||||
? 1.0
|
||||
: completionRatio > 0.5
|
||||
? 0.5
|
||||
: completionRatio > COMPLETION_RATIO_THRESHOLD
|
||||
? 0.2
|
||||
: -0.2;
|
||||
|
||||
for (const artistId of artistIds) {
|
||||
const name = track.artists?.find((a) => a.id === artistId)?.name || track.artist?.name || '';
|
||||
if (!d.artists[artistId]) {
|
||||
d.artists[artistId] = { name, affinity: 0, playCount: 0, skipCount: 0, totalPlayTime: 0 };
|
||||
}
|
||||
const a = d.artists[artistId];
|
||||
a.affinity = a.affinity * 0.9 + weight;
|
||||
a.playCount++;
|
||||
a.totalPlayTime += playTimeS;
|
||||
if (wasSkipped) a.skipCount++;
|
||||
if (name) a.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
getTrackSignal(trackId) {
|
||||
const d = this._load();
|
||||
return d.tracks[trackId] || null;
|
||||
}
|
||||
|
||||
getTrackScore(trackId) {
|
||||
const signal = this.getTrackSignal(trackId);
|
||||
if (!signal) return 0;
|
||||
const skipRate = signal.playCount > 0 ? signal.skipCount / signal.playCount : 0;
|
||||
const completionRate = signal.playCount > 0 ? signal.completionCount / signal.playCount : 0;
|
||||
return (
|
||||
signal.avgCompletionRatio * 2 + completionRate * 3 - skipRate * 4 + Math.log2(signal.playCount + 1) * 0.5
|
||||
);
|
||||
}
|
||||
|
||||
getArtistAffinity(artistId) {
|
||||
const d = this._load();
|
||||
return d.artists[artistId]?.affinity || 0;
|
||||
}
|
||||
|
||||
getTopArtists(limit = 20) {
|
||||
const d = this._load();
|
||||
return Object.entries(d.artists)
|
||||
.filter(([, v]) => v.playCount >= 2)
|
||||
.sort((a, b) => b[1].affinity - a[1].affinity)
|
||||
.slice(0, limit)
|
||||
.map(([id, v]) => ({ id, name: v.name, affinity: v.affinity, playCount: v.playCount }));
|
||||
}
|
||||
|
||||
getDislikedArtists(limit = 20) {
|
||||
const d = this._load();
|
||||
return Object.entries(d.artists)
|
||||
.filter(([, v]) => v.playCount >= 2 && v.affinity < -0.3)
|
||||
.sort((a, b) => a[1].affinity - b[1].affinity)
|
||||
.slice(0, limit)
|
||||
.map(([id, v]) => ({ id, name: v.name, affinity: v.affinity }));
|
||||
}
|
||||
|
||||
getHighlyPlayedTracks(limit = 50) {
|
||||
const d = this._load();
|
||||
return Object.entries(d.tracks)
|
||||
.filter(([, v]) => v.playCount >= 2 && v.avgCompletionRatio > 0.6)
|
||||
.sort((a, b) => b[1].playCount - a[1].playCount)
|
||||
.slice(0, limit)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
getFrequentlySkippedTrackIds(limit = 50) {
|
||||
const d = this._load();
|
||||
return Object.entries(d.tracks)
|
||||
.filter(([, v]) => v.playCount >= 2 && v.skipCount / v.playCount > 0.5)
|
||||
.sort((a, b) => b[1].skipCount / b[1].playCount - a[1].skipCount / a[1].playCount)
|
||||
.slice(0, limit)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
getShortPlayTrackIds(limit = 50) {
|
||||
const d = this._load();
|
||||
return Object.entries(d.tracks)
|
||||
.filter(([, v]) => v.playCount >= 2 && v.avgCompletionRatio < COMPLETION_RATIO_THRESHOLD)
|
||||
.sort((a, b) => a[1].avgCompletionRatio - b[1].avgCompletionRatio)
|
||||
.slice(0, limit)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
getDislikedArtistIds() {
|
||||
return this.getDislikedArtists(30).map((a) => a.id);
|
||||
}
|
||||
|
||||
getSessionSignals() {
|
||||
return {
|
||||
currentTrackId: this._currentTrackId,
|
||||
accumulatedPlayTime: this._accumulatedPlayTime,
|
||||
trackDuration: this._trackDuration,
|
||||
};
|
||||
}
|
||||
|
||||
forceFlush() {
|
||||
if (this._flushTimer) {
|
||||
clearTimeout(this._flushTimer);
|
||||
this._flushTimer = null;
|
||||
}
|
||||
this._save();
|
||||
}
|
||||
}
|
||||
|
||||
export const listeningTracker = new ListeningTracker();
|
||||
150
js/player.js
150
js/player.js
|
|
@ -16,6 +16,7 @@ import {
|
|||
exponentialVolumeSettings,
|
||||
audioEffectsSettings,
|
||||
radioSettings,
|
||||
autoplaySettings,
|
||||
binauralDspSettings,
|
||||
} from './storage.js';
|
||||
import { audioContextManager } from './audio-context.js';
|
||||
|
|
@ -162,10 +163,23 @@ export class Player {
|
|||
this.isFetchingRadio = false;
|
||||
this.radioFetchPromise = null;
|
||||
|
||||
this.autoplayEnabled = autoplaySettings.isEnabled();
|
||||
this.autoplaySeeds = [];
|
||||
this.isFetchingAutoplay = false;
|
||||
this.autoplayFetchPromise = null;
|
||||
this._recentlyPlayedIds = [];
|
||||
this._maxRecentlyPlayed = 100;
|
||||
|
||||
this.playbackSequence = 0;
|
||||
|
||||
window.addEventListener('beforeunload', async () => {
|
||||
await this.saveQueueState();
|
||||
import('./listening-tracker.js')
|
||||
.then(({ listeningTracker }) => {
|
||||
listeningTracker.onTrackEnd();
|
||||
listeningTracker.forceFlush();
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
// Handle visibility change - AudioContext can be suspended when backgrounded
|
||||
|
|
@ -898,7 +912,7 @@ export class Player {
|
|||
await this.saveQueueState();
|
||||
|
||||
this.currentTrack = track;
|
||||
|
||||
this.addToRecentlyPlayed(track.id);
|
||||
const trackTitle = getTrackTitle(track);
|
||||
const artistName = getTrackArtists(track);
|
||||
const trackArtistsHTML = getTrackArtistsHTML(track);
|
||||
|
|
@ -1336,6 +1350,15 @@ export class Player {
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (this.autoplayEnabled && isLastTrack) {
|
||||
this.fetchAutoplayRecommendations().then(async () => {
|
||||
const updatedQueue = this.getCurrentQueue();
|
||||
if (this.currentQueueIndex < updatedQueue.length - 1) {
|
||||
await this.playNext(0);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) {
|
||||
await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
|
||||
if (newTracks && newTracks.length > 0) {
|
||||
|
|
@ -1376,12 +1399,19 @@ export class Player {
|
|||
}
|
||||
});
|
||||
return;
|
||||
} else if (this.autoplayEnabled) {
|
||||
this.fetchAutoplayRecommendations().then(async () => {
|
||||
const updatedQueue = this.getCurrentQueue();
|
||||
if (this.currentQueueIndex < updatedQueue.length - 1) {
|
||||
await this.playNext(0);
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) {
|
||||
await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
|
||||
if (newTracks && newTracks.length > 0) {
|
||||
await this.addToQueue(newTracks);
|
||||
}
|
||||
// Now play the next track (which is now at currentQueueIndex + 1 if tracks were added)
|
||||
this.currentQueueIndex++;
|
||||
await this.playTrackFromQueue(0, recursiveCount);
|
||||
});
|
||||
|
|
@ -1467,12 +1497,20 @@ export class Player {
|
|||
...favorites.map((t) => t.id),
|
||||
...userPlaylists.flatMap((p) => (p.tracks || []).map((t) => t.id)),
|
||||
...history.map((t) => t.id),
|
||||
...this._recentlyPlayedIds,
|
||||
]);
|
||||
|
||||
const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
|
||||
let recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
|
||||
knownTrackIds: knownTrackIds,
|
||||
});
|
||||
|
||||
const { autoplaySettings: _autoplaySettings } = await import('./storage.js');
|
||||
if (_autoplaySettings.isSmartRecsEnabled()) {
|
||||
const { smartRecommendations } = await import('./smart-recommendations.js');
|
||||
recommendations = smartRecommendations.filterRecommendations(recommendations);
|
||||
recommendations = smartRecommendations.rankRecommendations(recommendations);
|
||||
}
|
||||
|
||||
if (recommendations && recommendations.length > 0) {
|
||||
const currentQueueIds = new Set(this.getCurrentQueue().map((t) => t.id));
|
||||
|
||||
|
|
@ -1498,6 +1536,14 @@ export class Player {
|
|||
}
|
||||
|
||||
async pickRadioSeeds() {
|
||||
try {
|
||||
const { smartRecommendations } = await import('./smart-recommendations.js');
|
||||
const smartSeeds = await smartRecommendations.getSmartSeeds(50);
|
||||
if (smartSeeds.length > 0) return smartSeeds;
|
||||
} catch (e) {
|
||||
console.warn('Smart seeds failed, falling back to basic seed selection:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
const [history, favorites, userPlaylists] = await Promise.all([
|
||||
db.getHistory(),
|
||||
|
|
@ -1553,6 +1599,97 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
enableAutoplay() {
|
||||
this.autoplayEnabled = true;
|
||||
autoplaySettings.setEnabled(true);
|
||||
}
|
||||
|
||||
disableAutoplay() {
|
||||
this.autoplayEnabled = false;
|
||||
autoplaySettings.setEnabled(false);
|
||||
}
|
||||
|
||||
addToRecentlyPlayed(trackId) {
|
||||
if (!trackId) return;
|
||||
this._recentlyPlayedIds = this._recentlyPlayedIds.filter((id) => id !== trackId);
|
||||
this._recentlyPlayedIds.push(trackId);
|
||||
if (this._recentlyPlayedIds.length > this._maxRecentlyPlayed) {
|
||||
this._recentlyPlayedIds = this._recentlyPlayedIds.slice(-this._maxRecentlyPlayed);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAutoplayRecommendations() {
|
||||
if (this.isFetchingAutoplay) return this.autoplayFetchPromise || Promise.resolve();
|
||||
this.isFetchingAutoplay = true;
|
||||
|
||||
this.showRadioLoading(true);
|
||||
|
||||
this.autoplayFetchPromise = (async () => {
|
||||
try {
|
||||
const { smartRecommendations } = await import('./smart-recommendations.js');
|
||||
const { autoplaySettings: _autoplaySettings } = await import('./storage.js');
|
||||
|
||||
const currentQueue = this.getCurrentQueue();
|
||||
const recentQueueTracks = currentQueue.slice(
|
||||
Math.max(0, this.currentQueueIndex - 10),
|
||||
this.currentQueueIndex + 1
|
||||
);
|
||||
|
||||
const seeds = await smartRecommendations.getAdaptiveQueueSeeds(
|
||||
recentQueueTracks,
|
||||
this._recentlyPlayedIds,
|
||||
5
|
||||
);
|
||||
|
||||
if (seeds.length === 0) {
|
||||
if (this.currentTrack) seeds.push(this.currentTrack);
|
||||
else return;
|
||||
}
|
||||
|
||||
const [favorites, userPlaylists, history] = await Promise.all([
|
||||
db.getFavorites('track'),
|
||||
db.getAll('user_playlists'),
|
||||
db.getHistory(),
|
||||
]);
|
||||
|
||||
const knownTrackIds = new Set([
|
||||
...favorites.map((t) => t.id),
|
||||
...userPlaylists.flatMap((p) => (p.tracks || []).map((t) => t.id)),
|
||||
...history.map((t) => t.id),
|
||||
...this._recentlyPlayedIds,
|
||||
...currentQueue.map((t) => t.id),
|
||||
]);
|
||||
|
||||
let recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
|
||||
knownTrackIds: knownTrackIds,
|
||||
});
|
||||
|
||||
if (_autoplaySettings.isSmartRecsEnabled()) {
|
||||
recommendations = smartRecommendations.filterRecommendations(recommendations);
|
||||
recommendations = smartRecommendations.rankRecommendations(recommendations);
|
||||
}
|
||||
|
||||
if (recommendations && recommendations.length > 0) {
|
||||
const currentQueueIds = new Set(currentQueue.map((t) => t.id));
|
||||
let newTracks = recommendations.filter((t) => !currentQueueIds.has(t.id));
|
||||
|
||||
if (newTracks.length > 0) {
|
||||
const tracksToAdd = newTracks.slice(0, 5);
|
||||
await this.addToQueue(tracksToAdd);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch autoplay recommendations:', error);
|
||||
} finally {
|
||||
this.isFetchingAutoplay = false;
|
||||
this.autoplayFetchPromise = null;
|
||||
setTimeout(() => this.showRadioLoading(false), 500);
|
||||
}
|
||||
})();
|
||||
|
||||
return this.autoplayFetchPromise;
|
||||
}
|
||||
|
||||
playPrev(recursiveCount = 0) {
|
||||
const el = this.activeElement;
|
||||
if (el.currentTime > 3) {
|
||||
|
|
@ -1560,7 +1697,6 @@ export class Player {
|
|||
this.updateMediaSessionPositionState();
|
||||
} else if (this.currentQueueIndex > 0) {
|
||||
this.currentQueueIndex--;
|
||||
// Skip unavailable and blocked tracks
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
|
||||
if (recursiveCount > currentQueue.length) {
|
||||
|
|
@ -1575,6 +1711,12 @@ export class Player {
|
|||
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
||||
return this.playPrev(recursiveCount + 1);
|
||||
}
|
||||
import('./listening-tracker.js')
|
||||
.then(({ listeningTracker }) => {
|
||||
listeningTracker.onSkip();
|
||||
listeningTracker.forceFlush();
|
||||
})
|
||||
.catch(() => {});
|
||||
await this.playTrackFromQueue(0, recursiveCount);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
|
|
|||
171
js/smart-recommendations.js
Normal file
171
js/smart-recommendations.js
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { listeningTracker } from './listening-tracker.js';
|
||||
import { db } from './db.js';
|
||||
|
||||
class SmartRecommendations {
|
||||
async getSmartSeeds(count = 50) {
|
||||
const [history, favorites, playlists] = await Promise.all([
|
||||
db.getHistory(),
|
||||
db.getFavorites('track'),
|
||||
db.getPlaylists(true),
|
||||
]);
|
||||
const playlistTracks = playlists.flatMap((p) => p.tracks || []);
|
||||
|
||||
const scoredTracks = new Map();
|
||||
|
||||
const addWithScore = (tracks, baseWeight) => {
|
||||
for (const t of tracks) {
|
||||
if (!t || !t.id) continue;
|
||||
const signalScore = listeningTracker.getTrackScore(t.id);
|
||||
const completionBonus = this._getCompletionBonus(t.id);
|
||||
const score = baseWeight + signalScore + completionBonus;
|
||||
const existing = scoredTracks.get(t.id);
|
||||
if (existing) {
|
||||
existing.score += score;
|
||||
existing.track = t;
|
||||
} else {
|
||||
scoredTracks.set(t.id, { score, track: t });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
addWithScore(favorites, 3);
|
||||
addWithScore(playlistTracks, 2);
|
||||
addWithScore(history, 1);
|
||||
|
||||
const sorted = [...scoredTracks.values()].sort((a, b) => b.score - a.score);
|
||||
|
||||
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
|
||||
|
||||
const filteredSeeds = sorted
|
||||
.filter((s) => {
|
||||
const t = s.track;
|
||||
if (this._isTrackByDislikedArtist(t, dislikedArtistIds)) return false;
|
||||
const signal = listeningTracker.getTrackSignal(t.id);
|
||||
if (signal && signal.playCount >= 3 && signal.avgCompletionRatio < 0.2) return false;
|
||||
return true;
|
||||
})
|
||||
.slice(0, count)
|
||||
.map((s) => s.track);
|
||||
|
||||
const shuffle = (arr) => [...arr].sort(() => Math.random() - 0.5);
|
||||
return shuffle(filteredSeeds);
|
||||
}
|
||||
|
||||
_getCompletionBonus(trackId) {
|
||||
const signal = listeningTracker.getTrackSignal(trackId);
|
||||
if (!signal) return 0;
|
||||
if (signal.avgCompletionRatio > 0.8) return 2;
|
||||
if (signal.avgCompletionRatio > 0.5) return 1;
|
||||
if (signal.avgCompletionRatio < 0.2 && signal.playCount >= 2) return -3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
_isTrackByDislikedArtist(track, dislikedArtistIds) {
|
||||
if (!track || dislikedArtistIds.size === 0) return false;
|
||||
if (track.artist?.id && dislikedArtistIds.has(String(track.artist.id))) return true;
|
||||
if (track.artists?.some((a) => a.id && dislikedArtistIds.has(String(a.id)))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
filterRecommendations(tracks) {
|
||||
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
|
||||
const frequentlySkippedIds = new Set(listeningTracker.getFrequentlySkippedTrackIds(100));
|
||||
const shortPlayIds = new Set(listeningTracker.getShortPlayTrackIds(100));
|
||||
|
||||
return tracks.filter((t) => {
|
||||
if (!t || !t.id) return false;
|
||||
if (frequentlySkippedIds.has(t.id)) return false;
|
||||
if (shortPlayIds.has(t.id)) return false;
|
||||
if (this._isTrackByDislikedArtist(t, dislikedArtistIds)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
scoreRecommendation(track) {
|
||||
if (!track) return 0;
|
||||
let score = 0;
|
||||
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
|
||||
const topArtists = listeningTracker.getTopArtists(30);
|
||||
const topArtistIds = new Set(topArtists.map((a) => a.id));
|
||||
|
||||
if (track.artist?.id && topArtistIds.has(String(track.artist.id))) {
|
||||
const artist = topArtists.find((a) => a.id === String(track.artist.id));
|
||||
score += artist ? Math.min(artist.affinity * 2, 5) : 1;
|
||||
}
|
||||
if (track.artists?.some((a) => a.id && topArtistIds.has(String(a.id)))) {
|
||||
score += 1;
|
||||
}
|
||||
if (this._isTrackByDislikedArtist(track, dislikedArtistIds)) {
|
||||
score -= 5;
|
||||
}
|
||||
const skipIds = new Set(listeningTracker.getFrequentlySkippedTrackIds(50));
|
||||
if (skipIds.has(track.id)) score -= 3;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
rankRecommendations(tracks) {
|
||||
return tracks
|
||||
.map((t) => ({ track: t, score: this.scoreRecommendation(t) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((t) => t.track);
|
||||
}
|
||||
|
||||
async getAdaptiveQueueSeeds(currentQueueTracks, recentlyPlayedIds, count = 5) {
|
||||
const topArtistIds = new Set(listeningTracker.getTopArtists(20).map((a) => a.id));
|
||||
|
||||
const queueArtistIds = new Set();
|
||||
for (const t of currentQueueTracks) {
|
||||
if (t.artist?.id) queueArtistIds.add(String(t.artist.id));
|
||||
if (t.artists)
|
||||
t.artists.forEach((a) => {
|
||||
if (a.id) queueArtistIds.add(String(a.id));
|
||||
});
|
||||
}
|
||||
|
||||
const currentArtistIds = new Set();
|
||||
for (const id of queueArtistIds) {
|
||||
if (topArtistIds.has(id)) currentArtistIds.add(id);
|
||||
}
|
||||
|
||||
const recentTrackIds = new Set(recentlyPlayedIds);
|
||||
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
|
||||
|
||||
const scoredTracks = [];
|
||||
for (const t of currentQueueTracks) {
|
||||
if (!t || recentTrackIds.has(t.id)) continue;
|
||||
if (this._isTrackByDislikedArtist(t, dislikedArtistIds)) continue;
|
||||
const signal = listeningTracker.getTrackSignal(t.id);
|
||||
const completionRatio = signal ? signal.avgCompletionRatio : 0.5;
|
||||
const score = completionRatio;
|
||||
scoredTracks.push({ track: t, score });
|
||||
}
|
||||
|
||||
scoredTracks.sort((a, b) => b.score - a.score);
|
||||
|
||||
const bestSeeds = scoredTracks.slice(0, Math.ceil(count / 2)).map((s) => s.track);
|
||||
|
||||
if (bestSeeds.length < count) {
|
||||
const smartSeeds = await this.getSmartSeeds(20);
|
||||
const additional = smartSeeds.filter((s) => {
|
||||
if (recentTrackIds.has(s.id)) return false;
|
||||
return !bestSeeds.some((b) => b.id === s.id);
|
||||
});
|
||||
bestSeeds.push(...additional.slice(0, count - bestSeeds.length));
|
||||
}
|
||||
|
||||
return bestSeeds.slice(0, count);
|
||||
}
|
||||
|
||||
getKnownBadTrackIds() {
|
||||
const skipped = new Set(listeningTracker.getFrequentlySkippedTrackIds(100));
|
||||
const shortPlay = new Set(listeningTracker.getShortPlayTrackIds(100));
|
||||
return new Set([...skipped, ...shortPlay]);
|
||||
}
|
||||
|
||||
getKnownBadArtistIds() {
|
||||
return new Set(listeningTracker.getDislikedArtistIds(30));
|
||||
}
|
||||
}
|
||||
|
||||
export const smartRecommendations = new SmartRecommendations();
|
||||
|
|
@ -2371,6 +2371,37 @@ export const radioSettings = {
|
|||
},
|
||||
};
|
||||
|
||||
export const autoplaySettings = {
|
||||
ENABLED_KEY: 'autoplay-enabled',
|
||||
SMART_RECS_KEY: 'smart-recommendations-enabled',
|
||||
|
||||
isEnabled() {
|
||||
try {
|
||||
const val = localStorage.getItem(this.ENABLED_KEY);
|
||||
return val === null ? true : val === 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
setEnabled(enabled) {
|
||||
localStorage.setItem(this.ENABLED_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
isSmartRecsEnabled() {
|
||||
try {
|
||||
const val = localStorage.getItem(this.SMART_RECS_KEY);
|
||||
return val === null ? true : val === 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
setSmartRecsEnabled(enabled) {
|
||||
localStorage.setItem(this.SMART_RECS_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
};
|
||||
|
||||
export const analyticsSettings = {
|
||||
ENABLED_KEY: 'analytics-enabled',
|
||||
|
||||
|
|
@ -2619,6 +2650,8 @@ export const fontSettings = {
|
|||
FONT_SIZE_KEY: 'monochrome-font-size',
|
||||
FONT_LINK_ID: 'monochrome-dynamic-font',
|
||||
FONT_FACE_ID: 'monochrome-dynamic-fontface',
|
||||
NOTO_FALLBACK:
|
||||
"'Noto Sans', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans Hebrew', 'Noto Sans Arabic', 'Noto Sans Devanagari', 'Noto Sans Bengali', 'Noto Sans Thai', 'Noto Sans Tamil', 'Noto Sans Telugu', 'Noto Sans Gujarati', 'Noto Sans Kannada', 'Noto Sans Malayalam', 'Noto Sans Sinhala', 'Noto Sans Khmer', 'Noto Sans Lao', 'Noto Sans Myanmar', 'Noto Sans Georgian', 'Noto Sans Armenian', 'Noto Sans Ethiopic', system-ui, sans-serif",
|
||||
|
||||
getDefaultConfig() {
|
||||
return {
|
||||
|
|
@ -2734,7 +2767,7 @@ export const fontSettings = {
|
|||
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
});
|
||||
|
||||
document.documentElement.style.setProperty('--font-family', `'${familyName}', sans-serif`);
|
||||
document.documentElement.style.setProperty('--font-family', `'${familyName}', ${this.NOTO_FALLBACK}`);
|
||||
},
|
||||
|
||||
async loadFontFromUrl(url, familyName) {
|
||||
|
|
@ -2769,7 +2802,7 @@ export const fontSettings = {
|
|||
weights: weights,
|
||||
});
|
||||
|
||||
document.documentElement.style.setProperty('--font-family', `'${fontFamily}', sans-serif`);
|
||||
document.documentElement.style.setProperty('--font-family', `'${fontFamily}', ${this.NOTO_FALLBACK}`);
|
||||
},
|
||||
|
||||
getFontFormat(url) {
|
||||
|
|
@ -2852,7 +2885,7 @@ export const fontSettings = {
|
|||
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
});
|
||||
|
||||
document.documentElement.style.setProperty('--font-family', `'${fontFamily}', sans-serif`);
|
||||
document.documentElement.style.setProperty('--font-family', `'${fontFamily}', ${this.NOTO_FALLBACK}`);
|
||||
},
|
||||
|
||||
deleteUploadedFont(fontId) {
|
||||
|
|
@ -2879,7 +2912,7 @@ export const fontSettings = {
|
|||
weights: [400, 500, 600, 700, 800],
|
||||
});
|
||||
|
||||
const fontValue = family === 'monospace' ? 'monospace' : `'${family}', ${fallback}`;
|
||||
const fontValue = family === 'monospace' ? 'monospace' : `'${family}', ${this.NOTO_FALLBACK}`;
|
||||
document.documentElement.style.setProperty('--font-family', fontValue);
|
||||
},
|
||||
|
||||
|
|
@ -2915,7 +2948,7 @@ export const fontSettings = {
|
|||
weights: [400, 500, 600, 700],
|
||||
});
|
||||
|
||||
document.documentElement.style.setProperty('--font-family', "'SF Pro Display', sans-serif");
|
||||
document.documentElement.style.setProperty('--font-family', `'SF Pro Display', ${this.NOTO_FALLBACK}`);
|
||||
},
|
||||
|
||||
async applyFont() {
|
||||
|
|
|
|||
|
|
@ -712,7 +712,7 @@ export class ThemeStore {
|
|||
--highlight: #3b82f6;
|
||||
--ring: #3b82f6;
|
||||
--radius: 8px;
|
||||
--font-family: 'Inter', sans-serif;
|
||||
--font-family: 'Inter', 'Noto Sans', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans Hebrew', 'Noto Sans Arabic', 'Noto Sans Devanagari', 'Noto Sans Bengali', 'Noto Sans Thai', 'Noto Sans Tamil', 'Noto Sans Telugu', 'Noto Sans Gujarati', 'Noto Sans Kannada', 'Noto Sans Malayalam', 'Noto Sans Sinhala', 'Noto Sans Khmer', 'Noto Sans Lao', 'Noto Sans Myanmar', 'Noto Sans Georgian', 'Noto Sans Armenian', 'Noto Sans Ethiopic', system-ui, sans-serif;
|
||||
--font-size-scale: 100%;
|
||||
}`;
|
||||
this.updatePreview();
|
||||
|
|
|
|||
30
js/ui.js
30
js/ui.js
|
|
@ -2280,6 +2280,7 @@ export class UIRenderer {
|
|||
});
|
||||
|
||||
this.player.activeElement.addEventListener('volumechange', updateFsVolumeUI);
|
||||
window.addEventListener('volume-change', updateFsVolumeUI);
|
||||
updateFsVolumeUI();
|
||||
}
|
||||
|
||||
|
|
@ -2999,13 +3000,22 @@ export class UIRenderer {
|
|||
}
|
||||
|
||||
async getSeeds() {
|
||||
try {
|
||||
const { smartRecommendations } = await import('./smart-recommendations.js');
|
||||
const { autoplaySettings } = await import('./storage.js');
|
||||
if (autoplaySettings.isSmartRecsEnabled()) {
|
||||
const smartSeeds = await smartRecommendations.getSmartSeeds(50);
|
||||
if (smartSeeds.length > 0) return smartSeeds;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Smart seeds failed, using basic seeds:', e);
|
||||
}
|
||||
|
||||
const history = await db.getHistory();
|
||||
const favorites = await db.getFavorites('track');
|
||||
const playlists = await db.getPlaylists(true);
|
||||
const playlistTracks = playlists.flatMap((p) => p.tracks || []);
|
||||
|
||||
// Prioritize: Playlists > Favorites > History
|
||||
// Take random samples from each to form seeds
|
||||
const shuffle = (arr) => [...arr].sort(() => Math.random() - 0.5);
|
||||
|
||||
const combined = [
|
||||
|
|
@ -3039,7 +3049,7 @@ export class UIRenderer {
|
|||
if (forceRefresh || songsContainer.children.length === 0) {
|
||||
songsContainer.innerHTML = this.createSkeletonTracks(10, true);
|
||||
} else if (!songsContainer.querySelector('.skeleton')) {
|
||||
return; // Already loaded
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -3056,11 +3066,22 @@ export class UIRenderer {
|
|||
...history.map((t) => t.id),
|
||||
]);
|
||||
|
||||
const recommendedTracks = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
|
||||
let recommendedTracks = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
|
||||
skipCache: forceRefresh,
|
||||
knownTrackIds: knownTrackIds,
|
||||
});
|
||||
|
||||
try {
|
||||
const { smartRecommendations } = await import('./smart-recommendations.js');
|
||||
const { autoplaySettings } = await import('./storage.js');
|
||||
if (autoplaySettings.isSmartRecsEnabled()) {
|
||||
recommendedTracks = smartRecommendations.filterRecommendations(recommendedTracks);
|
||||
recommendedTracks = smartRecommendations.rankRecommendations(recommendedTracks);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Smart filtering failed for home songs:', e);
|
||||
}
|
||||
|
||||
const filteredTracks = await this.filterUserContent(recommendedTracks, 'track');
|
||||
this.lastRecommendedTracks = filteredTracks;
|
||||
|
||||
|
|
@ -6230,6 +6251,7 @@ export class UIRenderer {
|
|||
|
||||
playBtn.onclick = () => {
|
||||
this.player.setQueue([track], 0);
|
||||
this.player.enableAutoplay();
|
||||
this.player.playTrackFromQueue();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@
|
|||
body {
|
||||
font-family:
|
||||
'Inter',
|
||||
'Noto Sans SC',
|
||||
'Noto Sans TC',
|
||||
'Noto Sans JP',
|
||||
'Noto Sans KR',
|
||||
'Noto Sans Hebrew',
|
||||
'Noto Sans Arabic',
|
||||
'Noto Sans Devanagari',
|
||||
'Noto Sans Thai',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
|
|
|
|||
15
styles.css
15
styles.css
|
|
@ -1,7 +1,13 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
--font-family: 'Inter', -apple-system, blinkmacsystemfont, 'Segoe UI', roboto, sans-serif;
|
||||
--font-family:
|
||||
'Inter', 'Noto Sans', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR',
|
||||
'Noto Sans Hebrew', 'Noto Sans Arabic', 'Noto Sans Devanagari', 'Noto Sans Bengali', 'Noto Sans Thai',
|
||||
'Noto Sans Tamil', 'Noto Sans Telugu', 'Noto Sans Gujarati', 'Noto Sans Kannada', 'Noto Sans Malayalam',
|
||||
'Noto Sans Sinhala', 'Noto Sans Khmer', 'Noto Sans Lao', 'Noto Sans Myanmar', 'Noto Sans Georgian',
|
||||
'Noto Sans Armenian', 'Noto Sans Ethiopic', system-ui, -apple-system, blinkmacsystemfont, 'Segoe UI', roboto,
|
||||
sans-serif;
|
||||
--font-size-scale: 100%;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
|
|
@ -10911,7 +10917,12 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
|||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-family: 'SF Pro Display', Inter, sans-serif;
|
||||
font-family:
|
||||
'SF Pro Display', Inter, 'Noto Sans', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans HK', 'Noto Sans JP',
|
||||
'Noto Sans KR', 'Noto Sans Hebrew', 'Noto Sans Arabic', 'Noto Sans Devanagari', 'Noto Sans Bengali',
|
||||
'Noto Sans Thai', 'Noto Sans Tamil', 'Noto Sans Telugu', 'Noto Sans Gujarati', 'Noto Sans Kannada',
|
||||
'Noto Sans Malayalam', 'Noto Sans Sinhala', 'Noto Sans Khmer', 'Noto Sans Lao', 'Noto Sans Myanmar',
|
||||
'Noto Sans Georgian', 'Noto Sans Armenian', 'Noto Sans Ethiopic', system-ui, sans-serif;
|
||||
|
||||
--lyplus-font-size-base: clamp(34px, 3vw, 52px);
|
||||
--lyplus-padding-line: 8px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue