diff --git a/groove/webserver/webserver.py b/groove/webserver/webserver.py index 72d1ec9..54fcf30 100644 --- a/groove/webserver/webserver.py +++ b/groove/webserver/webserver.py @@ -13,7 +13,7 @@ from groove.db.manager import database_manager from groove.playlist import Playlist from groove.webserver import requests -from groove.exceptions import APIHandlingException +# from groove.exceptions import APIHandlingException server = bottle.Bottle() @@ -53,6 +53,11 @@ def build(): return "Authenticated. Groovy." +@server.route('/static/') +def server_static(filepath): + return static_file(filepath, root='static') + + @bottle.auth_basic(is_authenticated) @server.route('/build/search/playlist') def search_playlist(slug, db): @@ -91,10 +96,16 @@ def serve_playlist(slug, db): logging.debug(f"Loaded {playlist.record}") logging.debug(playlist.as_dict['entries']) - args = [ - (requests.encode([str(entry['track_id'])], uri='/track'), entry['track_id']) - for entry in playlist.as_dict['entries'] - ] + pl = playlist.as_dict + + for entry in pl['entries']: + sig = requests.encode([str(entry['track_id'])], uri='/track') + entry['url'] = f"/track/{sig}/{entry['track_id']}" template_path = Path(os.environ['TEMPLATE_PATH']) / Path('playlist.tpl') - return template(str(template_path), url=requests.url(), playlist=playlist.as_dict, args=args) + body = template( + str(template_path), + url=requests.url(), + playlist=pl + ) + return HTTPResponse(status=200, body=body) diff --git a/static/player.js b/static/player.js new file mode 100644 index 0000000..3908e36 --- /dev/null +++ b/static/player.js @@ -0,0 +1,241 @@ +/*! + * Howler.js Audio Player Demo + * howlerjs.com + * + * (c) 2013-2020, James Simpson of GoldFire Studios + * goldfirestudios.com + * + * MIT License + */ + +// Cache references to DOM elements. +var elms = ['track', 'timer', 'duration', 'playBtn', 'pauseBtn', 'prevBtn', 'nextBtn', 'playlistBtn', 'progress', 'bar', 'loading', 'playlist', 'list', 'barEmpty', 'barFull', 'sliderBtn']; +elms.forEach(function(elm) { + window[elm] = document.getElementById(elm); +}); + +/** + * Player class containing the state of our playlist and where we are in it. + * Includes all methods for playing, skipping, updating the display, etc. + * @param {Array} playlist Array of objects with playlist song details ({title, url, howl}). + */ +var Player = function(playlist) { + this.playlist = playlist; + this.index = 0; + + // Display the title of the first track. + track.innerHTML = '1. ' + playlist[0].title; + + // Setup the playlist display. + playlist.forEach(function(song) { + var div = document.createElement('div'); + div.className = 'list-song'; + div.innerHTML = song.title; + div.onclick = function() { + player.skipTo(playlist.indexOf(song)); + }; + list.appendChild(div); + }); +}; +Player.prototype = { + /** + * Play a song in the playlist. + * @param {Number} index Index of the song in the playlist (leave empty to play the first or current). + */ + play: function(index) { + var self = this; + var sound; + + index = typeof index === 'number' ? index : self.index; + var data = self.playlist[index]; + + // If we already loaded this track, use the current one. + // Otherwise, setup and load a new Howl. + if (data.howl) { + sound = data.howl; + } else { + sound = data.howl = new Howl({ + src: [data.url], + html5: true, // Force to HTML5 so that the audio can stream in (best for large files). + onplay: function() { + // Display the duration. + duration.innerHTML = self.formatTime(Math.round(sound.duration())); + + // Start updating the progress of the track. + requestAnimationFrame(self.step.bind(self)); + + pauseBtn.style.display = 'block'; + }, + onload: function() { + loading.style.display = 'none'; + }, + onend: function() { + self.skip('next'); + }, + onpause: function() { + }, + onstop: function() { + }, + onseek: function() { + // Start updating the progress of the track. + requestAnimationFrame(self.step.bind(self)); + } + }); + } + + // Begin playing the sound. + sound.play(); + + // Update the track display. + track.innerHTML = (index + 1) + '. ' + data.title; + + // Show the pause button. + if (sound.state() === 'loaded') { + playBtn.style.display = 'none'; + pauseBtn.style.display = 'block'; + } else { + loading.style.display = 'block'; + playBtn.style.display = 'none'; + pauseBtn.style.display = 'none'; + } + + // Keep track of the index we are currently playing. + self.index = index; + }, + + /** + * Pause the currently playing track. + */ + pause: function() { + var self = this; + + // Get the Howl we want to manipulate. + var sound = self.playlist[self.index].howl; + + // Puase the sound. + sound.pause(); + + // Show the play button. + playBtn.style.display = 'block'; + pauseBtn.style.display = 'none'; + }, + + /** + * Skip to the next or previous track. + * @param {String} direction 'next' or 'prev'. + */ + skip: function(direction) { + var self = this; + + // Get the next track based on the direction of the track. + var index = 0; + if (direction === 'prev') { + index = self.index - 1; + if (index < 0) { + index = self.playlist.length - 1; + } + } else { + index = self.index + 1; + if (index >= self.playlist.length) { + index = 0; + } + } + + self.skipTo(index); + }, + + /** + * Skip to a specific track based on its playlist index. + * @param {Number} index Index in the playlist. + */ + skipTo: function(index) { + var self = this; + + // Stop the current track. + if (self.playlist[self.index].howl) { + self.playlist[self.index].howl.stop(); + } + + // Reset progress. + progress.style.width = '0%'; + + // Play the new track. + self.play(index); + }, + + /** + * Set the volume and update the volume slider display. + * @param {Number} val Volume between 0 and 1. + */ + volume: function(val) { + var self = this; + + // Update the global volume (affecting all Howls). + Howler.volume(val); + }, + + /** + * Seek to a new position in the currently playing track. + * @param {Number} per Percentage through the song to skip. + */ + seek: function(per) { + var self = this; + + // Get the Howl we want to manipulate. + var sound = self.playlist[self.index].howl; + + // Convert the percent into a seek position. + if (sound.playing()) { + sound.seek(sound.duration() * per); + } + }, + + /** + * The step called within requestAnimationFrame to update the playback position. + */ + step: function() { + var self = this; + + // Get the Howl we want to manipulate. + var sound = self.playlist[self.index].howl; + + // Determine our current seek position. + var seek = sound.seek() || 0; + timer.innerHTML = self.formatTime(Math.round(seek)); + progress.style.width = (((seek / sound.duration()) * 100) || 0) + '%'; + + // If the sound is still playing, continue stepping. + if (sound.playing()) { + requestAnimationFrame(self.step.bind(self)); + } + }, + + /** + * Format the time from seconds to M:SS. + * @param {Number} secs Seconds to format. + * @return {String} Formatted time. + */ + formatTime: function(secs) { + var minutes = Math.floor(secs / 60) || 0; + var seconds = (secs - minutes * 60) || 0; + + return minutes + ':' + (seconds < 10 ? '0' : '') + seconds; + } +}; + +// Setup our new audio player class and pass it the playlist. +var player = new Player(playlist_tracks); + +// Bind our player controls. +playBtn.addEventListener('click', function() { + player.play(); +}); +pauseBtn.addEventListener('click', function() { + player.pause(); +}); +prevBtn.addEventListener('click', function() { + player.skip('prev'); +}); +nextBtn.addEventListener('click', function() { + player.skip('next'); +}); diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..5070ab9 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,259 @@ +html { + width: 100%; + height: 100%; + overflow: hidden; + padding: 0; + margin: 0; + outline: 0; + font-family: 'Clarendon MT Std', sans-serif; +} + +body { + width: 100%; + height: 100%; + padding: 0; + margin: 0; + overflow: hidden; + background: rgb(0,4,16); + background: linear-gradient(0deg, rgba(0,4,16,1) 31%, rgba(1,125,147,1) 97%); +} + +a, a:visited { + text-decoration: none; + color: #f1f2f6; +} +a:active, a:hover { + color: #70bc45; +} + +#container { + overflow-x: wrap; + width: 96%; + height: 96%; + margin: auto; + margin-top: 1em; +} + +#details { + margin-left: 0.25em; + display: flex; + border-bottom: 1px solid rgb(255,255,255,0.3); +} + +#poster { + display: none; + width: 10em; + height: 10em; + background: #DDD; +} +#poster > img { + display: none; +} + + +/* informmation */ +#playlist_title { + margin-bottom: 0.25em; + color: #f1f2f6; +} +#playlist_desc { +} + + + +#player { + width: 100%; + margin: 1em 0; +} + +#controls { + width: 5em; + height: 5em; + vertical-align: middle; + padding-right: 1em; +} +#controls > div { + width: 5em; + height: 5em; +} + + +#track { + color: #70bc45; + font-size: 1.5em; +} + +#track_controls { + display: flex; +} + +#timer { + margin-left:0; + padding-left:0; +} +#duration { +} + +/* Controls */ +.widget { + margin-right: 0.5em; + padding: 0 0.5em; +} +.btn { + cursor: pointer; + opacity: 0.75; +} +.btn:hover { + opacity: 1; +} +#big_button { + position: relative; + background-color: #fff; + border-radius: 50%; + text-align: center; + vertical-align: middle; + line-height: 5em; +} +#playBtn { + font-family: sans-serif; + font-size: 5em; + padding-left: 0.1em; +} +#pauseBtn { + font-family: sans-serif; + display: none; + font-size: 4em; +} +#prevBtn { + color: #fff; + text-align: right; + vertical-align: middle; + line-height: 0.75em; + font-size: 1.5em; + padding:0; +} +#nextBtn { + color: #fff; + text-align: left; + vertical-align: middle; + line-height: 0.75em; + font-size: 1.5em; + padding:0; +} + + +/* Progress */ +#bar { + position: relative; + flex-grow: 1; + text-align: center; + vertical-align: middle; + line-height: 1em; +} +#bar > hr { + border: none; + border-bottom: 1px solid #000; + position: absolute; + height: 1px; + width: calc(100% - 1em); +} + +#progress { + position: relative; + width: 0%; + height: 0.33em; + margin: 0.33em 0; + background-color: #bbb; +} + +/* Loading */ +#loading { + display: none; +} + +/* Plylist */ +#playlist { + display: block; + margin-left: 0.25em; +} +#list { + font-size: 1.25em; +} +.list-song { + padding: 0.25em; +} +.list-song:hover { + cursor: pointer; + background: #f1f2f6; +} + +#footer { + font-family: helvetica, sans-serif; + border-top: 1px solid rgb(128,128,128,0.3); + text-align: right; + color: #888; + font-size: 0.9em; + letter-spacing: 0.1em; + margin: 0; + margin-top: 1em; + padding-top: 1em; +} +#footer a { + color: #aaa; +} + +/* Volume */ +#volume { + display: none; +} +.bar { +} +#barEmpty { + width: 90%; + opacity: 0.5; + cursor: pointer; +} +#barFull { + width: 90%; +} +#sliderBtn { + width: 50px; + height: 50px; + cursor: pointer; +} + +/* Fade-In */ +.fadeout { + webkit-animation: fadeout 0.5s; + -ms-animation: fadeout 0.5s; + animation: fadeout 0.5s; +} +.fadein { + webkit-animation: fadein 0.5s; + -ms-animation: fadein 0.5s; + animation: fadein 0.5s; +} +@keyframes fadein { + from { opacity: 0; } + to { opacity: 1; } +} +@-webkit-keyframes fadein { + from { opacity: 0; } + to { opacity: 1; } +} +@-ms-keyframes fadein { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes fadeout { + from { opacity: 1; } + to { opacity: 0; } +} +@-webkit-keyframes fadeout { + from { opacity: 1; } + to { opacity: 0; } +} +@-ms-keyframes fadeout { + from { opacity: 1; } + to { opacity: 0; } +} diff --git a/web-templates/playlist.tpl b/web-templates/playlist.tpl index d876aea..c38c7d8 100644 --- a/web-templates/playlist.tpl +++ b/web-templates/playlist.tpl @@ -1,6 +1,66 @@ - + + + + + + Groove On Demand + + + + + + + +
+ +
+
+ +
+
+

{{playlist['name']}}

+ {{playlist['description']}} +
+
+ + + + + + +
+
+
+
+
+
+
+
+
+
0:00
+
+
+
+
+
0:00
+
+
+
+
+
+
+
+ +
+ + +