Basic playlist support for webplayer

Webplayer can now handle a basic playlist, pushing multiple songs in the
playlist and passing from one song to another.

Some things that are not yet working:
* Using previous and next buttons and going outside of the playlist
breaks things.
* Random / repeat modes are not yet implemented.
* Playlist is not exposed in the UI at the moment.
* Seeking in a song is not exposed in the UI.
* When playing a song, webplayer does not automatically play the next
one when reaching the end of the song.
This commit is contained in:
Lucas Verney 2016-08-11 22:01:47 +02:00
parent b7f2f32e1d
commit ab7470d618
19 changed files with 206 additions and 64 deletions

View File

@ -127,11 +127,12 @@ export const PUSH_SONG = "PUSH_SONG";
*
* @param songID The id of the song to push.
* @param index [Optional] The position to insert at in the playlist.
* If negative, counts from the end. Defaults to last.
* If negative, counts from the end. Undefined (default)
* is last position.
*
* @return Dispatch a PUSH_SONG action.
*/
export function pushSong(songID, index=-1) {
export function pushSong(songID, index) {
return (dispatch) => {
// Handle reference counting
dispatch(incrementRefCount({
@ -197,31 +198,31 @@ export function jumpToSong(songID) {
}
export const PLAY_PREVIOUS = "PLAY_PREVIOUS";
export const PLAY_PREVIOUS_SONG = "PLAY_PREVIOUS_SONG";
/**
* Move one song backwards in the playlist.
*
* @return Dispatch a PLAY_PREVIOUS action.
* @return Dispatch a PLAY_PREVIOUS_SONG action.
*/
export function playPrevious() {
export function playPreviousSong() {
return (dispatch) => {
dispatch({
type: PLAY_PREVIOUS,
type: PLAY_PREVIOUS_SONG,
});
};
}
export const PLAY_NEXT = "PLAY_NEXT";
export const PLAY_NEXT_SONG = "PLAY_NEXT_SONG";
/**
* Move one song forward in the playlist.
*
* @return Dispatch a PLAY_NEXT action.
* @return Dispatch a PLAY_NEXT_SONG action.
*/
export function playNext() {
export function playNextSong() {
return (dispatch) => {
dispatch({
type: PLAY_NEXT,
type: PLAY_NEXT_SONG,
});
};
}

View File

@ -22,17 +22,47 @@ const albumMessages = defineMessages(messagesMap(Array.concat([], commonMessages
* Track row in an album tracks table.
*/
class AlbumTrackRowCSSIntl extends Component {
constructor(props) {
super(props);
// Bind this
this.onPlayClick = this.onPlayClick.bind(this);
this.onPlayNextClick = this.onPlayNextClick.bind(this);
}
/**
* Handle click on play button.
*/
onPlayClick() {
$(this.refs.play).blur();
this.props.playAction(this.props.song.get("id"));
}
/**
* Handle click on play next button.
*/
onPlayNextClick() {
$(this.refs.playNext).blur();
this.props.playNextAction(this.props.song.get("id"));
}
render() {
const { formatMessage } = this.props.intl;
const length = formatLength(this.props.track.get("time"));
return (
<tr>
<td>
<button styleName="play" title={formatMessage(albumMessages["app.common.play"])} onClick={() => this.props.playAction(this.props.track.get("id"))}>
<button styleName="play" title={formatMessage(albumMessages["app.common.play"])} onClick={this.onPlayClick}>
<span className="sr-only">
<FormattedMessage {...albumMessages["app.common.play"]} />
</span>
<FontAwesome name="play-circle-o" aria-hidden="true" />
</button>&nbsp;
<button styleName="playNext" title={formatMessage(albumMessages["app.common.playNext"])} onClick={this.onPlayNextClick} ref="playNext">
<span className="sr-only">
<FormattedMessage {...albumMessages["app.common.playNext"]} />
</span>
<FontAwesome name="plus-circle" aria-hidden="true" />
</button>
</td>
<td>{this.props.track.get("track")}</td>
@ -44,6 +74,7 @@ class AlbumTrackRowCSSIntl extends Component {
}
AlbumTrackRowCSSIntl.propTypes = {
playAction: PropTypes.func.isRequired,
playNextAction: PropTypes.func.isRequired,
track: PropTypes.instanceOf(Immutable.Map).isRequired,
intl: intlShape.isRequired,
};
@ -57,9 +88,9 @@ class AlbumTracksTableCSS extends Component {
render() {
let rows = [];
// Build rows for each track
const playAction = this.props.playAction;
const { playAction, playNextAction } = this.props;
this.props.tracks.forEach(function (item) {
rows.push(<AlbumTrackRow playAction={playAction} track={item} key={item.get("id")} />);
rows.push(<AlbumTrackRow playAction={playAction} playNextAction={playNextAction} track={item} key={item.get("id")} />);
});
return (
<table className="table table-hover" styleName="songs">
@ -72,6 +103,7 @@ class AlbumTracksTableCSS extends Component {
}
AlbumTracksTableCSS.propTypes = {
playAction: PropTypes.func.isRequired,
playNextAction: PropTypes.func.isRequired,
tracks: PropTypes.instanceOf(Immutable.List).isRequired,
};
export let AlbumTracksTable = CSSModules(AlbumTracksTableCSS, css);
@ -93,7 +125,7 @@ class AlbumRowCSS extends Component {
<div className="col-xs-9 col-sm-10 table-responsive">
{
this.props.songs.size > 0 ?
<AlbumTracksTable playAction={this.props.playAction} tracks={this.props.songs} /> :
<AlbumTracksTable playAction={this.props.playAction} playNextAction={this.props.playNextAction} tracks={this.props.songs} /> :
null
}
</div>
@ -103,6 +135,7 @@ class AlbumRowCSS extends Component {
}
AlbumRowCSS.propTypes = {
playAction: PropTypes.func.isRequired,
playNextAction: PropTypes.func.isRequired,
album: PropTypes.instanceOf(Immutable.Map).isRequired,
songs: PropTypes.instanceOf(Immutable.List).isRequired,
};

View File

@ -48,13 +48,13 @@ class ArtistCSS extends Component {
// Build album rows
let albumsRows = [];
const { albums, songs, playAction } = this.props;
const { albums, songs, playAction, playNextAction } = this.props;
if (albums && songs) {
albums.forEach(function (album) {
const albumSongs = album.get("tracks").map(
id => songs.get(id)
);
albumsRows.push(<AlbumRow playAction={playAction} album={album} songs={albumSongs} key={album.get("id")} />);
albumsRows.push(<AlbumRow playAction={playAction} playNextAction={playNextAction} album={album} songs={albumSongs} key={album.get("id")} />);
});
}
@ -85,6 +85,7 @@ ArtistCSS.propTypes = {
error: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
playAction: PropTypes.func.isRequired,
playNextAction: PropTypes.func.isRequired,
artist: PropTypes.instanceOf(Immutable.Map),
albums: PropTypes.instanceOf(Immutable.List),
songs: PropTypes.instanceOf(Immutable.Map),

View File

@ -30,6 +30,30 @@ const songsMessages = defineMessages(messagesMap(Array.concat([], commonMessages
* A single row for a single song in the songs table.
*/
class SongsTableRowCSSIntl extends Component {
constructor(props) {
super(props);
// Bind this
this.onPlayClick = this.onPlayClick.bind(this);
this.onPlayNextClick = this.onPlayNextClick.bind(this);
}
/**
* Handle click on play button.
*/
onPlayClick() {
$(this.refs.play).blur();
this.props.playAction(this.props.song.get("id"));
}
/**
* Handle click on play next button.
*/
onPlayNextClick() {
$(this.refs.playNext).blur();
this.props.playNextAction(this.props.song.get("id"));
}
render() {
const { formatMessage } = this.props.intl;
@ -40,11 +64,17 @@ class SongsTableRowCSSIntl extends Component {
return (
<tr>
<td>
<button styleName="play" title={formatMessage(songsMessages["app.common.play"])} onClick={() => this.props.playAction(this.props.song.get("id"))}>
<button styleName="play" title={formatMessage(songsMessages["app.common.play"])} onClick={this.onPlayClick} ref="play">
<span className="sr-only">
<FormattedMessage {...songsMessages["app.common.play"]} />
</span>
<FontAwesome name="play-circle-o" aria-hidden="true" />
</button>&nbsp;
<button styleName="playNext" title={formatMessage(songsMessages["app.common.playNext"])} onClick={this.onPlayNextClick} ref="playNext">
<span className="sr-only">
<FormattedMessage {...songsMessages["app.common.playNext"]} />
</span>
<FontAwesome name="plus-circle" aria-hidden="true" />
</button>
</td>
<td className="title">{this.props.song.get("name")}</td>
@ -58,6 +88,7 @@ class SongsTableRowCSSIntl extends Component {
}
SongsTableRowCSSIntl.propTypes = {
playAction: PropTypes.func.isRequired,
playNextAction: PropTypes.func.isRequired,
song: PropTypes.instanceOf(Immutable.Map).isRequired,
intl: intlShape.isRequired,
};
@ -86,9 +117,9 @@ class SongsTableCSS extends Component {
// Build song rows
let rows = [];
const { playAction } = this.props;
const { playAction, playNextAction } = this.props;
displayedSongs.forEach(function (song) {
rows.push(<SongsTableRow playAction={playAction} song={song} key={song.get("id")} />);
rows.push(<SongsTableRow playAction={playAction} playNextAction={playNextAction} song={song} key={song.get("id")} />);
});
// Handle login icon
@ -134,6 +165,7 @@ class SongsTableCSS extends Component {
}
SongsTableCSS.propTypes = {
playAction: PropTypes.func.isRequired,
playNextAction: PropTypes.func.isRequired,
songs: PropTypes.instanceOf(Immutable.List).isRequired,
filterText: PropTypes.string,
};
@ -180,6 +212,7 @@ export default class FilterablePaginatedSongsTable extends Component {
};
const songsTableProps = {
playAction: this.props.playAction,
playNextAction: this.props.playNextAction,
isFetching: this.props.isFetching,
songs: this.props.songs,
filterText: this.state.filterText,
@ -197,6 +230,7 @@ export default class FilterablePaginatedSongsTable extends Component {
}
FilterablePaginatedSongsTable.propTypes = {
playAction: PropTypes.func.isRequired,
playNextAction: PropTypes.func.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.string,
songs: PropTypes.instanceOf(Immutable.List).isRequired,

View File

@ -82,6 +82,32 @@ class WebPlayerCSSIntl extends Component {
}
}
// Click handlers
const onPrev = (function () {
$(this.refs.prevBtn).blur();
this.props.onPrev();
}).bind(this);
const onPlayPause = (function () {
$(this.refs.playPauseBtn).blur();
this.props.onPlayPause();
}).bind(this);
const onSkip = (function () {
$(this.refs.nextBtn).blur();
this.props.onSkip();
}).bind(this);
const onMute = (function () {
$(this.refs.volumeBtn).blur();
this.props.onMute();
}).bind(this);
const onRepeat = (function () {
$(this.refs.repeatBtn).blur();
this.props.onRepeat();
}).bind(this);
const onRandom = (function () {
$(this.refs.randomBtn).blur();
this.props.onRandom();
}).bind(this);
return (
<div id="row" styleName="webplayer">
<div className="col-xs-12">
@ -109,24 +135,24 @@ class WebPlayerCSSIntl extends Component {
<div className="row text-center" styleName="controls">
<div className="col-xs-12">
<button styleName="prevBtn" aria-label={formatMessage(webplayerMessages["app.webplayer.previous"])} title={formatMessage(webplayerMessages["app.webplayer.previous"])} onClick={this.props.onPrev}>
<button styleName="prevBtn" aria-label={formatMessage(webplayerMessages["app.webplayer.previous"])} title={formatMessage(webplayerMessages["app.webplayer.previous"])} onClick={onPrev} ref="prevBtn">
<FontAwesome name="step-backward" />
</button>
<button className="play" styleName="playPauseBtn" aria-label={formatMessage(webplayerMessages["app.common." + playPause])} title={formatMessage(webplayerMessages["app.common." + playPause])} onClick={this.props.onPlayPause}>
<button className="play" styleName="playPauseBtn" aria-label={formatMessage(webplayerMessages["app.common." + playPause])} title={formatMessage(webplayerMessages["app.common." + playPause])} onClick={onPlayPause.bind(this)} ref="playPauseBtn">
<FontAwesome name={playPause} />
</button>
<button styleName="nextBtn" aria-label={formatMessage(webplayerMessages["app.webplayer.next"])} title={formatMessage(webplayerMessages["app.webplayer.next"])} onClick={this.props.onSkip}>
<button styleName="nextBtn" aria-label={formatMessage(webplayerMessages["app.webplayer.next"])} title={formatMessage(webplayerMessages["app.webplayer.next"])} onClick={onSkip} ref="nextBtn">
<FontAwesome name="step-forward" />
</button>
</div>
<div className="col-xs-12">
<button styleName="volumeBtn" aria-label={formatMessage(webplayerMessages["app.webplayer.volume"])} title={formatMessage(webplayerMessages["app.webplayer.volume"])} onClick={this.props.onMute}>
<button styleName="volumeBtn" aria-label={formatMessage(webplayerMessages["app.webplayer.volume"])} title={formatMessage(webplayerMessages["app.webplayer.volume"])} onClick={onMute} ref="volumeBtn">
<FontAwesome name={volumeIcon} />
</button>
<button styleName={repeatBtnStyles.join(" ")} aria-label={formatMessage(webplayerMessages["app.webplayer.repeat"])} title={formatMessage(webplayerMessages["app.webplayer.repeat"])} aria-pressed={this.props.isRepeat} onClick={this.props.onRepeat}>
<button styleName={repeatBtnStyles.join(" ")} aria-label={formatMessage(webplayerMessages["app.webplayer.repeat"])} title={formatMessage(webplayerMessages["app.webplayer.repeat"])} aria-pressed={this.props.isRepeat} onClick={onRepeat} ref="repeatBtn">
<FontAwesome name="repeat" />
</button>
<button styleName={randomBtnStyles.join(" ")} aria-label={formatMessage(webplayerMessages["app.webplayer.random"])} title={formatMessage(webplayerMessages["app.webplayer.random"])} aria-pressed={this.props.isRandom} onClick={this.props.onRandom}>
<button styleName={randomBtnStyles.join(" ")} aria-label={formatMessage(webplayerMessages["app.webplayer.random"])} title={formatMessage(webplayerMessages["app.webplayer.random"])} aria-pressed={this.props.isRandom} onClick={onRandom} ref="randomBtn">
<FontAwesome name="random" />
</button>
<button styleName="playlistBtn" aria-label={formatMessage(webplayerMessages["app.webplayer.playlist"])} title={formatMessage(webplayerMessages["app.webplayer.playlist"])}>

View File

@ -11,6 +11,7 @@ module.exports = {
"app.common.loading": "Loading…", // Loading indicator
"app.common.pause": "Pause", // Pause icon description
"app.common.play": "Play", // Play icon description
"app.common.playNext": "Play next", // "Play next icon descripton"
"app.common.track": "{itemCount, plural, one {track} other {tracks}}", // Track
"app.filter.filter": "Filter…", // Filtering input placeholder
"app.filter.whatAreWeListeningToToday": "What are we listening to today?", // Description for the filter bar

View File

@ -11,6 +11,7 @@ module.exports = {
"app.common.loading": "Chargement…", // Loading indicator
"app.common.pause": "Pause", // Pause icon description
"app.common.play": "Jouer", // PLay icon description
"app.common.playNext": "Jouer après", // "Play next icon descripton"
"app.common.track": "{itemCount, plural, one {piste} other {pistes}}", // Track
"app.filter.filter": "Filtrer…", // Filtering input placeholder
"app.filter.whatAreWeListeningToToday": "Que voulez-vous écouter aujourd'hui\u00a0?", // Description for the filter bar

View File

@ -49,6 +49,11 @@ const messages = [
description: "Pause icon description",
defaultMessage: "Pause",
},
{
id: "app.common.playNext",
defaultMessage: "Play next",
description: "Play next icon descripton",
},
];
export default messages;

View File

@ -167,7 +167,7 @@ export default createReducer(initialState, {
newState = state.set("isFetching", false).set("error", payload.error);
// Merge entities
newState = newState.mergeIn(["entities"], payload.entities);
newState = newState.mergeDeepIn(["entities"], payload.entities);
// Increment reference counter
payload.refCountType.forEach(function (itemName) {

View File

@ -19,8 +19,8 @@ import {
PUSH_SONG,
POP_SONG,
JUMP_TO_SONG,
PLAY_PREVIOUS,
PLAY_NEXT,
PLAY_PREVIOUS_SONG,
PLAY_NEXT_SONG,
TOGGLE_RANDOM,
TOGGLE_REPEAT,
TOGGLE_MUTE,
@ -77,32 +77,66 @@ export default createReducer(initialState, {
},
[PUSH_SONG]: (state, payload) => {
// Push song to playlist
const newPlaylist = state.get("playlist").insert(payload.index, payload.song);
return state.set("playlist", newPlaylist);
let newState = state;
if (payload.index) {
// If index is specified, insert it at this position
newState = newState.set(
"playlist",
newState.get("playlist").insert(payload.index, payload.song)
);
if (payload.index <= newState.get("currentIndex")) { // "<=" because insertion is made before
// If we insert before the current position in the playlist, we
// update the current position to keep the currently played
// music
newState = newState.set(
"currentIndex",
Math.min(newState.get("currentIndex") + 1, newState.get("playlist").size)
);
}
} else {
// Else, push at the end of the playlist
newState = newState.set(
"playlist",
newState.get("playlist").push(payload.song)
);
}
return newState;
},
[POP_SONG]: (state, payload) => {
// Pop song from playlist
return state.deleteIn(["playlist", payload.index]);
let newState = state.deleteIn(["playlist", payload.index]);
if (payload.index < state.get("currentIndex")) {
// If we delete before the current position in the playlist, we
// update the current position to keep the currently played
// music
newState = newState.set(
"currentIndex",
Math.max(newState.get("currentIndex") - 1, 0)
);
}
return newState;
},
[JUMP_TO_SONG]: (state, payload) => {
// Set current index
const newCurrentIndex = state.get("playlist").findKey(x => x == payload.song);
return state.set("currentIndex", newCurrentIndex);
},
[PLAY_PREVIOUS]: (state) => {
[PLAY_PREVIOUS_SONG]: (state) => {
const newIndex = state.get("currentIndex") - 1;
if (newIndex < 0) {
// If there is an overlow on the left of the playlist, just stop
// playback
// TODO: Behavior is not correct
return stopPlayback(state);
} else {
return state.set("currentIndex", newIndex);
}
},
[PLAY_NEXT]: (state) => {
[PLAY_NEXT_SONG]: (state) => {
const newIndex = state.get("currentIndex") + 1;
if (newIndex > state.get("playlist").size) {
// If there is an overflow, just stop playback
// TODO: Behavior is not correct
return stopPlayback(state);
} else {
// Else, play next item

View File

@ -35,6 +35,11 @@ $artMarginBottom: 10px;
composes: play from "./Songs.scss";
}
/* Play next button is based on the one in Songs list. */
.playNext {
composes: playNext from "./Songs.scss";
}
@media (max-width: 767px) {
.nameRow h2 {
margin-top: 0;

View File

@ -1,7 +1,8 @@
/**
* Style for Songs component.
*/
.play {
.play,
.playNext {
background-color: transparent;
border: none;
text-align: center;

View File

@ -45,7 +45,7 @@ class ArtistPageIntl extends Component {
const error = handleErrorI18nObject(this.props.error, formatMessage, artistMessages);
return (
<Artist playAction={this.props.actions.playSong} isFetching={this.props.isFetching} error={error} artist={this.props.artist} albums={this.props.albums} songs={this.props.songs} />
<Artist playAction={this.props.actions.playSong} playNextAction={this.props.actions.pushSong} isFetching={this.props.isFetching} error={error} artist={this.props.artist} albums={this.props.albums} songs={this.props.songs} />
);
}
}

View File

@ -56,7 +56,7 @@ class SongsPageIntl extends Component {
const error = handleErrorI18nObject(this.props.error, formatMessage, songsMessages);
return (
<Songs playAction={this.props.actions.playSong} isFetching={this.props.isFetching} error={error} songs={this.props.songsList} pagination={pagination} />
<Songs playAction={this.props.actions.playSong} playNextAction={this.props.actions.pushSong} isFetching={this.props.isFetching} error={error} songs={this.props.songsList} pagination={pagination} />
);
}
}

View File

@ -102,8 +102,8 @@ class WebPlayer extends Component {
// Use a lambda to ensure no first argument is passed to
// togglePlaying
onPlayPause: (() => this.props.actions.togglePlaying()),
onPrev: this.props.actions.playPrevious,
onSkip: this.props.actions.playNext,
onPrev: this.props.actions.playPreviousSong,
onSkip: this.props.actions.playNextSong,
onRandom: this.props.actions.toggleRandom,
onRepeat: this.props.actions.toggleRepeat,
onMute: this.props.actions.toggleMute,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long