implementing themes and refactoring path operations
This commit is contained in:
parent
50d2fe1349
commit
d5934a99fb
|
@ -1,8 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
|
|
||||||
import music_tag
|
import music_tag
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
@ -10,20 +8,17 @@ from typing import Callable, Union, Iterable
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
import groove.db
|
import groove.db
|
||||||
|
import groove.path
|
||||||
|
|
||||||
|
|
||||||
class MediaScanner:
|
class MediaScanner:
|
||||||
"""
|
"""
|
||||||
Scan a directory structure containing audio files and import them into the database.
|
Scan a directory structure containing audio files and import them into the database.
|
||||||
"""
|
"""
|
||||||
def __init__(self, root: Path, db: Callable, glob: Union[str, None] = None) -> None:
|
def __init__(self, root: Union[Path, None], db: Callable, glob: Union[str, None] = None) -> None:
|
||||||
self._db = db
|
self._db = db
|
||||||
self._glob = tuple((glob or os.environ.get('MEDIA_GLOB')).split(','))
|
self._glob = tuple((glob or os.environ.get('MEDIA_GLOB')).split(','))
|
||||||
try:
|
self._root = root or groove.path.media_root()
|
||||||
self._root = root or Path(os.environ.get('MEDIA_ROOT'))
|
|
||||||
except TypeError:
|
|
||||||
logging.error("Could not find media root. Do you need to define MEDIA_ROOT in your environment?")
|
|
||||||
sys.exit(1)
|
|
||||||
logging.debug(f"Configured media scanner for root: {self._root}")
|
logging.debug(f"Configured media scanner for root: {self._root}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -3,3 +3,21 @@ class APIHandlingException(Exception):
|
||||||
"""
|
"""
|
||||||
An API reqeust could not be encoded or decoded.
|
An API reqeust could not be encoded or decoded.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeMissingException(Exception):
|
||||||
|
"""
|
||||||
|
The specified theme could not be loaded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeConfigurationError(Exception):
|
||||||
|
"""
|
||||||
|
A theme is missing required files or configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationError(Exception):
|
||||||
|
"""
|
||||||
|
An error was discovered with the Groove on Demand configuration.
|
||||||
|
"""
|
||||||
|
|
90
groove/path.py
Normal file
90
groove/path.py
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from groove.exceptions import ConfigurationError, ThemeMissingException
|
||||||
|
|
||||||
|
_setup_hint = "You may be able to solve this error by running 'groove setup'."
|
||||||
|
_reinstall_hint = "You might need to reinstall Groove On Demand to fix this error."
|
||||||
|
|
||||||
|
|
||||||
|
def root():
|
||||||
|
path = os.environ.get('GROOVE_ON_DEMAND_ROOT', None)
|
||||||
|
if not path:
|
||||||
|
raise ConfigurationError(f"GROOVE_ON_DEMAND_ROOT is not defined in your environment.\n\n{_setup_hint}")
|
||||||
|
path = Path(path).expanduser()
|
||||||
|
if not path.exists or not path.is_dir:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"The Groove on Demand root directory (GROOVE_ON_DEMAND_ROOT) "
|
||||||
|
f"does not exist or isn't a directory.\n\n{_reinstall_hint}"
|
||||||
|
)
|
||||||
|
return Path(path)
|
||||||
|
|
||||||
|
|
||||||
|
def media_root():
|
||||||
|
path = os.environ.get('MEDIA_ROOT', None)
|
||||||
|
if not path:
|
||||||
|
raise ConfigurationError(f"MEDIA_ROOT is not defined in your environment.\n\n{_setup_hint}")
|
||||||
|
path =Path(path).expanduser()
|
||||||
|
if not path.exists and path.is_dir:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"The media_root directory (MEDIA_ROOT) doesn't exist, or isn't a directory.\n\n{_setup_hint}"
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def media(relpath):
|
||||||
|
return themes_root() / Path(relpath)
|
||||||
|
|
||||||
|
|
||||||
|
def static_root():
|
||||||
|
dirname = os.environ.get('STATIC_PATH', 'static')
|
||||||
|
path = root() / Path(dirname)
|
||||||
|
if not path.exists or not path.is_dir:
|
||||||
|
raise ConfigurationError(
|
||||||
|
f"The static assets directory {dirname} (STATIC_PATH) "
|
||||||
|
f"doesn't exist, or isn't a directory.\n\n{_reinstall_hint}"
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def static(relpath):
|
||||||
|
return static_root() / Path(relpath)
|
||||||
|
|
||||||
|
|
||||||
|
def themes_root():
|
||||||
|
dirname = os.environ.get('THEMES_PATH', 'themes')
|
||||||
|
path = root() / Path(dirname)
|
||||||
|
if not path.exists or not path.is_dir:
|
||||||
|
raise ConfigurationError(
|
||||||
|
f"The themes directory {dirname} (THEMES_PATH) "
|
||||||
|
f"doesn't exist, or isn't a directory.\n\n{_reinstall_hint}"
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def theme(name):
|
||||||
|
path = themes_root() / Path(name)
|
||||||
|
if not path.exists or not path.is_dir:
|
||||||
|
available = ','.join(available_themes)
|
||||||
|
raise ThemeMissingException(
|
||||||
|
f"A theme directory named {name} does not exist or isn't a directory. "
|
||||||
|
"Perhaps there is a typo in the name?\n"
|
||||||
|
f"Available themes: {available}"
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def theme_static(relpath):
|
||||||
|
return Path('static') / Path(relpath)
|
||||||
|
|
||||||
|
|
||||||
|
def theme_template(template_name):
|
||||||
|
return Path('templates') / Path(f"{template_name}.tpl")
|
||||||
|
|
||||||
|
|
||||||
|
def available_themes():
|
||||||
|
return [theme.name for theme in themes_root().iterdir() if theme.is_dir()]
|
||||||
|
|
||||||
|
|
||||||
|
def database():
|
||||||
|
return root() / Path(os.environ.get('DATABASE_PATH', 'groove_on_demand.db'))
|
62
groove/webserver/themes.py
Normal file
62
groove/webserver/themes.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections import namedtuple
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from groove.exceptions import ThemeConfigurationError, ConfigurationError
|
||||||
|
|
||||||
|
import groove.path
|
||||||
|
|
||||||
|
|
||||||
|
Theme = namedtuple('Theme', 'path,author,author_link,version,about')
|
||||||
|
|
||||||
|
|
||||||
|
def load_theme(name=None):
|
||||||
|
name = os.environ.get('DEFAULT_THEME', name)
|
||||||
|
if not name:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"It seems like DEFAULT_THEME is not set in your current environment.\n"
|
||||||
|
"Running 'groove setup' may help you fix this problem."
|
||||||
|
)
|
||||||
|
theme_path = groove.path.theme(name)
|
||||||
|
theme_info = _get_theme_info(theme_path)
|
||||||
|
return Theme(
|
||||||
|
path=theme_path,
|
||||||
|
**theme_info
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_theme_info(theme_path):
|
||||||
|
readme = theme_path / Path('README.md')
|
||||||
|
if not readme.exists:
|
||||||
|
raise ThemeConfigurationError(
|
||||||
|
"The theme is missing a required file: README.md.\n"
|
||||||
|
"Refer to the Groove On Demand documentation for help creating themes."
|
||||||
|
)
|
||||||
|
config = {'about': ''}
|
||||||
|
with readme.open() as fh:
|
||||||
|
in_credits = False
|
||||||
|
in_block = False
|
||||||
|
for line in fh.readlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line == '## Credits':
|
||||||
|
in_credits = True
|
||||||
|
continue
|
||||||
|
config['about'] += line
|
||||||
|
if not in_credits:
|
||||||
|
continue
|
||||||
|
if line == '```':
|
||||||
|
if not in_block:
|
||||||
|
in_block = True
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
(key, value) = line.split(':', 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
except ValueError:
|
||||||
|
logging.warn(f"Could not parse credits line: {line}")
|
||||||
|
continue
|
||||||
|
logging.debug(f"Setting theme '{key}' to '{value}'.")
|
||||||
|
config[key] = value
|
||||||
|
return config
|
|
@ -1,7 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import bottle
|
import bottle
|
||||||
from bottle import HTTPResponse, template, static_file
|
from bottle import HTTPResponse, template, static_file
|
||||||
|
@ -11,9 +10,7 @@ import groove.db
|
||||||
from groove.auth import is_authenticated
|
from groove.auth import is_authenticated
|
||||||
from groove.db.manager import database_manager
|
from groove.db.manager import database_manager
|
||||||
from groove.playlist import Playlist
|
from groove.playlist import Playlist
|
||||||
from groove.webserver import requests
|
from groove.webserver import requests, themes
|
||||||
|
|
||||||
# from groove.exceptions import APIHandlingException
|
|
||||||
|
|
||||||
server = bottle.Bottle()
|
server = bottle.Bottle()
|
||||||
|
|
||||||
|
@ -42,6 +39,16 @@ def start(host: str, port: int, debug: bool) -> None: # pragma: no cover
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(template_name, theme=None, **template_args):
|
||||||
|
theme = themes.load_theme(theme)
|
||||||
|
return HTTPResponse(status=200, body=template(
|
||||||
|
str(theme.path / groove.path.theme_template(template_name)),
|
||||||
|
url=requests.url(),
|
||||||
|
theme=theme,
|
||||||
|
**template_args
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
@server.route('/')
|
@server.route('/')
|
||||||
def index():
|
def index():
|
||||||
return "Groovy."
|
return "Groovy."
|
||||||
|
@ -55,7 +62,18 @@ def build():
|
||||||
|
|
||||||
@server.route('/static/<filepath:path>')
|
@server.route('/static/<filepath:path>')
|
||||||
def server_static(filepath):
|
def server_static(filepath):
|
||||||
return static_file(filepath, root='static')
|
theme = themes.load_theme()
|
||||||
|
asset = theme.path / groove.path.theme_static(filepath)
|
||||||
|
if asset.exists():
|
||||||
|
root = asset.parent
|
||||||
|
else:
|
||||||
|
root = groove.path.static_root()
|
||||||
|
asset = groove.path.static(filepath)
|
||||||
|
if asset.is_dir():
|
||||||
|
logging.warning("Asset {asset} is a directory; returning 404.")
|
||||||
|
return HTTPResponse(status=404, body="Not found.")
|
||||||
|
logging.debug(f"Serving asset {asset.name} from {root}")
|
||||||
|
return static_file(asset.name, root=root)
|
||||||
|
|
||||||
|
|
||||||
@bottle.auth_basic(is_authenticated)
|
@bottle.auth_basic(is_authenticated)
|
||||||
|
@ -79,8 +97,11 @@ def serve_track(request, track_id, db):
|
||||||
groove.db.track.c.id == track_id
|
groove.db.track.c.id == track_id
|
||||||
).one()
|
).one()
|
||||||
|
|
||||||
path = Path(os.environ['MEDIA_ROOT']) / Path(track['relpath'])
|
path = groove.path.media(track['relpath'])
|
||||||
|
if path.exists:
|
||||||
return static_file(path.name, root=path.parent)
|
return static_file(path.name, root=path.parent)
|
||||||
|
else:
|
||||||
|
return HTTPResponse(status=404, body="Not found")
|
||||||
|
|
||||||
|
|
||||||
@server.route('/playlist/<slug>')
|
@server.route('/playlist/<slug>')
|
||||||
|
@ -97,15 +118,8 @@ def serve_playlist(slug, db):
|
||||||
logging.debug(playlist.as_dict['entries'])
|
logging.debug(playlist.as_dict['entries'])
|
||||||
|
|
||||||
pl = playlist.as_dict
|
pl = playlist.as_dict
|
||||||
|
|
||||||
for entry in pl['entries']:
|
for entry in pl['entries']:
|
||||||
sig = requests.encode([str(entry['track_id'])], uri='/track')
|
sig = requests.encode([str(entry['track_id'])], uri='/track')
|
||||||
entry['url'] = f"/track/{sig}/{entry['track_id']}"
|
entry['url'] = f"/track/{sig}/{entry['track_id']}"
|
||||||
|
|
||||||
template_path = Path(os.environ['TEMPLATE_PATH']) / Path('playlist.tpl')
|
return serve('playlist', playlist=pl)
|
||||||
body = template(
|
|
||||||
str(template_path),
|
|
||||||
url=requests.url(),
|
|
||||||
playlist=pl
|
|
||||||
)
|
|
||||||
return HTTPResponse(status=200, body=body)
|
|
||||||
|
|
241
static/player.js
Normal file
241
static/player.js
Normal file
|
@ -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');
|
||||||
|
});
|
10
themes/blue_train/README.md
Normal file
10
themes/blue_train/README.md
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
# Blue Train
|
||||||
|
|
||||||
|
This is the default theme for Groove on Demand, inspired by the cover to John Coltrane's 1957 album _Blue Train_, [designed by Reid Miles](https://www.immigrantentrepreneurship.org/images/john-coltrane-blue-train-album-cover-1957/).
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
```
|
||||||
|
author: @evilchili
|
||||||
|
author_link: https://linernotes.club/@evilchili
|
||||||
|
version: 1.0
|
||||||
|
```
|
259
themes/blue_train/static/styles.css
Normal file
259
themes/blue_train/static/styles.css
Normal file
|
@ -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; }
|
||||||
|
}
|
66
themes/blue_train/templates/playlist.tpl
Normal file
66
themes/blue_train/templates/playlist.tpl
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="user-scalable=no">
|
||||||
|
<title>Groove On Demand</title>
|
||||||
|
<link rel='stylesheet' href='/static/styles.css' />
|
||||||
|
<link rel='stylesheet' href="https://fonts.cdnfonts.com/css/clarendon-mt-std" />
|
||||||
|
<script defer crossorigin src='https://cdnjs.cloudflare.com/ajax/libs/howler/2.2.3/howler.core.min.js'></script>
|
||||||
|
<script defer src='/static/player.js'></script>
|
||||||
|
<script>
|
||||||
|
var playlist_tracks = [
|
||||||
|
% for entry in playlist['entries']:
|
||||||
|
{
|
||||||
|
title: "{{entry['artist']}} - {{entry['title']}}",
|
||||||
|
url: "{{entry['url']}}",
|
||||||
|
},
|
||||||
|
% end
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id='container'>
|
||||||
|
|
||||||
|
<div id='details'>
|
||||||
|
<div id='poster'>
|
||||||
|
<img src=''></img>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 id='playlist_title'>{{playlist['name']}}</h1>
|
||||||
|
<span id='playlist_desc'>{{playlist['description']}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table id='player'>
|
||||||
|
<tr>
|
||||||
|
<td id='controls'>
|
||||||
|
<div id='big_button' class='btn'>
|
||||||
|
<div id="loading"></div>
|
||||||
|
<div id="playBtn">⏵</div>
|
||||||
|
<div id="pauseBtn">⏸</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div id="track"></div>
|
||||||
|
<div id='track_controls'>
|
||||||
|
<div class='widget' id="timer">0:00</div>
|
||||||
|
<div class='widget' id="bar">
|
||||||
|
<hr>
|
||||||
|
<div id="progress"></div>
|
||||||
|
</div>
|
||||||
|
<div class='widget' id="duration">0:00</div>
|
||||||
|
<div class="widget btn" id="prevBtn">⏮</div>
|
||||||
|
<div class="widget btn" id="nextBtn">⏭</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div id="playlist">
|
||||||
|
<div id="list"></div>
|
||||||
|
</div>
|
||||||
|
<div id='footer'>groove on demand : an <a alt="evilchili at liner notes dot club" href="https://linernotes.club/@evilchili">@evilchili</a> jam</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue
Block a user