From 5f7948c0119cf69a05342fc198a03ce731bfcd18 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Tue, 25 Sep 2018 22:13:43 -0700 Subject: [PATCH] Add media session integration (#646) --- js/actionCreators/files.ts | 8 +++--- js/config.ts | 40 +++++++++++++++++++--------- js/fileUtils.ts | 1 + js/index.js | 12 +++++++++ js/mediaSession.js | 41 +++++++++++++++++++++++++++++ js/reducers/playlist.test.js | 6 ++--- js/reducers/playlist.ts | 3 ++- js/selectors.ts | 51 ++++++++++++++++++++++++++++++++++-- js/types.ts | 14 ++++++++++ js/webampLazy.js | 51 +++++++++++++++++++++++++++++------- 10 files changed, 195 insertions(+), 32 deletions(-) create mode 100644 js/mediaSession.js diff --git a/js/actionCreators/files.ts b/js/actionCreators/files.ts index a162003f..ed996896 100644 --- a/js/actionCreators/files.ts +++ b/js/actionCreators/files.ts @@ -262,8 +262,8 @@ export function loadMediaFile( } if (metaData != null) { - const { artist, title } = metaData; - dispatch({ type: SET_MEDIA_TAGS, artist, title, id }); + const { artist, title, album } = metaData; + dispatch({ type: SET_MEDIA_TAGS, artist, title, album, id }); } else if ("blob" in track) { // Blobs can be loaded quickly dispatch(fetchMediaTags(track.blob, id)); @@ -295,14 +295,14 @@ export function fetchMediaTags(file: string | Blob, id: number): Dispatchable { const data = await genMediaTags(file, await requireJSMediaTags()); // There's more data here, but we don't have a use for it yet: // https://github.com/aadsm/jsmediatags#shortcuts - const { artist, title, picture } = data.tags; + const { artist, title, album, picture } = data.tags; let albumArtUrl = null; if (picture) { const byteArray = new Uint8Array(picture.data); const blob = new Blob([byteArray], { type: picture.type }); albumArtUrl = URL.createObjectURL(blob); } - dispatch({ type: SET_MEDIA_TAGS, artist, title, albumArtUrl, id }); + dispatch({ type: SET_MEDIA_TAGS, artist, title, album, albumArtUrl, id }); } catch (e) { dispatch({ type: MEDIA_TAG_REQUEST_FAILED, id }); } diff --git a/js/config.ts b/js/config.ts index 6f806199..d375c36f 100644 --- a/js/config.ts +++ b/js/config.ts @@ -27,6 +27,10 @@ if (config.audioUrl && !config.initialTracks) { } export const skinUrl = config.skinUrl === undefined ? null : config.skinUrl; + +// https://freemusicarchive.org/music/netBloc_Artists/netBloc_Vol_24_tiuqottigeloot/ +const album = "netBloc Vol. 24: tiuqottigeloot"; + export const initialTracks = config.initialTracks || [ { metaData: { artist: "DJ Mike Llama", title: "Llama Whippin' Intro" }, @@ -39,7 +43,8 @@ export const initialTracks = config.initialTracks || [ duration: 322.612245, metaData: { title: "Heroines", - artist: "Diablo Swing Orchestra" + artist: "Diablo Swing Orchestra", + album } }, { @@ -48,7 +53,8 @@ export const initialTracks = config.initialTracks || [ duration: 190.093061, metaData: { title: "We Are Going To Eclecfunk Your Ass", - artist: "Eclectek" + artist: "Eclectek", + album } }, { @@ -57,7 +63,8 @@ export const initialTracks = config.initialTracks || [ duration: 214.622041, metaData: { title: "Seventeen", - artist: "Auto-Pilot" + artist: "Auto-Pilot", + album } }, { @@ -66,7 +73,8 @@ export const initialTracks = config.initialTracks || [ duration: 181.838367, metaData: { title: "Microphone", - artist: "Muha" + artist: "Muha", + album } }, { @@ -75,7 +83,8 @@ export const initialTracks = config.initialTracks || [ duration: 86.047347, metaData: { title: "Stumble", - artist: "Just Plain Ant" + artist: "Just Plain Ant", + album } }, { @@ -84,7 +93,8 @@ export const initialTracks = config.initialTracks || [ duration: 226.795102, metaData: { title: "God Damn", - artist: "Sleaze" + artist: "Sleaze", + album } }, { @@ -93,7 +103,8 @@ export const initialTracks = config.initialTracks || [ duration: 207.072653, metaData: { title: "Hola Hola Bossa Nova", - artist: "Juanitos" + artist: "Juanitos", + album } }, { @@ -102,7 +113,8 @@ export const initialTracks = config.initialTracks || [ duration: 314.331429, metaData: { title: "Resolutions (Chris Summer Remix)", - artist: "Entertainment for the Braindead" + artist: "Entertainment for the Braindead", + album } }, { @@ -111,7 +123,8 @@ export const initialTracks = config.initialTracks || [ duration: 204.042449, metaData: { title: "Trail", - artist: "Nobara Hayakawa" + artist: "Nobara Hayakawa", + album } }, { @@ -120,7 +133,8 @@ export const initialTracks = config.initialTracks || [ duration: 201.116735, metaData: { title: "Tongue Tied", - artist: "Paper Navy" + artist: "Paper Navy", + album } }, { @@ -129,7 +143,8 @@ export const initialTracks = config.initialTracks || [ duration: 245.394286, metaData: { title: "Garage", - artist: "60 Tigres" + artist: "60 Tigres", + album } }, { @@ -138,7 +153,8 @@ export const initialTracks = config.initialTracks || [ duration: 221.44, metaData: { title: "The Cycle (Featuring Mista Mista)", - artist: "CM aka Creative" + artist: "CM aka Creative", + album } } ]; diff --git a/js/fileUtils.ts b/js/fileUtils.ts index 29d04848..f479fdfb 100644 --- a/js/fileUtils.ts +++ b/js/fileUtils.ts @@ -4,6 +4,7 @@ export interface MediaTags { tags: { artist: string; title: string; + album: string; picture: { data: number[]; type: string; diff --git a/js/index.js b/js/index.js index c347e586..b32d025c 100644 --- a/js/index.js +++ b/js/index.js @@ -13,6 +13,7 @@ import green from "../skins/Green-Dimension-V2.wsz"; import MilkdropWindow from "./components/MilkdropWindow"; import screenshotInitialState from "./screenshotInitialState"; import WebampLazy from "./webampLazy"; +import enableMediaSession from "./mediaSession"; import { STEP_MARQUEE, UPDATE_TIME_ELAPSED, @@ -66,6 +67,8 @@ const requireJSMediaTags = () => { }); }; +const DEFAULT_DOCUMENT_TITLE = document.title; + const NOISY_ACTION_TYPES = new Set([ STEP_MARQUEE, UPDATE_TIME_ELAPSED, @@ -268,6 +271,15 @@ Raven.context(() => { } }); + webamp.__onTrackDidChange(track => { + document.title = + track == null + ? DEFAULT_DOCUMENT_TITLE + : `${track.metaData.title} - ${track.metaData.artist}`; + }); + + enableMediaSession(webamp); + webamp.renderWhenReady(document.getElementById("app")); // Expose webamp instance for debugging and integration tests. diff --git a/js/mediaSession.js b/js/mediaSession.js new file mode 100644 index 00000000..d187ba10 --- /dev/null +++ b/js/mediaSession.js @@ -0,0 +1,41 @@ +export default function enableMediaSession(webamp) { + if ("mediaSession" in navigator) { + /* global MediaMetadata */ + webamp.__onTrackDidChange(({ title, artist, album, albumArtUrl }) => { + navigator.mediaSession.metadata = new MediaMetadata({ + title, + artist, + album, + artwork: albumArtUrl + ? [ + { + src: albumArtUrl + // We don't currently know these. + // sizes: "96x96", + // type: "image/png" + } + ] + : [] + }); + }); + + navigator.mediaSession.setActionHandler("play", () => { + webamp.play(); + }); + navigator.mediaSession.setActionHandler("pause", () => { + webamp.pause(); + }); + navigator.mediaSession.setActionHandler("seekbackward", () => { + webamp.seekBackward(10); + }); + navigator.mediaSession.setActionHandler("seekforward", () => { + webamp.seekForward(10); + }); + navigator.mediaSession.setActionHandler("previoustrack", () => { + webamp.previousTrack(); + }); + navigator.mediaSession.setActionHandler("nexttrack", () => { + webamp.nextTrack(); + }); + } +} diff --git a/js/reducers/playlist.test.js b/js/reducers/playlist.test.js index 6ea67d8e..e8fc158e 100644 --- a/js/reducers/playlist.test.js +++ b/js/reducers/playlist.test.js @@ -26,7 +26,7 @@ describe("playlist reducer", () => { selected: false, duration: null, defaultName: "My Track Name", - mediaTagsRequestStatus: "NOT_REQUESTED", + mediaTagsRequestStatus: "INITIALIZED", url: "url://some-url" } }, @@ -57,7 +57,7 @@ describe("playlist reducer", () => { id: 100, selected: false, duration: null, - mediaTagsRequestStatus: "NOT_REQUESTED", + mediaTagsRequestStatus: "INITIALIZED", defaultName: "My Track Name", url: "url://some-url" } @@ -90,7 +90,7 @@ describe("playlist reducer", () => { id: 100, selected: false, duration: null, - mediaTagsRequestStatus: "NOT_REQUESTED", + mediaTagsRequestStatus: "INITIALIZED", defaultName: "My Track Name", url: "url://some-url" } diff --git a/js/reducers/playlist.ts b/js/reducers/playlist.ts index e45ae7d0..8ad992c4 100644 --- a/js/reducers/playlist.ts +++ b/js/reducers/playlist.ts @@ -183,7 +183,7 @@ const playlist = ( defaultName: action.defaultName || null, duration: action.duration == null ? null : action.duration, url: action.url, - mediaTagsRequestStatus: MEDIA_TAG_REQUEST_STATUS.NOT_REQUESTED + mediaTagsRequestStatus: MEDIA_TAG_REQUEST_STATUS.INITIALIZED } }, // TODO: This could probably be made to work, but we clear it just to be safe. @@ -212,6 +212,7 @@ const playlist = ( mediaTagsRequestStatus: MEDIA_TAG_REQUEST_STATUS.COMPLETE, title: action.title, artist: action.artist, + album: action.album, albumArtUrl: action.albumArtUrl } } diff --git a/js/selectors.ts b/js/selectors.ts index 2bafa021..22bde0c9 100644 --- a/js/selectors.ts +++ b/js/selectors.ts @@ -3,7 +3,8 @@ import { PlaylistTrack, WebampWindow, WindowId, - WindowInfo + WindowInfo, + LoadedURLTrack } from "./types"; import { createSelector } from "reselect"; import { @@ -18,7 +19,9 @@ import { TRACK_HEIGHT, WINDOW_RESIZE_SEGMENT_WIDTH, WINDOW_RESIZE_SEGMENT_HEIGHT, - WINDOW_WIDTH + WINDOW_WIDTH, + MEDIA_STATUS, + MEDIA_TAG_REQUEST_STATUS } from "./constants"; import { createPlaylistURL } from "./playlistHtml"; import * as fromPlaylist from "./reducers/playlist"; @@ -227,6 +230,50 @@ export const getCurrentTrackDisplayName = createSelector( } ); +export const getMediaIsPlaying = (state: AppState) => + state.media.status === MEDIA_STATUS.PLAYING; + +export const getCurrentTrack = createSelector( + getCurrentTrackId, + getPlaylist, + (trackId, playlist): PlaylistTrack | null => { + return trackId == null ? null : playlist.tracks[trackId]; + } +); +export const getCurrentlyPlayingTrackIdIfLoaded = createSelector( + getMediaIsPlaying, + getCurrentTrack, + (mediaIsPlaying, currentTrack) => { + if ( + !mediaIsPlaying || + !currentTrack || + currentTrack.mediaTagsRequestStatus === + MEDIA_TAG_REQUEST_STATUS.INITIALIZED + ) { + return null; + } + return currentTrack.id; + } +); + +export const getCurrentTrackInfo = createSelector( + getCurrentTrack, + (track: PlaylistTrack | null): LoadedURLTrack | null => { + if (track == null) { + return null; + } + return { + url: track.url, + metaData: { + title: track.title || null, + artist: track.artist || null, + album: track.album || null, + albumArtUrl: track.albumArtUrl || null + } + }; + } +); + export const getMinimalMediaText = createSelector( getCurrentTrackNumber, getCurrentTrackDisplayName, diff --git a/js/types.ts b/js/types.ts index cf72e23f..a8b21c84 100644 --- a/js/types.ts +++ b/js/types.ts @@ -297,6 +297,7 @@ export type Action = id: number; title: string; artist: string; + album?: string; albumArtUrl?: string | null; } | { @@ -394,6 +395,8 @@ interface TrackInfo { metaData?: { artist: string; title: string; + album?: string; + albumArtUrl?: string; }; /** @@ -422,6 +425,16 @@ export interface BlobTrack extends TrackInfo { blob: Blob; } +export interface LoadedURLTrack { + url: string; + metaData: { + artist: string | null; + title: string | null; + album: string | null; + albumArtUrl: string | null; + }; +} + /** * Many methods on the webamp instance deal with track. * @@ -433,6 +446,7 @@ export interface PlaylistTrack { id: number; artist?: string; title?: string; + album?: string; url: string; defaultName: string | null; albumArtUrl?: string | null; diff --git a/js/webampLazy.js b/js/webampLazy.js index 34eb257b..9f2483fa 100644 --- a/js/webampLazy.js +++ b/js/webampLazy.js @@ -6,12 +6,18 @@ import getStore from "./store"; import App from "./components/App"; import Hotkeys from "./hotkeys"; import Media from "./media"; -import { getTrackCount, getTracks } from "./selectors"; +import * as Selectors from "./selectors"; import { setSkinFromUrl, loadMediaFiles, setWindowSize, - loadFilesFromReferences + loadFilesFromReferences, + play, + pause, + seekBackward, + seekForward, + next, + previous } from "./actionCreators"; import { LOAD_STYLE } from "./constants"; import { uniqueId, objectMap, objectForEach } from "./utils"; @@ -27,7 +33,6 @@ import { LOADED, REGISTER_VISUALIZER, SET_Z_INDEX, - SET_MEDIA, CLOSE_REQUESTED } from "./actionTypes"; import Emitter from "./emitter"; @@ -172,9 +177,33 @@ class Winamp { } } + play() { + this.store.dispatch(play()); + } + + pause() { + this.store.dispatch(pause()); + } + + seekBackward(seconds) { + this.store.dispatch(seekBackward(seconds)); + } + + seekForward(seconds) { + this.store.dispatch(seekForward(seconds)); + } + + nextTrack() { + this.store.dispatch(next()); + } + + previousTrack() { + this.store.dispatch(previous()); + } + // Append this array of tracks to the end of the current playlist. appendTracks(tracks) { - const nextIndex = getTrackCount(this.store.getState()); + const nextIndex = Selectors.getTrackCount(this.store.getState()); this.store.dispatch(loadMediaFiles(tracks, LOAD_STYLE.BUFFER, nextIndex)); } @@ -193,14 +222,16 @@ class Winamp { return this._actionEmitter.on(CLOSE_WINAMP, cb); } - onTrackDidChange(cb) { - return this._actionEmitter.on(SET_MEDIA, action => { - const tracks = getTracks(this.store.getState()); - const track = tracks[action.id]; - if (track == null) { + __onTrackDidChange(cb) { + let previousTrackId = null; + this.store.subscribe(() => { + const state = this.store.getState(); + const trackId = Selectors.getCurrentlyPlayingTrackIdIfLoaded(state); + if (trackId === previousTrackId) { return; } - cb({ url: track.url }); + previousTrackId = trackId; + cb(trackId == null ? null : Selectors.getCurrentTrackInfo(state)); }); }