From 41862ffde6fe84677b096e1aea78826f12ebd4cb Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 28 Feb 2018 16:25:07 -0800 Subject: [PATCH 01/24] Improve handling of x-origin duration fetching --- js/actionCreators.js | 1 + 1 file changed, 1 insertion(+) diff --git a/js/actionCreators.js b/js/actionCreators.js index 7fbee50e..0ef1940d 100644 --- a/js/actionCreators.js +++ b/js/actionCreators.js @@ -232,6 +232,7 @@ export function fetchMediaDuration(url, id) { // TODO: Does this actually stop downloading the file once it's // got the duration? const audio = document.createElement("audio"); + audio.crossOrigin = "anonymous"; const durationChange = () => { const { duration } = audio; dispatch({ type: SET_MEDIA_DURATION, duration, id }); From f429fdcd0b6b6cd36c364f9f7a157afab501f193 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 28 Feb 2018 16:26:42 -0800 Subject: [PATCH 02/24] Fix ordering when loading multiple files --- js/actionCreators.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/actionCreators.js b/js/actionCreators.js index 0ef1940d..85259a09 100644 --- a/js/actionCreators.js +++ b/js/actionCreators.js @@ -262,7 +262,7 @@ export function loadMediaFiles(tracks, loadStyle = null, atIndex = 0) { } tracks.forEach((track, i) => { const priority = i === 0 && loadStyle != null ? loadStyle : null; - dispatch(loadMediaFile(track, priority, atIndex)); + dispatch(loadMediaFile(track, priority, atIndex + i)); }); }; } From 5836e9f542d1ac51decf833c8bb6c6d0ce56ce18 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 28 Feb 2018 16:31:19 -0800 Subject: [PATCH 03/24] Add infra for dropbox chooser and lazy id3 tags --- js/actionCreators.js | 60 ++++++++++++++++++++++++++++++++++-- js/actionTypes.js | 2 ++ js/constants.js | 7 +++++ js/reducers/playlist.js | 31 +++++++++++++++++-- js/reducers/playlist.test.js | 3 ++ js/selectors.js | 6 ++++ 6 files changed, 104 insertions(+), 5 deletions(-) diff --git a/js/actionCreators.js b/js/actionCreators.js index 85259a09..64a5576a 100644 --- a/js/actionCreators.js +++ b/js/actionCreators.js @@ -6,14 +6,20 @@ import { promptForFileReferences } from "./fileUtils"; import skinParser from "./skinParser"; -import { BANDS, TRACK_HEIGHT, LOAD_STYLE } from "./constants"; +import { + BANDS, + TRACK_HEIGHT, + LOAD_STYLE, + MEDIA_TAG_REQUEST_STATUS +} from "./constants"; import { getEqfData, nextTrack, getScrollOffset, getOverflowTrackCount, getPlaylistURL, - getSelectedTrackObjects + getSelectedTrackObjects, + getVisibleTracks } from "./selectors"; import { @@ -53,7 +59,9 @@ import { SET_MEDIA_TAGS, SET_MEDIA_DURATION, TOGGLE_SHADE_MODE, - TOGGLE_PLAYLIST_SHADE_MODE + TOGGLE_PLAYLIST_SHADE_MODE, + MEDIA_TAG_REQUEST_INITIALIZED, + MEDIA_TAG_REQUEST_FAILED } from "./actionTypes"; function playRandomTrack() { @@ -311,12 +319,27 @@ export function loadMediaFile(track, priority = null, atIndex = 0) { }; } +export function fetchMediaTagsForVisibleTracks() { + return (dispatch, getState) => { + getVisibleTracks(getState()) + .filter( + track => + track.mediaTagsRequestStatus === + MEDIA_TAG_REQUEST_STATUS.NOT_REQUESTED + ) + .forEach(track => { + dispatch(fetchMediaTags(track.url, track.id)); + }); + }; +} + export function fetchMediaTags(file, id) { // Workaround https://github.com/aadsm/jsmediatags/issues/83 if (typeof file === "string" && !/^[a-z]+:\/\//i.test(file)) { file = `${location.protocol}//${location.host}${location.pathname}${file}`; } return dispatch => { + dispatch({ type: MEDIA_TAG_REQUEST_INITIALIZED, id }); try { jsmediatags.read(file, { onSuccess: data => { @@ -326,6 +349,7 @@ export function fetchMediaTags(file, id) { dispatch({ type: SET_MEDIA_TAGS, artist, title, id }); }, onError: () => { + dispatch({ type: MEDIA_TAG_REQUEST_FAILED, id }); // Nothing to do. The filename will have to suffice. } }); @@ -333,6 +357,7 @@ export function fetchMediaTags(file, id) { // Possibly jsmediatags could not find a parser for this file? // Nothing to do. // Consider removing this after https://github.com/aadsm/jsmediatags/issues/83 is resolved. + dispatch({ type: MEDIA_TAG_REQUEST_FAILED, id }); } }; } @@ -390,6 +415,35 @@ export function openSkinFileDialog() { return _openFileDialog(".zip, .wsz"); } +// Requires Dropbox's Chooser to be loaded on the page +function genAudioFileUrlsFromDropbox() { + return new Promise((resolve, reject) => { + if (window.Dropbox == null) { + reject(); + } + window.Dropbox.choose({ + success: resolve, + error: reject, + linkType: "direct", + folderselect: false, + multiselect: true, + extensions: ["video", "audio"] + }); + }); +} + +export function openDropboxFileDialog() { + return async dispatch => { + const files = await genAudioFileUrlsFromDropbox(); + dispatch( + loadMediaFiles( + files.map(file => ({ url: file.link, defaultName: file.name })), + LOAD_STYLE.PLAY + ) + ); + }; +} + export function setEqBand(band, value) { return { type: SET_BAND_VALUE, band, value }; } diff --git a/js/actionTypes.js b/js/actionTypes.js index 92d52697..5add53b5 100644 --- a/js/actionTypes.js +++ b/js/actionTypes.js @@ -60,3 +60,5 @@ export const SET_MEDIA_TAGS = "SET_MEDIA_TAGS"; export const SET_MEDIA_DURATION = "SET_MEDIA_DURATION"; export const OPEN_GEN_WINDOW = "OPEN_GEN_WINDOW"; export const CLOSE_GEN_WINDOW = "CLOSE_GEN_WINDOW"; +export const MEDIA_TAG_REQUEST_INITIALIZED = "MEDIA_TAG_REQUEST_INITIALIZED"; +export const MEDIA_TAG_REQUEST_FAILED = "MEDIA_TAG_REQUEST_FAILED"; diff --git a/js/constants.js b/js/constants.js index 309fe8e1..802e17d2 100644 --- a/js/constants.js +++ b/js/constants.js @@ -11,6 +11,13 @@ export const LOAD_STYLE = { PLAY: "PLAY" }; +export const MEDIA_TAG_REQUEST_STATUS = { + INITIALIZED: "INITIALIZED", + FAILED: "FAILED", + COMPLETE: "COMPLETE", + NOT_REQUESTED: "NOT_REQUESTED" +}; + export const UTF8_ELLIPSIS = "\u2026"; export const CHARACTER_WIDTH = 5; export const PLAYLIST_RESIZE_SEGMENT_WIDTH = 25; diff --git a/js/reducers/playlist.js b/js/reducers/playlist.js index 918f7ca1..4f5313a9 100644 --- a/js/reducers/playlist.js +++ b/js/reducers/playlist.js @@ -16,8 +16,11 @@ import { BUFFER_TRACK, DRAG_SELECTED, SET_MEDIA_TAGS, - SET_MEDIA_DURATION + SET_MEDIA_DURATION, + MEDIA_TAG_REQUEST_INITIALIZED, + MEDIA_TAG_REQUEST_FAILED } from "../actionTypes"; +import { MEDIA_TAG_REQUEST_STATUS } from "../constants"; import { filenameFromUrl } from "../fileUtils"; import { shuffle, moveSelected, mapObject, filterObject } from "../utils"; @@ -148,7 +151,8 @@ const playlist = (state = defaultPlaylistState, action) => { selected: false, defaultName: action.defaultName, duration: null, - url: action.url + url: action.url, + mediaTagsRequestStatus: MEDIA_TAG_REQUEST_STATUS.NOT_REQUESTED } }, // TODO: This could probably be made to work, but we clear it just to be safe. @@ -172,11 +176,34 @@ const playlist = (state = defaultPlaylistState, action) => { ...state.tracks, [action.id]: { ...state.tracks[action.id], + mediaTagsRequestStatus: MEDIA_TAG_REQUEST_STATUS.COMPLETE, title: action.title, artist: action.artist } } }; + case MEDIA_TAG_REQUEST_INITIALIZED: + return { + ...state, + tracks: { + ...state.tracks, + [action.id]: { + ...state.tracks[action.id], + mediaTagsRequestStatus: MEDIA_TAG_REQUEST_STATUS.INITIALIZED + } + } + }; + case MEDIA_TAG_REQUEST_FAILED: + return { + ...state, + tracks: { + ...state.tracks, + [action.id]: { + ...state.tracks[action.id], + mediaTagsRequestStatus: MEDIA_TAG_REQUEST_STATUS.FAILED + } + } + }; case SET_MEDIA_DURATION: return { ...state, diff --git a/js/reducers/playlist.test.js b/js/reducers/playlist.test.js index 6a8fbbaf..bba91498 100644 --- a/js/reducers/playlist.test.js +++ b/js/reducers/playlist.test.js @@ -25,6 +25,7 @@ describe("playlist reducer", () => { selected: false, duration: null, defaultName: "My Track Name", + mediaTagsRequestStatus: "NOT_REQUESTED", url: "url://some-url" } }, @@ -54,6 +55,7 @@ describe("playlist reducer", () => { 100: { selected: false, duration: null, + mediaTagsRequestStatus: "NOT_REQUESTED", defaultName: "My Track Name", url: "url://some-url" } @@ -85,6 +87,7 @@ describe("playlist reducer", () => { 100: { selected: false, duration: null, + mediaTagsRequestStatus: "NOT_REQUESTED", defaultName: "My Track Name", url: "url://some-url" } diff --git a/js/selectors.js b/js/selectors.js index c9d036aa..78789bac 100644 --- a/js/selectors.js +++ b/js/selectors.js @@ -156,6 +156,12 @@ export const getVisibleTrackIds = createSelector( trackOrder.slice(offset, offset + numberOfVisibleTracks) ); +export const getVisibleTracks = createSelector( + getVisibleTrackIds, + getTracks, + (visibleTrackIds, tracks) => visibleTrackIds.map(id => tracks[id]) +); + export const getPlaylist = state => state.playlist; export const getDuration = state => { From b34289990ea8aa1ba560c76d1bf241a2017a6179 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sun, 4 Mar 2018 19:19:24 -0800 Subject: [PATCH 04/24] Add changelog and howtogeek to press page --- press.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/press.md b/press.md index ab7b6a4d..0fb45b38 100644 --- a/press.md +++ b/press.md @@ -15,7 +15,7 @@ ## News Aggregators -* [/r/InternetIsBeautiful](https://www.reddit.com/r/InternetIsBeautiful/comments/2lh3ob/winamp_2_preserved_in_html5/) +* [/r/InternetIsBeautiful](https://www.reddit.com/r/InternetIsBeautiful/comments/2lh3ob/winamp_2_preserved_in_html5/) * [Hacker News](https://news.ycombinator.com/item?id=16333550) (Feb. 2018) * [Hacker News](https://news.ycombinator.com/item?id=15314629) (Sept. 2017) * [Hacker News](https://news.ycombinator.com/item?id=8565665) (Nov. 2014) @@ -45,6 +45,7 @@ ## Articles (continued) ### Russian + * http://altervision.org/2018/02/winamp-zapustili-v-vashem-brauzere/ * http://droider.ru/post/emulyator-dlya-nostalgii-po-winamp-09-02-2018/ * http://fornote.net/2018/02/winamp2-js-legendarny-j-winamp-dostupny-j-pryamo-v-brauzere/ @@ -62,6 +63,7 @@ * https://tech.onliner.by/2018/02/09/winamp-5 ### Spanish + * http://datainfox.com/2018/02/lanzan-version-web-winamp/ * http://html5facil.com/noticias/winamp-web/ * http://omicrono.elespanol.com/2014/11/winamp-2-en-html5-es-nostalgia-pura/ @@ -81,6 +83,7 @@ * https://www.rocambola.com/winamp2-js-la-nueva-version-del-famoso-reproductor-en-formato-web/ ### Chinese + * http://baijiahao.baidu.com/s?id=1592074295875163282 * http://blog.csdn.net/souhugirl/article/details/43907469 * http://m.sohu.com/a/221750379_549015 @@ -95,6 +98,7 @@ * https://www.waerfa.com/winamp2-js ### English + * http://entertainment.ie/trending/news/Remember-Winamp-Now-theres-a-web-browser-version-of-it/402141.htm * http://newsvire.com/get-some-media-player-nostalgia-with-this-web-version-of-winamp/ * http://osxdaily.com/2018/02/10/run-winamp-web-browser/ @@ -103,8 +107,11 @@ * https://thenextweb.com/shareables/2018/02/09/bring-nostalgia-browser-winamp-emulator/ * https://www.follownews.com/winamp2js-is-a-webbased-version-of-audio-player-winamp-43icg * https://www.gizmodo.com.au/2018/02/someone-remade-winamp-so-it-runs-in-your-browser/ +* https://changelog.com/news/winamp-29-in-htmljs--PJr +* https://www.howtogeek.com/343813/re-live-90s-computing-in-your-browser-right-now/amp/ ### German + * http://de.engadget.com/2018/02/09/retro-winamp-emulator-fur-deinen-browser/ * http://www.chip.de/news/Winamp-ist-zurueck-Kult-Player-ueber-Browser-nutzen_124413881.html * http://www.intro.de/life/mit-winamp-zuruck-in-die-vergangenheit-nostalgische-browser-version-des-media-players @@ -113,6 +120,7 @@ * https://www.smartdroid.de/winamp2-js-laesst-den-klassiker-im-browser-wieder-aufleben/ ### Polish + * https://www.antyradio.pl/Technologia/Internet/Winamp-powrocil-jako-strona-internetowa-17497 * http://innpoland.pl/137663,kultowy-winamp-powraca-w-nowej-formie-uderza-tam-gdzie-jest-juz-spotify * http://www.instalki.pl/aktualnosci/software/30055-winamp-przegladarka.html @@ -120,6 +128,7 @@ * https://www.dobreprogramy.pl/Kultowy-odtwarzacz-muzyki-Winamp-powrocil-w-przegladarce-internetowej,News,86017.html ### French + * http://www.phonandroid.com/winamps-lecteur-multimedia-retour.html * http://www.lemonde.fr/pixels/breve/2014/11/07/winamp-en-html_4520190_4408996.html * https://korben.info/winamp-en-html-javascript.html @@ -128,6 +137,7 @@ * https://www.macg.co/logiciels/2018/02/revivez-les-glorieuses-annees-winamp-avec-votre-navigateur-101345 ### Italian + * https://tech.everyeye.it/notizie/il-ritorno-winamp-storico-player-riappare-in-una-versione-web-320373.html * https://www.hwupgrade.it/news/web/winamp-arriva-su-qualsiasi-browser-web-nostalgici-gioiscono_74100.html * https://www.wired.it/internet/web/2018/02/09/winamp-rinasce-sul-web/ @@ -135,47 +145,59 @@ * https://www.windowsblogitalia.com/2018/02/ascoltare-la-musica-edge-firefox-chrome-winamp2-js/ ### Thai + * http://www.flashfly.net/wp/208175 * https://www.aripfan.com/bring-winamp-emulator-in-web-browser/ * https://www.beartai.com/news/itnews/221530 ### Hungarian -* http://hvg.hu/tudomany/20180212_winamp_zenelejatszo_letoltes_bongeszoben + +* http://hvg.hu/tudomany/20180212_winamp_zenelejatszo_letoltes_bongeszoben * http://www.karpatinfo.net/cikk/tudomany/2567147-feltamadt-winamp-90-es-evek-kedvenc-zenelejatszoja-hasznalhatja * https://www.gamestar.hu/hir/winamp-html5-verzio-243839.html ### Bosnian + * https://www.b92.net/tehnopolis/vesti.php?yyyy=2018&mm=02&nav_id=1357852 * https://www.benchmark.rs/vesti/novi_winamp_emulator_budi_nostalgiju_na_stara_vremena-74941 ### Turkish + * http://www.bursateknikpc.com/winamp-2nin-html5-de-yazilmis-versiyonu-cikti/ * https://www.log.com.tr/winamp-efsanesini-yeniden-kullanima-sunan-site/ ### Japanese + * http://jp.techcrunch.com/2018/02/10/2018-02-09-whip-the-llamas-ass-with-this-javascript-winamp-emulator/ * https://gigazine.net/news/20180213-winamp2-js/ * https://www.gizmodo.jp/2014/11/90winamp_2html5.html ### Portuguese + * http://abertoatedemadrugada.com/2018/02/winamp-completamente-funcional-em.html * https://canaltech.com.br/musica/desenvolvedor-ressuscita-winamp-em-emulador-com-javascript-108112/ ### Swedish + * http://gaffa.se/nyhet/125537/klassisk-musikspelare-nu-pa-webben/ * https://www.idg.se/2.1085/1.697529/mp3-winamp-webblasare ### Armenian + * http://www.armblog.net/2018/02/github-winamp.htm ### Dutch + * https://tweakers.net/geek/135087/ontwikkelaar-bouwt-winamp-29-in-javascript.html ### Romanian + * https://zonait.tv/va-era-dor-de-winamp/ ### Czech + * https://www.zive.cz/clanky/zpatky-do-minulosti-winamp-muzete-pouzivat-primo-v-prohlizeci/sc-3-a-191743/default.aspx ### Croatian + * http://idesh.net/tech-i-web/internet/winamp-se-vratio-nostalgija-na-najjace/ From 4423bf2418e4b6d87846fdf09cf0be13eb2653cd Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 5 Mar 2018 16:41:17 -0800 Subject: [PATCH 05/24] Add todo --- js/media/elementSource.js | 1 + 1 file changed, 1 insertion(+) diff --git a/js/media/elementSource.js b/js/media/elementSource.js index 61df4398..7c0c6235 100644 --- a/js/media/elementSource.js +++ b/js/media/elementSource.js @@ -69,6 +69,7 @@ export default class ElementSource { } // Async for now, for compatibility with BufferAudioSource + // TODO: This does not need to be async async loadUrl(url) { this._audio.src = url; } From 736c54e1ecb1401e3825718542dd337892cf0d5d Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 9 Mar 2018 17:17:09 -0800 Subject: [PATCH 06/24] Allow keywords in dot notation --- .eslintrc | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.eslintrc b/.eslintrc index 088ba4a4..7fb6d974 100644 --- a/.eslintrc +++ b/.eslintrc @@ -33,12 +33,7 @@ "camelcase": "error", "consistent-return": "warn", "constructor-super": "error", - "dot-notation": [ - "error", - { - "allowKeywords": false - } - ], + "dot-notation": "error", "eqeqeq": ["error", "smart"], "guard-for-in": "error", "max-depth": ["warn", 4], From 700b7392cad613ccac38a624ead0c79afec276a2 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 9 Mar 2018 17:17:28 -0800 Subject: [PATCH 07/24] Ignore the skin museum --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c06694f5..e199ab42 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules /built /coverage +/experiments/winamp-skin-museum From 33e33b2dec0fe83c1eea84fb162c18040649eb3e Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 9 Mar 2018 20:32:55 -0800 Subject: [PATCH 08/24] Add dropbox support --- index.html | 1 + js/actionCreators.js | 139 +++++++++--------- js/components/MainWindow/MainContextMenu.js | 9 +- .../PlaylistWindow/PlaylistResizeTarget.js | 6 +- js/components/PlaylistWindow/index.js | 1 + js/fileUtils.js | 69 +++++++++ js/loadQueue.js | 47 ++++++ js/reducers/playlist.js | 1 + js/reducers/playlist.test.js | 19 ++- js/utils.js | 12 ++ loadQueue.test.js | 20 +++ package.json | 2 + yarn.lock | 10 ++ 13 files changed, 250 insertions(+), 86 deletions(-) create mode 100644 js/loadQueue.js create mode 100644 loadQueue.test.js diff --git a/index.html b/index.html index bb573f08..ec82b657 100755 --- a/index.html +++ b/index.html @@ -29,6 +29,7 @@ Feedback | GitHub

+ diff --git a/js/actionCreators.js b/js/actionCreators.js index 64a5576a..d31b5466 100644 --- a/js/actionCreators.js +++ b/js/actionCreators.js @@ -1,9 +1,11 @@ -import jsmediatags from "jsmediatags/dist/jsmediatags"; import { parser, creator } from "winamp-eqf"; import { genArrayBufferFromFileReference, genArrayBufferFromUrl, - promptForFileReferences + promptForFileReferences, + genMediaDuration, + genMediaTags, + genAudioFileUrlsFromDropbox } from "./fileUtils"; import skinParser from "./skinParser"; import { @@ -27,7 +29,8 @@ import { base64FromArrayBuffer, downloadURI, normalize, - sort + sort, + debounce } from "./utils"; import { CLOSE_WINAMP, @@ -61,9 +64,17 @@ import { TOGGLE_SHADE_MODE, TOGGLE_PLAYLIST_SHADE_MODE, MEDIA_TAG_REQUEST_INITIALIZED, - MEDIA_TAG_REQUEST_FAILED + MEDIA_TAG_REQUEST_FAILED, + PLAYLIST_SIZE_CHANGED } from "./actionTypes"; +import LoadQueue from "./loadQueue"; + +const DURATION_PRIORITY = 5; +const META_DATA_PRIORITY = 10; + +const loadQueue = new LoadQueue({ threads: 4 }); + function playRandomTrack() { return (dispatch, getState) => { const { playlist: { trackOrder, currentTrack } } = getState(); @@ -237,22 +248,14 @@ export function loadFilesFromReferences( export function fetchMediaDuration(url, id) { return dispatch => { - // TODO: Does this actually stop downloading the file once it's - // got the duration? - const audio = document.createElement("audio"); - audio.crossOrigin = "anonymous"; - const durationChange = () => { - const { duration } = audio; - dispatch({ type: SET_MEDIA_DURATION, duration, id }); - audio.removeEventListener("durationchange", durationChange); - audio.url = null; - // TODO: Not sure if this really gets cleaned up. - }; - audio.addEventListener("durationchange", durationChange); - audio.addEventListener("error", () => { - // TODO: Should we update the state to indicate that we don't know the length? - }); - audio.src = url; + loadQueue.push(() => { + return genMediaDuration(url) + .then(duration => dispatch({ type: SET_MEDIA_DURATION, duration, id })) + .catch(() => { + // TODO: Should we update the state to indicate that we don't know the length? + }); + // TODO: The priority should depend upon visiblity + }, () => DURATION_PRIORITY); }; } @@ -313,52 +316,51 @@ export function loadMediaFile(track, priority = null, atIndex = 0) { if (metaData != null) { const { artist, title } = metaData; dispatch({ type: SET_MEDIA_TAGS, artist, title, id }); + } else if (blob != null) { + // Blobs can be loaded quickly + dispatch(fetchMediaTags(blob, id)); } else { - dispatch(fetchMediaTags(url || blob, id)); + dispatch(fetchMediaTagsForVisibleTracks()); } }; } +const _fetchMediaTagsForVisibleTracksThunk = debounce((dispatch, getState) => { + getVisibleTracks(getState()) + .filter( + track => + track.mediaTagsRequestStatus === MEDIA_TAG_REQUEST_STATUS.NOT_REQUESTED + ) + .forEach(track => { + loadQueue.push( + () => dispatch(fetchMediaTags(track.url, track.id)), + () => META_DATA_PRIORITY + ); + }); +}, 200); + export function fetchMediaTagsForVisibleTracks() { - return (dispatch, getState) => { - getVisibleTracks(getState()) - .filter( - track => - track.mediaTagsRequestStatus === - MEDIA_TAG_REQUEST_STATUS.NOT_REQUESTED - ) - .forEach(track => { - dispatch(fetchMediaTags(track.url, track.id)); - }); - }; + return _fetchMediaTagsForVisibleTracksThunk; } +export const debouncedFetchMediaTagsForVisibleTracks = debounce( + fetchMediaTagsForVisibleTracks, + 200 +); + export function fetchMediaTags(file, id) { - // Workaround https://github.com/aadsm/jsmediatags/issues/83 - if (typeof file === "string" && !/^[a-z]+:\/\//i.test(file)) { - file = `${location.protocol}//${location.host}${location.pathname}${file}`; - } return dispatch => { dispatch({ type: MEDIA_TAG_REQUEST_INITIALIZED, id }); - try { - jsmediatags.read(file, { - onSuccess: data => { - const { artist, title } = data.tags; - // There's more data here, but we don't have a use for it yet: - // https://github.com/aadsm/jsmediatags#shortcuts - dispatch({ type: SET_MEDIA_TAGS, artist, title, id }); - }, - onError: () => { - dispatch({ type: MEDIA_TAG_REQUEST_FAILED, id }); - // Nothing to do. The filename will have to suffice. - } + return genMediaTags(file) + .then(data => { + const { artist, title } = data.tags; + // There's more data here, but we don't have a use for it yet: + // https://github.com/aadsm/jsmediatags#shortcuts + dispatch({ type: SET_MEDIA_TAGS, artist, title, id }); + }) + .catch(() => { + dispatch({ type: MEDIA_TAG_REQUEST_FAILED, id }); }); - } catch (e) { - // Possibly jsmediatags could not find a parser for this file? - // Nothing to do. - // Consider removing this after https://github.com/aadsm/jsmediatags/issues/83 is resolved. - dispatch({ type: MEDIA_TAG_REQUEST_FAILED, id }); - } }; } @@ -415,23 +417,6 @@ export function openSkinFileDialog() { return _openFileDialog(".zip, .wsz"); } -// Requires Dropbox's Chooser to be loaded on the page -function genAudioFileUrlsFromDropbox() { - return new Promise((resolve, reject) => { - if (window.Dropbox == null) { - reject(); - } - window.Dropbox.choose({ - success: resolve, - error: reject, - linkType: "direct", - folderselect: false, - multiselect: true, - extensions: ["video", "audio"] - }); - }); -} - export function openDropboxFileDialog() { return async dispatch => { const files = await genAudioFileUrlsFromDropbox(); @@ -566,7 +551,17 @@ export function toggleVisualizerStyle() { } export function setPlaylistScrollPosition(position) { - return { type: SET_PLAYLIST_SCROLL_POSITION, position }; + return dispatch => { + dispatch({ type: SET_PLAYLIST_SCROLL_POSITION, position }); + dispatch(fetchMediaTagsForVisibleTracks()); + }; +} + +export function setPlaylistSize(size) { + return dispatch => { + dispatch({ type: PLAYLIST_SIZE_CHANGED, size }); + dispatch(fetchMediaTagsForVisibleTracks()); + }; } export function scrollNTracks(n) { diff --git a/js/components/MainWindow/MainContextMenu.js b/js/components/MainWindow/MainContextMenu.js index f9ff8757..485698f4 100644 --- a/js/components/MainWindow/MainContextMenu.js +++ b/js/components/MainWindow/MainContextMenu.js @@ -5,7 +5,8 @@ import { close, setSkinFromUrl, openMediaFileDialog, - openSkinFileDialog + openSkinFileDialog, + openDropboxFileDialog } from "../../actionCreators"; import { ContextMenu, Hr, Node, Parent, LinkNode } from "../ContextMenu"; @@ -21,7 +22,10 @@ const MainContextMenu = props => ( label="Winamp2-js" />
- + + + + {!!props.avaliableSkins.length &&
} @@ -46,6 +50,7 @@ const mapDispatchToProps = { close, openSkinFileDialog, openMediaFileDialog, + openDropboxFileDialog, setSkin: setSkinFromUrl }; diff --git a/js/components/PlaylistWindow/PlaylistResizeTarget.js b/js/components/PlaylistWindow/PlaylistResizeTarget.js index 09837ebc..3ab02097 100644 --- a/js/components/PlaylistWindow/PlaylistResizeTarget.js +++ b/js/components/PlaylistWindow/PlaylistResizeTarget.js @@ -1,6 +1,6 @@ import { connect } from "react-redux"; import ResizeTarget from "../ResizeTarget"; -import { PLAYLIST_SIZE_CHANGED } from "../../actionTypes"; +import { setPlaylistSize } from "../../actionCreators"; import { PLAYLIST_RESIZE_SEGMENT_WIDTH, PLAYLIST_RESIZE_SEGMENT_HEIGHT @@ -13,8 +13,6 @@ const mapStateToProps = state => ({ id: "playlist-resize-target" }); -const mapDispatchToProps = { - setPlaylistSize: size => ({ type: PLAYLIST_SIZE_CHANGED, size }) -}; +const mapDispatchToProps = { setPlaylistSize }; export default connect(mapStateToProps, mapDispatchToProps)(ResizeTarget); diff --git a/js/components/PlaylistWindow/index.js b/js/components/PlaylistWindow/index.js index ae725a30..6c961827 100644 --- a/js/components/PlaylistWindow/index.js +++ b/js/components/PlaylistWindow/index.js @@ -48,6 +48,7 @@ class PlaylistWindow extends React.Component { _handleDrop(e, targetCoords) { const top = e.clientY - targetCoords.y; + // TODO: Include the scroll offset in this const atIndex = clamp( Math.round((top - 23) / TRACK_HEIGHT), 0, diff --git a/js/fileUtils.js b/js/fileUtils.js index 77f1986c..d295ed09 100644 --- a/js/fileUtils.js +++ b/js/fileUtils.js @@ -1,4 +1,73 @@ +import invariant from "invariant"; +import jsmediatags from "jsmediatags/dist/jsmediatags"; + +// Requires Dropbox's Chooser to be loaded on the page +export function genAudioFileUrlsFromDropbox() { + return new Promise((resolve, reject) => { + if (window.Dropbox == null) { + reject(); + } + window.Dropbox.choose({ + success: resolve, + error: reject, + linkType: "direct", + folderselect: false, + multiselect: true, + extensions: ["video", "audio"] + }); + }); +} + +export function genMediaTags(file) { + invariant( + file != null, + "Attempted to get the tags of media file without passing a file" + ); + // Workaround https://github.com/aadsm/jsmediatags/issues/83 + if (typeof file === "string" && !/^[a-z]+:\/\//i.test(file)) { + file = `${location.protocol}//${location.host}${location.pathname}${file}`; + } + return new Promise((resolve, reject) => { + try { + jsmediatags.read(file, { onSuccess: resolve, onError: reject }); + } catch (e) { + // Possibly jsmediatags could not find a parser for this file? + // Nothing to do. + // Consider removing this after https://github.com/aadsm/jsmediatags/issues/83 is resolved. + reject(e); + } + }); +} + +export function genMediaDuration(url) { + invariant( + typeof url === "string", + "Attempted to get the duration of media file without passing a url" + ); + return new Promise((resolve, reject) => { + // TODO: Does this actually stop downloading the file once it's + // got the duration? + const audio = document.createElement("audio"); + audio.crossOrigin = "anonymous"; + const durationChange = () => { + resolve(audio.duration); + audio.removeEventListener("durationchange", durationChange); + audio.url = null; + // TODO: Not sure if this really gets cleaned up. + }; + audio.addEventListener("durationchange", durationChange); + audio.addEventListener("error", e => { + reject(e); + }); + audio.src = url; + }); +} + export async function genArrayBufferFromFileReference(fileReference) { + invariant( + fileReference != null, + "Attempted to get an ArrayBuffer without assing a fileReference" + ); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = function(e) { diff --git a/js/loadQueue.js b/js/loadQueue.js new file mode 100644 index 00000000..99e323bb --- /dev/null +++ b/js/loadQueue.js @@ -0,0 +1,47 @@ +import invariant from "invariant"; +import TinyQueue from "tinyqueue"; + +// Push promises onto a queue with a priority. +// Run a given number of jobs in parallel +// Useful for prioritizing network requests +export default class LoadQueue { + constructor({ threads }) { + // TODO: Consider not running items with zero prioirty + // Priority is a function so that items can change their priority between + // when their priority is evaluated. + // For example, we might add a track to the playlist and then scroll to/away + // from it before it gets processed. + this._queue = new TinyQueue([], (a, b) => a.priority() > b.priority()); + this._avaliableThreads = threads; + } + + push(task, priority) { + const t = { task, priority }; + this._queue.push(t); + this._run(); + return () => { + // TODO: Could return a boolean representing if the task has already been + // kicke off. + this._queue = this._queue.filter(t1 => t1 !== t); + }; + } + + _run() { + while (this._avaliableThreads > 0) { + if (this._queue.length === 0) { + return; + } + this._avaliableThreads--; + const t = this._queue.pop(); + const promise = t.task(); + invariant( + typeof promise.then === "function", + `LoadQueue only supports loading Promises. Got ${promise}` + ); + promise.then(() => { + this._avaliableThreads++; + this._run(); + }); + } + } +} diff --git a/js/reducers/playlist.js b/js/reducers/playlist.js index 4f5313a9..2ec16f89 100644 --- a/js/reducers/playlist.js +++ b/js/reducers/playlist.js @@ -148,6 +148,7 @@ const playlist = (state = defaultPlaylistState, action) => { tracks: { ...state.tracks, [action.id]: { + id: action.id, selected: false, defaultName: action.defaultName, duration: null, diff --git a/js/reducers/playlist.test.js b/js/reducers/playlist.test.js index bba91498..6ea67d8e 100644 --- a/js/reducers/playlist.test.js +++ b/js/reducers/playlist.test.js @@ -22,6 +22,7 @@ describe("playlist reducer", () => { expect(nextState).toEqual({ tracks: { 100: { + id: 100, selected: false, duration: null, defaultName: "My Track Name", @@ -36,8 +37,8 @@ describe("playlist reducer", () => { it("defaults to adding new tracks to the end of the list", () => { const initialState = { tracks: { - 2: { selected: false }, - 3: { selected: false } + 2: { id: 2, selected: false }, + 3: { id: 3, selected: false } }, trackOrder: [3, 2], lastSelectedIndex: 0 @@ -50,9 +51,10 @@ describe("playlist reducer", () => { }); expect(nextState).toEqual({ tracks: { - 2: { selected: false }, - 3: { selected: false }, + 2: { id: 2, selected: false }, + 3: { id: 3, selected: false }, 100: { + id: 100, selected: false, duration: null, mediaTagsRequestStatus: "NOT_REQUESTED", @@ -67,8 +69,8 @@ describe("playlist reducer", () => { it("can handle adding a track at a given index", () => { const initialState = { tracks: { - 2: { selected: false }, - 3: { selected: false } + 2: { id: 2, selected: false }, + 3: { id: 3, selected: false } }, trackOrder: [3, 2], lastSelectedIndex: 0 @@ -82,9 +84,10 @@ describe("playlist reducer", () => { }); expect(nextState).toEqual({ tracks: { - 2: { selected: false }, - 3: { selected: false }, + 2: { id: 2, selected: false }, + 3: { id: 3, selected: false }, 100: { + id: 100, selected: false, duration: null, mediaTagsRequestStatus: "NOT_REQUESTED", diff --git a/js/utils.js b/js/utils.js index 377bf5d7..db8b22fd 100644 --- a/js/utils.js +++ b/js/utils.js @@ -232,3 +232,15 @@ export const arrayWithout = (arr, value) => { s["delete"](value); return Array.from(s); }; + +export function debounce(func, delay) { + let token; + return function(...args) { + if (token != null) { + clearTimeout(token); + } + token = setTimeout(() => { + func.apply(this, args); + }, delay); + }; +} diff --git a/loadQueue.test.js b/loadQueue.test.js new file mode 100644 index 00000000..8bf5c823 --- /dev/null +++ b/loadQueue.test.js @@ -0,0 +1,20 @@ +import LoadQueue from "./loadQueue"; + +describe("LoadQueue", () => { + it("executes some promises", () => { + const results = []; + const makePushPromise = n => { + return new Promise(resolve => { + results.push(n); + resolve(); + }); + }; + + const task1 = makePushPromise(1); + const task2 = makePushPromise(2); + const task3 = makePushPromise(3); + const task4 = makePushPromise(4); + + const loadQueue = new LoadQueue({ threads: 4 }); + }); +}); diff --git a/package.json b/package.json index 918cfacf..56e2e78f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "cardinal-spline-js": "^2.3.6", "classnames": "^2.2.5", "eslint-plugin-import": "^2.7.0", + "invariant": "^2.2.3", "jest": "^22.0.3", "jsmediatags": "^3.8.1", "jszip": "^3.1.3", @@ -82,6 +83,7 @@ "redux-devtools-extension": "^2.13.2", "redux-thunk": "^2.1.0", "reselect": "^3.0.1", + "tinyqueue": "^1.2.3", "webpack": "^3.6.0", "winamp-eqf": "^1.0.0" }, diff --git a/yarn.lock b/yarn.lock index c4db5113..81b43b6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3152,6 +3152,12 @@ invariant@^2.0.0, invariant@^2.2.2: dependencies: loose-envify "^1.0.0" +invariant@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.3.tgz#1a827dfde7dcbd7c323f0ca826be8fa7c5e9d688" + dependencies: + loose-envify "^1.0.0" + invert-kv@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" @@ -6208,6 +6214,10 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" +tinyqueue@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-1.2.3.tgz#b6a61de23060584da29f82362e45df1ec7353f3d" + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" From 6ee338fc765c70e2bf861cafdae98b4d404b0aec Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 15:14:29 -0800 Subject: [PATCH 09/24] Remove event listener I was a good boy, and made sure to remove my mouse move event listener when the user's mouse came up. Sadly, I didn't then _also_ remove the mouse up event listener. This meant that after clicking a few times, the whole app would stall quite a bit any time your mouse came up. --- js/components/WindowManager.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/js/components/WindowManager.js b/js/components/WindowManager.js index 7778fb21..db991f8b 100644 --- a/js/components/WindowManager.js +++ b/js/components/WindowManager.js @@ -166,9 +166,12 @@ class WindowManager extends React.Component { this.setState(stateDiff); }; - window.addEventListener("mouseup", () => { + const removeListeners = () => { window.removeEventListener("mousemove", handleMouseMove); - }); + window.removeEventListener("mouseup", removeListeners); + }; + + window.addEventListener("mouseup", removeListeners); window.addEventListener("mousemove", handleMouseMove); } From 4c229c358daeb62c294d4a8edfb15dca1a52a32c Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 15:15:38 -0800 Subject: [PATCH 10/24] Remove unused test --- loadQueue.test.js | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 loadQueue.test.js diff --git a/loadQueue.test.js b/loadQueue.test.js deleted file mode 100644 index 8bf5c823..00000000 --- a/loadQueue.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import LoadQueue from "./loadQueue"; - -describe("LoadQueue", () => { - it("executes some promises", () => { - const results = []; - const makePushPromise = n => { - return new Promise(resolve => { - results.push(n); - resolve(); - }); - }; - - const task1 = makePushPromise(1); - const task2 = makePushPromise(2); - const task3 = makePushPromise(3); - const task4 = makePushPromise(4); - - const loadQueue = new LoadQueue({ threads: 4 }); - }); -}); From 3fe6adda351a00c20f65b320a7d458cf2031c11b Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 15:21:47 -0800 Subject: [PATCH 11/24] Use dot notation --- experiments/automatedScreenshots/index.js | 2 +- js/utils.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/experiments/automatedScreenshots/index.js b/experiments/automatedScreenshots/index.js index 7c444469..e1a48ef8 100644 --- a/experiments/automatedScreenshots/index.js +++ b/experiments/automatedScreenshots/index.js @@ -116,7 +116,7 @@ const config = { config.skinUrl = skinUrl; const url = `http://localhost:8080/#${JSON.stringify(config)}`; console.log({ url }); - await page["goto"](url); + await page.goto(url); await page.waitForSelector("#main-window", { timeout: 2000 }); console.log("Writing screenshot to", screenshotFile); diff --git a/js/utils.js b/js/utils.js index db8b22fd..5066454b 100644 --- a/js/utils.js +++ b/js/utils.js @@ -229,7 +229,7 @@ export const arrayWith = (arr, value) => { export const arrayWithout = (arr, value) => { const s = new Set(arr); - s["delete"](value); + s.delete(value); return Array.from(s); }; From b8a3796295f893a919c3d8139a2dc6c5b0ded321 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 15:22:01 -0800 Subject: [PATCH 12/24] Skin museum has it's own eslint --- .eslintignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintignore b/.eslintignore index 8f579e37..2aef4f79 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ built/ coverage/ **/node_modules/ +experiments/winamp-skin-museum/ \ No newline at end of file From 5830ac1021f07998c41d2ead76f279a3b783051e Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 16:00:59 -0800 Subject: [PATCH 13/24] Pull Dropbox integration out of core Fixes #499 --- js/actionCreators.js | 19 +++----------- js/components/App.js | 5 ++-- js/components/MainWindow/MainContextMenu.js | 17 +++++++++--- js/components/MainWindow/index.js | 5 ++-- js/fileUtils.js | 17 ------------ js/index.js | 29 +++++++++++++++++++++ js/winamp.js | 6 ++++- 7 files changed, 56 insertions(+), 42 deletions(-) diff --git a/js/actionCreators.js b/js/actionCreators.js index d31b5466..783ff38d 100644 --- a/js/actionCreators.js +++ b/js/actionCreators.js @@ -4,8 +4,7 @@ import { genArrayBufferFromUrl, promptForFileReferences, genMediaDuration, - genMediaTags, - genAudioFileUrlsFromDropbox + genMediaTags } from "./fileUtils"; import skinParser from "./skinParser"; import { @@ -395,8 +394,8 @@ export function setSkinFromUrl(url) { }; } -// This function is private, since Winamp consumers may wish to support -// opening files via other means (Dropbox?). Only use the file type specific +// This function is private, since Winamp consumers can provide means for +// opening files via other methods. Only use the file type specific // versions below, since they can defer to the user-defined behavior. function _openFileDialog(accept) { return async dispatch => { @@ -417,18 +416,6 @@ export function openSkinFileDialog() { return _openFileDialog(".zip, .wsz"); } -export function openDropboxFileDialog() { - return async dispatch => { - const files = await genAudioFileUrlsFromDropbox(); - dispatch( - loadMediaFiles( - files.map(file => ({ url: file.link, defaultName: file.name })), - LOAD_STYLE.PLAY - ) - ); - }; -} - export function setEqBand(band, value) { return { type: SET_BAND_VALUE, band, value }; } diff --git a/js/components/App.js b/js/components/App.js index 23b30516..456dc26b 100644 --- a/js/components/App.js +++ b/js/components/App.js @@ -22,13 +22,14 @@ const App = ({ equalizer, playlist, openWindows, - container + container, + filePickers }) => { if (closed) { return null; } const windows = { - main: , + main: , equalizer: equalizer && , playlist: playlist && }; diff --git a/js/components/MainWindow/MainContextMenu.js b/js/components/MainWindow/MainContextMenu.js index 485698f4..dcf33464 100644 --- a/js/components/MainWindow/MainContextMenu.js +++ b/js/components/MainWindow/MainContextMenu.js @@ -5,9 +5,10 @@ import { close, setSkinFromUrl, openMediaFileDialog, - openSkinFileDialog, - openDropboxFileDialog + loadMediaFiles, + openSkinFileDialog } from "../../actionCreators"; +import { LOAD_STYLE } from "../../constants"; import { ContextMenu, Hr, Node, Parent, LinkNode } from "../ContextMenu"; const MainContextMenu = props => ( @@ -24,7 +25,15 @@ const MainContextMenu = props => (
- + {props.filePickers && + props.filePickers.map(picker => ( + { + props.loadMediaFiles(await picker.filePicker(), LOAD_STYLE.PLAY); + }} + label={picker.contextMenuName} + /> + ))} @@ -50,7 +59,7 @@ const mapDispatchToProps = { close, openSkinFileDialog, openMediaFileDialog, - openDropboxFileDialog, + loadMediaFiles, setSkin: setSkinFromUrl }; diff --git a/js/components/MainWindow/index.js b/js/components/MainWindow/index.js index 141e7b4f..538d9f61 100644 --- a/js/components/MainWindow/index.js +++ b/js/components/MainWindow/index.js @@ -60,7 +60,8 @@ export class MainWindow extends React.Component { shade, llama, status, - working + working, + filePickers } = this.props; const className = classnames({ @@ -89,7 +90,7 @@ export class MainWindow extends React.Component { className="selected title-bard draggable" onDoubleClick={this.props.toggleMainWindowShadeMode} > - + {shade && } diff --git a/js/fileUtils.js b/js/fileUtils.js index d295ed09..d70894b6 100644 --- a/js/fileUtils.js +++ b/js/fileUtils.js @@ -1,23 +1,6 @@ import invariant from "invariant"; import jsmediatags from "jsmediatags/dist/jsmediatags"; -// Requires Dropbox's Chooser to be loaded on the page -export function genAudioFileUrlsFromDropbox() { - return new Promise((resolve, reject) => { - if (window.Dropbox == null) { - reject(); - } - window.Dropbox.choose({ - success: resolve, - error: reject, - linkType: "direct", - folderselect: false, - multiselect: true, - extensions: ["video", "audio"] - }); - }); -} - export function genMediaTags(file) { invariant( file != null, diff --git a/js/index.js b/js/index.js index 247c31e2..4418e897 100644 --- a/js/index.js +++ b/js/index.js @@ -20,6 +20,23 @@ import { Raven.config(sentryDsn).install(); +// Requires Dropbox's Chooser to be loaded on the page +function genAudioFileUrlsFromDropbox() { + return new Promise((resolve, reject) => { + if (window.Dropbox == null) { + reject(); + } + window.Dropbox.choose({ + success: resolve, + error: reject, + linkType: "direct", + folderselect: false, + multiselect: true, + extensions: ["video", "audio"] + }); + }); +} + Raven.context(() => { if (hideAbout) { document.getElementsByClassName("about")[0].style.visibility = "hidden"; @@ -55,6 +72,18 @@ Raven.context(() => { { url: xmms, name: "XMMS Turquoise " }, { url: zaxon, name: "Zaxon Remake" } ], + filePickers: [ + { + contextMenuName: "Dropbox...", + filePicker: async () => { + const files = await genAudioFileUrlsFromDropbox(); + return files.map(file => ({ + url: file.link, + defaultName: file.name + })); + } + } + ], enableHotkeys: true, __initialState: initialState }); diff --git a/js/winamp.js b/js/winamp.js index 44a5d53b..fef0a74c 100644 --- a/js/winamp.js +++ b/js/winamp.js @@ -70,7 +70,11 @@ class Winamp { render( - + , node ); From a3c32fb916c546883a108d5313e60485ac48bed2 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 16:11:31 -0800 Subject: [PATCH 14/24] Move initial track default back to config --- js/config.js | 14 +++++++++++++- js/index.js | 16 ++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/js/config.js b/js/config.js index f3a942ee..aee45905 100644 --- a/js/config.js +++ b/js/config.js @@ -1,4 +1,5 @@ import skin from "../skins/base-2.91.wsz"; +import llamaAudio from "../mp3/llama-2.91.mp3"; /* global SENTRY_DSN */ @@ -12,9 +13,20 @@ if (hash) { } } +// Backwards compatibility with the old syntax +if (config.audioUrl && !config.initialTracks) { + config.initialTracks = [{ url: config.audioUrl }]; +} + // Turn on the incomplete playlist window export const skinUrl = config.skinUrl === undefined ? skin : config.skinUrl; -export const audioUrl = config.audioUrl; +export const initialTracks = config.initialTracks || [ + { + metaData: { artist: "DJ Mike Llama", title: "Llama Whippin' Intro" }, + url: llamaAudio + } +]; + export const hideAbout = config.hideAbout || false; export const initialState = config.initialState || undefined; export const sentryDsn = SENTRY_DSN; diff --git a/js/index.js b/js/index.js index 4418e897..aad66b5d 100644 --- a/js/index.js +++ b/js/index.js @@ -7,13 +7,12 @@ import visor from "../skins/Vizor1-01.wsz"; import xmms from "../skins/XMMS-Turquoise.wsz"; import zaxon from "../skins/ZaxonRemake1-0.wsz"; import green from "../skins/Green-Dimension-V2.wsz"; -import llamaAudio from "../mp3/llama-2.91.mp3"; import Winamp from "./winamp"; import { hideAbout, skinUrl, - audioUrl, + initialTracks, initialState, sentryDsn } from "./config"; @@ -47,22 +46,11 @@ Raven.context(() => { return; } - const audio = - audioUrl === undefined - ? { - metaData: { - artist: "DJ Mike Llama", - title: "Llama Whippin' Intro" - }, - url: llamaAudio - } - : { url: audioUrl }; - const winamp = new Winamp({ initialSkin: { url: skinUrl }, - initialTracks: [audio], + initialTracks, avaliableSkins: [ { url: base, name: "" }, { url: green, name: "Green Dimension V2" }, From 6cb8b50c098dd17bf7b17ffc03197d1cd3a28464 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 16:11:41 -0800 Subject: [PATCH 15/24] Add missing key --- js/components/MainWindow/MainContextMenu.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/components/MainWindow/MainContextMenu.js b/js/components/MainWindow/MainContextMenu.js index dcf33464..04494dbe 100644 --- a/js/components/MainWindow/MainContextMenu.js +++ b/js/components/MainWindow/MainContextMenu.js @@ -26,8 +26,9 @@ const MainContextMenu = props => ( {props.filePickers && - props.filePickers.map(picker => ( + props.filePickers.map((picker, i) => ( { props.loadMediaFiles(await picker.filePicker(), LOAD_STYLE.PLAY); }} From ef82da7664107843afffe8f7dbd03ad8155a80ce Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 16:24:07 -0800 Subject: [PATCH 16/24] Don't show colons for time that we don't know --- js/utils.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/js/utils.js b/js/utils.js index 5066454b..ca9c1ca6 100644 --- a/js/utils.js +++ b/js/utils.js @@ -37,6 +37,9 @@ export const getTimeObj = time => { }; export const getTimeStr = (time, truncate = true) => { + if (time == null) { + return ""; + } const { minutesFirstDigit, minutesSecondDigit, From f81ad68a46b7e5a2300eb35f693e13887fb742a1 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 16:25:53 -0800 Subject: [PATCH 17/24] Fix insert position of tracks dragged into the playlist while scrolled --- js/components/PlaylistWindow/index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/js/components/PlaylistWindow/index.js b/js/components/PlaylistWindow/index.js index 6c961827..a40ff779 100644 --- a/js/components/PlaylistWindow/index.js +++ b/js/components/PlaylistWindow/index.js @@ -22,6 +22,7 @@ import { togglePlaylistShadeMode, scrollVolume } from "../../actionCreators"; +import { getScrollOffset } from "../../selectors"; import { clamp } from "../../utils"; import DropTarget from "../DropTarget"; @@ -48,9 +49,8 @@ class PlaylistWindow extends React.Component { _handleDrop(e, targetCoords) { const top = e.clientY - targetCoords.y; - // TODO: Include the scroll offset in this const atIndex = clamp( - Math.round((top - 23) / TRACK_HEIGHT), + this.props.offset + Math.round((top - 23) / TRACK_HEIGHT), 0, this.props.maxTrackIndex + 1 ); @@ -181,6 +181,7 @@ const mapStateToProps = state => { } = state; return { + offset: getScrollOffset(state), maxTrackIndex: trackOrder.length - 1, focused, skinPlaylistStyle, From af9bb08a100403654f06d08539aa01234abfeb5b Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 16:27:08 -0800 Subject: [PATCH 18/24] Add more press sources --- press.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/press.md b/press.md index 0fb45b38..ab5195dd 100644 --- a/press.md +++ b/press.md @@ -109,6 +109,7 @@ * https://www.gizmodo.com.au/2018/02/someone-remade-winamp-so-it-runs-in-your-browser/ * https://changelog.com/news/winamp-29-in-htmljs--PJr * https://www.howtogeek.com/343813/re-live-90s-computing-in-your-browser-right-now/amp/ +* https://techcaption.com/run-winamp-web-browser/ ### German @@ -201,3 +202,7 @@ ### Croatian * http://idesh.net/tech-i-web/internet/winamp-se-vratio-nostalgija-na-najjace/ + +### Vietnamese + +* http://vnreview.vn/tin-tuc-xa-hoi-so/-/view_content/content/2421915/song-lai-thap-nien-90-voi-trai-nghiem-nhung-phan-mem-nay-ngay-tren-trinh-duyet From eff471105c7352ad4a8ac04201026dd6a084fc42 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 16:28:38 -0800 Subject: [PATCH 19/24] Use visibility to prioritize loading of metadata/duration --- js/actionCreators.js | 91 ++++++++++++++++++++------------------------ js/selectors.js | 10 ++++- 2 files changed, 50 insertions(+), 51 deletions(-) diff --git a/js/actionCreators.js b/js/actionCreators.js index 783ff38d..d46af414 100644 --- a/js/actionCreators.js +++ b/js/actionCreators.js @@ -7,12 +7,7 @@ import { genMediaTags } from "./fileUtils"; import skinParser from "./skinParser"; -import { - BANDS, - TRACK_HEIGHT, - LOAD_STYLE, - MEDIA_TAG_REQUEST_STATUS -} from "./constants"; +import { BANDS, TRACK_HEIGHT, LOAD_STYLE } from "./constants"; import { getEqfData, nextTrack, @@ -20,7 +15,8 @@ import { getOverflowTrackCount, getPlaylistURL, getSelectedTrackObjects, - getVisibleTracks + getTracks, + getTrackIsVisibleFunction } from "./selectors"; import { @@ -28,8 +24,7 @@ import { base64FromArrayBuffer, downloadURI, normalize, - sort, - debounce + sort } from "./utils"; import { CLOSE_WINAMP, @@ -69,8 +64,10 @@ import { import LoadQueue from "./loadQueue"; -const DURATION_PRIORITY = 5; -const META_DATA_PRIORITY = 10; +const META_DATA_VISIBLE_PRIORITY = 20; +const DURATION_VISIBLE_PRIORITY = 15; +const DURATION_PRIORITY = 10; +const META_DATA_PRIORITY = 5; const loadQueue = new LoadQueue({ threads: 4 }); @@ -246,15 +243,23 @@ export function loadFilesFromReferences( } export function fetchMediaDuration(url, id) { - return dispatch => { - loadQueue.push(() => { - return genMediaDuration(url) - .then(duration => dispatch({ type: SET_MEDIA_DURATION, duration, id })) - .catch(() => { - // TODO: Should we update the state to indicate that we don't know the length? - }); - // TODO: The priority should depend upon visiblity - }, () => DURATION_PRIORITY); + return (dispatch, getState) => { + loadQueue.push( + () => + genMediaDuration(url) + .then(duration => + dispatch({ type: SET_MEDIA_DURATION, duration, id }) + ) + .catch(() => { + // TODO: Should we update the state to indicate that we don't know the length? + }), + () => { + const trackIsVisible = getTrackIsVisibleFunction(getState()); + return trackIsVisible(id) + ? DURATION_VISIBLE_PRIORITY + : DURATION_PRIORITY; + } + ); }; } @@ -319,34 +324,26 @@ export function loadMediaFile(track, priority = null, atIndex = 0) { // Blobs can be loaded quickly dispatch(fetchMediaTags(blob, id)); } else { - dispatch(fetchMediaTagsForVisibleTracks()); + dispatch(queueFetchingMediaTags(id)); } }; } -const _fetchMediaTagsForVisibleTracksThunk = debounce((dispatch, getState) => { - getVisibleTracks(getState()) - .filter( - track => - track.mediaTagsRequestStatus === MEDIA_TAG_REQUEST_STATUS.NOT_REQUESTED - ) - .forEach(track => { - loadQueue.push( - () => dispatch(fetchMediaTags(track.url, track.id)), - () => META_DATA_PRIORITY - ); - }); -}, 200); - -export function fetchMediaTagsForVisibleTracks() { - return _fetchMediaTagsForVisibleTracksThunk; +function queueFetchingMediaTags(id) { + return (dispatch, getState) => { + const track = getTracks(getState())[id]; + return loadQueue.push( + () => dispatch(fetchMediaTags(track.url, id)), + () => { + const trackIsVisible = getTrackIsVisibleFunction(getState()); + return trackIsVisible(track.id) + ? META_DATA_VISIBLE_PRIORITY + : META_DATA_PRIORITY; + } + ); + }; } -export const debouncedFetchMediaTagsForVisibleTracks = debounce( - fetchMediaTagsForVisibleTracks, - 200 -); - export function fetchMediaTags(file, id) { return dispatch => { dispatch({ type: MEDIA_TAG_REQUEST_INITIALIZED, id }); @@ -538,17 +535,11 @@ export function toggleVisualizerStyle() { } export function setPlaylistScrollPosition(position) { - return dispatch => { - dispatch({ type: SET_PLAYLIST_SCROLL_POSITION, position }); - dispatch(fetchMediaTagsForVisibleTracks()); - }; + return { type: SET_PLAYLIST_SCROLL_POSITION, position }; } export function setPlaylistSize(size) { - return dispatch => { - dispatch({ type: PLAYLIST_SIZE_CHANGED, size }); - dispatch(fetchMediaTagsForVisibleTracks()); - }; + return { type: PLAYLIST_SIZE_CHANGED, size }; } export function scrollNTracks(n) { diff --git a/js/selectors.js b/js/selectors.js index 78789bac..9fbbe2e5 100644 --- a/js/selectors.js +++ b/js/selectors.js @@ -24,7 +24,7 @@ export const getEqfData = state => { return eqfData; }; -const getTracks = state => state.playlist.tracks; +export const getTracks = state => state.playlist.tracks; const getTrackOrder = state => state.playlist.trackOrder; export const getOrderedTracks = createSelector( @@ -44,6 +44,7 @@ export const getSelectedTrackObjects = createSelector( tracks => tracks.filter(track => track.selected) ); +// If a duration is `null`, it counts as zero, which seems fine enough. const runningTimeFromTracks = tracks => tracks.reduce((time, track) => time + Number(track.duration), 0); @@ -156,6 +157,13 @@ export const getVisibleTrackIds = createSelector( trackOrder.slice(offset, offset + numberOfVisibleTracks) ); +export const getTrackIsVisibleFunction = createSelector( + getVisibleTrackIds, + visibleTrackIds => { + return id => visibleTrackIds.includes(id); + } +); + export const getVisibleTracks = createSelector( getVisibleTrackIds, getTracks, From a2d914618833fe1adb0e51a8686c0c6e4b5656a3 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 16:37:28 -0800 Subject: [PATCH 20/24] Fix priority loading --- js/actionCreators.js | 9 +++++---- js/loadQueue.js | 12 +++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/js/actionCreators.js b/js/actionCreators.js index d46af414..e27f2636 100644 --- a/js/actionCreators.js +++ b/js/actionCreators.js @@ -64,10 +64,11 @@ import { import LoadQueue from "./loadQueue"; -const META_DATA_VISIBLE_PRIORITY = 20; -const DURATION_VISIBLE_PRIORITY = 15; -const DURATION_PRIORITY = 10; -const META_DATA_PRIORITY = 5; +// Lower is better +const META_DATA_VISIBLE_PRIORITY = 5; +const DURATION_VISIBLE_PRIORITY = 10; +const DURATION_PRIORITY = 15; +const META_DATA_PRIORITY = 20; const loadQueue = new LoadQueue({ threads: 4 }); diff --git a/js/loadQueue.js b/js/loadQueue.js index 99e323bb..393d481d 100644 --- a/js/loadQueue.js +++ b/js/loadQueue.js @@ -6,19 +6,24 @@ import TinyQueue from "tinyqueue"; // Useful for prioritizing network requests export default class LoadQueue { constructor({ threads }) { - // TODO: Consider not running items with zero prioirty + // TODO: Consider not running items with zero priority // Priority is a function so that items can change their priority between // when their priority is evaluated. // For example, we might add a track to the playlist and then scroll to/away // from it before it gets processed. - this._queue = new TinyQueue([], (a, b) => a.priority() > b.priority()); + this._queue = new TinyQueue([], (a, b) => a.priority() - b.priority()); this._avaliableThreads = threads; } push(task, priority) { const t = { task, priority }; this._queue.push(t); - this._run(); + // Wait until the next event loop to pick a task to run. This way, we can + // enqueue multiple items in an event loop, and be sure they will be run in + // priority order. + setTimeout(() => { + this._run(); + }, 0); return () => { // TODO: Could return a boolean representing if the task has already been // kicke off. @@ -33,6 +38,7 @@ export default class LoadQueue { } this._avaliableThreads--; const t = this._queue.pop(); + console.log({ priority: t.priority() }); const promise = t.task(); invariant( typeof promise.then === "function", From 9203a4a240f795353ff9d96654c0ba5c24dcb993 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 16:45:40 -0800 Subject: [PATCH 21/24] Pick different priority for duration. --- js/actionCreators.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/actionCreators.js b/js/actionCreators.js index e27f2636..7cd47702 100644 --- a/js/actionCreators.js +++ b/js/actionCreators.js @@ -65,8 +65,8 @@ import { import LoadQueue from "./loadQueue"; // Lower is better -const META_DATA_VISIBLE_PRIORITY = 5; -const DURATION_VISIBLE_PRIORITY = 10; +const DURATION_VISIBLE_PRIORITY = 5; +const META_DATA_VISIBLE_PRIORITY = 10; const DURATION_PRIORITY = 15; const META_DATA_PRIORITY = 20; From f8660f319bc4085ccdf05d9f6042679f1a577601 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 10 Mar 2018 18:59:59 -0800 Subject: [PATCH 22/24] Load directories --- js/actionCreators.js | 2 +- js/components/PlaylistWindow/AddMenu.js | 22 +++++++++++++++++----- js/fileUtils.js | 8 +++++++- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/js/actionCreators.js b/js/actionCreators.js index 7cd47702..204329b3 100644 --- a/js/actionCreators.js +++ b/js/actionCreators.js @@ -397,7 +397,7 @@ export function setSkinFromUrl(url) { // versions below, since they can defer to the user-defined behavior. function _openFileDialog(accept) { return async dispatch => { - const fileReferences = await promptForFileReferences(accept); + const fileReferences = await promptForFileReferences({ accept }); dispatch(loadFilesFromReferences(fileReferences)); }; } diff --git a/js/components/PlaylistWindow/AddMenu.js b/js/components/PlaylistWindow/AddMenu.js index 4e72e14c..74d4ba3e 100644 --- a/js/components/PlaylistWindow/AddMenu.js +++ b/js/components/PlaylistWindow/AddMenu.js @@ -4,18 +4,22 @@ import { addTracksFromReferences } from "../../actionCreators"; import { promptForFileReferences } from "../../fileUtils"; import PlaylistMenu from "./PlaylistMenu"; +const el = document.createElement("input"); +el.type = "file"; +const DIR_SUPPORT = + typeof el.webkitdirectory !== "undefined" || + typeof el.mozdirectory !== "undefined" || + typeof el.directory !== "undefined"; + /* eslint-disable no-alert */ -const AddMenu = ({ nextIndex, addFilesAtIndex }) => ( +const AddMenu = ({ nextIndex, addFilesAtIndex, addDirAtIndex }) => (
alert("Not supported in Winamp2-js")} /> -
alert("Not supported in Winamp2-js")} - /> +
addDirAtIndex(nextIndex)} />
addFilesAtIndex(nextIndex)} /> ); @@ -28,6 +32,14 @@ const mapDispatchToProps = dispatch => ({ addFilesAtIndex: async nextIndex => { const fileReferences = await promptForFileReferences(); dispatch(addTracksFromReferences(fileReferences, null, nextIndex)); + }, + addDirAtIndex: async nextIndex => { + if (!DIR_SUPPORT) { + alert("Not supported in your browser"); + return; + } + const fileReferences = await promptForFileReferences({ directory: true }); + dispatch(addTracksFromReferences(fileReferences, null, nextIndex)); } }); diff --git a/js/fileUtils.js b/js/fileUtils.js index d70894b6..f54ea1ac 100644 --- a/js/fileUtils.js +++ b/js/fileUtils.js @@ -80,7 +80,10 @@ export async function genArrayBufferFromUrl(url) { }); } -export async function promptForFileReferences(accept) { +export async function promptForFileReferences({ + accept = null, + directory = false +}) { return new Promise(resolve => { // Does this represent a memory leak somehow? // Can this fail? Do we ever reject? @@ -88,6 +91,9 @@ export async function promptForFileReferences(accept) { if (accept) fileInput.setAttribute("accept", accept); fileInput.type = "file"; fileInput.multiple = true; + fileInput.webkitdirectory = directory; + fileInput.directory = directory; + fileInput.mozdirectory = directory; // Not entirely sure why this is needed, since the input // was just created, but somehow this helps prevent change // events from getting swallowed. From 0a0ea9ffca128a745c6152fa86916ec287c9e418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C4=8Eura=C5=A1?= Date: Sat, 10 Mar 2018 19:07:28 +0100 Subject: [PATCH 23/24] Adds flooring of time elapsed for seeking bar position --- js/components/MainWindow/Position.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/components/MainWindow/Position.js b/js/components/MainWindow/Position.js index 20be46fe..0388c683 100644 --- a/js/components/MainWindow/Position.js +++ b/js/components/MainWindow/Position.js @@ -41,7 +41,7 @@ const Position = ({ }; const mapStateToProps = ({ media, userInput }) => { - const position = media.length ? media.timeElapsed / media.length * 100 : 0; + const position = media.length ? Math.floor(media.timeElapsed) / media.length * 100 : 0; const displayedPosition = userInput.focus === "position" ? userInput.scrubPosition : position; From 5303bf70c800759c39d150fc09d263ee86bba5d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C4=8Eura=C5=A1?= Date: Sun, 11 Mar 2018 20:26:03 +0100 Subject: [PATCH 24/24] Prettier position percentage calculation --- js/components/MainWindow/Position.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/components/MainWindow/Position.js b/js/components/MainWindow/Position.js index 0388c683..56595f06 100644 --- a/js/components/MainWindow/Position.js +++ b/js/components/MainWindow/Position.js @@ -41,7 +41,8 @@ const Position = ({ }; const mapStateToProps = ({ media, userInput }) => { - const position = media.length ? Math.floor(media.timeElapsed) / media.length * 100 : 0; + const positionPercentage = Math.floor(media.timeElapsed) / media.length * 100; + const position = media.length ? positionPercentage : 0; const displayedPosition = userInput.focus === "position" ? userInput.scrubPosition : position;