Merge pull request #1082 from chme/web_next

Player web interface v0.8.0
This commit is contained in:
Christian Meffert 2020-10-17 08:01:16 +02:00 committed by GitHub
commit 4331153966
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 3879 additions and 2840 deletions

View File

@ -10,10 +10,10 @@
<!-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.1/css/bulma.min.css"> --> <!-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.1/css/bulma.min.css"> -->
<!-- Local libraries --> <!-- Local libraries -->
<link rel="stylesheet" href="/admin/vendor/fontawesome/css/all.min.css"> <link rel="stylesheet" href="admin/vendor/fontawesome/css/all.min.css">
<link rel="stylesheet" href="/admin/vendor/bulma/bulma.min.css"> <link rel="stylesheet" href="admin/vendor/bulma/bulma.min.css">
<link rel="stylesheet" href="/admin/css/forked-daapd.css"> <link rel="stylesheet" href="admin/css/forked-daapd.css">
</head> </head>
<body> <body>
@ -25,7 +25,7 @@
--> -->
<nav class="navbar"> <nav class="navbar">
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item has-text-black" href="/">forked-daapd</a> <a class="navbar-item has-text-black" href="./">forked-daapd</a>
<a class="navbar-item" href="https://github.com/ejurgensen/forked-daapd" title="GitHub"><i class="fab fa-github"></i></a> <a class="navbar-item" href="https://github.com/ejurgensen/forked-daapd" title="GitHub"><i class="fab fa-github"></i></a>
</div> </div>
</nav> </nav>
@ -281,9 +281,9 @@
<!-- <script src="https://unpkg.com/axios@0.18.0/dist/axios.min.js"></script> --> <!-- <script src="https://unpkg.com/axios@0.18.0/dist/axios.min.js"></script> -->
<!-- Local libraries --> <!-- Local libraries -->
<script src="/admin/vendor/vue/vue.min.js"></script> <script src="admin/vendor/vue/vue.min.js"></script>
<script src="/admin/vendor/axios/axios.min.js"></script> <script src="admin/vendor/axios/axios.min.js"></script>
<script src="/admin/js/forked-daapd.js"></script> <script src="admin/js/forked-daapd.js"></script>
</body> </body>
</html> </html>

View File

