diff --git a/docs/README.md b/docs/README.md index 36121f2..26661f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,12 +65,10 @@ And all the developers and artists, for the following resources: ### JavaScript libraries -* [buffer](https://www.npmjs.com/package/buffer) - Node.js Buffer API, for the browser * [http-server](https://www.npmjs.com/package/http-server) - a simple zero-configuration command-line http server * [idb-keyval](https://www.npmjs.com/package/idb-keyval) - super-simple promise-based keyval store implemented with IndexedDB -* [music-metadata-browser](https://www.npmjs.com/package/music-metadata-browser) - stream and file based music metadata parser for the browser +* [music-metadata](https://www.npmjs.com/package/music-metadata) - Metadata parser for audio and video media files. Supports file and stream inputs in Node.js and browser environments, extracting format, tag, and duration information. * [notie](https://www.npmjs.com/package/notie) - clean and simple notification, input, and selection suite for javascript, with no dependencies -* [process](https://www.npmjs.com/package/process) - process information for node.js and browsers * [scrollIntoViewIfNeeded 4 everyone](https://gist.github.com/hsablonniere/2581101) - polyfill for non-standard scrollIntoViewIfNeeded() method * [sortablejs](https://www.npmjs.com/package/sortablejs) - JavaScript library for reorderable drag-and-drop lists * [webpack](https://www.npmjs.com/package/webpack) - JavaScript module bundler for the browser diff --git a/package.json b/package.json index 14f4c25..aa21348 100644 --- a/package.json +++ b/package.json @@ -13,16 +13,15 @@ }, "devDependencies": { "audiomotion-analyzer": "^4.5.1", - "buffer": "^6.0.3", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", "http-server": "^14.1.1", "idb-keyval": "^6.2.1", "js-yaml": "^4.1.0", "mini-css-extract-plugin": "^2.9.0", - "music-metadata-browser": "^2.5.10", + "music-metadata": "^11.7.1", "notie": "^4.3.1", - "process": "^0.11.10", + "p-queue": "^8.1.0", "sortablejs": "^1.15.2", "style-loader": "^4.0.0", "webpack": "^5.91.0", diff --git a/src/index.js b/src/index.js index a9e4bd5..75fca67 100644 --- a/src/index.js +++ b/src/index.js @@ -32,10 +32,11 @@ import AudioMotionAnalyzer from 'audiomotion-analyzer'; import packageJson from '../package.json'; import * as fileExplorer from './file-explorer.js'; -import * as mm from 'music-metadata-browser'; +import {parseBlob, parseWebStream} from 'music-metadata'; import './scrollIntoViewIfNeeded-polyfill.js'; import { get, set, del } from 'idb-keyval'; import * as yaml from 'js-yaml'; +import PQueue from 'p-queue'; import Sortable, { MultiDrag } from 'sortablejs'; Sortable.mount( new MultiDrag() ); @@ -790,7 +791,6 @@ let audioElement = [], supportsFileSystemAPI, // browser supports File System API (may be disabled via config.yaml) useFileSystemAPI, // load music from local device when in web server mode userPresets, - waitingMetadata = 0, wasMuted, // mute status before switching to microphone input webServer; // web server available? (boolean) @@ -910,6 +910,9 @@ const getCurrentSettings = _ => ({ weighting : getControlValue( elWeighting ) }); +// Limit the number of parallel metadata requests +const metadataRetrievalQueue = new PQueue({ concurrency: MAX_METADATA_REQUESTS }); + // get the array index for a preset key, or validate a given index; if invalid or not found returns -1 const getPresetIndex = key => { const index = ( +key == key ) ? key : presets.findIndex( item => item.key == key ); @@ -1173,76 +1176,67 @@ function addMetadata( metadata, target ) { * @param {object} { album, artist, codec, duration, title } * @returns {Promise} resolves to 1 when song added, or 0 if queue is full */ -function addSongToPlayQueue( fileObject, content ) { +async function addSongToPlayQueue( fileObject, content ) { - return new Promise( resolve => { - if ( queueLength() >= MAX_QUEUED_SONGS ) { - resolve(0); - return; - } + if ( queueLength() >= MAX_QUEUED_SONGS ) { + return 0; + } - const { fileName, baseName, extension } = parsePath( fileExplorer.decodeChars( fileObject.file ) ), - uri = normalizeSlashes( fileObject.file ), - newEl = document.createElement('li'), // create new list element - trackData = newEl.dataset; + const { fileName, baseName, extension } = parsePath( fileExplorer.decodeChars( fileObject.file ) ), + uri = normalizeSlashes( fileObject.file ), + newEl = document.createElement('li'), // create new list element + trackData = newEl.dataset; - Object.assign( trackData, DATASET_TEMPLATE ); // initialize element's dataset attributes + Object.assign( trackData, DATASET_TEMPLATE ); // initialize element's dataset attributes - if ( ! content ) - content = parseTrackName( baseName ); + if ( ! content ) + content = parseTrackName( baseName ); - trackData.album = content.album || ''; - trackData.artist = content.artist || ''; - trackData.title = content.title || fileName || uri.slice( uri.lastIndexOf('//') + 2 ); - trackData.duration = content.duration || ''; - trackData.codec = content.codec || extension.toUpperCase(); + trackData.album = content.album || ''; + trackData.artist = content.artist || ''; + trackData.title = content.title || fileName || uri.slice( uri.lastIndexOf('//') + 2 ); + trackData.duration = content.duration || ''; + trackData.codec = content.codec || extension.toUpperCase(); // trackData.subs = + !! fileObject.subs; // show 'subs' badge in the playqueue (TO-DO: resolve CSS conflict) - trackData.filename = fileName; - trackData.file = uri; // full path for web server access - newEl.handle = fileObject.handle; // for File System API access - newEl.dirHandle = fileObject.dirHandle; - newEl.subs = fileObject.subs; // only defined when coming from the file explorer (not playlists) - - elPlayqueue.appendChild( newEl ); - - if ( FILE_EXT_AUDIO.includes( extension ) || ! extension ) { - // disable retrieving metadata of video files for now - https://github.com/Borewit/music-metadata-browser/issues/950 - trackData.retrieve = 1; // flag this item as needing metadata - retrieveMetadata(); - } + rackData.filename = fileName; + trackData.file = uri; // full path for web server access + newEl.handle = fileObject.handle; // for File System API access + newEl.dirHandle = fileObject.dirHandle; + newEl.subs = fileObject.subs; // only defined when coming from the file explorer (not playlists) + + elPlayqueue.appendChild( newEl ); + + if ( FILE_EXT_AUDIO.includes( extension ) || ! extension ) { + // disable retrieving metadata of video files for now - https://github.com/Borewit/music-metadata-browser/issues/950 + trackData.retrieve = 1; // flag this item as needing metadata + retrieveMetadataForQueueItem( newEl ); // ToDo improve handling promise + } - if ( queueLength() == 1 && ! isPlaying() ) { - // if the added song is the only one in the queue and no audio is currently playing, load it into the primary media element - loadSong(0).then( () => resolve(1) ); - } - else { - // if the queue pointer was at the last song, load the newly added song into the secondary media element - if ( queueIndex == queueLength() - 2 ) - loadSong( NEXT_TRACK ); - resolve(1); - } - }); + if ( queueLength() === 1 && ! isPlaying() ) { + await loadSong(0); + } + else { + if ( queueIndex === queueLength() - 2 ) + await loadSong( NEXT_TRACK ); + } + return 1; } /** * Add a song or playlist to the play queue */ -function addToPlayQueue( fileObject, autoplay = false ) { - - let ret; +async function addToPlayQueue( fileObject, autoplay = false ) { + let n; if ( FILE_EXT_PLIST.includes( parsePath( fileObject.file ).extension ) ) - ret = loadPlaylist( fileObject ); + n = await loadPlaylist( fileObject ); else - ret = addSongToPlayQueue( fileObject ); + n =await addSongToPlayQueue( fileObject ); // when promise resolved, if autoplay requested start playing the first added song - ret.then( n => { - if ( autoplay && ! isPlaying() && n > 0 ) - playSong( queueLength() - n ); - }); - return ret; + if ( autoplay && ! isPlaying() && n > 0 ) + playSong( queueLength() - n ); } /** @@ -1251,7 +1245,7 @@ function addToPlayQueue( fileObject, autoplay = false ) { function changeFsHeight( incr ) { const val = +elFsHeight.value; - if ( incr == 1 && val < +elFsHeight.max || incr == -1 && val > +elFsHeight.min ) { + if ( incr === 1 && val < +elFsHeight.max || incr === -1 && val > +elFsHeight.min ) { elFsHeight.value = val + elFsHeight.step * incr; setProperty( elFsHeight ); } @@ -1973,7 +1967,7 @@ function keyboardControls( event ) { } /** - * Sets (or removes) the `src` attribute of a audio element and + * Sets (or removes) the `src` attribute of an audio element and * releases any data blob (File System API) previously in use by it * * @param {object} audio element @@ -2077,7 +2071,8 @@ function loadGradientIntoCurrentGradient(gradientKey) { /** * Load a music file from the user's computer */ -function loadLocalFile( obj ) { +async function loadLocalFile( obj ) { + const fileBlob = obj.files[0]; if ( fileBlob ) { @@ -2086,128 +2081,121 @@ function loadLocalFile( obj ) { audioEl.dataset.file = fileBlob.name; audioEl.dataset.title = parsePath( fileBlob.name ).baseName; - // load and play - loadFileBlob( fileBlob, audioEl, true ) - .then( url => mm.fetchFromUrl( url ) ) - .then( metadata => addMetadata( metadata, audioEl ) ) - .catch( e => {} ); + try { + const loadTask = await loadFileBlob(fileBlob, audioEl, true); + const metadata = await parseBlob( fileBlob ); + await addMetadata(metadata, audioEl); + } catch( error ) { + consoleLog("Failed to load local file", error); + } } } /** * Load a playlist file into the play queue */ -function loadPlaylist( fileObject ) { +async function loadPlaylist( fileObject ) { let path = normalizeSlashes( fileObject.file ); - return new Promise( async ( resolve ) => { - let promises = []; + let trackCounts = 0; - const resolveAddedSongs = saveQueue => { - Promise.all( promises ).then( added => { - const total = added.reduce( ( sum, val ) => sum + val, 0 ); - resolve( total ); - if ( saveQueue ) - storePlayQueue( true ); - }); - } + const parsePlaylistContent = async content => { + path = parsePath( path ).path; // extracts the path (no filename); also decodes/normalize slashes - const parsePlaylistContent = async content => { - path = parsePath( path ).path; // extracts the path (no filename); also decodes/normalize slashes + let album, songInfo; - let album, songInfo; + for ( let line of content.split(/[\r\n]+/) ) { + if ( line.charAt(0) != '#' && line.trim() != '' ) { // not a comment or blank line? + line = normalizeSlashes( line ); + if ( ! songInfo ) // if no #EXTINF tag found on previous line, use the filename + songInfo = parsePath( line ).baseName; - for ( let line of content.split(/[\r\n]+/) ) { - if ( line.charAt(0) != '#' && line.trim() != '' ) { // not a comment or blank line? - line = normalizeSlashes( line ); - if ( ! songInfo ) // if no #EXTINF tag found on previous line, use the filename - songInfo = parsePath( line ).baseName; + let handle, dirHandle; - let handle, dirHandle; + // external URLs do not require any processing + if ( ! isExternalURL( line ) ) { - // external URLs do not require any processing - if ( ! isExternalURL( line ) ) { - - // finds the filesystem handle for this file and its directory - if ( useFileSystemAPI ) { - ( { handle, dirHandle } = await fileExplorer.getHandles( line ) ); - if ( ! handle ) { - consoleLog( `Cannot resolve file handle for ${ line }`, true ); - songInfo = ''; - continue; // skip this entry - } + // finds the filesystem handle for this file and its directory + if ( useFileSystemAPI ) { + ( { handle, dirHandle } = await fileExplorer.getHandles( line ) ); + if ( ! handle ) { + consoleLog( `Cannot resolve file handle for ${ line }`, true ); + songInfo = ''; + continue; // skip this entry } - - // encode special characters into URL-safe codes - line = fileExplorer.encodeChars( line ); - - // if it's not an absolute path, prepend the current path to it - if ( line[1] != ':' && line[0] != '/' ) - line = path + line; } - promises.push( addSongToPlayQueue( { file: line, handle, dirHandle }, { ...parseTrackName( songInfo ), ...( album ? { album } : {} ) } ) ); - songInfo = ''; + // encode special characters into URL-safe codes + line = fileExplorer.encodeChars( line ); + + // if it's not an absolute path, prepend the current path to it + if ( line[1] !== ':' && line[0] !== '/' ) + line = path + line; } - else if ( line.startsWith('#EXTINF') ) - songInfo = line.slice(8); // save #EXTINF metadata for the next iteration - else if ( line.startsWith('#EXTALB') ) - album = line.slice(8); + + trackCounts += await addSongToPlayQueue( { file: line, handle, dirHandle }, { ...parseTrackName( songInfo ), ...( album ? { album } : {} ) } ); + songInfo = ''; } - resolveAddedSongs(); + else if ( line.startsWith('#EXTINF') ) + songInfo = line.slice(8); // save #EXTINF metadata for the next iteration + else if ( line.startsWith('#EXTALB') ) + album = line.slice(8); } + resolveAddedSongs(); + } - // --- main fuction --- + // --- main fuction --- - if ( ! path ) { - resolve( -1 ); - } - else if ( typeof path == 'string' && FILE_EXT_PLIST.includes( parsePath( path ).extension ) ) { - if ( fileObject.handle ) { - fileObject.handle.getFile() - .then( fileBlob => fileBlob.text() ) - .then( parsePlaylistContent ) - .catch( e => { - consoleLog( e, true ); - resolve( 0 ); - }); - } - else { - fetch( path ) - .then( response => { - if ( response.ok ) - return response.text(); - else { - consoleLog( `Fetch returned error code ${response.status} for URI ${path}`, true ); - return ''; - } - }) - .then( parsePlaylistContent ) - .catch( e => { - consoleLog( e, true ); - resolve( 0 ); - }); - } + if ( ! path ) { + resolve( -1 ); + } + else if ( typeof path == 'string' && FILE_EXT_PLIST.includes( parsePath( path ).extension ) ) { + if ( fileObject.handle ) { + fileObject.handle.getFile() + .then( fileBlob => fileBlob.text() ) + .then( parsePlaylistContent ) + .catch( e => { + consoleLog( e, true ); + resolve( 0 ); + }); } - else { // try to load playlist or last play queue from indexedDB - const list = await get( path === true ? KEY_PLAYQUEUE : PLAYLIST_PREFIX + path ); - - if ( Array.isArray( list ) ) { - list.forEach( entry => { - const { file, handle, dirHandle, subs, content } = entry; - promises.push( addSongToPlayQueue( { file, handle, dirHandle, ...( handle && ! dirHandle ? { subs } : {} ) }, content ) ); - // keep subs from old saved playlists only for filesystem entries, since they don't have the dirHandle stored + else { + fetch( path ) + .then( response => { + if ( response.ok ) + return response.text(); + else { + consoleLog( `Fetch returned error code ${response.status} for URI ${path}`, true ); + return ''; + } + }) + .then( parsePlaylistContent ) + .catch( e => { + consoleLog( e, true ); + resolve( 0 ); }); - resolveAddedSongs( list != KEY_PLAYQUEUE ); // save playqueue when loading an internal playlist + } + } + else { // try to load playlist or last play queue from indexedDB + const list = await get( path === true ? KEY_PLAYQUEUE : PLAYLIST_PREFIX + path ); + + if ( Array.isArray( list ) ) { + for(const entry of list) { + const { file, handle, dirHandle, subs, content } = entry; + trackCounts += await addSongToPlayQueue( { file, handle, dirHandle, ...( handle && ! dirHandle ? { subs } : {} ) }, content ); + // keep subs from old saved playlists only for filesystem entries, since they don't have the dirHandle stored } - else { - if ( path !== true ) // avoid error message if no play queue found on storage - consoleLog( `Unrecognized playlist file: ${path}`, true ); - resolve( 0 ); + if(list !== KEY_PLAYQUEUE ) { + storePlayQueue( true ); } } - }); + else { + if ( path !== true ) // avoid error message if no play queue found on storage + consoleLog( `Unrecognized playlist file: ${path}`, true ); + resolve( 0 ); + } + } } /** @@ -3237,71 +3225,75 @@ async function retrieveBackgrounds() { catch( e ) {} // needs permission to access local device } - if ( bgLocation != BGFOLDER_NONE ) { + if ( bgLocation !== BGFOLDER_NONE ) { const imageCount = bgImages.length, videoCount = bgVideos.length; - consoleLog( 'Found ' + ( imageCount + videoCount == 0 ? 'no media' : imageCount + ' image files and ' + videoCount + ' video' ) + ' files in the backgrounds folder' ); + consoleLog( 'Found ' + ( imageCount + videoCount === 0 ? 'no media' : imageCount + ' image files and ' + videoCount + ' video' ) + ' files in the backgrounds folder' ); } populateBackgrounds(); } /** - * Retrieve metadata for files in the play queue + * Retrieve metadata for element queueItem */ -async function retrieveMetadata() { - // leave when we already have enough concurrent requests pending - if ( waitingMetadata >= MAX_METADATA_REQUESTS ) - return; - - // find the first play queue item for which we haven't retrieved the metadata yet - const queueItem = Array.from( elPlayqueue.children ).find( el => el.dataset.retrieve ); - - if ( queueItem ) { +function retrieveMetadataForQueueItem(queueItem) { - let uri = queueItem.dataset.file, - revoke = false; + return metadataRetrievalQueue.add(async () => { + let metadata; + let file; - waitingMetadata++; - delete queueItem.dataset.retrieve; - - queryMetadata: { + try { if ( queueItem.handle ) { - try { - if ( await queueItem.handle.requestPermission() != 'granted' ) - break queryMetadata; - uri = URL.createObjectURL( await queueItem.handle.getFile() ); - revoke = true; - } - catch( e ) { - break queryMetadata; - } - } + // Fetch metadata from File object + if (await queueItem.handle.requestPermission() !== 'granted') + return; - try { - const metadata = await mm.fetchFromUrl( uri, { skipPostHeaders: true } ); - if ( metadata ) { - addMetadata( metadata, queueItem ); // add metadata to play queue item - syncMetadataToAudioElements( queueItem ); - if ( ! ( metadata.common.picture && metadata.common.picture.length ) ) { - getFolderCover( queueItem ).then( cover => { - queueItem.dataset.cover = cover; - syncMetadataToAudioElements( queueItem ); - }); + file = await queueItem.handle.getFile(); + metadata = await parseBlob(file); + } + else + { + // Fetch metadata from URI + const response = await fetch(queueItem.dataset.file); + if (response.ok) { + if (response.body?.getReader) { + const contentType = response.headers.get("Content-Type"); + const contentSize = response.headers.get("Content-Length"); + try { + metadata = await parseWebStream(response.body, { + mimeType: contentType, + size: contentSize ? Number.parseInt(contentSize, 10) : undefined + }, {skipPostHeaders: true}); + } finally { + await response.body.cancel(); + } + } else { + // Fallback to Blob, in case the HTTP Result cannot be streamed + metadata = await parseBlob(await response.blob()); } + } else { + consoleLog(`Failed to fetch metadata http-response=${response.status} for url=${queueItem.dataset.file}`, true); } } - catch( e ) {} + } + catch( e ) { + consoleLog(`Error converting queued file="${queueItem.dataset.file ?? '?'}" to URI`, e); + return; + } + + console.log(`Fetched metadata successful for url=${queueItem.dataset.file}`); + addMetadata( metadata, queueItem ); // add metadata to play queue item - if ( revoke ) - URL.revokeObjectURL( uri ); + // If no embedded picture, try folder cover + if ( ! ( metadata.common.picture && metadata.common.picture.length > 0) ) { + queueItem.dataset.cover = await getFolderCover( queueItem ); } - waitingMetadata--; - retrieveMetadata(); // call again to continue processing the queue - } + syncMetadataToAudioElements( queueItem ); + }); } /** @@ -3353,9 +3345,9 @@ function saveGradient( isImported ) { /** * Save/update an existing playlist */ -function savePlaylist( index ) { +async function savePlaylist( index ) { if ( ! index ) - storePlayQueue(); + await storePlayQueue(); else { notie.confirm({ text: `Overwrite "${elPlaylists[ index ].innerText}" with the current play queue?`, submitText: 'Overwrite', diff --git a/webpack.config.js b/webpack.config.js index f598811..197e572 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -36,10 +36,6 @@ module.exports = { new MiniCssExtractPlugin({ filename: 'styles.css', }), - new webpack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'], - process: 'process/browser.js', - }), ], output: { filename: pathData => { diff --git a/webpack.dev.js b/webpack.dev.js index 83d74a8..5f6adbd 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -35,10 +35,6 @@ module.exports = { }, plugins: [ new webpack.HotModuleReplacementPlugin(), - new webpack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'], - process: 'process/browser.js', - }), ], output: { filename: 'audioMotion.js',