diff --git a/index.html b/index.html
index d50b988..d000bea 100644
--- a/index.html
+++ b/index.html
@@ -222,7 +222,26 @@
-
+
Audio Quality
diff --git a/js/app.js b/js/app.js
index 11f4acf..021a203 100644
--- a/js/app.js
+++ b/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();
});
diff --git a/js/lastfm.js b/js/lastfm.js
new file mode 100644
index 0000000..5e87ae7
--- /dev/null
+++ b/js/lastfm.js
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/js/metadata.js b/js/metadata.js
deleted file mode 100644
index 82e4f77..0000000
--- a/js/metadata.js
+++ /dev/null
@@ -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, '\\"');
- }
-}
\ No newline at end of file
diff --git a/js/storage.js b/js/storage.js
index 7ced455..e148988 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -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');
+ }
};
\ No newline at end of file
diff --git a/js/utils.js b/js/utils.js
index efc61a6..05754a2 100644
--- a/js/utils.js
+++ b/js/utils.js
@@ -1,3 +1,4 @@
+//utils.js
export const QUALITY = 'LOSSLESS';
export const REPEAT_MODE = {
diff --git a/manifest.json b/manifest.json
index a016477..0a211d4 100644
--- a/manifest.json
+++ b/manifest.json
@@ -27,7 +27,10 @@
"purpose": "maskable"
}
],
- "categories": ["music", "entertainment"],
+ "categories": [
+ "music",
+ "entertainment"
+ ],
"shortcuts": [
{
"name": "Search",
diff --git a/styles.css b/styles.css
index f19e7ef..c4ac053 100644
--- a/styles.css
+++ b/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;
}
\ No newline at end of file