feat(player): playlists, media metadata and media keys (#336)

This commit is contained in:
Yannis Petitot
2021-03-21 23:07:33 +01:00
committed by GitHub
parent 52753227df
commit 6c1a4090f0
14 changed files with 363 additions and 123 deletions

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

+1 -5
View File
@@ -3,13 +3,9 @@
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
+33 -99
View File
@@ -1,18 +1,37 @@
type playingState = {
songTitle: string,
artist: string,
title: string,
beatmapSetId: int,
isPlaying: bool,
volume: int,
muted: bool,
hasNext: bool,
hasPrev: bool,
};
type song = {
id: int,
artist: string,
title: string,
};
type playlistItem = {
id: int,
path: string,
title: string,
artist: string,
};
type playlist = array(playlistItem);
type value = {
playingState,
playlist,
setPlaylist: playlist => unit,
setAudio:
(
~beatmapSetId: int,
~song: song,
~setIsPlayable: bool => unit,
~songTitle: string,
~audioFilePath: option(string),
~previewOffset: option(int)
) =>
@@ -21,24 +40,30 @@ type value = {
pause: unit => unit,
togglePlayPause: unit => unit,
setMuted: bool => unit,
playNext: unit => unit,
playPrevious: unit => unit,
};
let initialState: playingState = {
songTitle: "",
artist: "",
title: "",
isPlaying: false,
beatmapSetId: 0,
volume: 1,
muted: false,
hasNext: false,
hasPrev: false,
};
module Provider = {
let value = {
playingState: initialState,
playlist: [||],
setPlaylist: playlist => (),
setAudio:
(
~beatmapSetId: int,
~song: song,
~setIsPlayable: bool => unit,
~songTitle: string,
~audioFilePath: option(string),
~previewOffset: option(int),
) =>
@@ -47,6 +72,8 @@ module Provider = {
pause: () => (),
togglePlayPause: () => (),
setMuted: (muted: bool) => (),
playNext: () => (),
playPrevious: () => (),
};
let audioPlayerContext = React.createContext(value);
@@ -59,96 +86,3 @@ module Provider = {
};
let useAudioPlayer = () => React.useContext(Provider.audioPlayerContext);
let audio = Audio.make();
[@react.component]
[@genType]
let make = (~children) => {
let (playingState, setPlayingState) = React.useState(() => initialState);
Audio.onended(audio, _e => {
setPlayingState(oldState => {...oldState, isPlaying: false})
});
Audio.onpause(
audio,
_e => {
Js.log("PAUSEEEE");
setPlayingState(oldState => {...oldState, isPlaying: false});
},
);
Audio.onplay(audio, _e => {
setPlayingState(oldState => {...oldState, isPlaying: true})
});
Audio.oncanplay(audio, _e =>
setPlayingState(oldState => {...oldState, isPlaying: true})
);
Audio.onvolumechange(audio, e => {
setPlayingState(oldState => {...oldState, volume: e.target.volume})
});
let setPreviewAudio = (beatmapSetId: int) => {
Audio.setSrc(audio, {j|https://b.ppy.sh/preview/$beatmapSetId.mp3|j});
};
let setAudio =
(
~beatmapSetId,
~setIsPlayable: bool => unit,
~songTitle,
~audioFilePath: option(string),
~previewOffset: option(int),
) => {
Audio.onerror(
audio,
_e => {
setIsPlayable(false);
setPlayingState(oldState => {...oldState, isPlaying: false});
},
);
switch (audioFilePath, previewOffset) {
| (None, None) => setPreviewAudio(beatmapSetId)
| (None, Some(_)) => setPreviewAudio(beatmapSetId)
| (Some(audioFilePath), None) => Audio.setSrc(audio, audioFilePath)
| (Some(audioFilePath), Some(previewOffset)) =>
Audio.setSrc(audio, audioFilePath);
Audio.setCurrentTime(audio, previewOffset);
};
Audio.play(audio);
setPlayingState(oldState =>
{...oldState, isPlaying: false, beatmapSetId, songTitle}
);
};
let pause = () => {
Audio.pause(audio);
};
let play = () => {
Audio.play(audio);
};
let setVolume = Audio.setVolume(audio);
let togglePlayPause = () => Audio.paused(audio) ? play() : pause();
let setMuted = muted => {
Audio.setMuted(audio, muted);
setPlayingState(oldState => {...oldState, muted});
};
let value = {
playingState,
pause,
setAudio,
setVolume,
togglePlayPause,
setMuted,
};
<Provider value> children </Provider>;
};
+232
View File
@@ -0,0 +1,232 @@
open AudioPlayerProvider;
let audio = Audio.make();
let _play = () => {
Audio.play(audio);
};
let _updateMetadata = (song: song) => {
MediaMetadata.make({
title: song.title,
artist: song.artist,
album: "Beatconnect",
artwork: [|MediaMetadata.makeArtwork(song.id)|],
})
->MediaSession.setMediaSessionMetadata;
};
let _setPreviewAudio = (beatmapSetId: int) => {
Audio.setSrc(audio, {j|https://b.ppy.sh/preview/$beatmapSetId.mp3|j});
};
let _setAudioSrc = (~audioFilePath, ~previewOffset=?, ()) => {
Audio.setSrc(audio, audioFilePath);
switch (previewOffset) {
| Some(offset) => Audio.setCurrentTime(audio, offset)
| None => ()
};
};
let pause = () => {
Audio.pause(audio);
};
let togglePlayPause = () => Audio.paused(audio) ? _play() : pause();
let setVolume = Audio.setVolume(audio);
[@react.component]
[@genType]
let make = (~children) => {
let (playingState, setPlayingState) = React.useState(() => initialState);
let (playlist: playlist, setPlaylist) = React.useState(() => [||]);
let _canPlay = (offset: int) =>
if (playlist->Belt_Array.length > 0) {
let currentSongIndex =
playlist->Belt_Array.getIndexBy(item =>
item.id == playingState.beatmapSetId
);
switch (currentSongIndex) {
| None => None
| Some(index) =>
index + offset < playlist->Js_array.length && index + offset > (-1)
? Some(index + offset) : None
};
} else {
None;
};
let _canPlayNextSong = () => _canPlay(1);
let _canPlayPrevSong = () => _canPlay(-1);
let setPlaylist = (beatmapPlaylist: playlist) => {
setPlaylist(_ => beatmapPlaylist);
};
let _stop = () => {
pause();
setPlaylist([||]);
};
React.useEffect0(() => {
MediaSession.setActionHandler(`play, Some(_play));
MediaSession.setActionHandler(`pause, Some(pause));
MediaSession.setActionHandler(`stop, Some(_stop));
None;
});
let playFromPlaylist = (playlistindex: int) => {
let nextSong = playlist[playlistindex];
Audio.setSrc(audio, nextSong.path);
_updateMetadata({
id: nextSong.id,
title: nextSong.title,
artist: nextSong.artist,
});
_play();
setPlayingState(oldState =>
{
...oldState,
isPlaying: false,
beatmapSetId: nextSong.id,
title: nextSong.title,
artist: nextSong.artist,
}
);
setPlaylist(playlist);
};
let updateMediaHandlers = () => {
(
switch (_canPlayNextSong()) {
| Some(nextSongindex) =>
setPlayingState(prevState => {...prevState, hasNext: true});
Some(() => playFromPlaylist(nextSongindex));
| None =>
setPlayingState(prevState => {...prevState, hasNext: false});
None;
}
)
|> MediaSession.setActionHandler(`nexttrack);
(
switch (_canPlayPrevSong()) {
| Some(prevSongindex) =>
setPlayingState(prevState => {...prevState, hasPrev: true});
Some(() => playFromPlaylist(prevSongindex));
| None =>
setPlayingState(prevState => {...prevState, hasPrev: false});
None;
}
)
|> MediaSession.setActionHandler(`previoustrack);
};
React.useEffect2(
() => {
updateMediaHandlers();
None;
},
(playingState.beatmapSetId, playlist),
);
let playNext = () => {
switch (_canPlayNextSong()) {
| Some(nextSong) => playFromPlaylist(nextSong)
| None => ()
};
}
let playPrevious = () => {
switch (_canPlayPrevSong()) {
| Some(prevSong) => playFromPlaylist(prevSong)
| None => ()
};
}
let setAudio =
(
~song: song,
~setIsPlayable: bool => unit,
~audioFilePath: option(string),
~previewOffset: option(int),
) => {
Audio.onerror(
audio,
_e => {
setIsPlayable(false);
setPlayingState(oldState => {...oldState, isPlaying: false});
},
);
setPlaylist([||]);
_updateMetadata(song);
switch (audioFilePath, previewOffset) {
| (None, None) => _setPreviewAudio(song.id)
| (None, Some(_)) => _setPreviewAudio(song.id)
| (Some(audioFilePath), None) => _setAudioSrc(~audioFilePath, ())
| (Some(audioFilePath), Some(previewOffset)) =>
_setAudioSrc(~audioFilePath, ~previewOffset, ())
};
_play();
setPlayingState(oldState =>
{
...oldState,
isPlaying: false,
beatmapSetId: song.id,
artist: song.artist,
title: song.title,
}
);
};
let setMuted = muted => {
Audio.setMuted(audio, muted);
setPlayingState(oldState => {...oldState, muted});
};
Audio.onended(audio, _e => {
switch (_canPlayNextSong()) {
| Some(nextSongindex) => playFromPlaylist(nextSongindex)
| None => ()
}
});
Audio.onpause(
audio,
_e => {
Js.log("PAUSEEEE");
setPlayingState(oldState => {...oldState, isPlaying: false});
},
);
Audio.onplay(audio, _e => {
setPlayingState(oldState => {...oldState, isPlaying: true})
});
Audio.oncanplay(audio, _e =>
setPlayingState(oldState => {...oldState, isPlaying: true})
);
Audio.onvolumechange(audio, e => {
setPlayingState(oldState => {...oldState, volume: e.target.volume})
});
let value = {
playingState,
pause,
setAudio,
setPlaylist,
setVolume,
togglePlayPause,
setMuted,
playlist,
playNext,
playPrevious,
};
<Provider value> children </Provider>;
};
@@ -13,7 +13,7 @@ import config from '../../../../shared/config';
import { useDownloadHistory } from '../../../Providers/HistoryProvider';
import { getOsuSongPath } from '../../Settings/reducer/selectors';
import { getAudioFilePath } from './item.utils';
import { getThumbUrl } from '../../../../shared/ppy.helpers';
import { getListCoverUrl, getThumbUrl } from '../../../../shared/PpyHelpers.bs';
const useStyle = createUseStyles({
listItem: {
@@ -41,10 +41,12 @@ const useStyle = createUseStyles({
},
thumbnail: {
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '50px',
height: '40px',
margin: '5px 15px 5px 35px',
position: 'relative',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
'& .playIco': {
position: 'absolute',
content: '',
@@ -114,8 +116,17 @@ const BeatmapListItem = ({ index, style, data, osuSongPath }) => {
const playPreview = () => {
if (isSelected) audioPlayer.togglePlayPause();
else if (isLibraryMode) audioPlayer.setAudio(id, () => {}, `${title} - ${artist}`, audioPath || undefined);
else audioPlayer.setAudio(id, () => {}, `${title} - ${artist}`, audioPath || undefined, previewTime || undefined);
else if (isLibraryMode) {
audioPlayer.setAudio({ id, title, artist }, () => {}, audioPath || undefined);
audioPlayer.setPlaylist(
items.map(({ id: mapId, title: mapTitle, artist: mapArtist }) => ({
id: mapId,
title: mapTitle,
artist: mapArtist,
path: getAudioFilePath(osuSongPath, history.history[mapId].audioPath),
})),
);
} else audioPlayer.setAudio({ id, title, artist }, () => {}, audioPath || undefined, previewTime || undefined);
};
const downloadProgress = useCurrentDownloadItem(id);
@@ -142,7 +153,12 @@ const BeatmapListItem = ({ index, style, data, osuSongPath }) => {
return (
<div style={{ ...style, top: `${parseFloat(style.top) + 50}px` }} key={id} onClick={handleClick}>
<div className={classes.listItem} style={wrapperStyle}>
<div className={`${classes.thumbnail} thumbnail`} style={{ backgroundImage: `url(${getThumbUrl(id)})` }}>
<div
className={`${classes.thumbnail} thumbnail`}
style={{
backgroundImage: `url(${getListCoverUrl(id)}), url(${getThumbUrl(id)})`,
}}
>
<div
className="playIco clickable"
style={playIcoStyle}
@@ -3,13 +3,17 @@ import renderIcons from '../../../helpers/renderIcons';
import { useAudioPlayer } from '../../../Providers/AudioPlayerProvider.bs';
import Button from '../Button';
const PreviewBeatmapBtn = ({ beatmapSetId, theme, setIsPLaying, songTitle }) => {
const PreviewBeatmapBtn = ({ beatmapSetId, theme, setIsPLaying, title, artist }) => {
const audioPlayer = useAudioPlayer();
const [isPlayable, setIsPlayable] = useState(true);
const isPlaying = audioPlayer.playingState.beatmapSetId === beatmapSetId && audioPlayer.playingState.isPlaying;
if (setIsPLaying) setIsPLaying(isPlaying);
const playPreview = () => {
isPlaying ? audioPlayer.pause() : audioPlayer.setAudio(beatmapSetId, setIsPlayable, songTitle);
if (isPlaying) {
audioPlayer.pause();
} else {
audioPlayer.setAudio({ id: beatmapSetId, title, artist }, setIsPlayable);
}
};
return isPlayable ? (
<Button color={theme.palette.primary.accent} onClick={playPreview}>
+2 -1
View File
@@ -141,7 +141,8 @@ const Beatmap = ({ beatmap, noFade, autoDl, width, ...otherProps }) => {
theme={theme}
beatmapSetId={beatmapset_id || id}
setIsPLaying={setIsPLaying}
songTitle={`${title} - ${artist}`}
title={title}
artist={artist}
/>
<DownloadBeatmapBtn autoDl={autoDl} beatmapSet={beatmap} />
<Button
@@ -2,7 +2,7 @@ import React from 'react';
import { createUseStyles } from 'react-jss';
import { connect } from 'react-redux';
import config from '../../../../../shared/config';
import { getThumbUrl } from '../../../../../shared/ppy.helpers';
import { getThumbUrl } from '../../../../../shared/PpyHelpers.bs';
import renderIcons from '../../../../helpers/renderIcons';
import { useAudioPlayer } from '../../../../Providers/AudioPlayerProvider.bs';
import ScrollingText from '../../ScrollingText';
@@ -36,6 +36,7 @@ const useStyle = createUseStyles({
alignItems: 'center',
},
arrrowRight: {
visibility: ({ hasNext }) => (hasNext ? 'visible' : 'hidden'),
'& > svg': {
transform: 'rotate(180deg)',
},
@@ -44,12 +45,14 @@ const useStyle = createUseStyles({
cursor: 'pointer',
},
arrrowLeft: {
visibility: ({ hasPrev }) => (hasPrev ? 'visible' : 'hidden'),
width: '19px',
height: '19px',
cursor: 'pointer',
},
songImage: {
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: '5px',
width: '60px',
height: '60px',
@@ -60,26 +63,34 @@ const useStyle = createUseStyles({
});
const PlayingSong = ({ expended }) => {
const classes = useStyle({ expended });
const { playingState, togglePlayPause } = useAudioPlayer();
const visible = playingState.songTitle;
const { playingState, togglePlayPause, playNext, playPrevious } = useAudioPlayer();
const classes = useStyle({ expended, hasNext: playingState.hasNext, hasPrev: playingState.hasPrev });
const visible = playingState.beatmapSetId;
const handleNext = () => playNext();
const handlePrevious = () => playPrevious();
if (!visible) return null;
return (
<div className={classes.playingSongWrapper} onClick={togglePlayPause} role="tab">
<div className={classes.playingSongWrapper} role="tab">
<div className={classes.expendedContentWrapper}>
{expended && (
<div className={classes.top}>
<div className={classes.arrrowLeft}>{renderIcons({ name: 'Arrow' })}</div>
<div className={classes.arrrowLeft} onClick={handlePrevious}>
{renderIcons({ name: 'Arrow' })}
</div>
<div
onClick={togglePlayPause}
className={classes.songImage}
style={{ backgroundImage: `url(${getThumbUrl(playingState.beatmapSetId)})` }}
/>
<div className={classes.arrrowRight}>{renderIcons({ name: 'Arrow' })}</div>
<div className={classes.arrrowRight} onClick={handleNext}>
{renderIcons({ name: 'Arrow' })}
</div>
</div>
)}
<div className={classes.bottom}>
<div className={classes.bottom} onClick={togglePlayPause}>
<div className={classes.icon}>
{renderIcons({
name: playingState.isPlaying ? 'playButton' : 'pauseButton',
@@ -91,7 +102,7 @@ const PlayingSong = ({ expended }) => {
</div>
<div className={classes.label}>
<ScrollingText
text={playingState.songTitle}
text={`${playingState.artist} - ${playingState.title}`}
maxWidth={`${config.display.sidePanelExpandedLength - 44 - 2}px`}
visible={expended}
/>
+3 -1
View File
@@ -31,7 +31,9 @@ let makeControlStyle = (~bgColor=?, ~spacer=false, ()) =>
let make = (~height: int) => {
let {AudioPlayerProvider.playingState} =
AudioPlayerProvider.useAudioPlayer();
let songTitle = playingState.songTitle;
let artist = playingState.artist;
let title = playingState.title;
let songTitle = {j|$artist - $title|j};
let title =
playingState.isPlaying
? {j|Beatconnect \u23F5 $songTitle|j} : "Beatconnect";
+1 -1
View File
@@ -8,7 +8,7 @@ import DownloadManagerProvider from './Providers/downloadManager';
import ErrorBoundary from './ErrorBoundary';
import { make as TasksProvider } from './Providers/TaskProvider.bs';
import ThemeProvider from './Providers/ThemeProvider';
import { make as AudioPlayerProvider } from './Providers/AudioPlayerProvider.bs';
import { make as AudioPlayerProvider } from './Providers/Audioplayer.bs';
import dispatchOnResize from './resize';
dispatchOnResize();
+22
View File
@@ -0,0 +1,22 @@
type t;
type artwork = {
src: string,
sizes: string,
type_: string,
};
type metadata = {
title: string,
artist: string,
album: string,
artwork: array(artwork),
};
[@bs.new] external make: metadata => t = "MediaMetadata";
let makeArtwork = id => {
src: PpyHelpers.getListCoverUrl(id),
sizes: "160x100",
type_: "image/jpg",
};
+20
View File
@@ -0,0 +1,20 @@
type t;
type navigator;
[@bs.deriving {jsConverter: newType}]
type actionType = [ | `play | `pause | `previoustrack | `nexttrack | `stop];
[@bs.val] external mediaSession: t = "navigator.mediaSession";
[@bs.val] external navigator: navigator = "navigator";
[@bs.send]
external setActionHandler: (t, actionType, option(unit => unit)) => unit =
"setActionHandler";
let setActionHandler = setActionHandler(mediaSession);
[@bs.set]
external setMediaSessionMetadata: (t, MediaMetadata.t) => unit = "metadata";
let setMediaSessionMetadata = setMediaSessionMetadata(mediaSession);
+3
View File
@@ -0,0 +1,3 @@
let getThumbUrl = beatmapId => {j|https://b.ppy.sh/thumb/$beatmapId.jpg|j};
let getListCoverUrl = beatmapId => {j|https://assets.ppy.sh/beatmaps/$beatmapId/covers/list@2x.jpg|j};
-1
View File
@@ -1 +0,0 @@
export const getThumbUrl = beatmapId => `https://b.ppy.sh/thumb/${beatmapId}.jpg`;