Earshot is a single-page clone of Spotify. Earshot was created without the use of the Spotify API. At its core, Earshot is a media library and player. Users can create a personal music library through playlists and liked songs, artists, and albums.
Production Site: https://earshot-btd.herokuapp.com/#/
- Ruby on Rails
- PostGreSQL
- Redux
- React
- AWS - S3 storage
- HTML5 Media Elements
- Heroku
- Custom user authentication (signup, login, logout) and content protection.
Artist
show page- displays artist information
- selection of artists albums link to album show page
Abum
show page- displays album and individual track information
- allows playback on song selection
Home
- indexed selection of artists and albums from the user's personal library
- indexed selection of playlists
Search
- Displays live results.
- Component adapted for both stand-alone search and incorporated into
Playlist
CRUD.
Playlist
CRUD.- Users can create and delete personal playlists.
- Users can select songs to add or remove from their personal playlists.
Media Player
- persistent song player allows for page navigation with continuous playback
- custom controls allow play, pause, previous, next, seek (onClick), and volume (onClick)
- dynamic display of current song, artist, and album
The media player presented a several challenges. All functions and design are built from scratch and it uses HTML5 audio for playback. The HTML5 audio is sourced from an AWS S3 bucket and managed using Rails ActiveStorage.
In designing the overall application, the media player requires persistent and dynamic access to nearly all of the components in Earshot. I was able to design it such that it was self-contained and updates based on the context of the currently displayed main page component. (See Song
Component details below)
The media player maintains its own slice of state separate from the main react components (home, album, artist, search, etc.).
const entitiesReducer = combineReducers({
users: usersReducer,
songs: songsReducer,
albums: albumsReducer,
artists: artistsReducer,
playlists: playlistsReducer,
media: mediaReducer
})
This allows the media player to fetch a song and relevant queue (through Song
props), and maintain/manipulate playback independent of the main react components changing around it. See below snippet of mediaReducer
which shows manipulation of Song
data and updating the redux store.
const preloadedState = {
currentSong: null,
playback: false,
queue: [],
duration: null,
durationShow: '0:00',
currentTime: null,
currentTimeShow: '0:00'
};
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const formatSecs = secs < 10 ? `0${secs}` : `${secs}`;
return `${mins}:${formatSecs}`;
}
const MediaReducer = (oldState = preloadedState, action) => {
Object.freeze(oldState);
const newState = Object.assign({}, oldState);
const audioEl = document.getElementsByClassName("audio-element")[0];
switch (action.type) {
case FETCH_CURRENT_SONG:
newState.currentSong = action.song;
return newState;
case FETCH_DURATION:
if (!newState.currentSong) {
return newState.duration = null;
} else {
newState.durationShow = formatTime(audioEl.duration)
newState.duration = audioEl.duration;
return newState;
}
case FETCH_CURRENT_TIME:
if (!newState.currentSong) {
return newState.currentTime = null;
} else {
newState.currentTimeShow = formatTime(audioEl.currentTime);
newState.currentTime = audioEl.currentTime;
return newState;
}
// QUEUE SENT AS PROPS TO SONG OBJECT, CONTEXTUAL
case RECEIVE_QUEUE:
const songs = Object.values(action.songs);
songs.forEach(song => {
if (!newState.queue.includes(song)) {
newState.queue.push(song);
}
});
return newState
case PLAY_SONG:
newState.playback = true;
return newState;
case PAUSE_SONG:
newState.playback = false;
return newState;
case NEXT_SONG:
newState.queue.unshift(action.song);
return newState;
default:
return oldState;
}
};
The Song
component leverages rails associations to help maintain state efficiently, whether passed to the media player, albums, playists, or search. The component dynamically renders by passing its parent element in through props and returning based on a switch.
renderSwitch(props, activeSong) {
switch (props.parentEl) {
case 'album':
return (
<li>
//...
</li>
)
case 'playlist':
return(
<li>
//...
</li>
)
case 'search':
return(
<li>
//...
</li>
)
// etc.
}
}
render() {
// CONSTANTS
const { index, song, albumSongs, artist } = this.props;
if (!index || !song || !artist) return null;
const liPlayPause = document.getElementById('li-play-pause');
const audio = document.createElement('audio');
audio.src = song.songUrl;
// CURRENT PLAYBACK COLOR
const activeSong = (this.props.currentSong?.id === this.props.song?.id)
? { color: 'var(--green)'}
: {color: 'white'}
// RENDER contextually based on the switch params above
return this.renderSwitch(this.props, activeSong)
}
- Tests! First priority to add RSpec unit tests.
- Build a more robust
likes
frontend to allow users to like albums, artists, and playlists - Add 'right click' menus to
Song
components with contextual menus - Expand
Search
functionality to prioritize results by type (song, album, artist, playlist) - Additional media player functions - shuffle, loop, and show queue