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"> -->
<!-- Local libraries -->
<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/fontawesome/css/all.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>
<body>
@ -25,7 +25,7 @@
-->
<nav class="navbar">
<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>
</div>
</nav>
@ -281,9 +281,9 @@
<!-- <script src="https://unpkg.com/axios@0.18.0/dist/axios.min.js"></script> -->
<!-- Local libraries -->
<script src="/admin/vendor/vue/vue.min.js"></script>
<script src="/admin/vendor/axios/axios.min.js"></script>
<script src="/admin/js/forked-daapd.js"></script>
<script src="admin/vendor/vue/vue.min.js"></script>
<script src="admin/vendor/axios/axios.min.js"></script>
<script src="admin/js/forked-daapd.js"></script>
</body>
</html>

View File

@ -35,43 +35,43 @@ var app = new Vue({
methods: {
loadConfig: function() {
axios.get('/api/config').then(response => {
axios.get('./api/config').then(response => {
this.config = response.data;
this.connect()});
},
loadLibrary: function() {
axios.get('/api/library').then(response => this.library = response.data);
axios.get('./api/library').then(response => this.library = response.data);
},
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() {
axios.get('/api/spotify').then(response => this.spotify = response.data);
axios.get('./api/spotify').then(response => this.spotify = response.data);
},
loadPairing: function() {
axios.get('/api/pairing').then(response => this.pairing = response.data);
axios.get('./api/pairing').then(response => this.pairing = response.data);
},
loadLastfm: function() {
axios.get('/api/lastfm').then(response => this.lastfm = response.data);
axios.get('./api/lastfm').then(response => this.lastfm = response.data);
},
update: function() {
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() {
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() {
axios.post('/api/pairing', this.pairing_req).then(response => {
axios.post('./api/pairing', this.pairing_req).then(response => {
console.log('Kicked off pairing');
if (!this.config.websocket_port) {
this.pairing = {};
@ -80,7 +80,7 @@ var app = new Vue({
},
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');
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) {
this.loadOutputs();
}
@ -102,7 +102,7 @@ var app = new Vue({
},
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.password = '';
this.libspotify.errors.user = '';
@ -117,7 +117,7 @@ var app = new Vue({
},
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.password = '';
this.lastfm_login.errors.user = '';
@ -132,7 +132,7 @@ var app = new Vue({
},
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) {
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
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);
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
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);
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
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
httpd_request_etag_matches(struct evhttp_request *req, const char *etag);
void
httpd_response_not_cachable(struct evhttp_request *req);
/*
* Gzips an evbuffer
*

View File

@ -213,7 +213,7 @@ artist_to_json(struct db_group_info *dbgri)
if (ret < sizeof(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))
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))
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))
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;
char uri[100];
int intval;
bool boolval;
int ret;
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));
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));
}
@ -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_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, "artwork_url", json_object_new_string("/artwork/nowplaying"));
json_object_object_add(reply, "artwork_url", json_object_new_string("./artwork/nowplaying"));
}
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
// 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))
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
// 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).
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))
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 ret = 0;
if (!is_modified(hreq->req, DB_ADMIN_DB_MODIFIED))
return HTTP_NOTMODIFIED;
// Due to smart playlists possibly changing their tracks between rescans, disable caching in clients
httpd_response_not_cachable(hreq->req);
ret = safe_atoi32(hreq->uri_parsed->path_parts[3], &playlist_id);
if (ret < 0)

View File

@ -24,22 +24,29 @@
#include "db.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[] =
{
{ "show_composer_now_playing", SETTINGS_TYPE_BOOL },
{ "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[] =
{
{ "use_artwork_source_spotify", SETTINGS_TYPE_BOOL, NULL, artwork_spotify_default_getbool, NULL },
{ "use_artwork_source_discogs", SETTINGS_TYPE_BOOL, NULL, artwork_discogs_default_getbool, NULL },
{ "use_artwork_source_coverartarchive", SETTINGS_TYPE_BOOL, NULL, artwork_coverartarchive_default_getbool, NULL },
// Spotify source 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.
{ "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[] =
@ -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 -----------------------------*/
int
@ -185,8 +147,8 @@ settings_option_getint(struct settings_option *option)
if (ret == 0)
return intval;
if (option->default_getint)
return option->default_getint(option);
if (option->default_value.intval)
return option->default_value.intval;
return 0;
}
@ -204,8 +166,8 @@ settings_option_getbool(struct settings_option *option)
if (ret == 0)
return (intval != 0);
if (option->default_getbool)
return option->default_getbool(option);
if (option->default_value.boolval)
return option->default_value.boolval;
return false;
}
@ -223,8 +185,8 @@ settings_option_getstr(struct settings_option *option)
if (ret == 0)
return s;
if (option->default_getstr)
return option->default_getstr(option);
if (option->default_value.strval)
return option->default_value.strval;
return NULL;
}

View File

@ -12,12 +12,16 @@ enum settings_type {
SETTINGS_TYPE_CATEGORY,
};
union settings_default_value {
int intval;
bool boolval;
char *strval;
};
struct settings_option {
const char *name;
enum settings_type type;
int (*default_getint)(struct settings_option *option);
bool (*default_getbool)(struct settings_option *option);
char *(*default_getstr)(struct settings_option *option);
union settings_default_value default_value;
};
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",
"version": "0.7.2",
"version": "0.8.0",
"private": true,
"description": "forked-daapd web interface",
"author": "chme <christian.meffert@googlemail.com>",
@ -11,41 +11,44 @@
"dev": "vue-cli-service serve"
},
"dependencies": {
"axios": "^0.19.2",
"axios": "^0.20.0",
"bulma": "^0.9.0",
"bulma-switch": "^2.0.0",
"core-js": "^3.6.5",
"mdi": "^2.2.43",
"moment": "^2.27.0",
"moment": "^2.28.0",
"moment-duration-format": "^2.3.2",
"npm": "^6.14.5",
"npm": "^6.14.8",
"reconnectingwebsocket": "^1.0.0",
"spotify-web-api-js": "^1.4.0",
"string-to-color": "^2.1.4",
"v-click-outside": "^3.0.1",
"vue": "^2.6.11",
"spotify-web-api-js": "^1.5.0",
"string-to-color": "^2.2.2",
"v-click-outside": "^3.1.2",
"vue": "^2.6.12",
"vue-infinite-loading": "^2.4.5",
"vue-observe-visibility": "^0.4.6",
"vue-progressbar": "^0.7.5",
"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",
"vuedraggable": "^2.23.2",
"vuedraggable": "^2.24.1",
"vuex": "^3.5.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^4.4.6",
"@vue/cli-plugin-eslint": "^4.4.6",
"@vue/cli-service": "^4.4.6",
"@vue/cli-plugin-babel": "^4.5.6",
"@vue/cli-plugin-eslint": "^4.5.6",
"@vue/cli-service": "^4.5.6",
"@vue/eslint-config-standard": "^5.1.2",
"babel-eslint": "^10.1.0",
"eslint": "^7.3.1",
"eslint": "^7.9.0",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1",
"eslint-plugin-vue": "^6.2.2",
"sass": "^1.26.9",
"sass-loader": "^8.0.2",
"vue-template-compiler": "^2.6.11"
"sass": "^1.26.11",
"sass-loader": "^10.0.2",
"vue-template-compiler": "^2.6.12"
},
"license": "GPL-2.0"
}

View File

@ -4,11 +4,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<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">
<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">
</head>

View File

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

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>
<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>
</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>
</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>
<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 style="margin-top:0.7rem;">
<h1 class="title is-6">{{ props.album.name }}</h1>

View File

@ -1,5 +1,5 @@
<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">
<h1 class="title is-6">{{ props.artist.name }}</h1>
</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">
<a class="has-text-link" @click="open_album">{{ album.name }}</a>
</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="$emit('remove_podcast')">Remove podcast</a>
<a class="button is-small" @click="$emit('remove-podcast')">Remove podcast</a>
</div>
<div class="content is-small">
<p v-if="album.artist && media_kind !== 'audiobook'">
<p v-if="album.artist">
<span class="heading">Album artist</span>
<a class="title is-6 has-text-link" @click="open_artist">{{ album.artist }}</a>
</p>
<p v-if="album.artist && media_kind === 'audiobook'">
<span class="heading">Album artist</span>
<span class="title is-6">{{ album.artist }}</span>
<p v-if="album.date_released">
<span class="heading">Release date</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>
<span class="heading">Tracks</span>
<span class="title is-6">{{ album.track_count }}</span>
</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>
<footer class="card-footer">
@ -70,6 +86,10 @@ export default {
computed: {
artwork_url: function () {
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 () {
if (this.media_kind === 'podcast') {
if (this.media_kind_resolved === 'podcast') {
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 })
} else {
this.$router.push({ path: '/music/albums/' + this.album.id })
@ -100,7 +120,13 @@ export default {
},
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 () {

View File

@ -18,6 +18,14 @@
<span class="heading">Tracks</span>
<span class="title is-6">{{ artist.track_count }}</span>
</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>
<footer class="card-footer">

View File

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

View File

@ -81,7 +81,7 @@
</div>
<div class="level-item 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
class="slider fd-has-action"
min="0"
@ -174,7 +174,7 @@
</div>
<div class="level-item 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
class="slider fd-has-action"
min="0"

View File

@ -1,22 +1,25 @@
<template>
<nav class="fd-top-navbar navbar is-light is-fixed-top" :style="zindex" role="navigation" aria-label="main navigation">
<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>
</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>
</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>
</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>
</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>
</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>
</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/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/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="/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="/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="/search"><span class="icon"><i class="mdi mdi-magnify"></i></span> <b>Search</b></navbar-item-link>
<hr class="fd-navbar-divider">
<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>
@ -87,6 +90,28 @@ export default {
},
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 () {
return this.$store.state.player
},

View File

@ -1,9 +1,14 @@
<template>
<template functional>
<div class="media">
<div class="media-content fd-has-action is-clipped" v-on:click="open_album">
<h1 class="title is-6">{{ album.name }}</h1>
<h2 class="subtitle is-7 has-text-grey"><b>{{ album.artists[0].name }}</b></h2>
<h2 class="subtitle is-7 has-text-grey has-text-weight-normal">({{ album.album_type }}, {{ album.release_date | time('L') }})</h2>
<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">
<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 class="media-right">
<slot name="actions"></slot>
@ -14,14 +19,7 @@
<script>
export default {
name: 'SpotifyListItemAlbum',
props: ['album'],
methods: {
open_album: function () {
this.$router.push({ path: '/music/spotify/albums/' + this.album.id })
}
}
props: ['album']
}
</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>
</a>
</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">
<a>
<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 vClickOutside from 'v-click-outside'
import VueTinyLazyloadImg from 'vue-tiny-lazyload-img'
import VueObserveVisibility from 'vue-observe-visibility'
import VueScrollTo from 'vue-scrollto'
import 'mdi/css/materialdesignicons.css'
import 'vue-range-slider/dist/vue-range-slider.css'
import './mystyles.scss'
Vue.config.productionTip = false
Vue.use(vClickOutside)
Vue.use(VueTinyLazyloadImg)
Vue.use(VueObserveVisibility)
Vue.use(VueScrollTo)
/* eslint-disable no-new */
new Vue({

View File

@ -1,5 +1,6 @@
@import 'bulma';
@import '~bulma-switch';
.slider {
@ -80,7 +81,9 @@ a.navbar-item {
.fd-is-square .button {
height: 27px;
width: 27px;
min-width: 27px;
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.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);
}
/* Set minimum height to hide "option" section */
.fd-content-with-option {
min-height: calc(100vh - 3.25rem - 3.25rem - 5rem);
}
/* Now playing page */
.fd-is-fullheight {
height: calc(100vh - 3.25rem - 3.25rem);

View File

@ -26,7 +26,7 @@
<!-- Right side -->
<div class="level-right">
<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="buttons has-addons">
<a @click="update" class="button is-small">Update</a>
@ -126,6 +126,10 @@ export default {
},
methods: {
onClickOutside (event) {
this.show_update_dropdown = false
},
update: function () {
this.show_update_dropdown = false
webapi.library_update()

View File

@ -24,14 +24,7 @@
</template>
<template slot="content">
<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)">
<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" />
<list-tracks :tracks="tracks" :uris="album.uri"></list-tracks>
<modal-dialog-album :show="show_album_details_modal" :album="album" @close="show_album_details_modal = false" />
</template>
</content-with-hero>
@ -40,8 +33,7 @@
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHero from '@/templates/ContentWithHero'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ListTracks from '@/components/ListTracks'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import CoverArtwork from '@/components/CoverArtwork'
import webapi from '@/webapi'
@ -63,16 +55,13 @@ const albumData = {
export default {
name: 'PageAlbum',
mixins: [LoadDataBeforeEnterMixin(albumData)],
components: { ContentWithHero, ListItemTrack, ModalDialogTrack, ModalDialogAlbum, CoverArtwork },
components: { ContentWithHero, ListTracks, ModalDialogAlbum, CoverArtwork },
data () {
return {
album: {},
tracks: [],
show_details_modal: false,
selected_track: {},
show_album_details_modal: false
}
},
@ -85,15 +74,6 @@ export default {
play: function () {
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>
<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 slot="heading-left">
<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 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 slot="content">
<list-item-album v-for="album in albums_filtered"
: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" />
<list-albums :albums="albums_list"></list-albums>
</template>
</content-with-heading>
</div>
@ -40,10 +48,11 @@ import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import IndexButtonList from '@/components/IndexButtonList'
import ListItemAlbum from '@/components/ListItemAlbum'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ListAlbums from '@/components/ListAlbums'
import DropdownMenu from '@/components/DropdownMenu'
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
import Albums from '@/lib/Albums'
const albumsData = {
load: function (to) {
@ -61,48 +70,60 @@ const albumsData = {
export default {
name: 'PageAlbums',
mixins: [LoadDataBeforeEnterMixin(albumsData)],
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemAlbum, ModalDialogAlbum },
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListAlbums, DropdownMenu },
data () {
return {
albums: { items: [] },
index_list: [],
show_details_modal: false,
selected_album: {}
sort_options: ['Name', 'Recently added', 'Recently released']
}
},
computed: {
hide_singles () {
return this.$store.state.hide_singles
albums_list () {
return new Albums(this.albums.items, {
hideSingles: this.hide_singles,
hideSpotify: this.hide_spotify,
sort: this.sort,
group: true
})
},
albums_filtered () {
return this.albums.items.filter(album => !this.hide_singles || album.track_count > 2)
spotify_enabled () {
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: {
update_hide_singles: function (e) {
this.$store.commit(types.HIDE_SINGLES, !this.hide_singles)
},
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()))]
scrollToTop: function () {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
}

View File

@ -15,14 +15,7 @@
</template>
<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>
<list-item-album v-for="album in albums.items" :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" />
<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>
@ -31,8 +24,7 @@
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemAlbum from '@/components/ListItemAlbum'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ListAlbums from '@/components/ListAlbums'
import ModalDialogArtist from '@/components/ModalDialogArtist'
import webapi from '@/webapi'
@ -53,16 +45,13 @@ const artistData = {
export default {
name: 'PageArtist',
mixins: [LoadDataBeforeEnterMixin(artistData)],
components: { ContentWithHeading, ListItemAlbum, ModalDialogAlbum, ModalDialogArtist },
components: { ContentWithHeading, ListAlbums, ModalDialogArtist },
data () {
return {
artist: {},
albums: {},
show_details_modal: false,
selected_album: {},
show_artist_details_modal: false
}
},
@ -74,15 +63,6 @@ export default {
play: function () {
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 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>
<list-item-track v-for="(track, index) in tracks.items" :key="track.id" :track="track" @click="play_track(index)">
<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" />
<list-tracks :tracks="tracks.items" :uris="track_uris"></list-tracks>
<modal-dialog-artist :show="show_artist_details_modal" :artist="artist" @close="show_artist_details_modal = false" />
</template>
</content-with-heading>
@ -37,8 +30,7 @@
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import IndexButtonList from '@/components/IndexButtonList'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ListTracks from '@/components/ListTracks'
import ModalDialogArtist from '@/components/ModalDialogArtist'
import webapi from '@/webapi'
@ -59,16 +51,13 @@ const tracksData = {
export default {
name: 'PageArtistTracks',
mixins: [LoadDataBeforeEnterMixin(tracksData)],
components: { ContentWithHeading, ListItemTrack, IndexButtonList, ModalDialogTrack, ModalDialogArtist },
components: { ContentWithHeading, ListTracks, IndexButtonList, ModalDialogArtist },
data () {
return {
artist: {},
tracks: { items: [] },
show_details_modal: false,
selected_track: {},
show_artist_details_modal: false
}
},
@ -77,6 +66,10 @@ export default {
index_list () {
return [...new Set(this.tracks.items
.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 () {
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>
<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 slot="heading-left">
<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 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 slot="content">
<list-item-artist v-for="artist in artists_filtered"
: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" />
<list-artists :artists="artists_list"></list-artists>
</template>
</content-with-heading>
</div>
@ -40,14 +48,15 @@ import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import IndexButtonList from '@/components/IndexButtonList'
import ListItemArtist from '@/components/ListItemArtist'
import ModalDialogArtist from '@/components/ModalDialogArtist'
import ListArtists from '@/components/ListArtists'
import DropdownMenu from '@/components/DropdownMenu'
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
import Artists from '@/lib/Artists'
const artistsData = {
load: function (to) {
return webapi.library_artists()
return webapi.library_artists('music')
},
set: function (vm, response) {
@ -58,45 +67,60 @@ const artistsData = {
export default {
name: 'PageArtists',
mixins: [LoadDataBeforeEnterMixin(artistsData)],
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemArtist, ModalDialogArtist },
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListArtists, DropdownMenu },
data () {
return {
artists: { items: [] },
show_details_modal: false,
selected_artist: {}
sort_options: ['Name', 'Recently added']
}
},
computed: {
hide_singles () {
return this.$store.state.hide_singles
artists_list () {
return new Artists(this.artists.items, {
hideSingles: this.hide_singles,
hideSpotify: this.hide_spotify,
sort: this.sort,
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()))]
spotify_enabled () {
return this.$store.state.spotify.webapi_token_valid
},
artists_filtered () {
return this.artists.items.filter(artist => !this.hide_singles || artist.track_count > (artist.album_count * 2))
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.artists_sort
},
set (value) {
this.$store.commit(types.ARTISTS_SORT, value)
}
}
},
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
scrollToTop: function () {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
}

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>
<content-with-heading>
<content-with-hero>
<template slot="heading-left">
<div class="title is-4">{{ album.name }}</div>
<div class="title is-4 has-text-grey has-text-weight-normal">{{ album.artist }}</div>
</template>
<template slot="heading-right">
<div class="buttons is-centered">
<h1 class="title is-5">{{ album.name }}</h1>
<h2 class="subtitle is-6 has-text-link has-text-weight-normal"><a class="has-text-link" @click="open_artist">{{ album.artist }}</a></h2>
<div class="buttons fd-is-centered-mobile fd-has-margin-top">
<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">
<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>Play</span>
</a>
</div>
</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">
<p class="heading has-text-centered-mobile">{{ album.track_count }} tracks</p>
<list-item-track v-for="(track, index) in tracks" :key="track.id" :track="track" @click="play_track(index)">
<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" />
<p class="heading is-7 has-text-centered-mobile fd-has-margin-top">{{ album.track_count }} tracks</p>
<list-tracks :tracks="tracks" :uris="album.uri"></list-tracks>
<modal-dialog-album :show="show_album_details_modal" :album="album" :media_kind="'audiobook'" @close="show_album_details_modal = false" />
</template>
</content-with-heading>
</content-with-hero>
</template>
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ContentWithHero from '@/templates/ContentWithHero'
import ListTracks from '@/components/ListTracks'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import CoverArtwork from '@/components/CoverArtwork'
import webapi from '@/webapi'
const albumData = {
@ -55,23 +53,25 @@ const albumData = {
}
export default {
name: 'PageAudiobook',
name: 'PageAudiobooksAlbum',
mixins: [LoadDataBeforeEnterMixin(albumData)],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogAlbum },
components: { ContentWithHero, ListTracks, ModalDialogAlbum, CoverArtwork },
data () {
return {
album: {},
tracks: [],
show_details_modal: false,
selected_track: {},
show_album_details_modal: false
}
},
methods: {
open_artist: function () {
this.show_details_modal = false
this.$router.push({ path: '/audiobooks/artists/' + this.album.artist_id })
},
play: function () {
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>
<div>
<tabs-music></tabs-music>
<tabs-audiobooks></tabs-audiobooks>
<content-with-heading>
<template slot="options">
<index-button-list :index="index_list"></index-button-list>
<index-button-list :index="artists_list.indexList"></index-button-list>
</template>
<template slot="heading-left">
<p class="title is-4">Artists</p>
<p class="heading">{{ artists.total }} artists</p>
<p class="title is-4">Authors</p>
<p class="heading">{{ artists_list.sortedAndFiltered.length }} Authors</p>
</template>
<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 slot="content">
<list-item-artist v-for="artist in artists_filtered"
: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" />
<list-artists :artists="artists_list"></list-artists>
</template>
</content-with-heading>
</div>
@ -38,16 +22,15 @@
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import TabsAudiobooks from '@/components/TabsAudiobooks'
import IndexButtonList from '@/components/IndexButtonList'
import ListItemArtist from '@/components/ListItemArtist'
import ModalDialogArtist from '@/components/ModalDialogArtist'
import ListArtists from '@/components/ListArtists'
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
import Artists from '@/lib/Artists'
const artistsData = {
load: function (to) {
return webapi.library_artists()
return webapi.library_artists('audiobook')
},
set: function (vm, response) {
@ -56,48 +39,26 @@ const artistsData = {
}
export default {
name: 'PageArtists',
name: 'PageAudiobooksArtists',
mixins: [LoadDataBeforeEnterMixin(artistsData)],
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemArtist, ModalDialogArtist },
components: { ContentWithHeading, TabsAudiobooks, IndexButtonList, ListArtists },
data () {
return {
artists: { items: [] },
show_details_modal: false,
selected_artist: {}
artists: { items: [] }
}
},
computed: {
hide_singles () {
return this.$store.state.hide_singles
},
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))
artists_list () {
return new Artists(this.artists.items, {
sort: 'Name',
group: true
})
}
},
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>

View File

@ -9,14 +9,7 @@
<p class="heading">albums</p>
</template>
<template slot="content">
<list-item-album v-for="album in recently_added.items" :key="album.id" :album="album" @click="open_album(album)">
<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" />
<list-albums :albums="recently_added.items"></list-albums>
</template>
<template slot="footer">
<nav class="level">
@ -34,14 +27,7 @@
<p class="heading">tracks</p>
</template>
<template slot="content">
<list-item-track v-for="track in recently_played.items" :key="track.id" :track="track" @click="play_track(track)">
<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" />
<list-tracks :tracks="recently_played.items"></list-tracks>
</template>
<template slot="footer">
<nav class="level">
@ -58,10 +44,8 @@
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import ListItemAlbum from '@/components/ListItemAlbum'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ListAlbums from '@/components/ListAlbums'
import ListTracks from '@/components/ListTracks'
import webapi from '@/webapi'
const browseData = {
@ -81,7 +65,7 @@ const browseData = {
export default {
name: 'PageBrowse',
mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, ListItemAlbum, ListItemTrack, ModalDialogTrack, ModalDialogAlbum },
components: { ContentWithHeading, TabsMusic, ListAlbums, ListTracks },
data () {
return {
@ -89,34 +73,13 @@ export default {
recently_played: {},
show_track_details_modal: false,
selected_track: {},
show_album_details_modal: false,
selected_album: {}
selected_track: {}
}
},
methods: {
open_browse: function (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>
</template>
<template slot="content">
<list-item-album v-for="album in recently_added.items" :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" />
<list-albums :albums="recently_added.items"></list-albums>
</template>
</content-with-heading>
</div>
@ -25,8 +18,7 @@
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import ListItemAlbum from '@/components/ListItemAlbum'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ListAlbums from '@/components/ListAlbums'
import webapi from '@/webapi'
const browseData = {
@ -46,25 +38,11 @@ const browseData = {
export default {
name: 'PageBrowseType',
mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, ListItemAlbum, ModalDialogAlbum },
components: { ContentWithHeading, TabsMusic, ListAlbums },
data () {
return {
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
recently_added: {}
}
}
}

View File

@ -8,14 +8,7 @@
<p class="heading">tracks</p>
</template>
<template slot="content">
<list-item-track v-for="track in recently_played.items" :key="track.id" :track="track" @click="play_track(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" />
<list-tracks :tracks="recently_played.items"></list-tracks>
</template>
</content-with-heading>
</div>
@ -25,8 +18,7 @@
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ListTracks from '@/components/ListTracks'
import webapi from '@/webapi'
const browseData = {
@ -46,25 +38,11 @@ const browseData = {
export default {
name: 'PageBrowseType',
mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, ListItemTrack, ModalDialogTrack },
components: { ContentWithHeading, TabsMusic, ListTracks },
data () {
return {
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)
recently_played: {}
}
}
}

View File

@ -19,14 +19,7 @@
</template>
<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>
<list-item-albums v-for="album in genre_albums.items" :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-albums>
<modal-dialog-album :show="show_details_modal" :album="selected_album" @close="show_details_modal = false" />
<list-albums :albums="genre_albums.items"></list-albums>
<modal-dialog-genre :show="show_genre_details_modal" :genre="{ 'name': name }" @close="show_genre_details_modal = false" />
</template>
</content-with-heading>
@ -37,8 +30,7 @@
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import IndexButtonList from '@/components/IndexButtonList'
import ListItemAlbums from '@/components/ListItemAlbum'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ListAlbums from '@/components/ListAlbums'
import ModalDialogGenre from '@/components/ModalDialogGenre'
import webapi from '@/webapi'
@ -56,16 +48,13 @@ const genreData = {
export default {
name: 'PageGenre',
mixins: [LoadDataBeforeEnterMixin(genreData)],
components: { ContentWithHeading, IndexButtonList, ListItemAlbums, ModalDialogAlbum, ModalDialogGenre },
components: { ContentWithHeading, IndexButtonList, ListAlbums, ModalDialogGenre },
data () {
return {
name: '',
genre_albums: { items: [] },
show_details_modal: false,
selected_album: {},
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)
},
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 slot="content">
<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)">
<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" />
<list-tracks :tracks="tracks.items" :expression="expression"></list-tracks>
<modal-dialog-genre :show="show_genre_details_modal" :genre="{ 'name': genre }" @close="show_genre_details_modal = false" />
</template>
</content-with-heading>
@ -37,8 +30,7 @@
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import IndexButtonList from '@/components/IndexButtonList'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ListTracks from '@/components/ListTracks'
import ModalDialogGenre from '@/components/ModalDialogGenre'
import webapi from '@/webapi'
@ -56,16 +48,13 @@ const tracksData = {
export default {
name: 'PageGenreTracks',
mixins: [LoadDataBeforeEnterMixin(tracksData)],
components: { ContentWithHeading, ListItemTrack, IndexButtonList, ModalDialogTrack, ModalDialogGenre },
components: { ContentWithHeading, ListTracks, IndexButtonList, ModalDialogGenre },
data () {
return {
tracks: { items: [] },
genre: '',
show_details_modal: false,
selected_track: {},
show_genre_details_modal: false
}
},
@ -74,6 +63,10 @@ export default {
index_list () {
return [...new Set(this.tracks.items
.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 () {
webapi.player_play_expression('genre is "' + this.genre + '" and media_kind is music', 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
webapi.player_play_expression(this.expression, true)
}
}
}

View File

@ -15,15 +15,8 @@
</template>
<template slot="content">
<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)">
<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-playlist :show="show_playlist_details_modal" :playlist="playlist" @close="show_playlist_details_modal = false" />
<list-tracks :tracks="tracks" :uris="uris"></list-tracks>
<modal-dialog-playlist :show="show_playlist_details_modal" :playlist="playlist" :tracks="playlist.random ? tracks : undefined" @close="show_playlist_details_modal = false" />
</template>
</content-with-heading>
</template>
@ -31,8 +24,7 @@
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ListTracks from '@/components/ListTracks'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
import webapi from '@/webapi'
@ -53,32 +45,29 @@ const playlistData = {
export default {
name: 'PagePlaylist',
mixins: [LoadDataBeforeEnterMixin(playlistData)],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogPlaylist },
components: { ContentWithHeading, ListTracks, ModalDialogPlaylist },
data () {
return {
playlist: {},
tracks: [],
show_details_modal: false,
selected_track: {},
show_playlist_details_modal: false
}
},
computed: {
uris () {
if (this.playlist.random) {
return this.tracks.map(a => a.uri).join(',')
}
return this.playlist.uri
}
},
methods: {
play: function () {
webapi.player_play_uri(this.playlist.uri, 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
webapi.player_play_uri(this.uris, true)
}
}
}

View File

@ -5,19 +5,7 @@
<p class="heading">{{ playlists.total }} playlists</p>
</template>
<template slot="content">
<list-item-playlist v-for="playlist in playlists.items" :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" />
<list-playlists :playlists="playlists.items"></list-playlists>
</template>
</content-with-heading>
</template>
@ -25,8 +13,7 @@
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemPlaylist from '@/components/ListItemPlaylist'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
import ListPlaylists from '@/components/ListPlaylists'
import webapi from '@/webapi'
const playlistsData = {
@ -46,30 +33,12 @@ const playlistsData = {
export default {
name: 'PagePlaylists',
mixins: [LoadDataBeforeEnterMixin(playlistsData)],
components: { ContentWithHeading, ListItemPlaylist, ModalDialogPlaylist },
components: { ContentWithHeading, ListPlaylists },
data () {
return {
playlist: {},
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
playlists: {}
}
}
}

View File

@ -52,35 +52,14 @@
</div>
</template>
<template slot="content">
<list-item-album v-for="album in albums.items" :key="album.id" :album="album" :media_kind="'podcast'" @click="open_album(album)">
<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"
: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>
<list-albums :albums="albums.items"
@play_count_changed="reload_new_episodes()"
@podcast-deleted="reload_podcasts()">
</list-albums>
<modal-dialog-add-rss
:show="show_url_modal"
@close="show_url_modal = false"
@podcast_added="reload_podcasts" />
:show="show_url_modal"
@close="show_url_modal = false"
@podcast_added="reload_podcasts()" />
</template>
</content-with-heading>
</div>
@ -90,11 +69,9 @@
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack'
import ListItemAlbum from '@/components/ListItemAlbum'
import ListAlbums from '@/components/ListAlbums'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialogAddRss from '@/components/ModalDialogAddRss'
import ModalDialog from '@/components/ModalDialog'
import RangeSlider from 'vue-range-slider'
import webapi from '@/webapi'
@ -115,31 +92,21 @@ const albumsData = {
export default {
name: 'PagePodcasts',
mixins: [LoadDataBeforeEnterMixin(albumsData)],
components: { ContentWithHeading, ListItemTrack, ListItemAlbum, ModalDialogTrack, ModalDialogAlbum, ModalDialogAddRss, ModalDialog, RangeSlider },
components: { ContentWithHeading, ListItemTrack, ListAlbums, ModalDialogTrack, ModalDialogAddRss, RangeSlider },
data () {
return {
albums: {},
new_episodes: { items: [] },
show_album_details_modal: false,
selected_album: {},
show_url_modal: false,
show_track_details_modal: false,
selected_track: {},
show_remove_podcast_modal: false,
rss_playlist_to_remove: {}
selected_track: {}
}
},
methods: {
open_album: function (album) {
this.$router.push({ path: '/podcasts/' + album.id })
},
play_track: function (track) {
webapi.player_play_uri(track.uri, false)
},
@ -149,11 +116,6 @@ export default {
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 () {
this.new_episodes.items.forEach(ep => {
webapi.library_track_update(ep.id, { play_count: 'increment' })
@ -165,29 +127,6 @@ export default {
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 () {
webapi.library_podcasts_new_episodes().then(({ data }) => {
this.new_episodes = data.tracks

View File

@ -1,21 +1,12 @@
<template>
<div>
<tabs-music></tabs-music>
<content-with-heading>
<template slot="heading-left">
<p class="title is-4">Radio</p>
</template>
<template slot="content">
<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)">
<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" />
<list-tracks :tracks="tracks.items"></list-tracks>
</template>
</content-with-heading>
</div>
@ -23,10 +14,8 @@
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import TabsMusic from '@/components/TabsMusic'
import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ListTracks from '@/components/ListTracks'
import webapi from '@/webapi'
const streamsData = {
@ -42,25 +31,11 @@ const streamsData = {
export default {
name: 'PageRadioStreams',
mixins: [LoadDataBeforeEnterMixin(streamsData)],
components: { TabsMusic, ContentWithHeading, ListItemTrack, ModalDialogTrack },
components: { ContentWithHeading, ListTracks },
data () {
return {
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
tracks: { items: [] }
}
}
}

View File

@ -34,19 +34,12 @@
<p class="title is-4">Tracks</p>
</template>
<template slot="content">
<list-item-track v-for="track in tracks.items" :key="track.id" :track="track" @click="play_track(track)">
<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" />
<list-tracks :tracks="tracks.items"></list-tracks>
</template>
<template slot="footer">
<nav v-if="show_all_tracks_button" class="level">
<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>
</nav>
<p v-if="!tracks.total">No results</p>
@ -59,19 +52,12 @@
<p class="title is-4">Artists</p>
</template>
<template slot="content">
<list-item-artist v-for="artist in artists.items" :key="artist.id" :artist="artist" @click="open_artist(artist)">
<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" />
<list-artists :artists="artists.items"></list-artists>
</template>
<template slot="footer">
<nav v-if="show_all_artists_button" class="level">
<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>
</nav>
<p v-if="!artists.total">No results</p>
@ -84,19 +70,12 @@
<p class="title is-4">Albums</p>
</template>
<template slot="content">
<list-item-album v-for="album in albums.items" :key="album.id" :album="album" @click="open_album(album)">
<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" />
<list-albums :albums="albums.items"></list-albums>
</template>
<template slot="footer">
<nav v-if="show_all_albums_button" class="level">
<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>
</nav>
<p v-if="!albums.total">No results</p>
@ -109,44 +88,69 @@
<p class="title is-4">Playlists</p>
</template>
<template slot="content">
<list-item-playlist v-for="playlist in playlists.items" :key="playlist.id" :playlist="playlist" @click="open_playlist(playlist)">
<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" />
<list-playlists :playlists="playlists.items"></list-playlists>
</template>
<template slot="footer">
<nav v-if="show_all_playlists_button" class="level">
<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>
</nav>
<p v-if="!playlists.total">No results</p>
</template>
</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>
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsSearch from '@/components/TabsSearch'
import ListItemTrack from '@/components/ListItemTrack'
import ListItemArtist from '@/components/ListItemArtist'
import ListItemAlbum from '@/components/ListItemAlbum'
import ListItemPlaylist from '@/components/ListItemPlaylist'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialogArtist from '@/components/ModalDialogArtist'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
import ListTracks from '@/components/ListTracks'
import ListArtists from '@/components/ListArtists'
import ListAlbums from '@/components/ListAlbums'
import ListPlaylists from '@/components/ListPlaylists'
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
export default {
name: 'PageSearch',
components: { ContentWithHeading, TabsSearch, ListItemTrack, ListItemArtist, ListItemAlbum, ListItemPlaylist, ModalDialogTrack, ModalDialogAlbum, ModalDialogArtist, ModalDialogPlaylist },
components: { ContentWithHeading, TabsSearch, ListTracks, ListArtists, ListAlbums, ListPlaylists },
data () {
return {
@ -156,18 +160,8 @@ export default {
artists: { items: [], total: 0 },
albums: { items: [], total: 0 },
playlists: { items: [], total: 0 },
show_track_details_modal: false,
selected_track: {},
show_album_details_modal: false,
selected_album: {},
show_artist_details_modal: false,
selected_artist: {},
show_playlist_details_modal: false,
selected_playlist: {}
audiobooks: { items: [], total: 0 },
podcasts: { items: [], total: 0 }
}
},
@ -202,6 +196,24 @@ export default {
},
show_all_playlists_button () {
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
}
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 = {
type: route.query.type,
type: query.type,
media_kind: 'music'
}
if (route.query.query.startsWith('query:')) {
searchParams.expression = route.query.query.replace(/^query:/, '').trim()
if (query.query.startsWith('query:')) {
searchParams.expression = query.query.replace(/^query:/, '').trim()
} else {
searchParams.query = route.query.query
searchParams.query = query.query
}
if (route.query.limit) {
searchParams.limit = route.query.limit
searchParams.offset = route.query.offset
if (query.limit) {
searchParams.limit = query.limit
searchParams.offset = query.offset
}
webapi.search(searchParams).then(({ data }) => {
@ -234,8 +257,58 @@ export default {
this.artists = data.artists ? data.artists : { items: [], total: 0 }
this.albums = data.albums ? data.albums : { 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({
path: '/search/library',
query: {
type: 'track,artist,album,playlist',
type: 'track,artist,album,playlist,audiobook,podcast',
query: this.search_query,
limit: 3,
offset: 0
@ -296,45 +369,29 @@ export default {
})
},
play_track: function (track) {
webapi.player_play_uri(track.uri, false)
open_search_audiobooks: function () {
this.$router.push({
path: '/search/library',
query: {
type: 'audiobook',
query: this.$route.query.query
}
})
},
open_artist: function (artist) {
this.$router.push({ path: '/music/artists/' + artist.id })
},
open_album: function (album) {
this.$router.push({ path: '/music/albums/' + album.id })
},
open_playlist: function (playlist) {
this.$router.push({ path: '/playlists/' + playlist.id + '/tracks' })
open_search_podcasts: function () {
this.$router.push({
path: '/search/library',
query: {
type: 'podcast',
query: this.$route.query.query
}
})
},
open_recent_search: function (query) {
this.search_query = query
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>
<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>
<template slot="heading-left">
<div class="title is-4">Now playing page</div>

View File

@ -15,7 +15,20 @@
</template>
<template slot="content">
<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">
<a @click="open_dialog(album)">
<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 SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum'
import SpotifyModalDialogArtist from '@/components/SpotifyModalDialogArtist'
import CoverArtwork from '@/components/CoverArtwork'
import store from '@/store'
import webapi from '@/webapi'
import SpotifyWebApi from 'spotify-web-api-js'
@ -63,7 +77,7 @@ const artistData = {
export default {
name: 'SpotifyPageArtist',
mixins: [LoadDataBeforeEnterMixin(artistData)],
components: { ContentWithHeading, SpotifyListItemAlbum, SpotifyModalDialogAlbum, SpotifyModalDialogArtist, InfiniteLoading },
components: { ContentWithHeading, SpotifyListItemAlbum, SpotifyModalDialogAlbum, SpotifyModalDialogArtist, InfiniteLoading, CoverArtwork },
data () {
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: {
load_next: function ($state) {
const spotifyApi = new SpotifyWebApi()
@ -106,9 +126,20 @@ export default {
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) {
this.selected_album = album
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>
</template>
<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">
<a @click="open_album_dialog(album)">
<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 SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum'
import SpotifyModalDialogPlaylist from '@/components/SpotifyModalDialogPlaylist'
import CoverArtwork from '@/components/CoverArtwork'
import store from '@/store'
import * as types from '@/store/mutation_types'
import SpotifyWebApi from 'spotify-web-api-js'
@ -93,7 +107,7 @@ const browseData = {
export default {
name: 'SpotifyPageBrowse',
mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyListItemPlaylist, SpotifyModalDialogAlbum, SpotifyModalDialogPlaylist },
components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyListItemPlaylist, SpotifyModalDialogAlbum, SpotifyModalDialogPlaylist, CoverArtwork },
data () {
return {
@ -112,10 +126,19 @@ export default {
featured_playlists () {
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: {
open_album: function (album) {
this.$router.push({ path: '/music/spotify/albums/' + album.id })
},
open_album_dialog: function (album) {
this.selected_album = album
this.show_album_details_modal = true
@ -124,6 +147,13 @@ export default {
open_playlist_dialog: function (playlist) {
this.selected_playlist = playlist
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>
</template>
<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">
<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>
</a>
</template>
@ -26,6 +39,7 @@ import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import SpotifyListItemAlbum from '@/components/SpotifyListItemAlbum'
import SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum'
import CoverArtwork from '@/components/CoverArtwork'
import store from '@/store'
import * as types from '@/store/mutation_types'
import SpotifyWebApi from 'spotify-web-api-js'
@ -51,7 +65,7 @@ const browseData = {
export default {
name: 'SpotifyPageBrowseNewReleases',
mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyModalDialogAlbum },
components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyModalDialogAlbum, CoverArtwork },
data () {
return {
@ -63,13 +77,29 @@ export default {
computed: {
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: {
open_album: function (album) {
this.$router.push({ path: '/music/spotify/albums/' + album.id })
},
open_album_dialog: function (album) {
this.selected_album = album
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">
<nav v-if="show_all_tracks_button" class="level">
<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>
</nav>
<p v-if="!tracks.total">No results</p>
@ -70,7 +70,7 @@
<template slot="footer">
<nav v-if="show_all_artists_button" class="level">
<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>
</nav>
<p v-if="!artists.total">No results</p>
@ -83,7 +83,20 @@
<p class="title is-4">Albums</p>
</template>
<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">
<a @click="open_album_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
@ -96,7 +109,7 @@
<template slot="footer">
<nav v-if="show_all_albums_button" class="level">
<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>
</nav>
<p v-if="!albums.total">No results</p>
@ -122,7 +135,7 @@
<template slot="footer">
<nav v-if="show_all_playlists_button" class="level">
<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>
</nav>
<p v-if="!playlists.total">No results</p>
@ -142,6 +155,7 @@ import SpotifyModalDialogTrack from '@/components/SpotifyModalDialogTrack'
import SpotifyModalDialogArtist from '@/components/SpotifyModalDialogArtist'
import SpotifyModalDialogAlbum from '@/components/SpotifyModalDialogAlbum'
import SpotifyModalDialogPlaylist from '@/components/SpotifyModalDialogPlaylist'
import CoverArtwork from '@/components/CoverArtwork'
import SpotifyWebApi from 'spotify-web-api-js'
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
@ -149,7 +163,7 @@ import InfiniteLoading from 'vue-infinite-loading'
export default {
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 () {
return {
@ -172,7 +186,9 @@ export default {
selected_artist: {},
show_playlist_details_modal: false,
selected_playlist: {}
selected_playlist: {},
validSearchTypes: ['track', 'artist', 'album', 'playlist']
}
},
@ -207,6 +223,10 @@ export default {
},
show_all_playlists_button () {
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()
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({
path: '/search/spotify',
query: {
type: 'track,artist,album,playlist',
type: 'track,artist,album,playlist,audiobook,podcast',
query: this.search_query,
limit: 3,
offset: 0
@ -390,6 +411,17 @@ export default {
open_playlist_dialog: function (playlist) {
this.selected_playlist = playlist
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 PagePodcasts from '@/pages/PagePodcasts'
import PagePodcast from '@/pages/PagePodcast'
import PageAudiobooks from '@/pages/PageAudiobooks'
import PageAudiobook from '@/pages/PageAudiobook'
import PageAudiobooksAlbums from '@/pages/PageAudiobooksAlbums'
import PageAudiobooksArtists from '@/pages/PageAudiobooksArtists'
import PageAudiobooksArtist from '@/pages/PageAudiobooksArtist'
import PageAudiobooksAlbum from '@/pages/PageAudiobooksAlbum'
import PagePlaylists from '@/pages/PagePlaylists'
import PagePlaylist from '@/pages/PagePlaylist'
import PageFiles from '@/pages/PageFiles'
@ -126,12 +128,6 @@ export const router = new VueRouter({
component: PageGenreTracks,
meta: { show_progress: true, has_index: true }
},
{
path: '/music/radio',
name: 'Radio',
component: PageRadioStreams,
meta: { show_progress: true, has_tabs: true }
},
{
path: '/podcasts',
name: 'Podcasts',
@ -146,14 +142,36 @@ export const router = new VueRouter({
},
{
path: '/audiobooks',
name: 'Audiobooks',
component: PageAudiobooks,
redirect: '/audiobooks/artists'
},
{
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 }
},
{
path: '/audiobooks/albums',
name: 'AudiobooksAlbums',
component: PageAudiobooksAlbums,
meta: { show_progress: true, has_tabs: true, has_index: true }
},
{
path: '/audiobooks/:album_id',
name: 'Audiobook',
component: PageAudiobook,
component: PageAudiobooksAlbum,
meta: { show_progress: true }
},
{
path: '/radio',
name: 'Radio',
component: PageRadioStreams,
meta: { show_progress: true }
},
{
@ -258,11 +276,11 @@ export const router = new VueRouter({
}, 10)
})
} 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) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ selector: to.hash, offset: { x: 0, y: 90 } })
resolve({ selector: to.hash, offset: { x: 0, y: 120 } })
}, 10)
})
} else if (to.meta.has_index) {

View File

@ -53,6 +53,9 @@ export default new Vuex.Store({
recent_searches: [],
hide_singles: false,
hide_spotify: false,
artists_sort: 'Name',
albums_sort: 'Name',
show_only_next_items: false,
show_burger_menu: false,
show_player_menu: false
@ -91,6 +94,18 @@ export default new Vuex.Store({
}
}
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) {
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) {
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 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_BURGER_MENU = 'SHOW_BURGER_MENU'
export const SHOW_PLAYER_MENU = 'SHOW_PLAYER_MENU'

View File

@ -3,25 +3,35 @@
<div class="container">
<div class="columns is-centered">
<div class="column is-four-fifths">
<slot name="options"></slot>
<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>
<section v-if="$slots['options']">
<div v-observe-visibility="observer_options"></div>
<slot name="options"></slot>
<nav class="buttons is-centered" style="margin-bottom: 6px; margin-top: 16px;">
<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>
<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>
</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>
<!-- Right side -->
<div class="level-right has-text-centered-mobile">
<slot name="heading-right"></slot>
<!-- Right side -->
<div class="level-right has-text-centered-mobile">
<slot name="heading-right"></slot>
</div>
</nav>
<slot name="content"></slot>
<div style="margin-top: 16px;">
<slot name="footer"></slot>
</div>
</nav>
<slot name="content"></slot>
<div style="margin-top: 16px;">
<slot name="footer"></slot>
</div>
</div>
</div>
@ -30,6 +40,41 @@
</template>
<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>
<style>

View File

@ -12,51 +12,51 @@ axios.interceptors.response.use(function (response) {
export default {
config () {
return axios.get('/api/config')
return axios.get('./api/config')
},
settings () {
return axios.get('/api/settings')
return axios.get('./api/settings')
},
settings_update (categoryName, option) {
return axios.put('/api/settings/' + categoryName + '/' + option.name, option)
return axios.put('./api/settings/' + categoryName + '/' + option.name, option)
},
library_stats () {
return axios.get('/api/library')
return axios.get('./api/library')
},
library_update () {
return axios.put('/api/update')
return axios.put('./api/update')
},
library_rescan () {
return axios.put('/api/rescan')
return axios.put('./api/rescan')
},
library_count (expression) {
return axios.get('/api/library/count?expression=' + expression)
return axios.get('./api/library/count?expression=' + expression)
},
queue () {
return axios.get('/api/queue')
return axios.get('./api/queue')
},
queue_clear () {
return axios.put('/api/queue/clear')
return axios.put('./api/queue/clear')
},
queue_remove (itemId) {
return axios.delete('/api/queue/items/' + itemId)
return axios.delete('./api/queue/items/' + itemId)
},
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) {
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 })
return Promise.resolve(response)
})
@ -67,7 +67,7 @@ export default {
if (store.getters.now_playing && store.getters.now_playing.id) {
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 })
return Promise.resolve(response)
})
@ -77,7 +77,7 @@ export default {
var options = {}
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 })
return Promise.resolve(response)
})
@ -91,21 +91,21 @@ export default {
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 })
return Promise.resolve(response)
})
},
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 })
return Promise.resolve(response)
})
},
player_status () {
return axios.get('/api/player')
return axios.get('./api/player')
},
player_play_uri (uris, shuffle, position = undefined) {
@ -116,7 +116,7 @@ export default {
options.playback = 'start'
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) {
@ -127,111 +127,111 @@ export default {
options.playback = 'start'
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 = {}) {
return axios.put('/api/player/play', undefined, { params: options })
return axios.put('./api/player/play', undefined, { params: options })
},
player_playpos (position) {
return axios.put('/api/player/play?position=' + position)
return axios.put('./api/player/play?position=' + position)
},
player_playid (itemId) {
return axios.put('/api/player/play?item_id=' + itemId)
return axios.put('./api/player/play?item_id=' + itemId)
},
player_pause () {
return axios.put('/api/player/pause')
return axios.put('./api/player/pause')
},
player_stop () {
return axios.put('/api/player/stop')
return axios.put('./api/player/stop')
},
player_next () {
return axios.put('/api/player/next')
return axios.put('./api/player/next')
},
player_previous () {
return axios.put('/api/player/previous')
return axios.put('./api/player/previous')
},
player_shuffle (newState) {
var shuffle = newState ? 'true' : 'false'
return axios.put('/api/player/shuffle?state=' + shuffle)
return axios.put('./api/player/shuffle?state=' + shuffle)
},
player_consume (newState) {
var consume = newState ? 'true' : 'false'
return axios.put('/api/player/consume?state=' + consume)
return axios.put('./api/player/consume?state=' + consume)
},
player_repeat (newRepeatMode) {
return axios.put('/api/player/repeat?state=' + newRepeatMode)
return axios.put('./api/player/repeat?state=' + newRepeatMode)
},
player_volume (volume) {
return axios.put('/api/player/volume?volume=' + volume)
return axios.put('./api/player/volume?volume=' + volume)
},
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) {
return axios.put('/api/player/seek?position_ms=' + newPosition)
return axios.put('./api/player/seek?position_ms=' + newPosition)
},
player_seek (seekMs) {
return axios.put('/api/player/seek?seek_ms=' + seekMs)
return axios.put('./api/player/seek?seek_ms=' + seekMs)
},
outputs () {
return axios.get('/api/outputs')
return axios.get('./api/outputs')
},
output_update (outputId, output) {
return axios.put('/api/outputs/' + outputId, output)
return axios.put('./api/outputs/' + outputId, output)
},
output_toggle (outputId) {
return axios.put('/api/outputs/' + outputId + '/toggle')
return axios.put('./api/outputs/' + outputId + '/toggle')
},
library_artists () {
return axios.get('/api/library/artists?media_kind=music')
library_artists (media_kind = undefined) {
return axios.get('./api/library/artists', { params: { media_kind: media_kind } })
},
library_artist (artistId) {
return axios.get('/api/library/artists/' + artistId)
return axios.get('./api/library/artists/' + 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) {
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) {
return axios.get('/api/library/albums/' + albumId)
return axios.get('./api/library/albums/' + albumId)
},
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
})
},
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 () {
return axios.get('/api/library/genres')
return axios.get('./api/library/genres')
},
library_genre (genre) {
@ -240,7 +240,7 @@ export default {
media_kind: 'music',
expression: 'genre is "' + genre + '"'
}
return axios.get('/api/search', {
return axios.get('./api/search', {
params: genreParams
})
},
@ -251,7 +251,7 @@ export default {
media_kind: 'music',
expression: 'genre is "' + genre + '"'
}
return axios.get('/api/search', {
return axios.get('./api/search', {
params: genreParams
})
},
@ -262,7 +262,7 @@ export default {
media_kind: 'music',
expression: 'data_kind is url and song_length = 0'
}
return axios.get('/api/search', {
return axios.get('./api/search', {
params: params
})
},
@ -273,7 +273,7 @@ export default {
type: 'tracks',
expression: 'songartistid is "' + artist + '"'
}
return axios.get('/api/search', {
return axios.get('./api/search', {
params: artistParams
})
}
@ -284,7 +284,7 @@ export default {
type: 'tracks',
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
})
},
@ -294,86 +294,86 @@ export default {
type: 'tracks',
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
})
},
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) {
return axios.delete('/api/library/playlists/' + playlistId, undefined)
return axios.delete('./api/library/playlists/' + playlistId, undefined)
},
library_playlists () {
return axios.get('/api/library/playlists')
return axios.get('./api/library/playlists')
},
library_playlist_folder (playlistId = 0) {
return axios.get('/api/library/playlists/' + playlistId + '/playlists')
return axios.get('./api/library/playlists/' + playlistId + '/playlists')
},
library_playlist (playlistId) {
return axios.get('/api/library/playlists/' + playlistId)
return axios.get('./api/library/playlists/' + playlistId)
},
library_playlist_tracks (playlistId) {
return axios.get('/api/library/playlists/' + playlistId + '/tracks')
return axios.get('./api/library/playlists/' + playlistId + '/tracks')
},
library_track (trackId) {
return axios.get('/api/library/tracks/' + trackId)
return axios.get('./api/library/tracks/' + 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 = {}) {
return axios.put('/api/library/tracks/' + trackId, undefined, { params: attributes })
return axios.put('./api/library/tracks/' + trackId, undefined, { params: attributes })
},
library_files (directory = undefined) {
var filesParams = { directory: directory }
return axios.get('/api/library/files', {
return axios.get('./api/library/files', {
params: filesParams
})
},
search (searchParams) {
return axios.get('/api/search', {
return axios.get('./api/search', {
params: searchParams
})
},
spotify () {
return axios.get('/api/spotify')
return axios.get('./api/spotify')
},
spotify_login (credentials) {
return axios.post('/api/spotify-login', credentials)
return axios.post('./api/spotify-login', credentials)
},
lastfm () {
return axios.get('/api/lastfm')
return axios.get('./api/lastfm')
},
lastfm_login (credentials) {
return axios.post('/api/lastfm-login', credentials)
return axios.post('./api/lastfm-login', credentials)
},
lastfm_logout (credentials) {
return axios.get('/api/lastfm-logout')
return axios.get('./api/lastfm-logout')
},
pairing () {
return axios.get('/api/pairing')
return axios.get('./api/pairing')
},
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) {

View File

@ -10,6 +10,9 @@ module.exports = {
assetsDir: 'player',
// Relative public path
publicPath: './',
// 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
// build