lastfm integration
This commit is contained in:
parent
b0f6a11a94
commit
8ebc1542d5
8 changed files with 418 additions and 235 deletions
21
index.html
21
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>
|
||||
|
|
|
|||
138
js/app.js
138
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,
|
||||
|
|
@ -337,6 +338,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 +374,90 @@ 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');
|
||||
|
||||
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();
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
lastfmConnectBtn.disabled = true;
|
||||
lastfmConnectBtn.textContent = 'Opening Last.fm...';
|
||||
|
||||
const { token, url } = await scrobbler.getAuthUrl();
|
||||
|
||||
const authWindow = window.open(url, 'lastfm-auth', 'width=800,height=600');
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 +479,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() || {
|
||||
|
|
@ -809,6 +898,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();
|
||||
});
|
||||
|
|
|
|||
248
js/lastfm.js
Normal file
248
js/lastfm.js
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
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, '\\"');
|
||||
}
|
||||
}
|
||||
|
|
@ -276,4 +276,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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
14
styles.css
14
styles.css
|
|
@ -1748,4 +1748,18 @@ input:checked + .slider:before {
|
|||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
.btn-secondary.danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary.danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
#lastfm-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
Loading…
Reference in a new issue