@ -35,43 +35,43 @@ var app = new Vue({
methods: { methods: {
loadConfig: function() { loadConfig: function() {
axios.get('/api/config').then(response => { axios.get('./api/config').then(response => {
this.config = response.data; this.config = response.data;
this.connect()}); this.connect()});
}, },
loadLibrary: function() { loadLibrary: function() {
axios.get('/api/library').then(response => this.library = response.data); axios.get('./api/library').then(response => this.library = response.data);
}, },
loadOutputs: function() { loadOutputs: function() {
axios.get('/api/outputs').then(response => this.outputs = response.data.outputs); axios.get('./api/outputs').then(response => this.outputs = response.data.outputs);
}, },
loadSpotify: function() { loadSpotify: function() {
axios.get('/api/spotify').then(response => this.spotify = response.data); axios.get('./api/spotify').then(response => this.spotify = response.data);
}, },
loadPairing: function() { loadPairing: function() {
axios.get('/api/pairing').then(response => this.pairing = response.data); axios.get('./api/pairing').then(response => this.pairing = response.data);
}, },
loadLastfm: function() { loadLastfm: function() {
axios.get('/api/lastfm').then(response => this.lastfm = response.data); axios.get('./api/lastfm').then(response => this.lastfm = response.data);
}, },
update: function() { update: function() {
this.library.updating = true; this.library.updating = true;
axios.put('/api/update').then(console.log('Library is updating')); axios.put('./api/update').then(console.log('Library is updating'));
}, },
update_meta: function() { update_meta: function() {
this.library.updating = true; this.library.updating = true;
axios.put('/api/rescan').then(console.log('Library is rescanning meta')); axios.put('./api/rescan').then(console.log('Library is rescanning meta'));
}, },
kickoffPairing: function() { kickoffPairing: function() {
axios.post('/api/pairing', this.pairing_req).then(response => { axios.post('./api/pairing', this.pairing_req).then(response => {
console.log('Kicked off pairing'); console.log('Kicked off pairing');
if (!this.config.websocket_port) { if (!this.config.websocket_port) {
this.pairing = {}; this.pairing = {};
@ -80,7 +80,7 @@ var app = new Vue({
}, },
kickoffVerification: function() { kickoffVerification: function() {
axios.post('/api/verification', this.verification_req).then(response => { axios.post('./api/verification', this.verification_req).then(response => {
console.log('Kicked off verification'); console.log('Kicked off verification');
this.verification_req.pin = ''; this.verification_req.pin = '';
}); });
@ -94,7 +94,7 @@ var app = new Vue({
} }
} }
axios.put('/api/outputs/set', { outputs: selected_outputs }).then(response => { axios.put('./api/outputs/set', { outputs: selected_outputs }).then(response => {
if (!this.config.websocket_port) { if (!this.config.websocket_port) {
this.loadOutputs(); this.loadOutputs();
} }
@ -102,7 +102,7 @@ var app = new Vue({
}, },
loginLibspotify: function() { loginLibspotify: function() {
axios.post('/api/spotify-login', this.libspotify).then(response => { axios.post('./api/spotify-login', this.libspotify).then(response => {
this.libspotify.user = ''; this.libspotify.user = '';
this.libspotify.password = ''; this.libspotify.password = '';
this.libspotify.errors.user = ''; this.libspotify.errors.user = '';
@ -117,7 +117,7 @@ var app = new Vue({
}, },
loginLastfm: function() { loginLastfm: function() {
axios.post('/api/lastfm-login', this.lastfm_login).then(response => { axios.post('./api/lastfm-login', this.lastfm_login).then(response => {
this.lastfm_login.user = ''; this.lastfm_login.user = '';
this.lastfm_login.password = ''; this.lastfm_login.password = '';
this.lastfm_login.errors.user = ''; this.lastfm_login.errors.user = '';
@ -132,7 +132,7 @@ var app = new Vue({
}, },
logoutLastfm: function() { logoutLastfm: function() {
axios.get('/api/lastfm-logout', this.lastfm_login).then(response => { axios.get('./api/lastfm-logout', this.lastfm_login).then(response => {
if (!this.config.websocket_port) { if (!this.config.websocket_port) {
this.loadLastfm(); this.loadLastfm();
} }

View File

@ -1 +1 @@
<!DOCTYPE html><html class="has-navbar-fixed-top has-navbar-fixed-bottom"><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>forked-daapd-web 2</title><link rel=apple-touch-icon sizes=120x120 href=/apple-touch-icon.png?ver1.1><link rel=icon type=image/png sizes=32x32 href=/favicon-32x32.png><link rel=icon type=image/png sizes=16x16 href=/favicon-16x16.png><link rel=manifest href=/site.webmanifest><link rel=mask-icon href=/safari-pinned-tab.svg color=#5bbad5><meta name=msapplication-TileColor content=#da532c><meta name=theme-color content=#ffffff><link href=/player/css/app.css rel=preload as=style><link href=/player/css/chunk-vendors.css rel=preload as=style><link href=/player/js/app.js rel=modulepreload as=script><link href=/player/js/chunk-vendors.js rel=modulepreload as=script><link href=/player/css/chunk-vendors.css rel=stylesheet><link href=/player/css/app.css rel=stylesheet></head><body><div id=app></div><script type=module src=/player/js/chunk-vendors.js></script><script type=module src=/player/js/app.js></script><script>!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();</script><script src=/player/js/chunk-vendors-legacy.js nomodule></script><script src=/player/js/app-legacy.js nomodule></script></body></html> <!DOCTYPE html><html class="has-navbar-fixed-top has-navbar-fixed-bottom"><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>forked-daapd-web 2</title><link rel=apple-touch-icon sizes=120x120 href=apple-touch-icon.png?ver1.1><link rel=icon type=image/png sizes=32x32 href=favicon-32x32.png><link rel=icon type=image/png sizes=16x16 href=favicon-16x16.png><link rel=manifest href=site.webmanifest><link rel=mask-icon href=safari-pinned-tab.svg color=#5bbad5><meta name=msapplication-TileColor content=#da532c><meta name=theme-color content=#ffffff><link href=player/css/app.css rel=preload as=style><link href=player/css/chunk-vendors.css rel=preload as=style><link href=player/js/app.js rel=modulepreload as=script><link href=player/js/chunk-vendors.js rel=modulepreload as=script><link href=player/css/chunk-vendors.css rel=stylesheet><link href=player/css/app.css rel=stylesheet></head><body><div id=app></div><script type=module src=player/js/chunk-vendors.js></script><script type=module src=player/js/app.js></script><script>!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();</script><script src=player/js/chunk-vendors-legacy.js nomodule></script><script src=player/js/app-legacy.js nomodule></script></body></html>

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -301,7 +301,7 @@ httpd_request_etag_matches(struct evhttp_request *req, const char *etag)
// Add cache headers to allow client side caching // Add cache headers to allow client side caching
output_headers = evhttp_request_get_output_headers(req); output_headers = evhttp_request_get_output_headers(req);
evhttp_add_header(output_headers, "Cache-Control", "private no-cache"); evhttp_add_header(output_headers, "Cache-Control", "private,no-cache,max-age=0");
evhttp_add_header(output_headers, "ETag", etag); evhttp_add_header(output_headers, "ETag", etag);
return false; return false;
@ -338,12 +338,28 @@ httpd_request_not_modified_since(struct evhttp_request *req, time_t mtime)
// Add cache headers to allow client side caching // Add cache headers to allow client side caching
output_headers = evhttp_request_get_output_headers(req); output_headers = evhttp_request_get_output_headers(req);
evhttp_add_header(output_headers, "Cache-Control", "private no-cache"); evhttp_add_header(output_headers, "Cache-Control", "private,no-cache,max-age=0");
evhttp_add_header(output_headers, "Last-Modified", last_modified); evhttp_add_header(output_headers, "Last-Modified", last_modified);
return false; return false;
} }
void
httpd_response_not_cachable(struct evhttp_request *req)
{
struct evkeyvalq *output_headers;
output_headers = evhttp_request_get_output_headers(req);
// Remove potentially set cache control headers
evhttp_remove_header(output_headers, "Cache-Control");
evhttp_remove_header(output_headers, "Last-Modified");
evhttp_remove_header(output_headers, "ETag");
// Tell clients that they are not allowed to cache this response
evhttp_add_header(output_headers, "Cache-Control", "no-store");
}
static void static void
serve_file(struct evhttp_request *req, const char *uri) serve_file(struct evhttp_request *req, const char *uri)
{ {

View File

@ -107,6 +107,9 @@ httpd_request_not_modified_since(struct evhttp_request *req, time_t mtime);
bool bool
httpd_request_etag_matches(struct evhttp_request *req, const char *etag); httpd_request_etag_matches(struct evhttp_request *req, const char *etag);
void
httpd_response_not_cachable(struct evhttp_request *req);
/* /*
* Gzips an evbuffer * Gzips an evbuffer
* *

View File

@ -213,7 +213,7 @@ artist_to_json(struct db_group_info *dbgri)
if (ret < sizeof(uri)) if (ret < sizeof(uri))
json_object_object_add(item, "uri", json_object_new_string(uri)); json_object_object_add(item, "uri", json_object_new_string(uri));
ret = snprintf(artwork_url, sizeof(artwork_url), "/artwork/group/%s", dbgri->id); ret = snprintf(artwork_url, sizeof(artwork_url), "./artwork/group/%s", dbgri->id);
if (ret < sizeof(artwork_url)) if (ret < sizeof(artwork_url))
json_object_object_add(item, "artwork_url", json_object_new_string(artwork_url)); json_object_object_add(item, "artwork_url", json_object_new_string(artwork_url));
@ -261,7 +261,7 @@ album_to_json(struct db_group_info *dbgri)
if (ret < sizeof(uri)) if (ret < sizeof(uri))
json_object_object_add(item, "uri", json_object_new_string(uri)); json_object_object_add(item, "uri", json_object_new_string(uri));
ret = snprintf(artwork_url, sizeof(artwork_url), "/artwork/group/%s", dbgri->id); ret = snprintf(artwork_url, sizeof(artwork_url), "./artwork/group/%s", dbgri->id);
if (ret < sizeof(artwork_url)) if (ret < sizeof(artwork_url))
json_object_object_add(item, "artwork_url", json_object_new_string(artwork_url)); json_object_object_add(item, "artwork_url", json_object_new_string(artwork_url));
@ -338,6 +338,7 @@ playlist_to_json(struct db_playlist_info *dbpli)
json_object *item; json_object *item;
char uri[100]; char uri[100];
int intval; int intval;
bool boolval;
int ret; int ret;
item = json_object_new_object(); item = json_object_new_object();
@ -351,6 +352,10 @@ playlist_to_json(struct db_playlist_info *dbpli)
{ {
safe_json_add_string(item, "type", db_pl_type_label(intval)); safe_json_add_string(item, "type", db_pl_type_label(intval));
json_object_object_add(item, "smart_playlist", json_object_new_boolean(intval == PL_SMART)); json_object_object_add(item, "smart_playlist", json_object_new_boolean(intval == PL_SMART));
boolval = dbpli->query_order && strcasestr(dbpli->query_order, "random");
json_object_object_add(item, "random", json_object_new_boolean(boolval));
json_object_object_add(item, "folder", json_object_new_boolean(intval == PL_FOLDER)); json_object_object_add(item, "folder", json_object_new_boolean(intval == PL_FOLDER));
} }
@ -2099,7 +2104,7 @@ jsonapi_reply_player(struct httpd_request *hreq)
json_object_object_add(reply, "item_id", json_object_new_int(status.item_id)); json_object_object_add(reply, "item_id", json_object_new_int(status.item_id));
json_object_object_add(reply, "item_length_ms", json_object_new_int(status.len_ms)); json_object_object_add(reply, "item_length_ms", json_object_new_int(status.len_ms));
json_object_object_add(reply, "item_progress_ms", json_object_new_int(status.pos_ms)); json_object_object_add(reply, "item_progress_ms", json_object_new_int(status.pos_ms));
json_object_object_add(reply, "artwork_url", json_object_new_string("/artwork/nowplaying")); json_object_object_add(reply, "artwork_url", json_object_new_string("./artwork/nowplaying"));
} }
else else
{ {
@ -2194,7 +2199,7 @@ queue_item_to_json(struct db_queue_item *queue_item, char shuffle)
{ {
// Queue item does not have a valid artwork url, construct artwork url to // Queue item does not have a valid artwork url, construct artwork url to
// get the image through the httpd_artworkapi (uses the artwork handlers). // get the image through the httpd_artworkapi (uses the artwork handlers).
ret = snprintf(artwork_url, sizeof(artwork_url), "/artwork/item/%d", queue_item->file_id); ret = snprintf(artwork_url, sizeof(artwork_url), "./artwork/item/%d", queue_item->file_id);
if (ret < sizeof(artwork_url)) if (ret < sizeof(artwork_url))
json_object_object_add(item, "artwork_url", json_object_new_string(artwork_url)); json_object_object_add(item, "artwork_url", json_object_new_string(artwork_url));
} }
@ -2203,7 +2208,7 @@ queue_item_to_json(struct db_queue_item *queue_item, char shuffle)
// Pipe and stream metadata can change if the queue version changes. Construct artwork url // Pipe and stream metadata can change if the queue version changes. Construct artwork url
// similar to non-pipe items, but append the queue version to the url to force // similar to non-pipe items, but append the queue version to the url to force
// clients to reload image if the queue version changes (additional metadata was found). // clients to reload image if the queue version changes (additional metadata was found).
ret = snprintf(artwork_url, sizeof(artwork_url), "/artwork/item/%d?v=%d", queue_item->file_id, queue_item->queue_version); ret = snprintf(artwork_url, sizeof(artwork_url), "./artwork/item/%d?v=%d", queue_item->file_id, queue_item->queue_version);
if (ret < sizeof(artwork_url)) if (ret < sizeof(artwork_url))
json_object_object_add(item, "artwork_url", json_object_new_string(artwork_url)); json_object_object_add(item, "artwork_url", json_object_new_string(artwork_url));
} }
@ -3516,8 +3521,8 @@ jsonapi_reply_library_playlist_tracks(struct httpd_request *hreq)
int total; int total;
int ret = 0; int ret = 0;
if (!is_modified(hreq->req, DB_ADMIN_DB_MODIFIED)) // Due to smart playlists possibly changing their tracks between rescans, disable caching in clients
return HTTP_NOTMODIFIED; httpd_response_not_cachable(hreq->req);
ret = safe_atoi32(hreq->uri_parsed->path_parts[3], &playlist_id); ret = safe_atoi32(hreq->uri_parsed->path_parts[3], &playlist_id);
if (ret < 0) if (ret < 0)

View File

@ -24,22 +24,29 @@
#include "db.h" #include "db.h"
#include "conffile.h" #include "conffile.h"
// Forward - setting initializers
static bool artwork_spotify_default_getbool(struct settings_option *option);
static bool artwork_discogs_default_getbool(struct settings_option *option);
static bool artwork_coverartarchive_default_getbool(struct settings_option *option);
static struct settings_option webinterface_options[] = static struct settings_option webinterface_options[] =
{ {
{ "show_composer_now_playing", SETTINGS_TYPE_BOOL }, { "show_composer_now_playing", SETTINGS_TYPE_BOOL },
{ "show_composer_for_genre", SETTINGS_TYPE_STR }, { "show_composer_for_genre", SETTINGS_TYPE_STR },
{ "show_cover_artwork_in_album_lists", SETTINGS_TYPE_BOOL, { true } },
{ "show_menu_item_playlists", SETTINGS_TYPE_BOOL, { true } },
{ "show_menu_item_music", SETTINGS_TYPE_BOOL, { true } },
{ "show_menu_item_podcasts", SETTINGS_TYPE_BOOL, { true } },
{ "show_menu_item_audiobooks", SETTINGS_TYPE_BOOL, { true } },
{ "show_menu_item_radio", SETTINGS_TYPE_BOOL, { false } },
{ "show_menu_item_files", SETTINGS_TYPE_BOOL, { true } },
{ "show_menu_item_search", SETTINGS_TYPE_BOOL, { true } },
}; };
static struct settings_option artwork_options[] = static struct settings_option artwork_options[] =
{ {
{ "use_artwork_source_spotify", SETTINGS_TYPE_BOOL, NULL, artwork_spotify_default_getbool, NULL }, // Spotify source enabled by default, it will only work for premium users anyway.
{ "use_artwork_source_discogs", SETTINGS_TYPE_BOOL, NULL, artwork_discogs_default_getbool, NULL }, // So Spotify probably won't mind, and the user probably also won't mind that we
{ "use_artwork_source_coverartarchive", SETTINGS_TYPE_BOOL, NULL, artwork_coverartarchive_default_getbool, NULL }, // share data with Spotify, since he is already doing it.
{ "use_artwork_source_spotify", SETTINGS_TYPE_BOOL, { true } },
{ "use_artwork_source_discogs", SETTINGS_TYPE_BOOL, { false } },
{ "use_artwork_source_coverartarchive", SETTINGS_TYPE_BOOL, { false } },
}; };
static struct settings_option misc_options[] = static struct settings_option misc_options[] =
@ -64,51 +71,6 @@ static struct settings_category categories[] =
}; };
/* ---------------------------- DEFAULT SETTERS ------------------------------*/
static bool
artwork_default_getbool(bool no_cfg_default, const char *cfg_name)
{
cfg_t *lib = cfg_getsec(cfg, "library");
const char *name;
int n_cfg;
int i;
n_cfg = cfg_size(lib, "artwork_online_sources");
if (n_cfg == 0)
return no_cfg_default;
for (i = 0; i < n_cfg; i++)
{
name = cfg_getnstr(lib, "artwork_online_sources", i);
if (strcasecmp(name, cfg_name) == 0)
return true;
}
return false;
}
static bool
artwork_spotify_default_getbool(struct settings_option *option)
{
// Enabled by default, it will only work for premium users anyway. So Spotify
// probably won't mind, and the user probably also won't mind that we share
// data with Spotify, since he is already doing it.
return artwork_default_getbool(true, "spotify");
}
static bool
artwork_discogs_default_getbool(struct settings_option *option)
{
return artwork_default_getbool(false, "discogs");
}
static bool
artwork_coverartarchive_default_getbool(struct settings_option *option)
{
return artwork_default_getbool(false, "coverartarchive");
}
/* ------------------------------ IMPLEMENTATION -----------------------------*/ /* ------------------------------ IMPLEMENTATION -----------------------------*/
int int
@ -185,8 +147,8 @@ settings_option_getint(struct settings_option *option)
if (ret == 0) if (ret == 0)
return intval; return intval;
if (option->default_getint) if (option->default_value.intval)
return option->default_getint(option); return option->default_value.intval;
return 0; return 0;
} }
@ -204,8 +166,8 @@ settings_option_getbool(struct settings_option *option)
if (ret == 0) if (ret == 0)
return (intval != 0); return (intval != 0);
if (option->default_getbool) if (option->default_value.boolval)
return option->default_getbool(option); return option->default_value.boolval;
return false; return false;
} }
@ -223,8 +185,8 @@ settings_option_getstr(struct settings_option *option)
if (ret == 0) if (ret == 0)
return s; return s;
if (option->default_getstr) if (option->default_value.strval)
return option->default_getstr(option); return option->default_value.strval;
return NULL; return NULL;
} }

View File

@ -12,12 +12,16 @@ enum settings_type {
SETTINGS_TYPE_CATEGORY, SETTINGS_TYPE_CATEGORY,
}; };
union settings_default_value {
int intval;
bool boolval;
char *strval;
};
struct settings_option { struct settings_option {
const char *name; const char *name;
enum settings_type type; enum settings_type type;
int (*default_getint)(struct settings_option *option); union settings_default_value default_value;
bool (*default_getbool)(struct settings_option *option);
char *(*default_getstr)(struct settings_option *option);
}; };
struct settings_category { struct settings_category {

3752
web-src/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "forked-daapd-web", "name": "forked-daapd-web",
"version": "0.7.2", "version": "0.8.0",
"private": true, "private": true,
"description": "forked-daapd web interface", "description": "forked-daapd web interface",
"author": "chme <christian.meffert@googlemail.com>", "author": "chme <christian.meffert@googlemail.com>",
@ -11,41 +11,44 @@
"dev": "vue-cli-service serve" "dev": "vue-cli-service serve"
}, },
"dependencies": { "dependencies": {
"axios": "^0.19.2", "axios": "^0.20.0",
"bulma": "^0.9.0", "bulma": "^0.9.0",
"bulma-switch": "^2.0.0",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"mdi": "^2.2.43", "mdi": "^2.2.43",
"moment": "^2.27.0", "moment": "^2.28.0",
"moment-duration-format": "^2.3.2", "moment-duration-format": "^2.3.2",
"npm": "^6.14.5", "npm": "^6.14.8",
"reconnectingwebsocket": "^1.0.0", "reconnectingwebsocket": "^1.0.0",
"spotify-web-api-js": "^1.4.0", "spotify-web-api-js": "^1.5.0",
"string-to-color": "^2.1.4", "string-to-color": "^2.2.2",
"v-click-outside": "^3.0.1", "v-click-outside": "^3.1.2",
"vue": "^2.6.11", "vue": "^2.6.12",
"vue-infinite-loading": "^2.4.5", "vue-infinite-loading": "^2.4.5",
"vue-observe-visibility": "^0.4.6",
"vue-progressbar": "^0.7.5", "vue-progressbar": "^0.7.5",
"vue-range-slider": "^0.6.0", "vue-range-slider": "^0.6.0",
"vue-router": "^3.3.4", "vue-router": "^3.4.3",
"vue-scrollto": "^2.18.2",
"vue-tiny-lazyload-img": "^0.1.0", "vue-tiny-lazyload-img": "^0.1.0",
"vuedraggable": "^2.23.2", "vuedraggable": "^2.24.1",
"vuex": "^3.5.1" "vuex": "^3.5.1"
}, },
"devDependencies": { "devDependencies": {
"@vue/cli-plugin-babel": "^4.4.6", "@vue/cli-plugin-babel": "^4.5.6",
"@vue/cli-plugin-eslint": "^4.4.6", "@vue/cli-plugin-eslint": "^4.5.6",
"@vue/cli-service": "^4.4.6", "@vue/cli-service": "^4.5.6",
"@vue/eslint-config-standard": "^5.1.2", "@vue/eslint-config-standard": "^5.1.2",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"eslint": "^7.3.1", "eslint": "^7.9.0",
"eslint-plugin-import": "^2.22.0", "eslint-plugin-import": "^2.22.0",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1", "eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1", "eslint-plugin-standard": "^4.0.1",
"eslint-plugin-vue": "^6.2.2", "eslint-plugin-vue": "^6.2.2",
"sass": "^1.26.9", "sass": "^1.26.11",
"sass-loader": "^8.0.2", "sass-loader": "^10.0.2",
"vue-template-compiler": "^2.6.11" "vue-template-compiler": "^2.6.12"
}, },
"license": "GPL-2.0" "license": "GPL-2.0"
} }

View File

@ -4,11 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>forked-daapd-web 2</title> <title>forked-daapd-web 2</title>
<link rel="apple-touch-icon" sizes="120x120" href="/apple-touch-icon.png?ver1.1"> <link rel="apple-touch-icon" sizes="120x120" href="apple-touch-icon.png?ver1.1">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest"> <link rel="manifest" href="site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5"> <link rel="mask-icon" href="safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#da532c"> <meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
</head> </head>

View File

@ -1,7 +1,6 @@
<template> <template>
<figure> <figure>
<img v-lazyload <img v-lazyload
:src="dataURI"
:data-src="artwork_url_with_size" :data-src="artwork_url_with_size"
:data-err="dataURI" :data-err="dataURI"
@click="$emit('click')"> @click="$emit('click')">
@ -15,7 +14,7 @@ import stringToColor from 'string-to-color'
export default { export default {
name: 'CoverArtwork', name: 'CoverArtwork',
props: ['artist', 'album', 'artwork_url'], props: ['artist', 'album', 'artwork_url', 'maxwidth', 'maxheight'],
data () { data () {
return { return {
@ -30,6 +29,9 @@ export default {
computed: { computed: {
artwork_url_with_size: function () { artwork_url_with_size: function () {
if (this.maxwidth > 0 && this.maxheight > 0) {
return webapi.artwork_url_append_size_params(this.artwork_url, this.maxwidth, this.maxheight)
}
return webapi.artwork_url_append_size_params(this.artwork_url) return webapi.artwork_url_append_size_params(this.artwork_url)
}, },

View File

@ -0,0 +1,50 @@
<template>
<div class="dropdown" :class="{ 'is-active': is_active }" v-click-outside="onClickOutside">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu" @click="is_active = !is_active">
<span>{{ value }}</span>
<span class="icon is-small">
<i class="mdi mdi-chevron-down" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
<a class="dropdown-item"
v-for="option in options" :key="option"
:class="{'is-active': value === option}"
@click="select(option)">
{{ option }}
</a>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'DropdownMenu',
props: ['value', 'options'],
data () {
return {
is_active: false
}
},
methods: {
onClickOutside (event) {
this.is_active = false
},
select (option) {
this.is_active = false
this.$emit('input', option)
}
}
}
</script>
<style>
</style>

View File

@ -1,11 +1,8 @@
<template> <template>
<section> <section>
<nav class="buttons is-centered fd-is-square" style="margin-bottom: 48px;" v-if="filtered_index.length > 1"> <nav class="buttons is-centered fd-is-square" style="margin-bottom: 16px;">
<a v-for="char in filtered_index" :key="char" class="button is-small" @click="nav(char)">{{ char }}</a> <a v-for="char in filtered_index" :key="char" class="button is-small" @click="nav(char)">{{ char }}</a>
</nav> </nav>
<nav class="buttons is-centered" style="margin-bottom: 6px;" v-if="filtered_index.length > 1">
<a class="button is-small is-white" @click="scroll_to_top"><span class="icon is-small"><i class="mdi mdi-chevron-up"></i></span></a>
</nav>
</section> </section>
</template> </template>

View File

@ -0,0 +1,159 @@
<template>
<div>
<div v-if="is_grouped">
<div v-for="idx in albums.indexList" :key="idx" class="mb-6">
<span class="tag is-info is-light is-small has-text-weight-bold" :id="'index_' + idx">{{ idx }}</span>
<list-item-album v-for="album in albums.grouped[idx]"
:key="album.id"
:album="album"
@click="open_album(album)">
<template slot="artwork" v-if="is_visible_artwork">
<p class="image is-64x64 fd-has-shadow fd-has-action">
<cover-artwork
:artwork_url="album.artwork_url"
:artist="album.artist"
:album="album.name"
:maxwidth="64"
:maxheight="64" />
</p>
</template>
<template slot="actions">
<a @click="open_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-album>
</div>
</div>
<div v-else>
<list-item-album v-for="album in albums_list"
:key="album.id"
:album="album"
@click="open_album(album)">
<template slot="artwork" v-if="is_visible_artwork">
<p class="image is-64x64 fd-has-shadow fd-has-action">
<cover-artwork
:artwork_url="album.artwork_url"
:artist="album.artist"
:album="album.name"
:maxwidth="64"
:maxheight="64" />
</p>
</template>
<template slot="actions">
<a @click="open_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-album>
</div>
<modal-dialog-album
:show="show_details_modal"
:album="selected_album"
:media_kind="media_kind"
@remove-podcast="open_remove_podcast_dialog()"
@close="show_details_modal = false" />
<modal-dialog
:show="show_remove_podcast_modal"
title="Remove podcast"
delete_action="Remove"
@close="show_remove_podcast_modal = false"
@delete="remove_podcast">
<template slot="modal-content">
<p>Permanently remove this podcast from your library?</p>
<p class="is-size-7">(This will also remove the RSS playlist <b>{{ rss_playlist_to_remove.name }}</b>.)</p>
</template>
</modal-dialog>
</div>
</template>
<script>
import ListItemAlbum from '@/components/ListItemAlbum'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialog from '@/components/ModalDialog'
import CoverArtwork from '@/components/CoverArtwork'
import webapi from '@/webapi'
import Albums from '@/lib/Albums'
export default {
name: 'ListAlbums',
components: { ListItemAlbum, ModalDialogAlbum, ModalDialog, CoverArtwork },
props: ['albums', 'media_kind'],
data () {
return {
show_details_modal: false,
selected_album: {},
show_remove_podcast_modal: false,
rss_playlist_to_remove: {}
}
},
computed: {
is_visible_artwork () {
return this.$store.getters.settings_option('webinterface', 'show_cover_artwork_in_album_lists').value
},
media_kind_resolved: function () {
return this.media_kind ? this.media_kind : this.selected_album.media_kind
},
albums_list: function () {
if (Array.isArray(this.albums)) {
return this.albums
}
return this.albums.sortedAndFiltered
},
is_grouped: function () {
return (this.albums instanceof Albums && this.albums.options.group)
}
},
methods: {
open_album: function (album) {
this.selected_album = album
if (this.media_kind_resolved === 'podcast') {
this.$router.push({ path: '/podcasts/' + album.id })
} else if (this.media_kind_resolved === 'audiobook') {
this.$router.push({ path: '/audiobooks/' + album.id })
} else {
this.$router.push({ path: '/music/albums/' + album.id })
}
},
open_dialog: function (album) {
this.selected_album = album
this.show_details_modal = true
},
open_remove_podcast_dialog: function () {
webapi.library_album_tracks(this.selected_album.id, { limit: 1 }).then(({ data }) => {
webapi.library_track_playlists(data.items[0].id).then(({ data }) => {
const rssPlaylists = data.items.filter(pl => pl.type === 'rss')
if (rssPlaylists.length !== 1) {
this.$store.dispatch('add_notification', { text: 'Podcast cannot be removed. Probably it was not added as an RSS playlist.', type: 'danger' })
return
}
this.rss_playlist_to_remove = rssPlaylists[0]
this.show_remove_podcast_modal = true
this.show_details_modal = false
})
})
},
remove_podcast: function () {
this.show_remove_podcast_modal = false
webapi.library_playlist_delete(this.rss_playlist_to_remove.id).then(() => {
this.$emit('podcast-deleted')
})
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,90 @@
<template>
<div>
<div v-if="is_grouped">
<div v-for="idx in artists.indexList" :key="idx" class="mb-6">
<span class="tag is-info is-light is-small has-text-weight-bold" :id="'index_' + idx">{{ idx }}</span>
<list-item-artist v-for="artist in artists.grouped[idx]"
:key="artist.id"
:artist="artist"
@click="open_artist(artist)">
<template slot="actions">
<a @click="open_dialog(artist)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-artist>
</div>
</div>
<div v-else>
<list-item-artist v-for="artist in artists_list"
:key="artist.id"
:artist="artist"
@click="open_artist(artist)">
<template slot="actions">
<a @click="open_dialog(artist)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-artist>
</div>
<modal-dialog-artist :show="show_details_modal" :artist="selected_artist" :media_kind="media_kind" @close="show_details_modal = false" />
</div>
</template>
<script>
import ListItemArtist from '@/components/ListItemArtist'
import ModalDialogArtist from '@/components/ModalDialogArtist'
import Artists from '@/lib/Artists'
export default {
name: 'ListArtists',
components: { ListItemArtist, ModalDialogArtist },
props: ['artists', 'media_kind'],
data () {
return {
show_details_modal: false,
selected_artist: {}
}
},
computed: {
media_kind_resolved: function () {
return this.media_kind ? this.media_kind : this.selected_artist.media_kind
},
artists_list: function () {
if (Array.isArray(this.artists)) {
return this.artists
}
return this.artists.sortedAndFiltered
},
is_grouped: function () {
return (this.artists instanceof Artists && this.artists.options.group)
}
},
methods: {
open_artist: function (artist) {
this.selected_artist = artist
if (this.media_kind_resolved === 'podcast') {
// No artist page for podcasts
} else if (this.media_kind_resolved === 'audiobook') {
this.$router.push({ path: '/audiobooks/artists/' + artist.id })
} else {
this.$router.push({ path: '/music/artists/' + artist.id })
}
},
open_dialog: function (artist) {
this.selected_artist = artist
this.show_details_modal = true
}
}
}
</script>
<style>
</style>

View File

@ -1,6 +1,10 @@
<template functional> <template functional>
<div class="media" :id="'index_' + props.album.name_sort.charAt(0).toUpperCase()"> <div class="media" :id="'index_' + props.album.name_sort.charAt(0).toUpperCase()">
<slot name="artwork"></slot> <div class="media-left fd-has-action"
v-if="$slots['artwork']"
@click="listeners.click">
<slot name="artwork"></slot>
</div>
<div class="media-content fd-has-action is-clipped" @click="listeners.click"> <div class="media-content fd-has-action is-clipped" @click="listeners.click">
<div style="margin-top:0.7rem;"> <div style="margin-top:0.7rem;">
<h1 class="title is-6">{{ props.album.name }}</h1> <h1 class="title is-6">{{ props.album.name }}</h1>

View File

@ -1,5 +1,5 @@
<template functional> <template functional>
<div class="media" :id="'index_' + props.artist.name_sort.charAt(0).toUpperCase()"> <div class="media">
<div class="media-content fd-has-action is-clipped" @click="listeners.click"> <div class="media-content fd-has-action is-clipped" @click="listeners.click">
<h1 class="title is-6">{{ props.artist.name }}</h1> <h1 class="title is-6">{{ props.artist.name }}</h1>
</div> </div>

View File

@ -0,0 +1,54 @@
<template>
<div>
<list-item-playlist v-for="playlist in playlists" :key="playlist.id" :playlist="playlist" @click="open_playlist(playlist)">
<template slot="icon">
<span class="icon">
<i class="mdi" :class="{ 'mdi-library-music': playlist.type !== 'folder', 'mdi-rss': playlist.type === 'rss', 'mdi-folder': playlist.type === 'folder' }"></i>
</span>
</template>
<template slot="actions">
<a @click="open_dialog(playlist)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-playlist>
<modal-dialog-playlist :show="show_details_modal" :playlist="selected_playlist" @close="show_details_modal = false" />
</div>
</template>
<script>
import ListItemPlaylist from '@/components/ListItemPlaylist'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
export default {
name: 'ListPlaylists',
components: { ListItemPlaylist, ModalDialogPlaylist },
props: ['playlists'],
data () {
return {
show_details_modal: false,
selected_playlist: {}
}
},
methods: {
open_playlist: function (playlist) {
if (playlist.type !== 'folder') {
this.$router.push({ path: '/playlists/' + playlist.id + '/tracks' })
} else {
this.$router.push({ path: '/playlists/' + playlist.id })
}
},
open_dialog: function (playlist) {
this.selected_playlist = playlist
this.show_details_modal = true
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,52 @@
<template>
<div>
<list-item-track v-for="(track, index) in tracks" :key="track.id" :track="track" @click="play_track(index, track)">
<template slot="actions">
<a @click="open_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
</div>
</template>
<script>
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import webapi from '@/webapi'
export default {
name: 'ListTracks',
components: { ListItemTrack, ModalDialogTrack },
props: ['tracks', 'uris', 'expression'],
data () {
return {
show_details_modal: false,
selected_track: {}
}
},
methods: {
play_track: function (position, track) {
if (this.uris) {
webapi.player_play_uri(this.uris, false, position)
} else if (this.expression) {
webapi.player_play_expression(this.expression, false, position)
} else {
webapi.player_play_uri(track.uri, false)
}
},
open_dialog: function (track) {
this.selected_track = track
this.show_details_modal = true
}
}
}
</script>
<style>
</style>

View File

@ -14,23 +14,39 @@
<p class="title is-4"> <p class="title is-4">
<a class="has-text-link" @click="open_album">{{ album.name }}</a> <a class="has-text-link" @click="open_album">{{ album.name }}</a>
</p> </p>
<div class="buttons" v-if="media_kind === 'podcast'"> <div class="buttons" v-if="media_kind_resolved === 'podcast'">
<a class="button is-small" @click="mark_played">Mark as played</a> <a class="button is-small" @click="mark_played">Mark as played</a>
<a class="button is-small" @click="$emit('remove_podcast')">Remove podcast</a> <a class="button is-small" @click="$emit('remove-podcast')">Remove podcast</a>
</div> </div>
<div class="content is-small"> <div class="content is-small">
<p v-if="album.artist && media_kind !== 'audiobook'"> <p v-if="album.artist">
<span class="heading">Album artist</span> <span class="heading">Album artist</span>
<a class="title is-6 has-text-link" @click="open_artist">{{ album.artist }}</a> <a class="title is-6 has-text-link" @click="open_artist">{{ album.artist }}</a>
</p> </p>
<p v-if="album.artist && media_kind === 'audiobook'"> <p v-if="album.date_released">
<span class="heading">Album artist</span> <span class="heading">Release date</span>
<span class="title is-6">{{ album.artist }}</span> <span class="title is-6">{{ album.date_released | time('L') }}</span>
</p>
<p v-else-if="album.year > 0">
<span class="heading">Year</span>
<span class="title is-6">{{ album.year }}</span>
</p> </p>
<p> <p>
<span class="heading">Tracks</span> <span class="heading">Tracks</span>
<span class="title is-6">{{ album.track_count }}</span> <span class="title is-6">{{ album.track_count }}</span>
</p> </p>
<p>
<span class="heading">Length</span>
<span class="title is-6">{{ album.length_ms | duration }}</span>
</p>
<p>
<span class="heading">Type</span>
<span class="title is-6">{{ album.media_kind }} - {{ album.data_kind }}</span>
</p>
<p>
<span class="heading">Added at</span>
<span class="title is-6">{{ album.time_added | time('L LT') }}</span>
</p>
</div> </div>
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
@ -70,6 +86,10 @@ export default {
computed: { computed: {
artwork_url: function () { artwork_url: function () {
return webapi.artwork_url_append_size_params(this.album.artwork_url) return webapi.artwork_url_append_size_params(this.album.artwork_url)
},
media_kind_resolved: function () {
return this.media_kind ? this.media_kind : this.album.media_kind
} }
}, },
@ -90,9 +110,9 @@ export default {
}, },
open_album: function () { open_album: function () {
if (this.media_kind === 'podcast') { if (this.media_kind_resolved === 'podcast') {
this.$router.push({ path: '/podcasts/' + this.album.id }) this.$router.push({ path: '/podcasts/' + this.album.id })
} else if (this.media_kind === 'audiobook') { } else if (this.media_kind_resolved === 'audiobook') {
this.$router.push({ path: '/audiobooks/' + this.album.id }) this.$router.push({ path: '/audiobooks/' + this.album.id })
} else { } else {
this.$router.push({ path: '/music/albums/' + this.album.id }) this.$router.push({ path: '/music/albums/' + this.album.id })
@ -100,7 +120,13 @@ export default {
}, },
open_artist: function () { open_artist: function () {
this.$router.push({ path: '/music/artists/' + this.album.artist_id }) if (this.media_kind_resolved === 'podcast') {
// No artist page for podcasts
} else if (this.media_kind_resolved === 'audiobook') {
this.$router.push({ path: '/audiobooks/artists/' + this.album.artist_id })
} else {
this.$router.push({ path: '/music/artists/' + this.album.artist_id })
}
}, },
mark_played: function () { mark_played: function () {

View File

@ -18,6 +18,14 @@
<span class="heading">Tracks</span> <span class="heading">Tracks</span>
<span class="title is-6">{{ artist.track_count }}</span> <span class="title is-6">{{ artist.track_count }}</span>
</p> </p>
<p>
<span class="heading">Type</span>
<span class="title is-6">{{ artist.data_kind }}</span>
</p>
<p>
<span class="heading">Added at</span>
<span class="title is-6">{{ artist.time_added | time('L LT') }}</span>
</p>
</div> </div>
</div> </div>
<footer class="card-footer"> <footer class="card-footer">

View File

@ -44,7 +44,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogPlaylist', name: 'ModalDialogPlaylist',
props: ['show', 'playlist'], props: ['show', 'playlist', 'tracks'],
methods: { methods: {
play: function () { play: function () {

View File

@ -81,7 +81,7 @@
</div> </div>
<div class="level-item fd-expanded"> <div class="level-item fd-expanded">
<div class="fd-expanded"> <div class="fd-expanded">
<p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="/stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p> <p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p>
<range-slider <range-slider
class="slider fd-has-action" class="slider fd-has-action"
min="0" min="0"
@ -174,7 +174,7 @@
</div> </div>
<div class="level-item fd-expanded"> <div class="level-item fd-expanded">
<div class="fd-expanded"> <div class="fd-expanded">
<p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="/stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p> <p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p>
<range-slider <range-slider
class="slider fd-has-action" class="slider fd-has-action"
min="0" min="0"

View File

@ -1,22 +1,25 @@
<template> <template>
<nav class="fd-top-navbar navbar is-light is-fixed-top" :style="zindex" role="navigation" aria-label="main navigation"> <nav class="fd-top-navbar navbar is-light is-fixed-top" :style="zindex" role="navigation" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<navbar-item-link to="/playlists"> <navbar-item-link to="/playlists" v-if="is_visible_playlists">
<span class="icon"><i class="mdi mdi-library-music"></i></span> <span class="icon"><i class="mdi mdi-library-music"></i></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/music"> <navbar-item-link to="/music" v-if="is_visible_music">
<span class="icon"><i class="mdi mdi-music"></i></span> <span class="icon"><i class="mdi mdi-music"></i></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/podcasts"> <navbar-item-link to="/podcasts" v-if="is_visible_podcasts">
<span class="icon"><i class="mdi mdi-microphone"></i></span> <span class="icon"><i class="mdi mdi-microphone"></i></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/audiobooks" v-if="audiobooks.tracks > 0"> <navbar-item-link to="/audiobooks" v-if="is_visible_audiobooks">
<span class="icon"><i class="mdi mdi-book-open-variant"></i></span> <span class="icon"><i class="mdi mdi-book-open-variant"></i></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/files"> <navbar-item-link to="/radio" v-if="is_visible_radio">
<span class="icon"><i class="mdi mdi-radio"></i></span>
</navbar-item-link>
<navbar-item-link to="/files" v-if="is_visible_files">
<span class="icon"><i class="mdi mdi-folder-open"></i></span> <span class="icon"><i class="mdi mdi-folder-open"></i></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/search"> <navbar-item-link to="/search" v-if="is_visible_search">
<span class="icon"><i class="mdi mdi-magnify"></i></span> <span class="icon"><i class="mdi mdi-magnify"></i></span>
</navbar-item-link> </navbar-item-link>
@ -49,16 +52,16 @@
<navbar-item-link to="/music/artists"><span class="fd-navbar-item-level2">Artists</span></navbar-item-link> <navbar-item-link to="/music/artists"><span class="fd-navbar-item-level2">Artists</span></navbar-item-link>
<navbar-item-link to="/music/albums"><span class="fd-navbar-item-level2">Albums</span></navbar-item-link> <navbar-item-link to="/music/albums"><span class="fd-navbar-item-level2">Albums</span></navbar-item-link>
<navbar-item-link to="/music/genres"><span class="fd-navbar-item-level2">Genres</span></navbar-item-link> <navbar-item-link to="/music/genres"><span class="fd-navbar-item-level2">Genres</span></navbar-item-link>
<navbar-item-link to="/music/radio"><span class="fd-navbar-item-level2">Radio</span></navbar-item-link>
<navbar-item-link to="/music/spotify" v-if="spotify_enabled"><span class="fd-navbar-item-level2">Spotify</span></navbar-item-link> <navbar-item-link to="/music/spotify" v-if="spotify_enabled"><span class="fd-navbar-item-level2">Spotify</span></navbar-item-link>
<navbar-item-link to="/podcasts"><span class="icon"><i class="mdi mdi-microphone"></i></span> <b>Podcasts</b></navbar-item-link> <navbar-item-link to="/podcasts"><span class="icon"><i class="mdi mdi-microphone"></i></span> <b>Podcasts</b></navbar-item-link>
<navbar-item-link to="/audiobooks"><span class="icon"><i class="mdi mdi-book-open-variant"></i></span> <b>Audiobooks</b></navbar-item-link> <navbar-item-link to="/audiobooks"><span class="icon"><i class="mdi mdi-book-open-variant"></i></span> <b>Audiobooks</b></navbar-item-link>
<navbar-item-link to="/radio"><span class="icon"><i class="mdi mdi-radio"></i></span> <b>Radio</b></navbar-item-link>
<navbar-item-link to="/files"><span class="icon"><i class="mdi mdi-folder-open"></i></span> <b>Files</b></navbar-item-link> <navbar-item-link to="/files"><span class="icon"><i class="mdi mdi-folder-open"></i></span> <b>Files</b></navbar-item-link>
<navbar-item-link to="/search"><span class="icon"><i class="mdi mdi-magnify"></i></span> <b>Search</b></navbar-item-link> <navbar-item-link to="/search"><span class="icon"><i class="mdi mdi-magnify"></i></span> <b>Search</b></navbar-item-link>
<hr class="fd-navbar-divider"> <hr class="fd-navbar-divider">
<navbar-item-link to="/settings/webinterface">Settings</navbar-item-link> <navbar-item-link to="/settings/webinterface">Settings</navbar-item-link>
<navbar-item-link to="/about">About</navbar-item-link> <navbar-item-link to="/about">Update Library</navbar-item-link>
<div class="navbar-item is-hidden-desktop" style="margin-bottom: 2.5rem;"></div> <div class="navbar-item is-hidden-desktop" style="margin-bottom: 2.5rem;"></div>
</div> </div>
@ -87,6 +90,28 @@ export default {
}, },
computed: { computed: {
is_visible_playlists () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_playlists').value
},
is_visible_music () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_music').value
},
is_visible_podcasts () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_podcasts').value
},
is_visible_audiobooks () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_audiobooks').value
},
is_visible_radio () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_radio').value
},
is_visible_files () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_files').value
},
is_visible_search () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_search').value
},
player () { player () {
return this.$store.state.player return this.$store.state.player
}, },

View File

@ -1,9 +1,14 @@
<template> <template functional>
<div class="media"> <div class="media">
<div class="media-content fd-has-action is-clipped" v-on:click="open_album"> <div class="media-left fd-has-action"
<h1 class="title is-6">{{ album.name }}</h1> v-if="$slots['artwork']"
<h2 class="subtitle is-7 has-text-grey"><b>{{ album.artists[0].name }}</b></h2> @click="listeners.click">
<h2 class="subtitle is-7 has-text-grey has-text-weight-normal">({{ album.album_type }}, {{ album.release_date | time('L') }})</h2> <slot name="artwork"></slot>
</div>
<div class="media-content fd-has-action is-clipped" @click="listeners.click">
<h1 class="title is-6">{{ props.album.name }}</h1>
<h2 class="subtitle is-7 has-text-grey"><b>{{ props.album.artists[0].name }}</b></h2>
<h2 class="subtitle is-7 has-text-grey has-text-weight-normal">({{ props.album.album_type }}, {{ props.album.release_date | time('L') }})</h2>
</div> </div>
<div class="media-right"> <div class="media-right">
<slot name="actions"></slot> <slot name="actions"></slot>
@ -14,14 +19,7 @@
<script> <script>
export default { export default {
name: 'SpotifyListItemAlbum', name: 'SpotifyListItemAlbum',
props: ['album']
props: ['album'],
methods: {
open_album: function () {
this.$router.push({ path: '/music/spotify/albums/' + this.album.id })
}
}
} }
</script> </script>

View File

@ -0,0 +1,35 @@
<template>
<section class="section fd-tabs-section">
<div class="container">
<div class="columns is-centered">
<div class="column is-four-fifths">
<div class="tabs is-centered is-small">
<ul>
<router-link tag="li" to="/audiobooks/artists" active-class="is-active">
<a>
<span class="icon is-small"><i class="mdi mdi-artist"></i></span>
<span class="">Authors</span>
</a>
</router-link>
<router-link tag="li" to="/audiobooks/albums" active-class="is-active">
<a>
<span class="icon is-small"><i class="mdi mdi-album"></i></span>
<span class="">Audiobooks</span>
</a>
</router-link>
</ul>
</div>
</div>
</div>
</div>
</section>
</template>
<script>
export default {
name: 'TabsAudiobooks'
}
</script>
<style>
</style>

View File

@ -29,12 +29,6 @@
<span class="">Genres</span> <span class="">Genres</span>
</a> </a>
</router-link> </router-link>
<router-link tag="li" to="/music/radio" active-class="is-active">
<a>
<span class="icon is-small"><i class="mdi mdi-radio"></i></span>
<span class="">Radio</span>
</a>
</router-link>
<router-link tag="li" to="/music/spotify" v-if="spotify_enabled" active-class="is-active"> <router-link tag="li" to="/music/spotify" v-if="spotify_enabled" active-class="is-active">
<a> <a>
<span class="icon is-small"><i class="mdi mdi-spotify"></i></span> <span class="icon is-small"><i class="mdi mdi-spotify"></i></span>

74
web-src/src/lib/Albums.js Normal file
View File

@ -0,0 +1,74 @@
export default class Albums {
constructor (items, options = { hideSingles: false, hideSpotify: false, sort: 'Name', group: false }) {
this.items = items
this.options = options
this.grouped = {}
this.sortedAndFiltered = []
this.indexList = []
this.init()
}
init () {
this.createSortedAndFilteredList()
this.createGroupedList()
this.createIndexList()
}
getAlbumIndex (album) {
if (this.options.sort === 'Recently added') {
return album.time_added.substring(0, 4)
} else if (this.options.sort === 'Recently released') {
return album.date_released ? album.date_released.substring(0, 4) : '0000'
}
return album.name_sort.charAt(0).toUpperCase()
}
isAlbumVisible (album) {
if (this.options.hideSingles && album.track_count <= 2) {
return false
}
if (this.options.hideSpotify && album.data_kind === 'spotify') {
return false
}
return true
}
createIndexList () {
this.indexList = [...new Set(this.sortedAndFiltered
.map(album => this.getAlbumIndex(album)))]
}
createSortedAndFilteredList () {
var albumsSorted = this.items
if (this.options.hideSingles || this.options.hideSpotify || this.options.hideOther) {
albumsSorted = albumsSorted.filter(album => this.isAlbumVisible(album))
}
if (this.options.sort === 'Recently added') {
albumsSorted = [...albumsSorted].sort((a, b) => b.time_added.localeCompare(a.time_added))
} else if (this.options.sort === 'Recently released') {
albumsSorted = [...albumsSorted].sort((a, b) => {
if (!a.date_released) {
return 1
}
if (!b.date_released) {
return -1
}
return b.date_released.localeCompare(a.date_released)
})
}
this.sortedAndFiltered = albumsSorted
}
createGroupedList () {
if (!this.options.group) {
this.grouped = {}
}
this.grouped = this.sortedAndFiltered.reduce((r, album) => {
const idx = this.getAlbumIndex(album)
r[idx] = [...r[idx] || [], album]
return r
}, {})
}
}

View File

@ -0,0 +1,62 @@
export default class Artists {
constructor (items, options = { hideSingles: false, hideSpotify: false, sort: 'Name', group: false }) {
this.items = items
this.options = options
this.grouped = {}
this.sortedAndFiltered = []
this.indexList = []
this.init()
}
init () {
this.createSortedAndFilteredList()
this.createGroupedList()
this.createIndexList()
}
getArtistIndex (artist) {
if (this.options.sort === 'Name') {
return artist.name_sort.charAt(0).toUpperCase()
}
return artist.time_added.substring(0, 4)
}
isArtistVisible (artist) {
if (this.options.hideSingles && artist.track_count <= (artist.album_count * 2)) {
return false
}
if (this.options.hideSpotify && artist.data_kind === 'spotify') {
return false
}
return true
}
createIndexList () {
this.indexList = [...new Set(this.sortedAndFiltered
.map(artist => this.getArtistIndex(artist)))]
}
createSortedAndFilteredList () {
var artistsSorted = this.items
if (this.options.hideSingles || this.options.hideSpotify || this.options.hideOther) {
artistsSorted = artistsSorted.filter(artist => this.isArtistVisible(artist))
}
if (this.options.sort === 'Recently added') {
artistsSorted = [...artistsSorted].sort((a, b) => b.time_added.localeCompare(a.time_added))
}
this.sortedAndFiltered = artistsSorted
}
createGroupedList () {
if (!this.options.group) {
this.grouped = {}
}
this.grouped = this.sortedAndFiltered.reduce((r, artist) => {
const idx = this.getArtistIndex(artist)
r[idx] = [...r[idx] || [], artist]
return r
}, {})
}
}

View File

@ -8,13 +8,18 @@ import './filter'
import './progress' import './progress'
import vClickOutside from 'v-click-outside' import vClickOutside from 'v-click-outside'
import VueTinyLazyloadImg from 'vue-tiny-lazyload-img' import VueTinyLazyloadImg from 'vue-tiny-lazyload-img'
import VueObserveVisibility from 'vue-observe-visibility'
import VueScrollTo from 'vue-scrollto'
import 'mdi/css/materialdesignicons.css' import 'mdi/css/materialdesignicons.css'
import 'vue-range-slider/dist/vue-range-slider.css' import 'vue-range-slider/dist/vue-range-slider.css'
import './mystyles.scss' import './mystyles.scss'
Vue.config.productionTip = false Vue.config.productionTip = false
Vue.use(vClickOutside) Vue.use(vClickOutside)
Vue.use(VueTinyLazyloadImg) Vue.use(VueTinyLazyloadImg)
Vue.use(VueObserveVisibility)
Vue.use(VueScrollTo)
/* eslint-disable no-new */ /* eslint-disable no-new */
new Vue({ new Vue({

View File

@ -1,5 +1,6 @@
@import 'bulma'; @import 'bulma';
@import '~bulma-switch';
.slider { .slider {
@ -80,7 +81,9 @@ a.navbar-item {
.fd-is-square .button { .fd-is-square .button {
height: 27px; height: 27px;
width: 27px; min-width: 27px;
padding-left: 0.25rem;
padding-right: 0.25rem;
} }
.fd-is-text-clipped { .fd-is-text-clipped {
@ -115,6 +118,11 @@ section.hero + section.fd-content {
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
} }
/* Set minimum height to hide "option" section */
.fd-content-with-option {
min-height: calc(100vh - 3.25rem - 3.25rem - 5rem);
}
/* Now playing page */ /* Now playing page */
.fd-is-fullheight { .fd-is-fullheight {
height: calc(100vh - 3.25rem - 3.25rem); height: calc(100vh - 3.25rem - 3.25rem);

View File

@ -26,7 +26,7 @@
<!-- Right side --> <!-- Right side -->
<div class="level-right"> <div class="level-right">
<div v-if="library.updating"><a class="button is-small is-loading">Update</a></div> <div v-if="library.updating"><a class="button is-small is-loading">Update</a></div>
<div v-else class="dropdown is-right" :class="{ 'is-active': show_update_dropdown }"> <div v-else class="dropdown is-right" :class="{ 'is-active': show_update_dropdown }" v-click-outside="onClickOutside">
<div class="dropdown-trigger"> <div class="dropdown-trigger">
<div class="buttons has-addons"> <div class="buttons has-addons">
<a @click="update" class="button is-small">Update</a> <a @click="update" class="button is-small">Update</a>
@ -126,6 +126,10 @@ export default {
}, },
methods: { methods: {
onClickOutside (event) {
this.show_update_dropdown = false
},
update: function () { update: function () {
this.show_update_dropdown = false this.show_update_dropdown = false
webapi.library_update() webapi.library_update()

View File

@ -24,14 +24,7 @@
</template> </template>
<template slot="content"> <template slot="content">
<p class="heading is-7 has-text-centered-mobile fd-has-margin-top">{{ album.track_count }} tracks</p> <p class="heading is-7 has-text-centered-mobile fd-has-margin-top">{{ album.track_count }} tracks</p>
<list-item-track v-for="(track, index) in tracks" :key="track.id" :track="track" @click="play_track(index)"> <list-tracks :tracks="tracks" :uris="album.uri"></list-tracks>
<template slot="actions">
<a @click="open_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
<modal-dialog-album :show="show_album_details_modal" :album="album" @close="show_album_details_modal = false" /> <modal-dialog-album :show="show_album_details_modal" :album="album" @close="show_album_details_modal = false" />
</template> </template>
</content-with-hero> </content-with-hero>
@ -40,8 +33,7 @@
<script> <script>
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHero from '@/templates/ContentWithHero' import ContentWithHero from '@/templates/ContentWithHero'
import ListItemTrack from '@/components/ListItemTrack' import ListTracks from '@/components/ListTracks'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum' import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import CoverArtwork from '@/components/CoverArtwork' import CoverArtwork from '@/components/CoverArtwork'
import webapi from '@/webapi' import webapi from '@/webapi'
@ -63,16 +55,13 @@ const albumData = {
export default { export default {
name: 'PageAlbum', name: 'PageAlbum',
mixins: [LoadDataBeforeEnterMixin(albumData)], mixins: [LoadDataBeforeEnterMixin(albumData)],
components: { ContentWithHero, ListItemTrack, ModalDialogTrack, ModalDialogAlbum, CoverArtwork }, components: { ContentWithHero, ListTracks, ModalDialogAlbum, CoverArtwork },
data () { data () {
return { return {
album: {}, album: {},
tracks: [], tracks: [],
show_details_modal: false,
selected_track: {},
show_album_details_modal: false show_album_details_modal: false
} }
}, },
@ -85,15 +74,6 @@ export default {
play: function () { play: function () {
webapi.player_play_uri(this.album.uri, true) webapi.player_play_uri(this.album.uri, true)
},
play_track: function (position) {
webapi.player_play_uri(this.album.uri, false, position)
},
open_dialog: function (track) {
this.selected_track = track
this.show_details_modal = true
} }
} }
} }

View File

@ -4,32 +4,40 @@
<content-with-heading> <content-with-heading>
<template slot="options"> <template slot="options">
<index-button-list :index="index_list"></index-button-list> <index-button-list :index="albums_list.indexList"></index-button-list>
<div class="columns">
<div class="column">
<p class="heading" style="margin-bottom: 24px;">Filter</p>
<div class="field">
<div class="control">
<input id="switchHideSingles" type="checkbox" name="switchHideSingles" class="switch" v-model="hide_singles">
<label for="switchHideSingles">Hide singles</label>
</div>
<p class="help">If active, hides singles and albums with tracks that only appear in playlists.</p>
</div>
<div class="field" v-if="spotify_enabled">
<div class="control">
<input id="switchHideSpotify" type="checkbox" name="switchHideSpotify" class="switch" v-model="hide_spotify">
<label for="switchHideSpotify">Hide albums from Spotify</label>
</div>
<p class="help">If active, hides albums that only appear in your Spotify library.</p>
</div>
</div>
<div class="column">
<p class="heading" style="margin-bottom: 24px;">Sort by</p>
<dropdown-menu v-model="sort" :options="sort_options"></dropdown-menu>
</div>
</div>
</template> </template>
<template slot="heading-left"> <template slot="heading-left">
<p class="title is-4">Albums</p> <p class="title is-4">Albums</p>
<p class="heading">{{ albums.total }} albums</p> <p class="heading">{{ albums_list.sortedAndFiltered.length }} Albums</p>
</template> </template>
<template slot="heading-right"> <template slot="heading-right">
<a class="button is-small" :class="{ 'is-info': hide_singles }" @click="update_hide_singles">
<span class="icon">
<i class="mdi mdi-numeric-1-box-multiple-outline"></i>
</span>
<span>Hide singles</span>
</a>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-album v-for="album in albums_filtered" <list-albums :albums="albums_list"></list-albums>
:key="album.id"
:album="album"
@click="open_album(album)">
<template slot="actions">
<a @click="open_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-album>
<modal-dialog-album :show="show_details_modal" :album="selected_album" @close="show_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
</div> </div>
@ -40,10 +48,11 @@ import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic' import TabsMusic from '@/components/TabsMusic'
import IndexButtonList from '@/components/IndexButtonList' import IndexButtonList from '@/components/IndexButtonList'
import ListItemAlbum from '@/components/ListItemAlbum' import ListAlbums from '@/components/ListAlbums'
import ModalDialogAlbum from '@/components/ModalDialogAlbum' import DropdownMenu from '@/components/DropdownMenu'
import webapi from '@/webapi' import webapi from '@/webapi'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
import Albums from '@/lib/Albums'
const albumsData = { const albumsData = {
load: function (to) { load: function (to) {
@ -61,48 +70,60 @@ const albumsData = {
export default { export default {
name: 'PageAlbums', name: 'PageAlbums',
mixins: [LoadDataBeforeEnterMixin(albumsData)], mixins: [LoadDataBeforeEnterMixin(albumsData)],
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemAlbum, ModalDialogAlbum }, components: { ContentWithHeading, TabsMusic, IndexButtonList, ListAlbums, DropdownMenu },
data () { data () {
return { return {
albums: { items: [] }, albums: { items: [] },
index_list: [], sort_options: ['Name', 'Recently added', 'Recently released']
show_details_modal: false,
selected_album: {}
} }
}, },
computed: { computed: {
hide_singles () { albums_list () {
return this.$store.state.hide_singles return new Albums(this.albums.items, {
hideSingles: this.hide_singles,
hideSpotify: this.hide_spotify,
sort: this.sort,
group: true
})
}, },
albums_filtered () { spotify_enabled () {
return this.albums.items.filter(album => !this.hide_singles || album.track_count > 2) return this.$store.state.spotify.webapi_token_valid
},
hide_singles: {
get () {
return this.$store.state.hide_singles
},
set (value) {
this.$store.commit(types.HIDE_SINGLES, value)
}
},
hide_spotify: {
get () {
return this.$store.state.hide_spotify
},
set (value) {
this.$store.commit(types.HIDE_SPOTIFY, value)
}
},
sort: {
get () {
return this.$store.state.albums_sort
},
set (value) {
this.$store.commit(types.ALBUMS_SORT, value)
}
} }
}, },
methods: { methods: {
update_hide_singles: function (e) { scrollToTop: function () {
this.$store.commit(types.HIDE_SINGLES, !this.hide_singles) window.scrollTo({ top: 0, behavior: 'smooth' })
},
open_album: function (album) {
this.$router.push({ path: '/music/albums/' + album.id })
},
open_dialog: function (album) {
this.selected_album = album
this.show_details_modal = true
}
},
watch: {
'hide_singles' () {
this.index_list = [...new Set(this.albums.items
.filter(album => !this.$store.state.hide_singles || album.track_count > 2)
.map(album => album.name_sort.charAt(0).toUpperCase()))]
} }
} }
} }

View File

@ -15,14 +15,7 @@
</template> </template>
<template slot="content"> <template slot="content">
<p class="heading has-text-centered-mobile">{{ artist.album_count }} albums | <a class="has-text-link" @click="open_tracks">{{ artist.track_count }} tracks</a></p> <p class="heading has-text-centered-mobile">{{ artist.album_count }} albums | <a class="has-text-link" @click="open_tracks">{{ artist.track_count }} tracks</a></p>
<list-item-album v-for="album in albums.items" :key="album.id" :album="album" @click="open_album(album)"> <list-albums :albums="albums.items"></list-albums>
<template slot="actions">
<a @click="open_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-album>
<modal-dialog-album :show="show_details_modal" :album="selected_album" @close="show_details_modal = false" />
<modal-dialog-artist :show="show_artist_details_modal" :artist="artist" @close="show_artist_details_modal = false" /> <modal-dialog-artist :show="show_artist_details_modal" :artist="artist" @close="show_artist_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
@ -31,8 +24,7 @@
<script> <script>
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemAlbum from '@/components/ListItemAlbum' import ListAlbums from '@/components/ListAlbums'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialogArtist from '@/components/ModalDialogArtist' import ModalDialogArtist from '@/components/ModalDialogArtist'
import webapi from '@/webapi' import webapi from '@/webapi'
@ -53,16 +45,13 @@ const artistData = {
export default { export default {
name: 'PageArtist', name: 'PageArtist',
mixins: [LoadDataBeforeEnterMixin(artistData)], mixins: [LoadDataBeforeEnterMixin(artistData)],
components: { ContentWithHeading, ListItemAlbum, ModalDialogAlbum, ModalDialogArtist }, components: { ContentWithHeading, ListAlbums, ModalDialogArtist },
data () { data () {
return { return {
artist: {}, artist: {},
albums: {}, albums: {},
show_details_modal: false,
selected_album: {},
show_artist_details_modal: false show_artist_details_modal: false
} }
}, },
@ -74,15 +63,6 @@ export default {
play: function () { play: function () {
webapi.player_play_uri(this.albums.items.map(a => a.uri).join(','), true) webapi.player_play_uri(this.albums.items.map(a => a.uri).join(','), true)
},
open_album: function (album) {
this.$router.push({ path: '/music/albums/' + album.id })
},
open_dialog: function (album) {
this.selected_album = album
this.show_details_modal = true
} }
} }
} }

View File

@ -19,14 +19,7 @@
</template> </template>
<template slot="content"> <template slot="content">
<p class="heading has-text-centered-mobile"><a class="has-text-link" @click="open_artist">{{ artist.album_count }} albums</a> | {{ artist.track_count }} tracks</p> <p class="heading has-text-centered-mobile"><a class="has-text-link" @click="open_artist">{{ artist.album_count }} albums</a> | {{ artist.track_count }} tracks</p>
<list-item-track v-for="(track, index) in tracks.items" :key="track.id" :track="track" @click="play_track(index)"> <list-tracks :tracks="tracks.items" :uris="track_uris"></list-tracks>
<template slot="actions">
<a @click="open_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
<modal-dialog-artist :show="show_artist_details_modal" :artist="artist" @close="show_artist_details_modal = false" /> <modal-dialog-artist :show="show_artist_details_modal" :artist="artist" @close="show_artist_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
@ -37,8 +30,7 @@
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import IndexButtonList from '@/components/IndexButtonList' import IndexButtonList from '@/components/IndexButtonList'
import ListItemTrack from '@/components/ListItemTrack' import ListTracks from '@/components/ListTracks'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogArtist from '@/components/ModalDialogArtist' import ModalDialogArtist from '@/components/ModalDialogArtist'
import webapi from '@/webapi' import webapi from '@/webapi'
@ -59,16 +51,13 @@ const tracksData = {
export default { export default {
name: 'PageArtistTracks', name: 'PageArtistTracks',
mixins: [LoadDataBeforeEnterMixin(tracksData)], mixins: [LoadDataBeforeEnterMixin(tracksData)],
components: { ContentWithHeading, ListItemTrack, IndexButtonList, ModalDialogTrack, ModalDialogArtist }, components: { ContentWithHeading, ListTracks, IndexButtonList, ModalDialogArtist },
data () { data () {
return { return {
artist: {}, artist: {},
tracks: { items: [] }, tracks: { items: [] },
show_details_modal: false,
selected_track: {},
show_artist_details_modal: false show_artist_details_modal: false
} }
}, },
@ -77,6 +66,10 @@ export default {
index_list () { index_list () {
return [...new Set(this.tracks.items return [...new Set(this.tracks.items
.map(track => track.title_sort.charAt(0).toUpperCase()))] .map(track => track.title_sort.charAt(0).toUpperCase()))]
},
track_uris () {
return this.tracks.items.map(a => a.uri).join(',')
} }
}, },
@ -88,15 +81,6 @@ export default {
play: function () { play: function () {
webapi.player_play_uri(this.tracks.items.map(a => a.uri).join(','), true) webapi.player_play_uri(this.tracks.items.map(a => a.uri).join(','), true)
},
play_track: function (position) {
webapi.player_play_uri(this.tracks.items.map(a => a.uri).join(','), false, position)
},
open_dialog: function (track) {
this.selected_track = track
this.show_details_modal = true
} }
} }
} }

View File

@ -4,32 +4,40 @@
<content-with-heading> <content-with-heading>
<template slot="options"> <template slot="options">
<index-button-list :index="index_list"></index-button-list> <index-button-list :index="artists_list.indexList"></index-button-list>
<div class="columns">
<div class="column">
<p class="heading" style="margin-bottom: 24px;">Filter</p>
<div class="field">
<div class="control">
<input id="switchHideSingles" type="checkbox" name="switchHideSingles" class="switch" v-model="hide_singles">
<label for="switchHideSingles">Hide singles</label>
</div>
<p class="help">If active, hides artists that only appear on singles or playlists.</p>
</div>
<div class="field" v-if="spotify_enabled">
<div class="control">
<input id="switchHideSpotify" type="checkbox" name="switchHideSpotify" class="switch" v-model="hide_spotify">
<label for="switchHideSpotify">Hide artists from Spotify</label>
</div>
<p class="help">If active, hides artists that only appear in your Spotify library.</p>
</div>
</div>
<div class="column">
<p class="heading" style="margin-bottom: 24px;">Sort by</p>
<dropdown-menu v-model="sort" :options="sort_options"></dropdown-menu>
</div>
</div>
</template> </template>
<template slot="heading-left"> <template slot="heading-left">
<p class="title is-4">Artists</p> <p class="title is-4">Artists</p>
<p class="heading">{{ artists.total }} artists</p> <p class="heading">{{ artists_list.sortedAndFiltered.length }} Artists</p>
</template> </template>
<template slot="heading-right"> <template slot="heading-right">
<a class="button is-small" :class="{ 'is-info': hide_singles }" @click="update_hide_singles">
<span class="icon">
<i class="mdi mdi-numeric-1-box-multiple-outline"></i>
</span>
<span>Hide singles</span>
</a>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-artist v-for="artist in artists_filtered" <list-artists :artists="artists_list"></list-artists>
:key="artist.id"
:artist="artist"
@click="open_artist(artist)">
<template slot="actions">
<a @click="open_dialog(artist)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-artist>
<modal-dialog-artist :show="show_details_modal" :artist="selected_artist" @close="show_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
</div> </div>
@ -40,14 +48,15 @@ import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic' import TabsMusic from '@/components/TabsMusic'
import IndexButtonList from '@/components/IndexButtonList' import IndexButtonList from '@/components/IndexButtonList'
import ListItemArtist from '@/components/ListItemArtist' import ListArtists from '@/components/ListArtists'
import ModalDialogArtist from '@/components/ModalDialogArtist' import DropdownMenu from '@/components/DropdownMenu'
import webapi from '@/webapi' import webapi from '@/webapi'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
import Artists from '@/lib/Artists'
const artistsData = { const artistsData = {
load: function (to) { load: function (to) {
return webapi.library_artists() return webapi.library_artists('music')
}, },
set: function (vm, response) { set: function (vm, response) {
@ -58,45 +67,60 @@ const artistsData = {
export default { export default {
name: 'PageArtists', name: 'PageArtists',
mixins: [LoadDataBeforeEnterMixin(artistsData)], mixins: [LoadDataBeforeEnterMixin(artistsData)],
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemArtist, ModalDialogArtist }, components: { ContentWithHeading, TabsMusic, IndexButtonList, ListArtists, DropdownMenu },
data () { data () {
return { return {
artists: { items: [] }, artists: { items: [] },
sort_options: ['Name', 'Recently added']
show_details_modal: false,
selected_artist: {}
} }
}, },
computed: { computed: {
hide_singles () { artists_list () {
return this.$store.state.hide_singles return new Artists(this.artists.items, {
hideSingles: this.hide_singles,
hideSpotify: this.hide_spotify,
sort: this.sort,
group: true
})
}, },
index_list () { spotify_enabled () {
return [...new Set(this.artists.items return this.$store.state.spotify.webapi_token_valid
.filter(artist => !this.$store.state.hide_singles || artist.track_count > (artist.album_count * 2))
.map(artist => artist.name_sort.charAt(0).toUpperCase()))]
}, },
artists_filtered () { hide_singles: {
return this.artists.items.filter(artist => !this.hide_singles || artist.track_count > (artist.album_count * 2)) get () {
return this.$store.state.hide_singles
},
set (value) {
this.$store.commit(types.HIDE_SINGLES, value)
}
},
hide_spotify: {
get () {
return this.$store.state.hide_spotify
},
set (value) {
this.$store.commit(types.HIDE_SPOTIFY, value)
}
},
sort: {
get () {
return this.$store.state.artists_sort
},
set (value) {
this.$store.commit(types.ARTISTS_SORT, value)
}
} }
}, },
methods: { methods: {
update_hide_singles: function (e) { scrollToTop: function () {
this.$store.commit(types.HIDE_SINGLES, !this.hide_singles) window.scrollTo({ top: 0, behavior: 'smooth' })
},
open_artist: function (artist) {
this.$router.push({ path: '/music/artists/' + artist.id })
},
open_dialog: function (artist) {
this.selected_artist = artist
this.show_details_modal = true
} }
} }
} }

View File

@ -1,67 +0,0 @@
<template>
<div>
<content-with-heading>
<template slot="heading-left">
<p class="title is-4">Audiobooks</p>
<p class="heading">{{ albums.total }} audiobooks</p>
</template>
<template slot="content">
<list-item-album v-for="album in albums.items" :key="album.id" :album="album" :media_kind="'audiobook'" @click="open_album(album)">
<template slot="actions">
<a @click="open_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-album>
<modal-dialog-album :show="show_details_modal" :album="selected_album" :media_kind="'audiobook'" @close="show_details_modal = false" />
</template>
</content-with-heading>
</div>
</template>
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemAlbum from '@/components/ListItemAlbum'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import webapi from '@/webapi'
const albumsData = {
load: function (to) {
return webapi.library_albums('audiobook')
},
set: function (vm, response) {
vm.albums = response.data
}
}
export default {
name: 'PageAudiobooks',
mixins: [LoadDataBeforeEnterMixin(albumsData)],
components: { ContentWithHeading, ListItemAlbum, ModalDialogAlbum },
data () {
return {
albums: {},
show_details_modal: false,
selected_album: {}
}
},
methods: {
open_album: function (album) {
this.$router.push({ path: '/audiobooks/' + album.id })
},
open_dialog: function (album) {
this.selected_album = album
this.show_details_modal = true
}
}
}
</script>
<style>
</style>

View File

@ -1,43 +1,41 @@
<template> <template>
<content-with-heading> <content-with-hero>
<template slot="heading-left"> <template slot="heading-left">
<div class="title is-4">{{ album.name }}</div> <h1 class="title is-5">{{ album.name }}</h1>
<div class="title is-4 has-text-grey has-text-weight-normal">{{ album.artist }}</div> <h2 class="subtitle is-6 has-text-link has-text-weight-normal"><a class="has-text-link" @click="open_artist">{{ album.artist }}</a></h2>
</template>
<template slot="heading-right"> <div class="buttons fd-is-centered-mobile fd-has-margin-top">
<div class="buttons is-centered"> <a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span>Play</span>
</a>
<a class="button is-small is-light is-rounded" @click="show_album_details_modal = true"> <a class="button is-small is-light is-rounded" @click="show_album_details_modal = true">
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span> <span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
</a> </a>
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon">
<i class="mdi mdi-play"></i>
</span>
<span>Play</span>
</a>
</div> </div>
</template> </template>
<template slot="heading-right">
<p class="image is-square fd-has-shadow fd-has-action">
<cover-artwork
:artwork_url="album.artwork_url"
:artist="album.artist"
:album="album.name"
@click="show_album_details_modal = true" />
</p>
</template>
<template slot="content"> <template slot="content">
<p class="heading has-text-centered-mobile">{{ album.track_count }} tracks</p> <p class="heading is-7 has-text-centered-mobile fd-has-margin-top">{{ album.track_count }} tracks</p>
<list-item-track v-for="(track, index) in tracks" :key="track.id" :track="track" @click="play_track(index)"> <list-tracks :tracks="tracks" :uris="album.uri"></list-tracks>
<template slot="actions">
<a @click="open_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
<modal-dialog-album :show="show_album_details_modal" :album="album" :media_kind="'audiobook'" @close="show_album_details_modal = false" /> <modal-dialog-album :show="show_album_details_modal" :album="album" :media_kind="'audiobook'" @close="show_album_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-hero>
</template> </template>
<script> <script>
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHero from '@/templates/ContentWithHero'
import ListItemTrack from '@/components/ListItemTrack' import ListTracks from '@/components/ListTracks'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum' import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import CoverArtwork from '@/components/CoverArtwork'
import webapi from '@/webapi' import webapi from '@/webapi'
const albumData = { const albumData = {
@ -55,23 +53,25 @@ const albumData = {
} }
export default { export default {
name: 'PageAudiobook', name: 'PageAudiobooksAlbum',
mixins: [LoadDataBeforeEnterMixin(albumData)], mixins: [LoadDataBeforeEnterMixin(albumData)],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogAlbum }, components: { ContentWithHero, ListTracks, ModalDialogAlbum, CoverArtwork },
data () { data () {
return { return {
album: {}, album: {},
tracks: [], tracks: [],
show_details_modal: false,
selected_track: {},
show_album_details_modal: false show_album_details_modal: false
} }
}, },
methods: { methods: {
open_artist: function () {
this.show_details_modal = false
this.$router.push({ path: '/audiobooks/artists/' + this.album.artist_id })
},
play: function () { play: function () {
webapi.player_play_uri(this.album.uri, false) webapi.player_play_uri(this.album.uri, false)
}, },

View File

@ -0,0 +1,65 @@
<template>
<div>
<tabs-audiobooks></tabs-audiobooks>
<content-with-heading>
<template slot="options">
<index-button-list :index="albums_list.indexList"></index-button-list>
</template>
<template slot="heading-left">
<p class="title is-4">Audiobooks</p>
<p class="heading">{{ albums_list.sortedAndFiltered.length }} Audiobooks</p>
</template>
<template slot="content">
<list-albums :albums="albums_list"></list-albums>
</template>
</content-with-heading>
</div>
</template>
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import TabsAudiobooks from '@/components/TabsAudiobooks'
import IndexButtonList from '@/components/IndexButtonList'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListAlbums from '@/components/ListAlbums'
import webapi from '@/webapi'
import Albums from '@/lib/Albums'
const albumsData = {
load: function (to) {
return webapi.library_albums('audiobook')
},
set: function (vm, response) {
vm.albums = response.data
}
}
export default {
name: 'PageAudiobooksAlbums',
mixins: [LoadDataBeforeEnterMixin(albumsData)],
components: { TabsAudiobooks, ContentWithHeading, IndexButtonList, ListAlbums },
data () {
return {
albums: { items: [] }
}
},
computed: {
albums_list () {
return new Albums(this.albums.items, {
sort: 'Name',
group: true
})
}
},
methods: {
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,68 @@
<template>
<content-with-heading>
<template slot="heading-left">
<p class="title is-4">{{ artist.name }}</p>
</template>
<template slot="heading-right">
<div class="buttons is-centered">
<a class="button is-small is-light is-rounded" @click="show_artist_details_modal = true">
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
</a>
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span>Shuffle</span>
</a>
</div>
</template>
<template slot="content">
<p class="heading has-text-centered-mobile">{{ artist.album_count }} albums</p>
<list-albums :albums="albums.items"></list-albums>
<modal-dialog-artist :show="show_artist_details_modal" :artist="artist" @close="show_artist_details_modal = false" />
</template>
</content-with-heading>
</template>
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListAlbums from '@/components/ListAlbums'
import ModalDialogArtist from '@/components/ModalDialogArtist'
import webapi from '@/webapi'
const artistData = {
load: function (to) {
return Promise.all([
webapi.library_artist(to.params.artist_id),
webapi.library_artist_albums(to.params.artist_id)
])
},
set: function (vm, response) {
vm.artist = response[0].data
vm.albums = response[1].data
}
}
export default {
name: 'PageAudiobooksArtist',
mixins: [LoadDataBeforeEnterMixin(artistData)],
components: { ContentWithHeading, ListAlbums, ModalDialogArtist },
data () {
return {
artist: {},
albums: {},
show_artist_details_modal: false
}
},
methods: {
play: function () {
webapi.player_play_uri(this.albums.items.map(a => a.uri).join(','), false)
}
}
}
</script>
<style>
</style>

View File

@ -1,35 +1,19 @@
<template> <template>
<div> <div>
<tabs-music></tabs-music> <tabs-audiobooks></tabs-audiobooks>
<content-with-heading> <content-with-heading>
<template slot="options"> <template slot="options">
<index-button-list :index="index_list"></index-button-list> <index-button-list :index="artists_list.indexList"></index-button-list>
</template> </template>
<template slot="heading-left"> <template slot="heading-left">
<p class="title is-4">Artists</p> <p class="title is-4">Authors</p>
<p class="heading">{{ artists.total }} artists</p> <p class="heading">{{ artists_list.sortedAndFiltered.length }} Authors</p>
</template> </template>
<template slot="heading-right"> <template slot="heading-right">
<a class="button is-small" :class="{ 'is-info': hide_singles }" @click="update_hide_singles">
<span class="icon">
<i class="mdi mdi-numeric-1-box-multiple-outline"></i>
</span>
<span>Hide singles</span>
</a>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-artist v-for="artist in artists_filtered" <list-artists :artists="artists_list"></list-artists>
:key="artist.id"
:artist="artist"
@click="open_artist(artist)">
<template slot="actions">
<a @click="open_dialog(artist)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-artist>
<modal-dialog-artist :show="show_details_modal" :artist="selected_artist" @close="show_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
</div> </div>
@ -38,16 +22,15 @@
<script> <script>
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic' import TabsAudiobooks from '@/components/TabsAudiobooks'
import IndexButtonList from '@/components/IndexButtonList' import IndexButtonList from '@/components/IndexButtonList'
import ListItemArtist from '@/components/ListItemArtist' import ListArtists from '@/components/ListArtists'
import ModalDialogArtist from '@/components/ModalDialogArtist'
import webapi from '@/webapi' import webapi from '@/webapi'
import * as types from '@/store/mutation_types' import Artists from '@/lib/Artists'
const artistsData = { const artistsData = {
load: function (to) { load: function (to) {
return webapi.library_artists() return webapi.library_artists('audiobook')
}, },
set: function (vm, response) { set: function (vm, response) {
@ -56,48 +39,26 @@ const artistsData = {
} }
export default { export default {
name: 'PageArtists', name: 'PageAudiobooksArtists',
mixins: [LoadDataBeforeEnterMixin(artistsData)], mixins: [LoadDataBeforeEnterMixin(artistsData)],
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemArtist, ModalDialogArtist }, components: { ContentWithHeading, TabsAudiobooks, IndexButtonList, ListArtists },
data () { data () {
return { return {
artists: { items: [] }, artists: { items: [] }
show_details_modal: false,
selected_artist: {}
} }
}, },
computed: { computed: {
hide_singles () { artists_list () {
return this.$store.state.hide_singles return new Artists(this.artists.items, {
}, sort: 'Name',
group: true
index_list () { })
return [...new Set(this.artists.items
.filter(artist => !this.$store.state.hide_singles || artist.track_count > (artist.album_count * 2))
.map(artist => artist.name_sort.charAt(0).toUpperCase()))]
},
artists_filtered () {
return this.artists.items.filter(artist => !this.hide_singles || artist.track_count > (artist.album_count * 2))
} }
}, },
methods: { methods: {
update_hide_singles: function (e) {
this.$store.commit(types.HIDE_SINGLES, !this.hide_singles)
},
open_artist: function (artist) {
this.$router.push({ path: '/music/artists/' + artist.id })
},
open_dialog: function (artist) {
this.selected_artist = artist
this.show_details_modal = true
}
} }
} }
</script> </script>

View File

@ -9,14 +9,7 @@
<p class="heading">albums</p> <p class="heading">albums</p>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-album v-for="album in recently_added.items" :key="album.id" :album="album" @click="open_album(album)"> <list-albums :albums="recently_added.items"></list-albums>
<template slot="actions">
<a @click="open_album_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-album>
<modal-dialog-album :show="show_album_details_modal" :album="selected_album" @close="show_album_details_modal = false" />
</template> </template>
<template slot="footer"> <template slot="footer">
<nav class="level"> <nav class="level">
@ -34,14 +27,7 @@
<p class="heading">tracks</p> <p class="heading">tracks</p>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-track v-for="track in recently_played.items" :key="track.id" :track="track" @click="play_track(track)"> <list-tracks :tracks="recently_played.items"></list-tracks>
<template slot="actions">
<a @click="open_track_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_track_details_modal" :track="selected_track" @close="show_track_details_modal = false" />
</template> </template>
<template slot="footer"> <template slot="footer">
<nav class="level"> <nav class="level">
@ -58,10 +44,8 @@
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic' import TabsMusic from '@/components/TabsMusic'
import ListItemAlbum from '@/components/ListItemAlbum' import ListAlbums from '@/components/ListAlbums'
import ListItemTrack from '@/components/ListItemTrack' import ListTracks from '@/components/ListTracks'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import webapi from '@/webapi' import webapi from '@/webapi'
const browseData = { const browseData = {
@ -81,7 +65,7 @@ const browseData = {
export default { export default {
name: 'PageBrowse', name: 'PageBrowse',
mixins: [LoadDataBeforeEnterMixin(browseData)], mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, ListItemAlbum, ListItemTrack, ModalDialogTrack, ModalDialogAlbum }, components: { ContentWithHeading, TabsMusic, ListAlbums, ListTracks },
data () { data () {
return { return {
@ -89,34 +73,13 @@ export default {
recently_played: {}, recently_played: {},
show_track_details_modal: false, show_track_details_modal: false,
selected_track: {}, selected_track: {}
show_album_details_modal: false,
selected_album: {}
} }
}, },
methods: { methods: {
open_browse: function (type) { open_browse: function (type) {
this.$router.push({ path: '/music/browse/' + type }) this.$router.push({ path: '/music/browse/' + type })
},
open_track_dialog: function (track) {
this.selected_track = track
this.show_track_details_modal = true
},
open_album: function (album) {
this.$router.push({ path: '/music/albums/' + album.id })
},
open_album_dialog: function (album) {
this.selected_album = album
this.show_album_details_modal = true
},
play_track: function (track) {
webapi.player_play_uri(track.uri, false)
} }
} }
} }

View File

@ -8,14 +8,7 @@
<p class="heading">albums</p> <p class="heading">albums</p>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-album v-for="album in recently_added.items" :key="album.id" :album="album" @click="open_album(album)"> <list-albums :albums="recently_added.items"></list-albums>
<template slot="actions">
<a @click="open_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-album>
<modal-dialog-album :show="show_details_modal" :album="selected_album" @close="show_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
</div> </div>
@ -25,8 +18,7 @@
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic' import TabsMusic from '@/components/TabsMusic'
import ListItemAlbum from '@/components/ListItemAlbum' import ListAlbums from '@/components/ListAlbums'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import webapi from '@/webapi' import webapi from '@/webapi'
const browseData = { const browseData = {
@ -46,25 +38,11 @@ const browseData = {
export default { export default {
name: 'PageBrowseType', name: 'PageBrowseType',
mixins: [LoadDataBeforeEnterMixin(browseData)], mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, ListItemAlbum, ModalDialogAlbum }, components: { ContentWithHeading, TabsMusic, ListAlbums },
data () { data () {
return { return {
recently_added: {}, recently_added: {}
show_details_modal: false,
selected_album: {}
}
},
methods: {
open_album: function (album) {
this.$router.push({ path: '/music/albums/' + album.id })
},
open_dialog: function (album) {
this.selected_album = album
this.show_details_modal = true
} }
} }
} }

View File

@ -8,14 +8,7 @@
<p class="heading">tracks</p> <p class="heading">tracks</p>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-track v-for="track in recently_played.items" :key="track.id" :track="track" @click="play_track(track)"> <list-tracks :tracks="recently_played.items"></list-tracks>
<template slot="actions">
<a @click="open_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
</div> </div>
@ -25,8 +18,7 @@
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic' import TabsMusic from '@/components/TabsMusic'
import ListItemTrack from '@/components/ListItemTrack' import ListTracks from '@/components/ListTracks'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import webapi from '@/webapi' import webapi from '@/webapi'
const browseData = { const browseData = {
@ -46,25 +38,11 @@ const browseData = {
export default { export default {
name: 'PageBrowseType', name: 'PageBrowseType',
mixins: [LoadDataBeforeEnterMixin(browseData)], mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, ListItemTrack, ModalDialogTrack }, components: { ContentWithHeading, TabsMusic, ListTracks },
data () { data () {
return { return {
recently_played: {}, recently_played: {}
show_details_modal: false,
selected_track: {}
}
},
methods: {
open_dialog: function (track) {
this.selected_track = track
this.show_details_modal = true
},
play_track: function (track) {
webapi.player_play_uri(track.uri, false)
} }
} }
} }

View File

@ -19,14 +19,7 @@
</template> </template>
<template slot="content"> <template slot="content">
<p class="heading has-text-centered-mobile">{{ genre_albums.total }} albums | <a class="has-text-link" @click="open_tracks">tracks</a></p> <p class="heading has-text-centered-mobile">{{ genre_albums.total }} albums | <a class="has-text-link" @click="open_tracks">tracks</a></p>
<list-item-albums v-for="album in genre_albums.items" :key="album.id" :album="album" @click="open_album(album)"> <list-albums :albums="genre_albums.items"></list-albums>
<template slot="actions">
<a @click="open_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-albums>
<modal-dialog-album :show="show_details_modal" :album="selected_album" @close="show_details_modal = false" />
<modal-dialog-genre :show="show_genre_details_modal" :genre="{ 'name': name }" @close="show_genre_details_modal = false" /> <modal-dialog-genre :show="show_genre_details_modal" :genre="{ 'name': name }" @close="show_genre_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
@ -37,8 +30,7 @@
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import IndexButtonList from '@/components/IndexButtonList' import IndexButtonList from '@/components/IndexButtonList'
import ListItemAlbums from '@/components/ListItemAlbum' import ListAlbums from '@/components/ListAlbums'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialogGenre from '@/components/ModalDialogGenre' import ModalDialogGenre from '@/components/ModalDialogGenre'
import webapi from '@/webapi' import webapi from '@/webapi'
@ -56,16 +48,13 @@ const genreData = {
export default { export default {
name: 'PageGenre', name: 'PageGenre',
mixins: [LoadDataBeforeEnterMixin(genreData)], mixins: [LoadDataBeforeEnterMixin(genreData)],
components: { ContentWithHeading, IndexButtonList, ListItemAlbums, ModalDialogAlbum, ModalDialogGenre }, components: { ContentWithHeading, IndexButtonList, ListAlbums, ModalDialogGenre },
data () { data () {
return { return {
name: '', name: '',
genre_albums: { items: [] }, genre_albums: { items: [] },
show_details_modal: false,
selected_album: {},
show_genre_details_modal: false show_genre_details_modal: false
} }
}, },
@ -87,10 +76,6 @@ export default {
webapi.player_play_expression('genre is "' + this.name + '" and media_kind is music', true) webapi.player_play_expression('genre is "' + this.name + '" and media_kind is music', true)
}, },
open_album: function (album) {
this.$router.push({ path: '/music/albums/' + album.id })
},
open_dialog: function (album) { open_dialog: function (album) {
this.selected_album = album this.selected_album = album
this.show_details_modal = true this.show_details_modal = true

View File

@ -19,14 +19,7 @@
</template> </template>
<template slot="content"> <template slot="content">
<p class="heading has-text-centered-mobile"><a class="has-text-link" @click="open_genre">albums</a> | {{ tracks.total }} tracks</p> <p class="heading has-text-centered-mobile"><a class="has-text-link" @click="open_genre">albums</a> | {{ tracks.total }} tracks</p>
<list-item-track v-for="(track, index) in tracks.items" :key="track.id" :track="track" @click="play_track(index)"> <list-tracks :tracks="tracks.items" :expression="expression"></list-tracks>
<template slot="actions">
<a @click="open_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
<modal-dialog-genre :show="show_genre_details_modal" :genre="{ 'name': genre }" @close="show_genre_details_modal = false" /> <modal-dialog-genre :show="show_genre_details_modal" :genre="{ 'name': genre }" @close="show_genre_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
@ -37,8 +30,7 @@
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import IndexButtonList from '@/components/IndexButtonList' import IndexButtonList from '@/components/IndexButtonList'
import ListItemTrack from '@/components/ListItemTrack' import ListTracks from '@/components/ListTracks'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogGenre from '@/components/ModalDialogGenre' import ModalDialogGenre from '@/components/ModalDialogGenre'
import webapi from '@/webapi' import webapi from '@/webapi'
@ -56,16 +48,13 @@ const tracksData = {
export default { export default {
name: 'PageGenreTracks', name: 'PageGenreTracks',
mixins: [LoadDataBeforeEnterMixin(tracksData)], mixins: [LoadDataBeforeEnterMixin(tracksData)],
components: { ContentWithHeading, ListItemTrack, IndexButtonList, ModalDialogTrack, ModalDialogGenre }, components: { ContentWithHeading, ListTracks, IndexButtonList, ModalDialogGenre },
data () { data () {
return { return {
tracks: { items: [] }, tracks: { items: [] },
genre: '', genre: '',
show_details_modal: false,
selected_track: {},
show_genre_details_modal: false show_genre_details_modal: false
} }
}, },
@ -74,6 +63,10 @@ export default {
index_list () { index_list () {
return [...new Set(this.tracks.items return [...new Set(this.tracks.items
.map(track => track.title_sort.charAt(0).toUpperCase()))] .map(track => track.title_sort.charAt(0).toUpperCase()))]
},
expression () {
return 'genre is "' + this.genre + '" and media_kind is music'
} }
}, },
@ -84,16 +77,7 @@ export default {
}, },
play: function () { play: function () {
webapi.player_play_expression('genre is "' + this.genre + '" and media_kind is music', true) webapi.player_play_expression(this.expression, true)
},
play_track: function (position) {
webapi.player_play_expression('genre is "' + this.genre + '" and media_kind is music', false, position)
},
open_dialog: function (track) {
this.selected_track = track
this.show_details_modal = true
} }
} }
} }

View File

@ -15,15 +15,8 @@
</template> </template>
<template slot="content"> <template slot="content">
<p class="heading has-text-centered-mobile">{{ tracks.length }} tracks</p> <p class="heading has-text-centered-mobile">{{ tracks.length }} tracks</p>
<list-item-track v-for="(track, index) in tracks" :key="track.id" :track="track" @click="play_track(index)"> <list-tracks :tracks="tracks" :uris="uris"></list-tracks>
<template slot="actions"> <modal-dialog-playlist :show="show_playlist_details_modal" :playlist="playlist" :tracks="playlist.random ? tracks : undefined" @close="show_playlist_details_modal = false" />
<a @click="open_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
<modal-dialog-playlist :show="show_playlist_details_modal" :playlist="playlist" @close="show_playlist_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
</template> </template>
@ -31,8 +24,7 @@
<script> <script>
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack' import ListTracks from '@/components/ListTracks'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist' import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
import webapi from '@/webapi' import webapi from '@/webapi'
@ -53,32 +45,29 @@ const playlistData = {
export default { export default {
name: 'PagePlaylist', name: 'PagePlaylist',
mixins: [LoadDataBeforeEnterMixin(playlistData)], mixins: [LoadDataBeforeEnterMixin(playlistData)],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogPlaylist }, components: { ContentWithHeading, ListTracks, ModalDialogPlaylist },
data () { data () {
return { return {
playlist: {}, playlist: {},
tracks: [], tracks: [],
show_details_modal: false,
selected_track: {},
show_playlist_details_modal: false show_playlist_details_modal: false
} }
}, },
computed: {
uris () {
if (this.playlist.random) {
return this.tracks.map(a => a.uri).join(',')
}
return this.playlist.uri
}
},
methods: { methods: {
play: function () { play: function () {
webapi.player_play_uri(this.playlist.uri, true) webapi.player_play_uri(this.uris, true)
},
play_track: function (position) {
webapi.player_play_uri(this.playlist.uri, false, position)
},
open_dialog: function (track) {
this.selected_track = track
this.show_details_modal = true
} }
} }
} }

View File

@ -5,19 +5,7 @@
<p class="heading">{{ playlists.total }} playlists</p> <p class="heading">{{ playlists.total }} playlists</p>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-playlist v-for="playlist in playlists.items" :key="playlist.id" :playlist="playlist" @click="open_playlist(playlist)"> <list-playlists :playlists="playlists.items"></list-playlists>
<template slot="icon">
<span class="icon">
<i class="mdi" :class="{ 'mdi-library-music': playlist.type !== 'folder', 'mdi-rss': playlist.type === 'rss', 'mdi-folder': playlist.type === 'folder' }"></i>
</span>
</template>
<template slot="actions">
<a @click="open_dialog(playlist)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-playlist>
<modal-dialog-playlist :show="show_details_modal" :playlist="selected_playlist" @close="show_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
</template> </template>
@ -25,8 +13,7 @@
<script> <script>
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemPlaylist from '@/components/ListItemPlaylist' import ListPlaylists from '@/components/ListPlaylists'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
import webapi from '@/webapi' import webapi from '@/webapi'
const playlistsData = { const playlistsData = {
@ -46,30 +33,12 @@ const playlistsData = {
export default { export default {
name: 'PagePlaylists', name: 'PagePlaylists',
mixins: [LoadDataBeforeEnterMixin(playlistsData)], mixins: [LoadDataBeforeEnterMixin(playlistsData)],
components: { ContentWithHeading, ListItemPlaylist, ModalDialogPlaylist }, components: { ContentWithHeading, ListPlaylists },
data () { data () {
return { return {
playlist: {}, playlist: {},
playlists: {}, playlists: {}
show_details_modal: false,
selected_playlist: {}
}
},
methods: {
open_playlist: function (playlist) {
if (playlist.type !== 'folder') {
this.$router.push({ path: '/playlists/' + playlist.id + '/tracks' })
} else {
this.$router.push({ path: '/playlists/' + playlist.id })
}
},
open_dialog: function (playlist) {
this.selected_playlist = playlist
this.show_details_modal = true
} }
} }
} }

View File

@ -52,35 +52,14 @@
</div> </div>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-album v-for="album in albums.items" :key="album.id" :album="album" :media_kind="'podcast'" @click="open_album(album)"> <list-albums :albums="albums.items"
<template slot="actions"> @play_count_changed="reload_new_episodes()"
<a @click="open_album_dialog(album)"> @podcast-deleted="reload_podcasts()">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> </list-albums>
</a>
</template>
</list-item-album>
<modal-dialog-album
:show="show_album_details_modal"
:album="selected_album"
:media_kind="'podcast'"
@close="show_album_details_modal = false"
@play_count_changed="reload_new_episodes"
@remove_podcast="open_remove_podcast_dialog" />
<modal-dialog
:show="show_remove_podcast_modal"
title="Remove podcast"
delete_action="Remove"
@close="show_remove_podcast_modal = false"
@delete="remove_podcast">
<template slot="modal-content">
<p>Permanently remove this podcast from your library?</p>
<p class="is-size-7">(This will also remove the RSS playlist <b>{{ rss_playlist_to_remove.name }}</b>.)</p>
</template>
</modal-dialog>
<modal-dialog-add-rss <modal-dialog-add-rss
:show="show_url_modal" :show="show_url_modal"
@close="show_url_modal = false" @close="show_url_modal = false"
@podcast_added="reload_podcasts" /> @podcast_added="reload_podcasts()" />
</template> </template>
</content-with-heading> </content-with-heading>
</div> </div>
@ -90,11 +69,9 @@
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack' import ListItemTrack from '@/components/ListItemTrack'
import ListItemAlbum from '@/components/ListItemAlbum' import ListAlbums from '@/components/ListAlbums'
import ModalDialogTrack from '@/components/ModalDialogTrack' import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialogAddRss from '@/components/ModalDialogAddRss' import ModalDialogAddRss from '@/components/ModalDialogAddRss'
import ModalDialog from '@/components/ModalDialog'
import RangeSlider from 'vue-range-slider' import RangeSlider from 'vue-range-slider'
import webapi from '@/webapi' import webapi from '@/webapi'
@ -115,31 +92,21 @@ const albumsData = {
export default { export default {
name: 'PagePodcasts', name: 'PagePodcasts',
mixins: [LoadDataBeforeEnterMixin(albumsData)], mixins: [LoadDataBeforeEnterMixin(albumsData)],
components: { ContentWithHeading, ListItemTrack, ListItemAlbum, ModalDialogTrack, ModalDialogAlbum, ModalDialogAddRss, ModalDialog, RangeSlider }, components: { ContentWithHeading, ListItemTrack, ListAlbums, ModalDialogTrack, ModalDialogAddRss, RangeSlider },
data () { data () {
return { return {
albums: {}, albums: {},
new_episodes: { items: [] }, new_episodes: { items: [] },
show_album_details_modal: false,
selected_album: {},
show_url_modal: false, show_url_modal: false,
show_track_details_modal: false, show_track_details_modal: false,
selected_track: {}, selected_track: {}
show_remove_podcast_modal: false,
rss_playlist_to_remove: {}
} }
}, },
methods: { methods: {
open_album: function (album) {
this.$router.push({ path: '/podcasts/' + album.id })
},
play_track: function (track) { play_track: function (track) {
webapi.player_play_uri(track.uri, false) webapi.player_play_uri(track.uri, false)
}, },
@ -149,11 +116,6 @@ export default {
this.show_track_details_modal = true this.show_track_details_modal = true
}, },
open_album_dialog: function (album) {
this.selected_album = album
this.show_album_details_modal = true
},
mark_all_played: function () { mark_all_played: function () {
this.new_episodes.items.forEach(ep => { this.new_episodes.items.forEach(ep => {
webapi.library_track_update(ep.id, { play_count: 'increment' }) webapi.library_track_update(ep.id, { play_count: 'increment' })
@ -165,29 +127,6 @@ export default {
this.show_url_modal = true this.show_url_modal = true
}, },
open_remove_podcast_dialog: function () {
this.show_album_details_modal = false
webapi.library_album_tracks(this.selected_album.id, { limit: 1 }).then(({ data }) => {
webapi.library_track_playlists(data.items[0].id).then(({ data }) => {
const rssPlaylists = data.items.filter(pl => pl.type === 'rss')
if (rssPlaylists.length !== 1) {
this.$store.dispatch('add_notification', { text: 'Podcast cannot be removed. Probably it was not added as an RSS playlist.', type: 'danger' })
return
}
this.rss_playlist_to_remove = rssPlaylists[0]
this.show_remove_podcast_modal = true
})
})
},
remove_podcast: function () {
this.show_remove_podcast_modal = false
webapi.library_playlist_delete(this.rss_playlist_to_remove.id).then(() => {
this.reload_podcasts()
})
},
reload_new_episodes: function () { reload_new_episodes: function () {
webapi.library_podcasts_new_episodes().then(({ data }) => { webapi.library_podcasts_new_episodes().then(({ data }) => {
this.new_episodes = data.tracks this.new_episodes = data.tracks

View File

@ -1,21 +1,12 @@
<template> <template>
<div> <div>
<tabs-music></tabs-music>
<content-with-heading> <content-with-heading>
<template slot="heading-left"> <template slot="heading-left">
<p class="title is-4">Radio</p> <p class="title is-4">Radio</p>
</template> </template>
<template slot="content"> <template slot="content">
<p class="heading has-text-centered-mobile">{{ tracks.total }} tracks</p> <p class="heading has-text-centered-mobile">{{ tracks.total }} tracks</p>
<list-item-track v-for="track in tracks.items" :key="track.id" :track="track" @click="play_track(track)"> <list-tracks :tracks="tracks.items"></list-tracks>
<template slot="actions">
<a @click="open_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
</template> </template>
</content-with-heading> </content-with-heading>
</div> </div>
@ -23,10 +14,8 @@
<script> <script>
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import TabsMusic from '@/components/TabsMusic'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack' import ListTracks from '@/components/ListTracks'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import webapi from '@/webapi' import webapi from '@/webapi'
const streamsData = { const streamsData = {
@ -42,25 +31,11 @@ const streamsData = {
export default { export default {
name: 'PageRadioStreams', name: 'PageRadioStreams',
mixins: [LoadDataBeforeEnterMixin(streamsData)], mixins: [LoadDataBeforeEnterMixin(streamsData)],
components: { TabsMusic, ContentWithHeading, ListItemTrack, ModalDialogTrack }, components: { ContentWithHeading, ListTracks },
data () { data () {
return { return {
tracks: { items: [] }, tracks: { items: [] }
show_details_modal: false,
selected_track: {}
}
},
methods: {
play_track: function (track) {
webapi.player_play_uri(track.uri, false)
},
open_dialog: function (track) {
this.selected_track = track
this.show_details_modal = true
} }
} }
} }

View File

@ -34,19 +34,12 @@
<p class="title is-4">Tracks</p> <p class="title is-4">Tracks</p>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-track v-for="track in tracks.items" :key="track.id" :track="track" @click="play_track(track)"> <list-tracks :tracks="tracks.items"></list-tracks>
<template slot="actions">
<a @click="open_track_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_track_details_modal" :track="selected_track" @close="show_track_details_modal = false" />
</template> </template>
<template slot="footer"> <template slot="footer">
<nav v-if="show_all_tracks_button" class="level"> <nav v-if="show_all_tracks_button" class="level">
<p class="level-item"> <p class="level-item">
<a class="button is-light is-small is-rounded" v-on:click="open_search_tracks">Show all {{ tracks.total }} tracks</a> <a class="button is-light is-small is-rounded" v-on:click="open_search_tracks">Show all {{ tracks.total.toLocaleString() }} tracks</a>
</p> </p>
</nav> </nav>
<p v-if="!tracks.total">No results</p> <p v-if="!tracks.total">No results</p>
@ -59,19 +52,12 @@
<p class="title is-4">Artists</p> <p class="title is-4">Artists</p>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-artist v-for="artist in artists.items" :key="artist.id" :artist="artist" @click="open_artist(artist)"> <list-artists :artists="artists.items"></list-artists>
<template slot="actions">
<a @click="open_artist_dialog(artist)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-artist>
<modal-dialog-artist :show="show_artist_details_modal" :artist="selected_artist" @close="show_artist_details_modal = false" />
</template> </template>
<template slot="footer"> <template slot="footer">
<nav v-if="show_all_artists_button" class="level"> <nav v-if="show_all_artists_button" class="level">
<p class="level-item"> <p class="level-item">
<a class="button is-light is-small is-rounded" v-on:click="open_search_artists">Show all {{ artists.total }} artists</a> <a class="button is-light is-small is-rounded" v-on:click="open_search_artists">Show all {{ artists.total.toLocaleString() }} artists</a>
</p> </p>
</nav> </nav>
<p v-if="!artists.total">No results</p> <p v-if="!artists.total">No results</p>
@ -84,19 +70,12 @@
<p class="title is-4">Albums</p> <p class="title is-4">Albums</p>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-album v-for="album in albums.items" :key="album.id" :album="album" @click="open_album(album)"> <list-albums :albums="albums.items"></list-albums>
<template slot="actions">
<a @click="open_album_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-album>
<modal-dialog-album :show="show_album_details_modal" :album="selected_album" @close="show_album_details_modal = false" />
</template> </template>
<template slot="footer"> <template slot="footer">
<nav v-if="show_all_albums_button" class="level"> <nav v-if="show_all_albums_button" class="level">
<p class="level-item"> <p class="level-item">
<a class="button is-light is-small is-rounded" v-on:click="open_search_albums">Show all {{ albums.total }} albums</a> <a class="button is-light is-small is-rounded" v-on:click="open_search_albums">Show all {{ albums.total.toLocaleString() }} albums</a>
</p> </p>
</nav> </nav>
<p v-if="!albums.total">No results</p> <p v-if="!albums.total">No results</p>
@ -109,44 +88,69 @@
<p class="title is-4">Playlists</p> <p class="title is-4">Playlists</p>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-playlist v-for="playlist in playlists.items" :key="playlist.id" :playlist="playlist" @click="open_playlist(playlist)"> <list-playlists :playlists="playlists.items"></list-playlists>
<template slot="actions">
<a @click="open_playlist_dialog(playlist)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-playlist>
<modal-dialog-playlist :show="show_playlist_details_modal" :playlist="selected_playlist" @close="show_playlist_details_modal = false" />
</template> </template>
<template slot="footer"> <template slot="footer">
<nav v-if="show_all_playlists_button" class="level"> <nav v-if="show_all_playlists_button" class="level">
<p class="level-item"> <p class="level-item">
<a class="button is-light is-small is-rounded" v-on:click="open_search_playlists">Show all {{ playlists.total }} playlists</a> <a class="button is-light is-small is-rounded" v-on:click="open_search_playlists">Show all {{ playlists.total.toLocaleString() }} playlists</a>
</p> </p>
</nav> </nav>
<p v-if="!playlists.total">No results</p> <p v-if="!playlists.total">No results</p>
</template> </template>
</content-with-heading> </content-with-heading>
<!-- Podcasts -->
<content-with-heading v-if="show_podcasts">
<template slot="heading-left">
<p class="title is-4">Podcasts</p>
</template>
<template slot="content">
<list-albums :albums="podcasts.items"></list-albums>
</template>
<template slot="footer">
<nav v-if="show_all_podcasts_button" class="level">
<p class="level-item">
<a class="button is-light is-small is-rounded" v-on:click="open_search_podcasts">Show all {{ podcasts.total.toLocaleString() }} podcasts</a>
</p>
</nav>
<p v-if="!podcasts.total">No results</p>
</template>
</content-with-heading>
<!-- Audiobooks -->
<content-with-heading v-if="show_audiobooks">
<template slot="heading-left">
<p class="title is-4">Audiobooks</p>
</template>
<template slot="content">
<list-albums :albums="audiobooks.items"></list-albums>
</template>
<template slot="footer">
<nav v-if="show_all_audiobooks_button" class="level">
<p class="level-item">
<a class="button is-light is-small is-rounded" v-on:click="open_search_audiobooks">Show all {{ audiobooks.total.toLocaleString() }} audiobooks</a>
</p>
</nav>
<p v-if="!audiobooks.total">No results</p>
</template>
</content-with-heading>
</div> </div>
</template> </template>
<script> <script>
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsSearch from '@/components/TabsSearch' import TabsSearch from '@/components/TabsSearch'
import ListItemTrack from '@/components/ListItemTrack' import ListTracks from '@/components/ListTracks'
import ListItemArtist from '@/components/ListItemArtist' import ListArtists from '@/components/ListArtists'
import ListItemAlbum from '@/components/ListItemAlbum' import ListAlbums from '@/components/ListAlbums'
import ListItemPlaylist from '@/components/ListItemPlaylist' import ListPlaylists from '@/components/ListPlaylists'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialogArtist from '@/components/ModalDialogArtist'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
import webapi from '@/webapi' import webapi from '@/webapi'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
export default { export default {
name: 'PageSearch', name: 'PageSearch',
components: { ContentWithHeading, TabsSearch, ListItemTrack, ListItemArtist, ListItemAlbum, ListItemPlaylist, ModalDialogTrack, ModalDialogAlbum, ModalDialogArtist, ModalDialogPlaylist }, components: { ContentWithHeading, TabsSearch, ListTracks, ListArtists, ListAlbums, ListPlaylists },
data () { data () {
return { return {
@ -156,18 +160,8 @@ export default {
artists: { items: [], total: 0 }, artists: { items: [], total: 0 },
albums: { items: [], total: 0 }, albums: { items: [], total: 0 },
playlists: { items: [], total: 0 }, playlists: { items: [], total: 0 },
audiobooks: { items: [], total: 0 },
show_track_details_modal: false, podcasts: { items: [], total: 0 }
selected_track: {},
show_album_details_modal: false,
selected_album: {},
show_artist_details_modal: false,
selected_artist: {},
show_playlist_details_modal: false,
selected_playlist: {}
} }
}, },
@ -202,6 +196,24 @@ export default {
}, },
show_all_playlists_button () { show_all_playlists_button () {
return this.playlists.total > this.playlists.items.length return this.playlists.total > this.playlists.items.length
},
show_audiobooks () {
return this.$route.query.type && this.$route.query.type.includes('audiobook')
},
show_all_audiobooks_button () {
return this.audiobooks.total > this.audiobooks.items.length
},
show_podcasts () {
return this.$route.query.type && this.$route.query.type.includes('podcast')
},
show_all_podcasts_button () {
return this.podcasts.total > this.podcasts.items.length
},
is_visible_artwork () {
return this.$store.getters.settings_option('webinterface', 'show_cover_artwork_in_album_lists').value
} }
}, },
@ -213,20 +225,31 @@ export default {
return return
} }
this.searchMusic(route.query)
this.searchAudiobooks(route.query)
this.searchPodcasts(route.query)
this.$store.commit(types.ADD_RECENT_SEARCH, route.query.query)
},
searchMusic: function (query) {
if (query.type.indexOf('track') < 0 && query.type.indexOf('artist') < 0 && query.type.indexOf('album') < 0 && query.type.indexOf('playlist') < 0) {
return
}
var searchParams = { var searchParams = {
type: route.query.type, type: query.type,
media_kind: 'music' media_kind: 'music'
} }
if (route.query.query.startsWith('query:')) { if (query.query.startsWith('query:')) {
searchParams.expression = route.query.query.replace(/^query:/, '').trim() searchParams.expression = query.query.replace(/^query:/, '').trim()
} else { } else {
searchParams.query = route.query.query searchParams.query = query.query
} }
if (route.query.limit) { if (query.limit) {
searchParams.limit = route.query.limit searchParams.limit = query.limit
searchParams.offset = route.query.offset searchParams.offset = query.offset
} }
webapi.search(searchParams).then(({ data }) => { webapi.search(searchParams).then(({ data }) => {
@ -234,8 +257,58 @@ export default {
this.artists = data.artists ? data.artists : { items: [], total: 0 } this.artists = data.artists ? data.artists : { items: [], total: 0 }
this.albums = data.albums ? data.albums : { items: [], total: 0 } this.albums = data.albums ? data.albums : { items: [], total: 0 }
this.playlists = data.playlists ? data.playlists : { items: [], total: 0 } this.playlists = data.playlists ? data.playlists : { items: [], total: 0 }
})
},
this.$store.commit(types.ADD_RECENT_SEARCH, route.query.query) searchAudiobooks: function (query) {
if (query.type.indexOf('audiobook') < 0) {
return
}
var searchParams = {
type: 'album',
media_kind: 'audiobook'
}
if (query.query.startsWith('query:')) {
searchParams.expression = query.query.replace(/^query:/, '').trim()
} else {
searchParams.expression = '((album includes "' + query.query + '" or artist includes "' + query.query + '") and media_kind is audiobook)'
}
if (query.limit) {
searchParams.limit = query.limit
searchParams.offset = query.offset
}
webapi.search(searchParams).then(({ data }) => {
this.audiobooks = data.albums ? data.albums : { items: [], total: 0 }
})
},
searchPodcasts: function (query) {
if (query.type.indexOf('podcast') < 0) {
return
}
var searchParams = {
type: 'album',
media_kind: 'podcast'
}
if (query.query.startsWith('query:')) {
searchParams.expression = query.query.replace(/^query:/, '').trim()
} else {
searchParams.expression = '((album includes "' + query.query + '" or artist includes "' + query.query + '") and media_kind is podcast)'
}
if (query.limit) {
searchParams.limit = query.limit
searchParams.offset = query.offset
}
webapi.search(searchParams).then(({ data }) => {
this.podcasts = data.albums ? data.albums : { items: [], total: 0 }
}) })
}, },
@ -247,7 +320,7 @@ export default {
this.$router.push({ this.$router.push({
path: '/search/library', path: '/search/library',
query: { query: {
type: 'track,artist,album,playlist', type: 'track,artist,album,playlist,audiobook,podcast',
query: this.search_query, query: this.search_query,
limit: 3, limit: 3,
offset: 0 offset: 0
@ -296,45 +369,29 @@ export default {
}) })
}, },
play_track: function (track) { open_search_audiobooks: function () {
webapi.player_play_uri(track.uri, false) this.$router.push({
path: '/search/library',
query: {
type: 'audiobook',
query: this.$route.query.query
}
})
}, },
open_artist: function (artist) { open_search_podcasts: function () {
this.$router.push({ path: '/music/artists/' + artist.id }) this.$router.push({
}, path: '/search/library',
query: {
open_album: function (album) { type: 'podcast',
this.$router.push({ path: '/music/albums/' + album.id }) query: this.$route.query.query
}, }
})
open_playlist: function (playlist) {
this.$router.push({ path: '/playlists/' + playlist.id + '/tracks' })
}, },
open_recent_search: function (query) { open_recent_search: function (query) {
this.search_query = query this.search_query = query
this.new_search() this.new_search()
},
open_track_dialog: function (track) {
this.selected_track = track
this.show_track_details_modal = true
},
open_album_dialog: function (album) {
this.selected_album = album
this.show_album_details_modal = true
},
open_artist_dialog: function (artist) {
this.selected_artist = artist
this.show_artist_details_modal = true
},
open_playlist_dialog: function (playlist) {
this.selected_playlist = playlist
this.show_playlist_details_modal = true
} }
}, },

View File

@ -2,6 +2,54 @@
<div> <div>
<tabs-settings></tabs-settings> <tabs-settings></tabs-settings>
<content-with-heading>
<template slot="heading-left">
<div class="title is-4">Navbar items</div>
</template>
<template slot="content">
<p class="content">
Select the top navigation bar menu items
</p>
<div class="notification is-size-7">
Be aware that if you select more items than can be shown on your screen will result in the burger menu item to disapear.
</div>
<settings-checkbox category_name="webinterface" option_name="show_menu_item_playlists">
<template slot="label"> Playlists</template>
</settings-checkbox>
<settings-checkbox category_name="webinterface" option_name="show_menu_item_music">
<template slot="label"> Music</template>
</settings-checkbox>
<settings-checkbox category_name="webinterface" option_name="show_menu_item_podcasts">
<template slot="label"> Podcasts</template>
</settings-checkbox>
<settings-checkbox category_name="webinterface" option_name="show_menu_item_audiobooks">
<template slot="label"> Audiobooks</template>
</settings-checkbox>
<settings-checkbox category_name="webinterface" option_name="show_menu_item_radio">
<template slot="label"> Radio</template>
</settings-checkbox>
<settings-checkbox category_name="webinterface" option_name="show_menu_item_files">
<template slot="label"> Files</template>
</settings-checkbox>
<settings-checkbox category_name="webinterface" option_name="show_menu_item_search">
<template slot="label"> Search</template>
</settings-checkbox>
</template>
</content-with-heading>
<content-with-heading>
<template slot="heading-left">
<div class="title is-4">Album lists</div>
</template>
<template slot="content">
<settings-checkbox category_name="webinterface" option_name="show_cover_artwork_in_album_lists">
<template slot="label"> Show cover artwork in album list</template>
</settings-checkbox>
</template>
</content-with-heading>
<content-with-heading> <content-with-heading>
<template slot="heading-left"> <template slot="heading-left">
<div class="title is-4">Now playing page</div> <div class="title is-4">Now playing page</div>

View File

@ -15,7 +15,20 @@
</template> </template>
<template slot="content"> <template slot="content">
<p class="heading has-text-centered-mobile">{{ total }} albums</p> <p class="heading has-text-centered-mobile">{{ total }} albums</p>
<spotify-list-item-album v-for="album in albums" :key="album.id" :album="album"> <spotify-list-item-album v-for="album in albums"
:key="album.id"
:album="album"
@click="open_album(album)">
<template slot="artwork" v-if="is_visible_artwork">
<p class="image is-64x64 fd-has-shadow fd-has-action">
<cover-artwork
:artwork_url="artwork_url(album)"
:artist="album.artist"
:album="album.name"
:maxwidth="64"
:maxheight="64" />
</p>
</template>
<template slot="actions"> <template slot="actions">
<a @click="open_dialog(album)"> <a @click="open_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> <span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
@ -35,6 +48,7 @@ import ContentWithHeading from '@/templates/ContentWithHeading'
import SpotifyListItemAlbum from '@/components/SpotifyListItemAlbum' import SpotifyListItemAlbum from '@/components/SpotifyListItemAlbum'
import SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum' import SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum'
import SpotifyModalDialogArtist from '@/components/SpotifyModalDialogArtist' import SpotifyModalDialogArtist from '@/components/SpotifyModalDialogArtist'
import CoverArtwork from '@/components/CoverArtwork'
import store from '@/store' import store from '@/store'
import webapi from '@/webapi' import webapi from '@/webapi'
import SpotifyWebApi from 'spotify-web-api-js' import SpotifyWebApi from 'spotify-web-api-js'
@ -63,7 +77,7 @@ const artistData = {
export default { export default {
name: 'SpotifyPageArtist', name: 'SpotifyPageArtist',
mixins: [LoadDataBeforeEnterMixin(artistData)], mixins: [LoadDataBeforeEnterMixin(artistData)],
components: { ContentWithHeading, SpotifyListItemAlbum, SpotifyModalDialogAlbum, SpotifyModalDialogArtist, InfiniteLoading }, components: { ContentWithHeading, SpotifyListItemAlbum, SpotifyModalDialogAlbum, SpotifyModalDialogArtist, InfiniteLoading, CoverArtwork },
data () { data () {
return { return {
@ -79,6 +93,12 @@ export default {
} }
}, },
computed: {
is_visible_artwork () {
return this.$store.getters.settings_option('webinterface', 'show_cover_artwork_in_album_lists').value
}
},
methods: { methods: {
load_next: function ($state) { load_next: function ($state) {
const spotifyApi = new SpotifyWebApi() const spotifyApi = new SpotifyWebApi()
@ -106,9 +126,20 @@ export default {
webapi.player_play_uri(this.artist.uri, true) webapi.player_play_uri(this.artist.uri, true)
}, },
open_album: function (album) {
this.$router.push({ path: '/music/spotify/albums/' + album.id })
},
open_dialog: function (album) { open_dialog: function (album) {
this.selected_album = album this.selected_album = album
this.show_details_modal = true this.show_details_modal = true
},
artwork_url: function (album) {
if (album.images && album.images.length > 0) {
return album.images[0].url
}
return ''
} }
} }
} }

View File

@ -8,7 +8,20 @@
<p class="title is-4">New Releases</p> <p class="title is-4">New Releases</p>
</template> </template>
<template slot="content"> <template slot="content">
<spotify-list-item-album v-for="album in new_releases" :key="album.id" :album="album"> <spotify-list-item-album v-for="album in new_releases"
:key="album.id"
:album="album"
@click="open_album(album)">
<template slot="artwork" v-if="is_visible_artwork">
<p class="image is-64x64 fd-has-shadow fd-has-action">
<cover-artwork
:artwork_url="artwork_url(album)"
:artist="album.artist"
:album="album.name"
:maxwidth="64"
:maxheight="64" />
</p>
</template>
<template slot="actions"> <template slot="actions">
<a @click="open_album_dialog(album)"> <a @click="open_album_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> <span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
@ -64,6 +77,7 @@ import SpotifyListItemAlbum from '@/components/SpotifyListItemAlbum'
import SpotifyListItemPlaylist from '@/components/SpotifyListItemPlaylist' import SpotifyListItemPlaylist from '@/components/SpotifyListItemPlaylist'
import SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum' import SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum'
import SpotifyModalDialogPlaylist from '@/components/SpotifyModalDialogPlaylist' import SpotifyModalDialogPlaylist from '@/components/SpotifyModalDialogPlaylist'
import CoverArtwork from '@/components/CoverArtwork'
import store from '@/store' import store from '@/store'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
import SpotifyWebApi from 'spotify-web-api-js' import SpotifyWebApi from 'spotify-web-api-js'
@ -93,7 +107,7 @@ const browseData = {
export default { export default {
name: 'SpotifyPageBrowse', name: 'SpotifyPageBrowse',
mixins: [LoadDataBeforeEnterMixin(browseData)], mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyListItemPlaylist, SpotifyModalDialogAlbum, SpotifyModalDialogPlaylist }, components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyListItemPlaylist, SpotifyModalDialogAlbum, SpotifyModalDialogPlaylist, CoverArtwork },
data () { data () {
return { return {
@ -112,10 +126,19 @@ export default {
featured_playlists () { featured_playlists () {
return this.$store.state.spotify_featured_playlists.slice(0, 3) return this.$store.state.spotify_featured_playlists.slice(0, 3)
},
is_visible_artwork () {
return this.$store.getters.settings_option('webinterface', 'show_cover_artwork_in_album_lists').value
} }
}, },
methods: { methods: {
open_album: function (album) {
this.$router.push({ path: '/music/spotify/albums/' + album.id })
},
open_album_dialog: function (album) { open_album_dialog: function (album) {
this.selected_album = album this.selected_album = album
this.show_album_details_modal = true this.show_album_details_modal = true
@ -124,6 +147,13 @@ export default {
open_playlist_dialog: function (playlist) { open_playlist_dialog: function (playlist) {
this.selected_playlist = playlist this.selected_playlist = playlist
this.show_playlist_details_modal = true this.show_playlist_details_modal = true
},
artwork_url: function (album) {
if (album.images && album.images.length > 0) {
return album.images[0].url
}
return ''
} }
} }
} }

View File

@ -7,9 +7,22 @@
<p class="title is-4">New Releases</p> <p class="title is-4">New Releases</p>
</template> </template>
<template slot="content"> <template slot="content">
<spotify-list-item-album v-for="album in new_releases" :key="album.id" :album="album"> <spotify-list-item-album v-for="album in new_releases"
:key="album.id"
:album="album"
@click="open_album(album)">
<template slot="artwork" v-if="is_visible_artwork">
<p class="image is-64x64 fd-has-shadow fd-has-action">
<cover-artwork
:artwork_url="artwork_url(album)"
:artist="album.artist"
:album="album.name"
:maxwidth="64"
:maxheight="64" />
</p>
</template>
<template slot="actions"> <template slot="actions">
<a @click="open_album(album)"> <a @click="open_album_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> <span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a> </a>
</template> </template>
@ -26,6 +39,7 @@ import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic' import TabsMusic from '@/components/TabsMusic'
import SpotifyListItemAlbum from '@/components/SpotifyListItemAlbum' import SpotifyListItemAlbum from '@/components/SpotifyListItemAlbum'
import SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum' import SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum'
import CoverArtwork from '@/components/CoverArtwork'
import store from '@/store' import store from '@/store'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
import SpotifyWebApi from 'spotify-web-api-js' import SpotifyWebApi from 'spotify-web-api-js'
@ -51,7 +65,7 @@ const browseData = {
export default { export default {
name: 'SpotifyPageBrowseNewReleases', name: 'SpotifyPageBrowseNewReleases',
mixins: [LoadDataBeforeEnterMixin(browseData)], mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyModalDialogAlbum }, components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyModalDialogAlbum, CoverArtwork },
data () { data () {
return { return {
@ -63,13 +77,29 @@ export default {
computed: { computed: {
new_releases () { new_releases () {
return this.$store.state.spotify_new_releases return this.$store.state.spotify_new_releases
},
is_visible_artwork () {
return this.$store.getters.settings_option('webinterface', 'show_cover_artwork_in_album_lists').value
} }
}, },
methods: { methods: {
open_album: function (album) { open_album: function (album) {
this.$router.push({ path: '/music/spotify/albums/' + album.id })
},
open_album_dialog: function (album) {
this.selected_album = album this.selected_album = album
this.show_album_details_modal = true this.show_album_details_modal = true
},
artwork_url: function (album) {
if (album.images && album.images.length > 0) {
return album.images[0].url
}
return ''
} }
} }
} }

View File

@ -44,7 +44,7 @@
<template slot="footer"> <template slot="footer">
<nav v-if="show_all_tracks_button" class="level"> <nav v-if="show_all_tracks_button" class="level">
<p class="level-item"> <p class="level-item">
<a class="button is-light is-small is-rounded" v-on:click="open_search_tracks">Show all {{ tracks.total }} tracks</a> <a class="button is-light is-small is-rounded" v-on:click="open_search_tracks">Show all {{ tracks.total.toLocaleString() }} tracks</a>
</p> </p>
</nav> </nav>
<p v-if="!tracks.total">No results</p> <p v-if="!tracks.total">No results</p>
@ -70,7 +70,7 @@
<template slot="footer"> <template slot="footer">
<nav v-if="show_all_artists_button" class="level"> <nav v-if="show_all_artists_button" class="level">
<p class="level-item"> <p class="level-item">
<a class="button is-light is-small is-rounded" v-on:click="open_search_artists">Show all {{ artists.total }} artists</a> <a class="button is-light is-small is-rounded" v-on:click="open_search_artists">Show all {{ artists.total.toLocaleString() }} artists</a>
</p> </p>
</nav> </nav>
<p v-if="!artists.total">No results</p> <p v-if="!artists.total">No results</p>
@ -83,7 +83,20 @@
<p class="title is-4">Albums</p> <p class="title is-4">Albums</p>
</template> </template>
<template slot="content"> <template slot="content">
<spotify-list-item-album v-for="album in albums.items" :key="album.id" :album="album"> <spotify-list-item-album v-for="album in albums.items"
:key="album.id"
:album="album"
@click="open_album(album)">
<template slot="artwork" v-if="is_visible_artwork">
<p class="image is-64x64 fd-has-shadow fd-has-action">
<cover-artwork
:artwork_url="artwork_url(album)"
:artist="album.artist"
:album="album.name"
:maxwidth="64"
:maxheight="64" />
</p>
</template>
<template slot="actions"> <template slot="actions">
<a @click="open_album_dialog(album)"> <a @click="open_album_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> <span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
@ -96,7 +109,7 @@
<template slot="footer"> <template slot="footer">
<nav v-if="show_all_albums_button" class="level"> <nav v-if="show_all_albums_button" class="level">
<p class="level-item"> <p class="level-item">
<a class="button is-light is-small is-rounded" v-on:click="open_search_albums">Show all {{ albums.total }} albums</a> <a class="button is-light is-small is-rounded" v-on:click="open_search_albums">Show all {{ albums.total.toLocaleString() }} albums</a>
</p> </p>
</nav> </nav>
<p v-if="!albums.total">No results</p> <p v-if="!albums.total">No results</p>
@ -122,7 +135,7 @@
<template slot="footer"> <template slot="footer">
<nav v-if="show_all_playlists_button" class="level"> <nav v-if="show_all_playlists_button" class="level">
<p class="level-item"> <p class="level-item">
<a class="button is-light is-small is-rounded" v-on:click="open_search_playlists">Show all {{ playlists.total }} playlists</a> <a class="button is-light is-small is-rounded" v-on:click="open_search_playlists">Show all {{ playlists.total.toLocaleString() }} playlists</a>
</p> </p>
</nav> </nav>
<p v-if="!playlists.total">No results</p> <p v-if="!playlists.total">No results</p>
@ -142,6 +155,7 @@ import SpotifyModalDialogTrack from '@/components/SpotifyModalDialogTrack'
import SpotifyModalDialogArtist from '@/components/SpotifyModalDialogArtist' import SpotifyModalDialogArtist from '@/components/SpotifyModalDialogArtist'
import SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum' import SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum'
import SpotifyModalDialogPlaylist from '@/components/SpotifyModalDialogPlaylist' import SpotifyModalDialogPlaylist from '@/components/SpotifyModalDialogPlaylist'
import CoverArtwork from '@/components/CoverArtwork'
import SpotifyWebApi from 'spotify-web-api-js' import SpotifyWebApi from 'spotify-web-api-js'
import webapi from '@/webapi' import webapi from '@/webapi'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
@ -149,7 +163,7 @@ import InfiniteLoading from 'vue-infinite-loading'
export default { export default {
name: 'SpotifyPageSearch', name: 'SpotifyPageSearch',
components: { ContentWithHeading, TabsSearch, SpotifyListItemTrack, SpotifyListItemArtist, SpotifyListItemAlbum, SpotifyListItemPlaylist, SpotifyModalDialogTrack, SpotifyModalDialogArtist, SpotifyModalDialogAlbum, SpotifyModalDialogPlaylist, InfiniteLoading }, components: { ContentWithHeading, TabsSearch, SpotifyListItemTrack, SpotifyListItemArtist, SpotifyListItemAlbum, SpotifyListItemPlaylist, SpotifyModalDialogTrack, SpotifyModalDialogArtist, SpotifyModalDialogAlbum, SpotifyModalDialogPlaylist, InfiniteLoading, CoverArtwork },
data () { data () {
return { return {
@ -172,7 +186,9 @@ export default {
selected_artist: {}, selected_artist: {},
show_playlist_details_modal: false, show_playlist_details_modal: false,
selected_playlist: {} selected_playlist: {},
validSearchTypes: ['track', 'artist', 'album', 'playlist']
} }
}, },
@ -207,6 +223,10 @@ export default {
}, },
show_all_playlists_button () { show_all_playlists_button () {
return this.playlists.total > this.playlists.items.length return this.playlists.total > this.playlists.items.length
},
is_visible_artwork () {
return this.$store.getters.settings_option('webinterface', 'show_cover_artwork_in_album_lists').value
} }
}, },
@ -245,7 +265,8 @@ export default {
var spotifyApi = new SpotifyWebApi() var spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token) spotifyApi.setAccessToken(data.webapi_token)
return spotifyApi.search(this.query.query, this.query.type.split(','), this.search_param) var types = this.query.type.split(',').filter(type => this.validSearchTypes.includes(type))
return spotifyApi.search(this.query.query, types, this.search_param)
}) })
}, },
@ -318,7 +339,7 @@ export default {
this.$router.push({ this.$router.push({
path: '/search/spotify', path: '/search/spotify',
query: { query: {
type: 'track,artist,album,playlist', type: 'track,artist,album,playlist,audiobook,podcast',
query: this.search_query, query: this.search_query,
limit: 3, limit: 3,
offset: 0 offset: 0
@ -390,6 +411,17 @@ export default {
open_playlist_dialog: function (playlist) { open_playlist_dialog: function (playlist) {
this.selected_playlist = playlist this.selected_playlist = playlist
this.show_playlist_details_modal = true this.show_playlist_details_modal = true
},
open_album: function (album) {
this.$router.push({ path: '/music/spotify/albums/' + album.id })
},
artwork_url: function (album) {
if (album.images && album.images.length > 0) {
return album.images[0].url
}
return ''
} }
}, },

View File

@ -17,8 +17,10 @@ import PageGenreTracks from '@/pages/PageGenreTracks'
import PageArtistTracks from '@/pages/PageArtistTracks' import PageArtistTracks from '@/pages/PageArtistTracks'
import PagePodcasts from '@/pages/PagePodcasts' import PagePodcasts from '@/pages/PagePodcasts'
import PagePodcast from '@/pages/PagePodcast' import PagePodcast from '@/pages/PagePodcast'
import PageAudiobooks from '@/pages/PageAudiobooks' import PageAudiobooksAlbums from '@/pages/PageAudiobooksAlbums'
import PageAudiobook from '@/pages/PageAudiobook' import PageAudiobooksArtists from '@/pages/PageAudiobooksArtists'
import PageAudiobooksArtist from '@/pages/PageAudiobooksArtist'
import PageAudiobooksAlbum from '@/pages/PageAudiobooksAlbum'
import PagePlaylists from '@/pages/PagePlaylists' import PagePlaylists from '@/pages/PagePlaylists'
import PagePlaylist from '@/pages/PagePlaylist' import PagePlaylist from '@/pages/PagePlaylist'
import PageFiles from '@/pages/PageFiles' import PageFiles from '@/pages/PageFiles'
@ -126,12 +128,6 @@ export const router = new VueRouter({
component: PageGenreTracks, component: PageGenreTracks,
meta: { show_progress: true, has_index: true } meta: { show_progress: true, has_index: true }
}, },
{
path: '/music/radio',
name: 'Radio',
component: PageRadioStreams,
meta: { show_progress: true, has_tabs: true }
},
{ {
path: '/podcasts', path: '/podcasts',
name: 'Podcasts', name: 'Podcasts',
@ -146,14 +142,36 @@ export const router = new VueRouter({
}, },
{ {
path: '/audiobooks', path: '/audiobooks',
name: 'Audiobooks', redirect: '/audiobooks/artists'
component: PageAudiobooks, },
{
path: '/audiobooks/artists',
name: 'AudiobooksArtists',
component: PageAudiobooksArtists,
meta: { show_progress: true, has_tabs: true, has_index: true }
},
{
path: '/audiobooks/artists/:artist_id',
name: 'AudiobooksArtist',
component: PageAudiobooksArtist,
meta: { show_progress: true } meta: { show_progress: true }
}, },
{
path: '/audiobooks/albums',
name: 'AudiobooksAlbums',
component: PageAudiobooksAlbums,
meta: { show_progress: true, has_tabs: true, has_index: true }
},
{ {
path: '/audiobooks/:album_id', path: '/audiobooks/:album_id',
name: 'Audiobook', name: 'Audiobook',
component: PageAudiobook, component: PageAudiobooksAlbum,
meta: { show_progress: true }
},
{
path: '/radio',
name: 'Radio',
component: PageRadioStreams,
meta: { show_progress: true } meta: { show_progress: true }
}, },
{ {
@ -258,11 +276,11 @@ export const router = new VueRouter({
}, 10) }, 10)
}) })
} else if (to.path === from.path && to.hash) { } else if (to.path === from.path && to.hash) {
return { selector: to.hash, offset: { x: 0, y: 90 } } return { selector: to.hash, offset: { x: 0, y: 120 } }
} else if (to.hash) { } else if (to.hash) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
setTimeout(() => { setTimeout(() => {
resolve({ selector: to.hash, offset: { x: 0, y: 90 } }) resolve({ selector: to.hash, offset: { x: 0, y: 120 } })
}, 10) }, 10)
}) })
} else if (to.meta.has_index) { } else if (to.meta.has_index) {

View File

@ -53,6 +53,9 @@ export default new Vuex.Store({
recent_searches: [], recent_searches: [],
hide_singles: false, hide_singles: false,
hide_spotify: false,
artists_sort: 'Name',
albums_sort: 'Name',
show_only_next_items: false, show_only_next_items: false,
show_burger_menu: false, show_burger_menu: false,
show_player_menu: false show_player_menu: false
@ -91,6 +94,18 @@ export default new Vuex.Store({
} }
} }
return null return null
},
settings_category: (state) => (categoryName) => {
return state.settings.categories.find(elem => elem.name === categoryName)
},
settings_option: (state) => (categoryName, optionName) => {
const category = state.settings.categories.find(elem => elem.name === categoryName)
if (!category) {
return {}
}
return category.options.find(elem => elem.name === optionName)
} }
}, },
@ -171,6 +186,15 @@ export default new Vuex.Store({
[types.HIDE_SINGLES] (state, hideSingles) { [types.HIDE_SINGLES] (state, hideSingles) {
state.hide_singles = hideSingles state.hide_singles = hideSingles
}, },
[types.HIDE_SPOTIFY] (state, hideSpotify) {
state.hide_spotify = hideSpotify
},
[types.ARTISTS_SORT] (state, sort) {
state.artists_sort = sort
},
[types.ALBUMS_SORT] (state, sort) {
state.albums_sort = sort
},
[types.SHOW_ONLY_NEXT_ITEMS] (state, showOnlyNextItems) { [types.SHOW_ONLY_NEXT_ITEMS] (state, showOnlyNextItems) {
state.show_only_next_items = showOnlyNextItems state.show_only_next_items = showOnlyNextItems
}, },

View File

@ -19,6 +19,9 @@ export const DELETE_NOTIFICATION = 'DELETE_NOTIFICATION'
export const ADD_RECENT_SEARCH = 'ADD_RECENT_SEARCH' export const ADD_RECENT_SEARCH = 'ADD_RECENT_SEARCH'
export const HIDE_SINGLES = 'HIDE_SINGLES' export const HIDE_SINGLES = 'HIDE_SINGLES'
export const HIDE_SPOTIFY = 'HIDE_SPOTIFY'
export const ARTISTS_SORT = 'ARTISTS_SORT'
export const ALBUMS_SORT = 'ALBUMS_SORT'
export const SHOW_ONLY_NEXT_ITEMS = 'SHOW_ONLY_NEXT_ITEMS' export const SHOW_ONLY_NEXT_ITEMS = 'SHOW_ONLY_NEXT_ITEMS'
export const SHOW_BURGER_MENU = 'SHOW_BURGER_MENU' export const SHOW_BURGER_MENU = 'SHOW_BURGER_MENU'
export const SHOW_PLAYER_MENU = 'SHOW_PLAYER_MENU' export const SHOW_PLAYER_MENU = 'SHOW_PLAYER_MENU'

View File

@ -3,25 +3,35 @@
<div class="container"> <div class="container">
<div class="columns is-centered"> <div class="columns is-centered">
<div class="column is-four-fifths"> <div class="column is-four-fifths">
<slot name="options"></slot> <section v-if="$slots['options']">
<nav class="level" id="top"> <div v-observe-visibility="observer_options"></div>
<!-- Left side --> <slot name="options"></slot>
<div class="level-left"> <nav class="buttons is-centered" style="margin-bottom: 6px; margin-top: 16px;">
<div class="level-item has-text-centered-mobile"> <a v-if="!options_visible" class="button is-small is-white" @click="scroll_to_top"><span class="icon is-small"><i class="mdi mdi-chevron-up"></i></span></a>
<div> <a v-else class="button is-small is-white" @click="scroll_to_content"><span class="icon is-small"><i class="mdi mdi-chevron-down"></i></span></a>
<slot name="heading-left"></slot> </nav>
</section>
<div :class="{'fd-content-with-option': $slots['options']}">
<nav class="level" id="top">
<!-- Left side -->
<div class="level-left">
<div class="level-item has-text-centered-mobile">
<div>
<slot name="heading-left"></slot>
</div>
</div> </div>
</div> </div>
</div>
<!-- Right side --> <!-- Right side -->
<div class="level-right has-text-centered-mobile"> <div class="level-right has-text-centered-mobile">
<slot name="heading-right"></slot> <slot name="heading-right"></slot>
</div>
</nav>
<slot name="content"></slot>
<div style="margin-top: 16px;">
<slot name="footer"></slot>
</div> </div>
</nav>
<slot name="content"></slot>
<div style="margin-top: 16px;">
<slot name="footer"></slot>
</div> </div>
</div> </div>
</div> </div>
@ -30,6 +40,41 @@
</template> </template>
<script> <script>
export default {
name: 'ContentWithHeading',
data () {
return {
options_visible: false,
observer_options: {
callback: this.visibilityChanged,
intersection: {
rootMargin: '-100px',
threshold: 0.3
}
}
}
},
methods: {
scroll_to_top: function () {
window.scrollTo({ top: 0, behavior: 'smooth' })
},
scroll_to_content: function () {
// window.scrollTo({ top: 80, behavior: 'smooth' })
if (this.$route.meta.has_tabs) {
this.$scrollTo('#top', { offset: -140 })
} else {
this.$scrollTo('#top', { offset: -100 })
}
},
visibilityChanged: function (isVisible) {
this.options_visible = isVisible
}
}
}
</script> </script>
<style> <style>

View File

@ -12,51 +12,51 @@ axios.interceptors.response.use(function (response) {
export default { export default {
config () { config () {
return axios.get('/api/config') return axios.get('./api/config')
}, },
settings () { settings () {
return axios.get('/api/settings') return axios.get('./api/settings')
}, },
settings_update (categoryName, option) { settings_update (categoryName, option) {
return axios.put('/api/settings/' + categoryName + '/' + option.name, option) return axios.put('./api/settings/' + categoryName + '/' + option.name, option)
}, },
library_stats () { library_stats () {
return axios.get('/api/library') return axios.get('./api/library')
}, },
library_update () { library_update () {
return axios.put('/api/update') return axios.put('./api/update')
}, },
library_rescan () { library_rescan () {
return axios.put('/api/rescan') return axios.put('./api/rescan')
}, },
library_count (expression) { library_count (expression) {
return axios.get('/api/library/count?expression=' + expression) return axios.get('./api/library/count?expression=' + expression)
}, },
queue () { queue () {
return axios.get('/api/queue') return axios.get('./api/queue')
}, },
queue_clear () { queue_clear () {
return axios.put('/api/queue/clear') return axios.put('./api/queue/clear')
}, },
queue_remove (itemId) { queue_remove (itemId) {
return axios.delete('/api/queue/items/' + itemId) return axios.delete('./api/queue/items/' + itemId)
}, },
queue_move (itemId, newPosition) { queue_move (itemId, newPosition) {
return axios.put('/api/queue/items/' + itemId + '?new_position=' + newPosition) return axios.put('./api/queue/items/' + itemId + '?new_position=' + newPosition)
}, },
queue_add (uri) { queue_add (uri) {
return axios.post('/api/queue/items/add?uris=' + uri).then((response) => { return axios.post('./api/queue/items/add?uris=' + uri).then((response) => {
store.dispatch('add_notification', { text: response.data.count + ' tracks appended to queue', type: 'info', timeout: 2000 }) store.dispatch('add_notification', { text: response.data.count + ' tracks appended to queue', type: 'info', timeout: 2000 })
return Promise.resolve(response) return Promise.resolve(response)
}) })
@ -67,7 +67,7 @@ export default {
if (store.getters.now_playing && store.getters.now_playing.id) { if (store.getters.now_playing && store.getters.now_playing.id) {
position = store.getters.now_playing.position + 1 position = store.getters.now_playing.position + 1
} }
return axios.post('/api/queue/items/add?uris=' + uri + '&position=' + position).then((response) => { return axios.post('./api/queue/items/add?uris=' + uri + '&position=' + position).then((response) => {
store.dispatch('add_notification', { text: response.data.count + ' tracks appended to queue', type: 'info', timeout: 2000 }) store.dispatch('add_notification', { text: response.data.count + ' tracks appended to queue', type: 'info', timeout: 2000 })
return Promise.resolve(response) return Promise.resolve(response)
}) })
@ -77,7 +77,7 @@ export default {
var options = {} var options = {}
options.expression = expression options.expression = expression
return axios.post('/api/queue/items/add', undefined, { params: options }).then((response) => { return axios.post('./api/queue/items/add', undefined, { params: options }).then((response) => {
store.dispatch('add_notification', { text: response.data.count + ' tracks appended to queue', type: 'info', timeout: 2000 }) store.dispatch('add_notification', { text: response.data.count + ' tracks appended to queue', type: 'info', timeout: 2000 })
return Promise.resolve(response) return Promise.resolve(response)
}) })
@ -91,21 +91,21 @@ export default {
options.position = store.getters.now_playing.position + 1 options.position = store.getters.now_playing.position + 1
} }
return axios.post('/api/queue/items/add', undefined, { params: options }).then((response) => { return axios.post('./api/queue/items/add', undefined, { params: options }).then((response) => {
store.dispatch('add_notification', { text: response.data.count + ' tracks appended to queue', type: 'info', timeout: 2000 }) store.dispatch('add_notification', { text: response.data.count + ' tracks appended to queue', type: 'info', timeout: 2000 })
return Promise.resolve(response) return Promise.resolve(response)
}) })
}, },
queue_save_playlist (name) { queue_save_playlist (name) {
return axios.post('/api/queue/save', undefined, { params: { name: name } }).then((response) => { return axios.post('./api/queue/save', undefined, { params: { name: name } }).then((response) => {
store.dispatch('add_notification', { text: 'Queue saved to playlist "' + name + '"', type: 'info', timeout: 2000 }) store.dispatch('add_notification', { text: 'Queue saved to playlist "' + name + '"', type: 'info', timeout: 2000 })
return Promise.resolve(response) return Promise.resolve(response)
}) })
}, },
player_status () { player_status () {
return axios.get('/api/player') return axios.get('./api/player')
}, },
player_play_uri (uris, shuffle, position = undefined) { player_play_uri (uris, shuffle, position = undefined) {
@ -116,7 +116,7 @@ export default {
options.playback = 'start' options.playback = 'start'
options.playback_from_position = position options.playback_from_position = position
return axios.post('/api/queue/items/add', undefined, { params: options }) return axios.post('./api/queue/items/add', undefined, { params: options })
}, },
player_play_expression (expression, shuffle, position = undefined) { player_play_expression (expression, shuffle, position = undefined) {
@ -127,111 +127,111 @@ export default {
options.playback = 'start' options.playback = 'start'
options.playback_from_position = position options.playback_from_position = position
return axios.post('/api/queue/items/add', undefined, { params: options }) return axios.post('./api/queue/items/add', undefined, { params: options })
}, },
player_play (options = {}) { player_play (options = {}) {
return axios.put('/api/player/play', undefined, { params: options }) return axios.put('./api/player/play', undefined, { params: options })
}, },
player_playpos (position) { player_playpos (position) {
return axios.put('/api/player/play?position=' + position) return axios.put('./api/player/play?position=' + position)
}, },
player_playid (itemId) { player_playid (itemId) {
return axios.put('/api/player/play?item_id=' + itemId) return axios.put('./api/player/play?item_id=' + itemId)
}, },
player_pause () { player_pause () {
return axios.put('/api/player/pause') return axios.put('./api/player/pause')
}, },
player_stop () { player_stop () {
return axios.put('/api/player/stop') return axios.put('./api/player/stop')
}, },
player_next () { player_next () {
return axios.put('/api/player/next') return axios.put('./api/player/next')
}, },
player_previous () { player_previous () {
return axios.put('/api/player/previous') return axios.put('./api/player/previous')
}, },
player_shuffle (newState) { player_shuffle (newState) {
var shuffle = newState ? 'true' : 'false' var shuffle = newState ? 'true' : 'false'
return axios.put('/api/player/shuffle?state=' + shuffle) return axios.put('./api/player/shuffle?state=' + shuffle)
}, },
player_consume (newState) { player_consume (newState) {
var consume = newState ? 'true' : 'false' var consume = newState ? 'true' : 'false'
return axios.put('/api/player/consume?state=' + consume) return axios.put('./api/player/consume?state=' + consume)
}, },
player_repeat (newRepeatMode) { player_repeat (newRepeatMode) {
return axios.put('/api/player/repeat?state=' + newRepeatMode) return axios.put('./api/player/repeat?state=' + newRepeatMode)
}, },
player_volume (volume) { player_volume (volume) {
return axios.put('/api/player/volume?volume=' + volume) return axios.put('./api/player/volume?volume=' + volume)
}, },
player_output_volume (outputId, outputVolume) { player_output_volume (outputId, outputVolume) {
return axios.put('/api/player/volume?volume=' + outputVolume + '&output_id=' + outputId) return axios.put('./api/player/volume?volume=' + outputVolume + '&output_id=' + outputId)
}, },
player_seek_to_pos (newPosition) { player_seek_to_pos (newPosition) {
return axios.put('/api/player/seek?position_ms=' + newPosition) return axios.put('./api/player/seek?position_ms=' + newPosition)
}, },
player_seek (seekMs) { player_seek (seekMs) {
return axios.put('/api/player/seek?seek_ms=' + seekMs) return axios.put('./api/player/seek?seek_ms=' + seekMs)
}, },
outputs () { outputs () {
return axios.get('/api/outputs') return axios.get('./api/outputs')
}, },
output_update (outputId, output) { output_update (outputId, output) {
return axios.put('/api/outputs/' + outputId, output) return axios.put('./api/outputs/' + outputId, output)
}, },
output_toggle (outputId) { output_toggle (outputId) {
return axios.put('/api/outputs/' + outputId + '/toggle') return axios.put('./api/outputs/' + outputId + '/toggle')
}, },
library_artists () { library_artists (media_kind = undefined) {
return axios.get('/api/library/artists?media_kind=music') return axios.get('./api/library/artists', { params: { media_kind: media_kind } })
}, },
library_artist (artistId) { library_artist (artistId) {
return axios.get('/api/library/artists/' + artistId) return axios.get('./api/library/artists/' + artistId)
}, },
library_artist_albums (artistId) { library_artist_albums (artistId) {
return axios.get('/api/library/artists/' + artistId + '/albums') return axios.get('./api/library/artists/' + artistId + '/albums')
}, },
library_albums (media_kind = undefined) { library_albums (media_kind = undefined) {
return axios.get('/api/library/albums', { params: { media_kind: media_kind } }) return axios.get('./api/library/albums', { params: { media_kind: media_kind } })
}, },
library_album (albumId) { library_album (albumId) {
return axios.get('/api/library/albums/' + albumId) return axios.get('./api/library/albums/' + albumId)
}, },
library_album_tracks (albumId, filter = { limit: -1, offset: 0 }) { library_album_tracks (albumId, filter = { limit: -1, offset: 0 }) {
return axios.get('/api/library/albums/' + albumId + '/tracks', { return axios.get('./api/library/albums/' + albumId + '/tracks', {
params: filter params: filter
}) })
}, },
library_album_track_update (albumId, attributes) { library_album_track_update (albumId, attributes) {
return axios.put('/api/library/albums/' + albumId + '/tracks', undefined, { params: attributes }) return axios.put('./api/library/albums/' + albumId + '/tracks', undefined, { params: attributes })
}, },
library_genres () { library_genres () {
return axios.get('/api/library/genres') return axios.get('./api/library/genres')
}, },
library_genre (genre) { library_genre (genre) {
@ -240,7 +240,7 @@ export default {
media_kind: 'music', media_kind: 'music',
expression: 'genre is "' + genre + '"' expression: 'genre is "' + genre + '"'
} }
return axios.get('/api/search', { return axios.get('./api/search', {
params: genreParams params: genreParams
}) })
}, },
@ -251,7 +251,7 @@ export default {
media_kind: 'music', media_kind: 'music',
expression: 'genre is "' + genre + '"' expression: 'genre is "' + genre + '"'
} }
return axios.get('/api/search', { return axios.get('./api/search', {
params: genreParams params: genreParams
}) })
}, },
@ -262,7 +262,7 @@ export default {
media_kind: 'music', media_kind: 'music',
expression: 'data_kind is url and song_length = 0' expression: 'data_kind is url and song_length = 0'
} }
return axios.get('/api/search', { return axios.get('./api/search', {
params: params params: params
}) })
}, },
@ -273,7 +273,7 @@ export default {
type: 'tracks', type: 'tracks',
expression: 'songartistid is "' + artist + '"' expression: 'songartistid is "' + artist + '"'
} }
return axios.get('/api/search', { return axios.get('./api/search', {
params: artistParams params: artistParams
}) })
} }
@ -284,7 +284,7 @@ export default {
type: 'tracks', type: 'tracks',
expression: 'media_kind is podcast and play_count = 0 ORDER BY time_added DESC' expression: 'media_kind is podcast and play_count = 0 ORDER BY time_added DESC'
} }
return axios.get('/api/search', { return axios.get('./api/search', {
params: episodesParams params: episodesParams
}) })
}, },
@ -294,86 +294,86 @@ export default {
type: 'tracks', type: 'tracks',
expression: 'media_kind is podcast and songalbumid is "' + albumId + '" ORDER BY date_released DESC' expression: 'media_kind is podcast and songalbumid is "' + albumId + '" ORDER BY date_released DESC'
} }
return axios.get('/api/search', { return axios.get('./api/search', {
params: episodesParams params: episodesParams
}) })
}, },
library_add (url) { library_add (url) {
return axios.post('/api/library/add', undefined, { params: { url: url } }) return axios.post('./api/library/add', undefined, { params: { url: url } })
}, },
library_playlist_delete (playlistId) { library_playlist_delete (playlistId) {
return axios.delete('/api/library/playlists/' + playlistId, undefined) return axios.delete('./api/library/playlists/' + playlistId, undefined)
}, },
library_playlists () { library_playlists () {
return axios.get('/api/library/playlists') return axios.get('./api/library/playlists')
}, },
library_playlist_folder (playlistId = 0) { library_playlist_folder (playlistId = 0) {
return axios.get('/api/library/playlists/' + playlistId + '/playlists') return axios.get('./api/library/playlists/' + playlistId + '/playlists')
}, },
library_playlist (playlistId) { library_playlist (playlistId) {
return axios.get('/api/library/playlists/' + playlistId) return axios.get('./api/library/playlists/' + playlistId)
}, },
library_playlist_tracks (playlistId) { library_playlist_tracks (playlistId) {
return axios.get('/api/library/playlists/' + playlistId + '/tracks') return axios.get('./api/library/playlists/' + playlistId + '/tracks')
}, },
library_track (trackId) { library_track (trackId) {
return axios.get('/api/library/tracks/' + trackId) return axios.get('./api/library/tracks/' + trackId)
}, },
library_track_playlists (trackId) { library_track_playlists (trackId) {
return axios.get('/api/library/tracks/' + trackId + '/playlists') return axios.get('./api/library/tracks/' + trackId + '/playlists')
}, },
library_track_update (trackId, attributes = {}) { library_track_update (trackId, attributes = {}) {
return axios.put('/api/library/tracks/' + trackId, undefined, { params: attributes }) return axios.put('./api/library/tracks/' + trackId, undefined, { params: attributes })
}, },
library_files (directory = undefined) { library_files (directory = undefined) {
var filesParams = { directory: directory } var filesParams = { directory: directory }
return axios.get('/api/library/files', { return axios.get('./api/library/files', {
params: filesParams params: filesParams
}) })
}, },
search (searchParams) { search (searchParams) {
return axios.get('/api/search', { return axios.get('./api/search', {
params: searchParams params: searchParams
}) })
}, },
spotify () { spotify () {
return axios.get('/api/spotify') return axios.get('./api/spotify')
}, },
spotify_login (credentials) { spotify_login (credentials) {
return axios.post('/api/spotify-login', credentials) return axios.post('./api/spotify-login', credentials)
}, },
lastfm () { lastfm () {
return axios.get('/api/lastfm') return axios.get('./api/lastfm')
}, },
lastfm_login (credentials) { lastfm_login (credentials) {
return axios.post('/api/lastfm-login', credentials) return axios.post('./api/lastfm-login', credentials)
}, },
lastfm_logout (credentials) { lastfm_logout (credentials) {
return axios.get('/api/lastfm-logout') return axios.get('./api/lastfm-logout')
}, },
pairing () { pairing () {
return axios.get('/api/pairing') return axios.get('./api/pairing')
}, },
pairing_kickoff (pairingReq) { pairing_kickoff (pairingReq) {
return axios.post('/api/pairing', pairingReq) return axios.post('./api/pairing', pairingReq)
}, },
artwork_url_append_size_params (artworkUrl, maxwidth = 600, maxheight = 600) { artwork_url_append_size_params (artworkUrl, maxwidth = 600, maxheight = 600) {

View File

@ -10,6 +10,9 @@ module.exports = {
assetsDir: 'player', assetsDir: 'player',
// Relative public path
publicPath: './',
// Do not add hashes to the generated js/css filenames, would otherwise // Do not add hashes to the generated js/css filenames, would otherwise
// require to adjust the Makefile in htdocs each time the web interface is // require to adjust the Makefile in htdocs each time the web interface is
// build // build