Merge branch 'main' into main
This commit is contained in:
commit
0b024ff7a3
15 changed files with 1130 additions and 833 deletions
BIN
assets/button/880x310.png
Normal file
BIN
assets/button/880x310.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/button/88x31.png
Normal file
BIN
assets/button/88x31.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 921 B |
27
index.html
27
index.html
|
|
@ -222,7 +222,26 @@
|
|||
<button class="btn-secondary" id="reset-custom-theme">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Last.fm Scrobbling</span>
|
||||
<span class="description" id="lastfm-status">Connect your Last.fm account to scrobble tracks</span>
|
||||
</div>
|
||||
<div id="lastfm-controls">
|
||||
<button id="lastfm-connect-btn" class="btn-secondary">Connect Last.fm</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="lastfm-toggle-setting" style="display: none;">
|
||||
<div class="info">
|
||||
<span class="label">Enable Scrobbling</span>
|
||||
<span class="description">Automatically scrobble played tracks</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="lastfm-toggle">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Audio Quality</span>
|
||||
|
|
@ -371,7 +390,9 @@
|
|||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script defer data-domain="monochrome.tf" src="https://plausible.canine.tools/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js"></script>
|
||||
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
[
|
||||
"https://tidal.401658.xyz",
|
||||
"https://triton.squid.wtf",
|
||||
"https://aether.squid.wtf",
|
||||
"https://zeus.squid.wtf",
|
||||
"https://kraken.squid.wtf",
|
||||
|
|
|
|||
106
js/api.js
106
js/api.js
|
|
@ -1,3 +1,4 @@
|
|||
//api.js
|
||||
import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js';
|
||||
import { APICache } from './cache.js';
|
||||
|
||||
|
|
@ -308,60 +309,67 @@ export class LosslessAPI {
|
|||
return result;
|
||||
}
|
||||
|
||||
async getArtist(id) {
|
||||
const cached = await this.cache.get('artist', id);
|
||||
if (cached) return cached;
|
||||
async getArtist(artistId) {
|
||||
const cached = await this.cache.get('artist', artistId);
|
||||
if (cached) return cached;
|
||||
|
||||
const [primaryResponse, contentResponse] = await Promise.all([
|
||||
this.fetchWithRetry(`/artist/?id=${id}`),
|
||||
this.fetchWithRetry(`/artist/?f=${id}`)
|
||||
]);
|
||||
const [primaryResponse, contentResponse] = await Promise.all([
|
||||
this.fetchWithRetry(`/artist/?id=${artistId}`),
|
||||
this.fetchWithRetry(`/artist/?f=${artistId}`)
|
||||
]);
|
||||
|
||||
const primaryData = await primaryResponse.json();
|
||||
const rawArtist = Array.isArray(primaryData) ? primaryData[0] : primaryData;
|
||||
|
||||
if (!rawArtist) throw new Error('Primary artist details not found.');
|
||||
|
||||
// Ensure artist has required fields
|
||||
const artist = {
|
||||
...this.prepareArtist(rawArtist),
|
||||
picture: rawArtist.picture || null,
|
||||
name: rawArtist.name || 'Unknown Artist'
|
||||
};
|
||||
|
||||
const contentData = await contentResponse.json();
|
||||
const entries = Array.isArray(contentData) ? contentData : [contentData];
|
||||
|
||||
const albumMap = new Map();
|
||||
const trackMap = new Map();
|
||||
|
||||
const isTrack = v => v?.id && v.duration && v.album;
|
||||
const isAlbum = v => v?.id && 'numberOfTracks' in v;
|
||||
|
||||
const scan = (value, visited = new Set()) => {
|
||||
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
||||
visited.add(value);
|
||||
|
||||
const primaryData = await primaryResponse.json();
|
||||
const artist = this.prepareArtist(Array.isArray(primaryData) ? primaryData[0] : primaryData);
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => scan(item, visited));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!artist) throw new Error('Primary artist details not found.');
|
||||
const item = value.item || value;
|
||||
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
|
||||
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
|
||||
|
||||
const contentData = await contentResponse.json();
|
||||
const entries = Array.isArray(contentData) ? contentData : [contentData];
|
||||
|
||||
const albumMap = new Map();
|
||||
const trackMap = new Map();
|
||||
|
||||
const isTrack = v => v?.id && v.duration && v.album;
|
||||
const isAlbum = v => v?.id && v.cover && 'numberOfTracks' in v;
|
||||
|
||||
const scan = (value, visited = new Set()) => {
|
||||
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
||||
visited.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => scan(item, visited));
|
||||
return;
|
||||
}
|
||||
|
||||
const item = value.item || value;
|
||||
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
|
||||
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
|
||||
|
||||
Object.values(value).forEach(nested => scan(nested, visited));
|
||||
};
|
||||
|
||||
entries.forEach(entry => scan(entry));
|
||||
|
||||
const albums = Array.from(albumMap.values()).sort((a, b) =>
|
||||
new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
|
||||
);
|
||||
|
||||
const tracks = Array.from(trackMap.values())
|
||||
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
|
||||
.slice(0, 10);
|
||||
|
||||
const result = { ...artist, albums, tracks };
|
||||
Object.values(value).forEach(nested => scan(nested, visited));
|
||||
};
|
||||
|
||||
entries.forEach(entry => scan(entry));
|
||||
|
||||
const albums = Array.from(albumMap.values()).sort((a, b) =>
|
||||
new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
|
||||
);
|
||||
|
||||
const tracks = Array.from(trackMap.values())
|
||||
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
|
||||
.slice(0, 10);
|
||||
|
||||
const result = { ...artist, albums, tracks };
|
||||
|
||||
await this.cache.set('artist', id, result);
|
||||
return result;
|
||||
}
|
||||
await this.cache.set('artist', artistId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getTrack(id, quality = 'LOSSLESS') {
|
||||
const cacheKey = `${id}_${quality}`;
|
||||
|
|
|
|||
198
js/app.js
198
js/app.js
|
|
@ -1,7 +1,8 @@
|
|||
import { LosslessAPI } from './api.js';
|
||||
import { apiSettings, themeManager } from './storage.js';
|
||||
import { apiSettings, themeManager, lastFMStorage } from './storage.js';
|
||||
import { UIRenderer } from './ui.js';
|
||||
import { Player } from './player.js';
|
||||
import { LastFMScrobbler } from './lastfm.js';
|
||||
import {
|
||||
REPEAT_MODE, SVG_PLAY, SVG_PAUSE,
|
||||
SVG_VOLUME, SVG_MUTE, formatTime, trackDataStore,
|
||||
|
|
@ -329,6 +330,21 @@ function completeBulkDownload(notifEl, success = true, message = null) {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadHomeFeed(api) {
|
||||
try {
|
||||
const response = await api.fetchWithRetry('/home/');
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) return null;
|
||||
|
||||
const homeData = data[0];
|
||||
return homeData;
|
||||
} catch (error) {
|
||||
console.error('Failed to load home feed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const api = new LosslessAPI(apiSettings);
|
||||
const ui = new UIRenderer(api);
|
||||
|
|
@ -337,6 +353,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
|
||||
const player = new Player(audioPlayer, api, currentQuality);
|
||||
|
||||
const scrobbler = new LastFMScrobbler();
|
||||
|
||||
const savedCrossfade = localStorage.getItem('crossfade-enabled') === 'true';
|
||||
const savedCrossfadeDuration = parseInt(localStorage.getItem('crossfade-duration') || '5');
|
||||
player.setCrossfade(savedCrossfade, savedCrossfadeDuration);
|
||||
|
|
@ -371,6 +389,102 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
let contextTrack = null;
|
||||
let draggedQueueIndex = null;
|
||||
|
||||
const lastfmConnectBtn = document.getElementById('lastfm-connect-btn');
|
||||
const lastfmStatus = document.getElementById('lastfm-status');
|
||||
const lastfmToggle = document.getElementById('lastfm-toggle');
|
||||
const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting');
|
||||
|
||||
window.loadHomeFeed = loadHomeFeed;
|
||||
|
||||
function updateLastFMUI() {
|
||||
if (scrobbler.isAuthenticated()) {
|
||||
lastfmStatus.textContent = `Connected as ${scrobbler.username}`;
|
||||
lastfmConnectBtn.textContent = 'Disconnect';
|
||||
lastfmConnectBtn.classList.add('danger');
|
||||
lastfmToggleSetting.style.display = 'flex';
|
||||
lastfmToggle.checked = lastFMStorage.isEnabled();
|
||||
} else {
|
||||
lastfmStatus.textContent = 'Connect your Last.fm account to scrobble tracks';
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.classList.remove('danger');
|
||||
lastfmToggleSetting.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateLastFMUI();
|
||||
|
||||
lastfmConnectBtn?.addEventListener('click', async () => {
|
||||
if (scrobbler.isAuthenticated()) {
|
||||
if (confirm('Disconnect from Last.fm?')) {
|
||||
scrobbler.disconnect();
|
||||
updateLastFMUI();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const authWindow = window.open('', '_blank');
|
||||
|
||||
lastfmConnectBtn.disabled = true;
|
||||
lastfmConnectBtn.textContent = 'Opening Last.fm...';
|
||||
|
||||
try {
|
||||
const { token, url } = await scrobbler.getAuthUrl();
|
||||
|
||||
if (authWindow) {
|
||||
authWindow.location.href = url;
|
||||
} else {
|
||||
alert('Popup blocked! Please allow popups.');
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
lastfmConnectBtn.textContent = 'Waiting for authorization...';
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30;
|
||||
|
||||
const checkAuth = setInterval(async () => {
|
||||
attempts++;
|
||||
|
||||
if (attempts > maxAttempts) {
|
||||
clearInterval(checkAuth);
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.disabled = false;
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
alert('Authorization timed out. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await scrobbler.completeAuthentication(token);
|
||||
|
||||
if (result.success) {
|
||||
clearInterval(checkAuth);
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
updateLastFMUI();
|
||||
lastfmConnectBtn.disabled = false;
|
||||
lastFMStorage.setEnabled(true);
|
||||
lastfmToggle.checked = true;
|
||||
alert(`Successfully connected to Last.fm as ${result.username}!`);
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Last.fm connection failed:', error);
|
||||
alert('Failed to connect to Last.fm: ' + error.message);
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.disabled = false;
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
}
|
||||
});
|
||||
|
||||
lastfmToggle?.addEventListener('change', (e) => {
|
||||
lastFMStorage.setEnabled(e.target.checked);
|
||||
});
|
||||
|
||||
const themePicker = document.getElementById('theme-picker');
|
||||
themePicker.querySelectorAll('.theme-option').forEach(option => {
|
||||
if (option.dataset.theme === currentTheme) {
|
||||
|
|
@ -392,29 +506,31 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('refresh-speed-test-btn');
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Testing...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
await apiSettings.refreshSpeedTests();
|
||||
ui.renderApiSettings();
|
||||
btn.textContent = 'Done!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh speed tests:', error);
|
||||
btn.textContent = 'Error';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
const btn = document.getElementById('refresh-speed-test-btn');
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Testing...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
await apiSettings.refreshSpeedTests();
|
||||
ui.renderApiSettings();
|
||||
btn.textContent = 'Done!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh speed tests:', error);
|
||||
btn.textContent = 'Error';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
|
||||
function renderCustomThemeEditor() {
|
||||
const grid = document.getElementById('theme-color-grid');
|
||||
const customTheme = themeManager.getCustomTheme() || {
|
||||
|
|
@ -481,6 +597,19 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
});
|
||||
}
|
||||
|
||||
const normalizeToggle = document.querySelectorAll('.setting-item').forEach(item => {
|
||||
const label = item.querySelector('.label');
|
||||
if (label && label.textContent.includes('Normalize Volume')) {
|
||||
const toggle = item.querySelector('input[type="checkbox"]');
|
||||
if (toggle) {
|
||||
toggle.checked = localStorage.getItem('normalize-volume') === 'true';
|
||||
toggle.addEventListener('change', (e) => {
|
||||
localStorage.setItem('normalize-volume', e.target.checked ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('.now-playing-bar .title').addEventListener('click', () => {
|
||||
const track = player.currentTrack;
|
||||
if (track?.album?.id) {
|
||||
|
|
@ -597,10 +726,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
};
|
||||
|
||||
const renderQueue = () => {
|
||||
if (!queueModalOverlay.style.display || queueModalOverlay.style.display === "none") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentQueue = player.getCurrentQueue();
|
||||
|
||||
if (currentQueue.length === 0) {
|
||||
|
|
@ -712,6 +837,22 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
});
|
||||
|
||||
mainContent.addEventListener('click', e => {
|
||||
const menuBtn = e.target.closest('.track-menu-btn');
|
||||
if (menuBtn) {
|
||||
e.stopPropagation();
|
||||
const trackItem = menuBtn.closest('.track-item');
|
||||
if (trackItem && !trackItem.dataset.queueIndex) {
|
||||
contextTrack = trackDataStore.get(trackItem);
|
||||
if (contextTrack) {
|
||||
const rect = menuBtn.getBoundingClientRect();
|
||||
contextMenu.style.top = `${rect.bottom + 5}px`;
|
||||
contextMenu.style.left = `${rect.left}px`;
|
||||
contextMenu.style.display = 'block';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const trackItem = e.target.closest('.track-item');
|
||||
if (trackItem && !trackItem.dataset.queueIndex) {
|
||||
const parentList = trackItem.closest('.track-list');
|
||||
|
|
@ -809,6 +950,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
});
|
||||
|
||||
audioPlayer.addEventListener('play', () => {
|
||||
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) {
|
||||
scrobbler.updateNowPlaying(player.currentTrack);
|
||||
}
|
||||
playPauseBtn.innerHTML = SVG_PAUSE;
|
||||
player.updateMediaSessionPlaybackState();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
//cache.js
|
||||
export class APICache {
|
||||
constructor(options = {}) {
|
||||
this.memoryCache = new Map();
|
||||
|
|
|
|||
249
js/lastfm.js
Normal file
249
js/lastfm.js
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
//lastfm.js
|
||||
import { delay } from './utils.js';
|
||||
|
||||
export class LastFMScrobbler {
|
||||
constructor() {
|
||||
this.API_KEY = '0fc32c426d943d34a662977b31b98b67';
|
||||
this.API_SECRET = '53acf2466be726db021e7fdfd0ad1084';
|
||||
this.API_URL = 'https://ws.audioscrobbler.com/2.0/';
|
||||
|
||||
this.sessionKey = null;
|
||||
this.username = null;
|
||||
this.currentTrack = null;
|
||||
this.scrobbleTimer = null;
|
||||
this.scrobbleThreshold = 0;
|
||||
this.hasScrobbled = false;
|
||||
|
||||
this.loadSession();
|
||||
}
|
||||
|
||||
loadSession() {
|
||||
try {
|
||||
const session = localStorage.getItem('lastfm-session');
|
||||
if (session) {
|
||||
const data = JSON.parse(session);
|
||||
this.sessionKey = data.key;
|
||||
this.username = data.name;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load Last.fm session:', e);
|
||||
}
|
||||
}
|
||||
|
||||
saveSession(sessionKey, username) {
|
||||
this.sessionKey = sessionKey;
|
||||
this.username = username;
|
||||
localStorage.setItem('lastfm-session', JSON.stringify({
|
||||
key: sessionKey,
|
||||
name: username
|
||||
}));
|
||||
}
|
||||
|
||||
clearSession() {
|
||||
this.sessionKey = null;
|
||||
this.username = null;
|
||||
localStorage.removeItem('lastfm-session');
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
return !!this.sessionKey;
|
||||
}
|
||||
|
||||
async generateSignature(params) {
|
||||
const filteredParams = { ...params };
|
||||
delete filteredParams.format;
|
||||
delete filteredParams.callback;
|
||||
|
||||
const sortedKeys = Object.keys(filteredParams).sort();
|
||||
|
||||
const signatureString = sortedKeys
|
||||
.map(key => `${key}${filteredParams[key]}`)
|
||||
.join('') + this.API_SECRET;
|
||||
|
||||
console.log('Signature string:', signatureString);
|
||||
|
||||
try {
|
||||
const { default: md5 } = await import('https://cdn.jsdelivr.net/npm/md5@2.3.0/+esm');
|
||||
return md5(signatureString);
|
||||
} catch (e) {
|
||||
console.error('MD5 library not available');
|
||||
throw new Error('MD5 library required for Last.fm');
|
||||
}
|
||||
}
|
||||
|
||||
async makeRequest(method, params = {}, requiresAuth = false) {
|
||||
const requestParams = {
|
||||
method,
|
||||
api_key: this.API_KEY,
|
||||
...params
|
||||
};
|
||||
|
||||
if (requiresAuth && this.sessionKey) {
|
||||
requestParams.sk = this.sessionKey;
|
||||
}
|
||||
|
||||
const signature = await this.generateSignature(requestParams);
|
||||
|
||||
const formData = new URLSearchParams({
|
||||
...requestParams,
|
||||
api_sig: signature,
|
||||
format: 'json'
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(this.API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.message || 'Last.fm API error');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Last.fm API request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthUrl() {
|
||||
try {
|
||||
const data = await this.makeRequest('auth.getToken');
|
||||
const token = data.token;
|
||||
|
||||
return {
|
||||
token,
|
||||
url: `https://www.last.fm/api/auth/?api_key=${this.API_KEY}&token=${token}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get auth URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async completeAuthentication(token) {
|
||||
try {
|
||||
const data = await this.makeRequest('auth.getSession', { token });
|
||||
|
||||
if (data.session) {
|
||||
this.saveSession(data.session.key, data.session.name);
|
||||
return {
|
||||
success: true,
|
||||
username: data.session.name
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('No session returned');
|
||||
} catch (error) {
|
||||
console.error('Authentication failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateNowPlaying(track) {
|
||||
if (!this.isAuthenticated()) return;
|
||||
|
||||
this.currentTrack = track;
|
||||
this.hasScrobbled = false;
|
||||
this.clearScrobbleTimer();
|
||||
|
||||
try {
|
||||
const params = {
|
||||
artist: track.artist?.name || 'Unknown Artist',
|
||||
track: track.title
|
||||
};
|
||||
|
||||
if (track.album?.title) {
|
||||
params.album = track.album.title;
|
||||
}
|
||||
|
||||
if (track.duration) {
|
||||
params.duration = Math.floor(track.duration);
|
||||
}
|
||||
|
||||
if (track.trackNumber) {
|
||||
params.trackNumber = track.trackNumber;
|
||||
}
|
||||
|
||||
await this.makeRequest('track.updateNowPlaying', params, true);
|
||||
|
||||
console.log('Now playing updated:', track.title);
|
||||
|
||||
this.scrobbleThreshold = Math.min(track.duration / 2, 240);
|
||||
this.scheduleScrobble(this.scrobbleThreshold * 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update now playing:', error);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleScrobble(delay) {
|
||||
this.clearScrobbleTimer();
|
||||
|
||||
this.scrobbleTimer = setTimeout(() => {
|
||||
this.scrobbleCurrentTrack();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
clearScrobbleTimer() {
|
||||
if (this.scrobbleTimer) {
|
||||
clearTimeout(this.scrobbleTimer);
|
||||
this.scrobbleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async scrobbleCurrentTrack() {
|
||||
if (!this.isAuthenticated() || !this.currentTrack || this.hasScrobbled) return;
|
||||
|
||||
try {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const params = {
|
||||
artist: this.currentTrack.artist?.name || 'Unknown Artist',
|
||||
track: this.currentTrack.title,
|
||||
timestamp: timestamp
|
||||
};
|
||||
|
||||
if (this.currentTrack.album?.title) {
|
||||
params.album = this.currentTrack.album.title;
|
||||
}
|
||||
|
||||
if (this.currentTrack.duration) {
|
||||
params.duration = Math.floor(this.currentTrack.duration);
|
||||
}
|
||||
|
||||
if (this.currentTrack.trackNumber) {
|
||||
params.trackNumber = this.currentTrack.trackNumber;
|
||||
}
|
||||
|
||||
await this.makeRequest('track.scrobble', params, true);
|
||||
|
||||
this.hasScrobbled = true;
|
||||
console.log('Scrobbled:', this.currentTrack.title);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to scrobble:', error);
|
||||
}
|
||||
}
|
||||
|
||||
onTrackChange(track) {
|
||||
if (!this.isAuthenticated()) return;
|
||||
this.updateNowPlaying(track);
|
||||
}
|
||||
|
||||
onPlaybackStop() {
|
||||
this.clearScrobbleTimer();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.clearSession();
|
||||
this.clearScrobbleTimer();
|
||||
this.currentTrack = null;
|
||||
}
|
||||
}
|
||||
210
js/metadata.js
210
js/metadata.js
|
|
@ -1,210 +0,0 @@
|
|||
export class MetadataEmbedder {
|
||||
constructor() {
|
||||
this.ffmpegLoaded = false;
|
||||
this.ffmpeg = null;
|
||||
this.fetchFile = null;
|
||||
}
|
||||
|
||||
async loadFFmpeg() {
|
||||
if (this.ffmpegLoaded) return;
|
||||
|
||||
try {
|
||||
console.log('[FFmpeg] Loading FFmpeg...');
|
||||
|
||||
if (typeof FFmpegWASM === 'undefined' || typeof FFmpegUtil === 'undefined') {
|
||||
throw new Error('FFmpeg libraries not loaded. Please check your internet connection.');
|
||||
}
|
||||
|
||||
const { FFmpeg } = FFmpegWASM;
|
||||
const { fetchFile } = FFmpegUtil;
|
||||
|
||||
this.ffmpeg = new FFmpeg();
|
||||
this.fetchFile = fetchFile;
|
||||
|
||||
this.ffmpeg.on('log', ({ message }) => {
|
||||
console.log('[FFmpeg]', message);
|
||||
});
|
||||
|
||||
const baseURL = window.location.origin + '/ffmpeg';
|
||||
|
||||
await this.ffmpeg.load({
|
||||
coreURL: `${baseURL}/ffmpeg-core.js`,
|
||||
wasmURL: `${baseURL}/ffmpeg-core.wasm`
|
||||
});
|
||||
|
||||
this.ffmpegLoaded = true;
|
||||
console.log('[FFmpeg] Loaded successfully');
|
||||
} catch (error) {
|
||||
console.error('[FFmpeg] Failed to load:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async embedMetadata(audioBlob, track, coverImageUrl, onProgress) {
|
||||
console.log('[Metadata] Starting embedding for:', track.title);
|
||||
|
||||
if (!this.ffmpegLoaded) {
|
||||
try {
|
||||
await this.loadFFmpeg();
|
||||
} catch (error) {
|
||||
console.error('[Metadata] Cannot load FFmpeg, skipping metadata:', error);
|
||||
return audioBlob;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.ffmpeg || !this.fetchFile) {
|
||||
console.error('[Metadata] FFmpeg not properly initialized');
|
||||
return audioBlob;
|
||||
}
|
||||
|
||||
const inputName = 'input.flac';
|
||||
const coverName = 'cover.jpg';
|
||||
const outputName = 'output.flac';
|
||||
|
||||
try {
|
||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||
await this.ffmpeg.writeFile(inputName, new Uint8Array(arrayBuffer));
|
||||
console.log('[Metadata] Wrote input file:', inputName, 'size:', arrayBuffer.byteLength);
|
||||
|
||||
let hasCover = false;
|
||||
if (coverImageUrl) {
|
||||
try {
|
||||
console.log('[Metadata] Fetching cover from:', coverImageUrl);
|
||||
const coverData = await this.fetchFile(coverImageUrl);
|
||||
await this.ffmpeg.writeFile(coverName, coverData);
|
||||
hasCover = true;
|
||||
console.log('[Metadata] Cover image written successfully, size:', coverData.length);
|
||||
} catch (coverError) {
|
||||
console.warn('[Metadata] Failed to fetch cover image:', coverError);
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = this.buildMetadataArgs(track);
|
||||
console.log('[Metadata] Building metadata with', metadata.length / 2, 'fields');
|
||||
|
||||
let args;
|
||||
if (hasCover) {
|
||||
args = [
|
||||
'-i', inputName,
|
||||
'-i', coverName,
|
||||
'-map', '0:a',
|
||||
'-map', '1',
|
||||
'-c:a', 'copy',
|
||||
'-c:v', 'copy',
|
||||
...metadata,
|
||||
'-metadata:s:v', 'title=Album cover',
|
||||
'-metadata:s:v', 'comment=Cover (front)',
|
||||
'-disposition:v', 'attached_pic',
|
||||
outputName
|
||||
];
|
||||
} else {
|
||||
args = [
|
||||
'-i', inputName,
|
||||
...metadata,
|
||||
'-c:a', 'copy',
|
||||
outputName
|
||||
];
|
||||
}
|
||||
|
||||
console.log('[Metadata] Executing FFmpeg...');
|
||||
|
||||
if (onProgress) {
|
||||
this.ffmpeg.on('progress', ({ progress }) => {
|
||||
onProgress(progress);
|
||||
});
|
||||
}
|
||||
|
||||
await this.ffmpeg.exec(args);
|
||||
console.log('[Metadata] FFmpeg exec completed successfully');
|
||||
|
||||
const outputData = await this.ffmpeg.readFile(outputName);
|
||||
const outputBlob = new Blob([outputData], { type: 'audio/flac' });
|
||||
console.log('[Metadata] ✓ Success! Input:', arrayBuffer.byteLength, 'bytes → Output:', outputBlob.size, 'bytes');
|
||||
|
||||
await this.ffmpeg.deleteFile(inputName);
|
||||
await this.ffmpeg.deleteFile(outputName);
|
||||
if (hasCover) {
|
||||
await this.ffmpeg.deleteFile(coverName);
|
||||
}
|
||||
console.log('[Metadata] Cleanup complete');
|
||||
|
||||
return outputBlob;
|
||||
} catch (error) {
|
||||
console.error('[Metadata] ✗ Embedding failed:', error);
|
||||
console.error('[Metadata] Error details:', {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
return audioBlob;
|
||||
}
|
||||
}
|
||||
|
||||
buildMetadataArgs(track) {
|
||||
const args = [];
|
||||
|
||||
if (track.title) {
|
||||
args.push('-metadata', `title=${this.escapeMetadata(track.title)}`);
|
||||
}
|
||||
|
||||
if (track.artist?.name) {
|
||||
args.push('-metadata', `artist=${this.escapeMetadata(track.artist.name)}`);
|
||||
}
|
||||
|
||||
if (track.album?.title) {
|
||||
args.push('-metadata', `album=${this.escapeMetadata(track.album.title)}`);
|
||||
}
|
||||
|
||||
if (track.album?.artist?.name) {
|
||||
args.push('-metadata', `album_artist=${this.escapeMetadata(track.album.artist.name)}`);
|
||||
}
|
||||
|
||||
if (track.trackNumber) {
|
||||
const trackNum = Number(track.trackNumber);
|
||||
if (Number.isFinite(trackNum) && trackNum > 0) {
|
||||
const totalTracks = track.album?.numberOfTracks;
|
||||
if (totalTracks && Number.isFinite(totalTracks) && totalTracks > 0) {
|
||||
args.push('-metadata', `track=${trackNum}/${totalTracks}`);
|
||||
} else {
|
||||
args.push('-metadata', `track=${trackNum}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (track.volumeNumber) {
|
||||
const discNum = Number(track.volumeNumber);
|
||||
if (Number.isFinite(discNum) && discNum > 0) {
|
||||
const totalDiscs = track.album?.numberOfVolumes;
|
||||
if (totalDiscs && Number.isFinite(totalDiscs) && totalDiscs > 0) {
|
||||
args.push('-metadata', `disc=${discNum}/${totalDiscs}`);
|
||||
} else {
|
||||
args.push('-metadata', `disc=${discNum}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (track.album?.releaseDate) {
|
||||
const year = new Date(track.album.releaseDate).getFullYear();
|
||||
if (!isNaN(year)) {
|
||||
args.push('-metadata', `date=${year}`);
|
||||
args.push('-metadata', `year=${year}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (track.album?.upc) {
|
||||
args.push('-metadata', `barcode=${track.album.upc}`);
|
||||
}
|
||||
|
||||
if (track.isrc) {
|
||||
args.push('-metadata', `isrc=${track.isrc}`);
|
||||
}
|
||||
|
||||
args.push('-metadata', 'comment=https://monochrome.tf/');
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
escapeMetadata(value) {
|
||||
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
}
|
||||
131
js/player.js
131
js/player.js
|
|
@ -1,3 +1,4 @@
|
|||
//player.js
|
||||
import { REPEAT_MODE, formatTime } from './utils.js';
|
||||
|
||||
export class Player {
|
||||
|
|
@ -118,58 +119,74 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
async playTrackFromQueue() {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
this.currentTrack = track;
|
||||
|
||||
document.querySelector('.now-playing-bar .cover').src =
|
||||
this.api.getCoverUrl(track.album?.cover, '160');
|
||||
document.querySelector('.now-playing-bar .title').textContent = track.title;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = track.artist?.name || 'Unknown Artist';
|
||||
document.title = `${track.title} • ${track.artist?.name || 'Unknown'}`;
|
||||
|
||||
this.updatePlayingTrackIndicator();
|
||||
this.updateMediaSession(track);
|
||||
|
||||
try {
|
||||
let streamUrl;
|
||||
|
||||
if (this.preloadCache.has(track.id)) {
|
||||
streamUrl = this.preloadCache.get(track.id);
|
||||
} else {
|
||||
streamUrl = await this.api.getStreamUrl(track.id, this.quality);
|
||||
}
|
||||
|
||||
if (this.isCrossfading && this.nextAudioElement.src === streamUrl) {
|
||||
const temp = this.audio;
|
||||
this.audio = this.nextAudioElement;
|
||||
this.nextAudioElement = temp;
|
||||
|
||||
this.nextAudioElement.pause();
|
||||
this.nextAudioElement.currentTime = 0;
|
||||
} else {
|
||||
this.audio.src = streamUrl;
|
||||
}
|
||||
|
||||
await this.audio.play();
|
||||
this.isCrossfading = false;
|
||||
|
||||
this.updateMediaSessionPlaybackState();
|
||||
this.preloadNextTracks();
|
||||
this.setupCrossfadeListener();
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Could not play track: ${track.title}`, error);
|
||||
document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
|
||||
}
|
||||
async playTrackFromQueue() {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
this.currentTrack = track;
|
||||
|
||||
document.querySelector('.now-playing-bar .cover').src =
|
||||
this.api.getCoverUrl(track.album?.cover, '1280');
|
||||
document.querySelector('.now-playing-bar .title').textContent = track.title;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = track.artist?.name || 'Unknown Artist';
|
||||
document.title = `${track.title} • ${track.artist?.name || 'Unknown'}`;
|
||||
|
||||
this.updatePlayingTrackIndicator();
|
||||
this.updateMediaSession(track);
|
||||
|
||||
try {
|
||||
let streamUrl;
|
||||
|
||||
if (this.preloadCache.has(track.id)) {
|
||||
streamUrl = this.preloadCache.get(track.id);
|
||||
} else {
|
||||
const trackData = await this.api.getTrack(track.id, this.quality);
|
||||
|
||||
// Store replayGain for normalization
|
||||
if (trackData.track?.replayGain !== undefined) {
|
||||
window.currentGain = trackData.track.replayGain;
|
||||
} else {
|
||||
window.currentGain = track.replayGain || null;
|
||||
}
|
||||
|
||||
if (trackData.originalTrackUrl) {
|
||||
streamUrl = trackData.originalTrackUrl;
|
||||
} else {
|
||||
streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isCrossfading && this.nextAudioElement.src === streamUrl) {
|
||||
const temp = this.audio;
|
||||
this.audio = this.nextAudioElement;
|
||||
this.nextAudioElement = temp;
|
||||
|
||||
this.nextAudioElement.pause();
|
||||
this.nextAudioElement.currentTime = 0;
|
||||
} else {
|
||||
this.audio.src = streamUrl;
|
||||
}
|
||||
|
||||
// Apply normalization if enabled
|
||||
this.applyNormalization();
|
||||
|
||||
await this.audio.play();
|
||||
this.isCrossfading = false;
|
||||
|
||||
this.updateMediaSessionPlaybackState();
|
||||
this.preloadNextTracks();
|
||||
this.setupCrossfadeListener();
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Could not play track: ${track.title}`, error);
|
||||
document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
|
||||
}
|
||||
}
|
||||
|
||||
setupCrossfadeListener() {
|
||||
if (!this.crossfadeEnabled) return;
|
||||
|
||||
|
|
@ -391,7 +408,7 @@ export class Player {
|
|||
if (!('mediaSession' in navigator)) return;
|
||||
|
||||
const artwork = [];
|
||||
const sizes = ['96', '128', '192', '256', '384', '512'];
|
||||
const sizes = ['1280'];
|
||||
const coverId = track.album?.cover;
|
||||
|
||||
if (coverId) {
|
||||
|
|
@ -414,6 +431,16 @@ export class Player {
|
|||
this.updateMediaSessionPlaybackState();
|
||||
this.updateMediaSessionPositionState();
|
||||
}
|
||||
applyNormalization() {
|
||||
const normalizeEnabled = localStorage.getItem('normalize-volume') === 'true';
|
||||
|
||||
if (normalizeEnabled && window.currentGain !== null && window.currentGain !== undefined) {
|
||||
const baseVolume = parseFloat(localStorage.getItem('base-volume') || '0.7');
|
||||
const replayGain = parseFloat(window.currentGain);
|
||||
const adjustment = Math.pow(10, replayGain / 20);
|
||||
this.audio.volume = Math.min(1, Math.max(0, baseVolume * adjustment));
|
||||
}
|
||||
}
|
||||
|
||||
updateMediaSessionPlaybackState() {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
|
@ -440,4 +467,4 @@ export class Player {
|
|||
console.debug('Failed to update Media Session position:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
//storage.js
|
||||
export const apiSettings = {
|
||||
STORAGE_KEY: 'monochrome-api-instances',
|
||||
INSTANCES_URL: 'https://raw.githubusercontent.com/EduardPrigoana/hifi-instances/refs/heads/main/instances.json',
|
||||
|
|
@ -53,8 +54,8 @@ export const apiSettings = {
|
|||
|
||||
async speedTestInstance(url) {
|
||||
const testUrl = url.endsWith('/')
|
||||
? `${url}search/?s=kanye`
|
||||
: `${url}/search/?s=kanye`;
|
||||
? `${url}track/?id=204567804&quality=HIGH`
|
||||
: `${url}/track/?id=204567804&quality=HIGH`;
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
|
|
@ -276,4 +277,20 @@ export const themeManager = {
|
|||
root.style.setProperty(`--${key}`, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const lastFMStorage = {
|
||||
STORAGE_KEY: 'lastfm-enabled',
|
||||
|
||||
isEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(this.STORAGE_KEY) === 'true';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setEnabled(enabled) {
|
||||
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||
}
|
||||
};
|
||||
120
js/ui.js
120
js/ui.js
|
|
@ -1,3 +1,4 @@
|
|||
//ui.js
|
||||
import { formatTime, createPlaceholder, trackDataStore, hasExplicitContent } from './utils.js';
|
||||
import { recentActivityManager } from './storage.js';
|
||||
|
||||
|
|
@ -10,28 +11,46 @@ 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 width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
createTrackItemHTML(track, index, showCover = false) {
|
||||
const playIconSmall = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>';
|
||||
const trackNumberHTML = `<div class="track-number">${showCover ? playIconSmall : index + 1}</div>`;
|
||||
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
|
||||
|
||||
return `
|
||||
<div class="track-item" data-track-id="${track.id}">
|
||||
${trackNumberHTML}
|
||||
<div class="track-item-info">
|
||||
${showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover" loading="lazy">` : ''}
|
||||
<div class="track-item-details">
|
||||
<div class="title">
|
||||
${track.title}
|
||||
${explicitBadge}
|
||||
</div>
|
||||
<div class="artist">${track.artist?.name ?? 'Unknown Artist'}</div>
|
||||
const playIconSmall = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>';
|
||||
const trackNumberHTML = `<div class="track-number">${showCover ? playIconSmall : index + 1}</div>`;
|
||||
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
|
||||
|
||||
return `
|
||||
<div class="track-item" data-track-id="${track.id}">
|
||||
${trackNumberHTML}
|
||||
<div class="track-item-info">
|
||||
${showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover" loading="lazy">` : ''}
|
||||
<div class="track-item-details">
|
||||
<div class="title">
|
||||
${track.title}
|
||||
${explicitBadge}
|
||||
</div>
|
||||
<div class="artist">${track.artist?.name ?? 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<div class="track-item-duration">${formatTime(track.duration)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
<div class="track-item-duration">${formatTime(track.duration)}</div>
|
||||
<button class="track-menu-btn" type="button" title="More options">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
createAlbumCardHTML(album) {
|
||||
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
|
||||
|
|
@ -129,18 +148,71 @@ export class UIRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
renderHomePage() {
|
||||
this.showPage('home');
|
||||
const recents = recentActivityManager.getRecents();
|
||||
|
||||
document.getElementById('home-recent-albums').innerHTML = recents.albums.length
|
||||
async renderHomePage() {
|
||||
this.showPage('home');
|
||||
const recents = recentActivityManager.getRecents();
|
||||
|
||||
const albumsContainer = document.getElementById('home-recent-albums');
|
||||
const artistsContainer = document.getElementById('home-recent-artists');
|
||||
|
||||
if (recents.albums.length > 0 || recents.artists.length > 0) {
|
||||
albumsContainer.innerHTML = recents.albums.length
|
||||
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('')
|
||||
: createPlaceholder("You haven't viewed any albums yet.");
|
||||
|
||||
document.getElementById('home-recent-artists').innerHTML = recents.artists.length
|
||||
artistsContainer.innerHTML = recents.artists.length
|
||||
? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('')
|
||||
: createPlaceholder("You haven't viewed any artists yet.");
|
||||
} else {
|
||||
// Load from API
|
||||
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
|
||||
|
||||
const homeData = await window.loadHomeFeed(this.api, this);
|
||||
|
||||
if (homeData && homeData.rows) {
|
||||
let albums = [];
|
||||
let playlists = [];
|
||||
|
||||
homeData.rows.forEach(row => {
|
||||
row.modules?.forEach(module => {
|
||||
if (module.type === 'ALBUM_LIST' && module.pagedList?.items) {
|
||||
albums.push(...module.pagedList.items);
|
||||
} else if (module.type === 'PLAYLIST_LIST' && module.pagedList?.items) {
|
||||
playlists.push(...module.pagedList.items);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (albums.length > 0) {
|
||||
albumsContainer.innerHTML = albums.slice(0, 10).map(album =>
|
||||
this.createAlbumCardHTML(album)
|
||||
).join('');
|
||||
} else {
|
||||
albumsContainer.innerHTML = createPlaceholder("No albums available.");
|
||||
}
|
||||
|
||||
if (playlists.length > 0) {
|
||||
document.querySelector('#home-recent-artists').parentElement.querySelector('.section-title').textContent = 'Featured Playlists';
|
||||
artistsContainer.innerHTML = playlists.slice(0, 10).map(playlist => `
|
||||
<a href="#playlist/${playlist.uuid}" class="card">
|
||||
<div class="card-image-wrapper">
|
||||
<img src="${this.api.getCoverUrl(playlist.image || playlist.squareImage, '320')}"
|
||||
alt="${playlist.title}" class="card-image" loading="lazy">
|
||||
</div>
|
||||
<h3 class="card-title">${playlist.title}</h3>
|
||||
<p class="card-subtitle">${playlist.numberOfTracks} tracks</p>
|
||||
</a>
|
||||
`).join('');
|
||||
} else {
|
||||
artistsContainer.innerHTML = createPlaceholder("No playlists available.");
|
||||
}
|
||||
} else {
|
||||
albumsContainer.innerHTML = createPlaceholder("Unable to load content.");
|
||||
artistsContainer.innerHTML = createPlaceholder("Unable to load content.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async renderSearchPage(query) {
|
||||
this.showPage('search');
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
//utils.js
|
||||
export const QUALITY = 'LOSSLESS';
|
||||
|
||||
export const REPEAT_MODE = {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@
|
|||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["music", "entertainment"],
|
||||
"categories": [
|
||||
"music",
|
||||
"entertainment"
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Search",
|
||||
|
|
|
|||
892
styles.css
892
styles.css
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue