Merge pull request #1422 from chme/web/next

[web] Migration to Vue 3 and Vite
This commit is contained in:
Christian Meffert 2022-03-20 15:37:04 +01:00 committed by GitHub
commit 44d2c02b35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
168 changed files with 8322 additions and 38848 deletions

View File

@ -1,6 +1,7 @@
# OwnTone player web interface # OwnTone web interface
Mobile friendly player web interface for [OwnTone](http://owntone.github.io/owntone-server/) build with [Vue.js](https://vuejs.org), [Bulma](http://bulma.io). Mobile friendly player web interface for [OwnTone](http://owntone.github.io/owntone-server/) build
with [Vue.js](https://vuejs.org), [Bulma](http://bulma.io).
## Screenshots ## Screenshots
@ -21,20 +22,34 @@ The source is located in the `web-src` folder.
cd web-src cd web-src
``` ```
It is based on the Vue.js webpack template. For a detailed explanation on how things work, check out the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader). The web interface is built with [Vite](https://vitejs.dev/), makes use of Prettier for code formatting
and ESLint for code linting (the project was set up following the guide [ESLint and Prettier with Vite and Vue.js 3](https://vueschool.io/articles/vuejs-tutorials/eslint-and-prettier-with-vite-and-vue-js-3/)
``` bash ``` bash
# install dependencies # install dependencies
npm install npm install
# serve with hot reload at localhost:8080 # Serve with hot reload at localhost:3000
npm run dev # (assumes that OwnTone server is running on localhost:3689)
npm run serve
# build for production with minification (will update player web interface in "../htdocs") # Serve with hot reload at localhost:3000
# (with remote OwnTone server reachable under owntone.local:3689)
VITE_OWNTONE_URL=http://owntone.local:3689 npm run serve
# Build for production with minification (will update web interface in "../htdocs")
npm run build npm run build
# build for production and view the bundle analyzer report # Format code
npm run build --report npm run format
# Lint code (and fix errors that can be automatically fixed)
npm run lint
``` ```
After running `npm run dev` the web interface is reachable at [localhost:8080](http://localhost:8080). By default it expects **owntone** to be running at [localhost:3689](http://localhost:3689) and proxies all JSON API calls to this location. If the server is running at a different location you need to modify the `proxyTable` configuration in `config/index.js` After running `npm run serve` the web interface is reachable at [localhost:3000](http://localhost:3000).
By default it expects **owntone** to be running at [localhost:3689](http://localhost:3689) and proxies all
JSON API calls to this location.
If the server is running at a different location you have to set the env variable `VITE_OWNTONE_URL`.

View File

@ -20,36 +20,15 @@ dist_htdocs_DATA = \
site.webmanifest site.webmanifest
if COND_WEBINTERFACE if COND_WEBINTERFACE
htdocsplayercssdir = $(datadir)/owntone/htdocs/player/css htdocsassetsdir = $(datadir)/owntone/htdocs/assets
dist_htdocsplayercss_DATA = \ dist_htdocsassets_DATA = \
player/css/app.css \ assets/index.css \
player/css/app.css.map \ assets/index.js \
player/css/chunk-vendors.css \ assets/vendor.js \
player/css/chunk-vendors.css.map assets/materialdesignicons-webfont.svg \
assets/materialdesignicons-webfont.ttf \
htdocsplayerfontsdir = $(datadir)/owntone/htdocs/player/fonts assets/materialdesignicons-webfont.woff2 \
assets/materialdesignicons-webfont.woff \
dist_htdocsplayerfonts_DATA = \ assets/materialdesignicons-webfont.eot
player/fonts/materialdesignicons-webfont.ttf \
player/fonts/materialdesignicons-webfont.woff2 \
player/fonts/materialdesignicons-webfont.woff \
player/fonts/materialdesignicons-webfont.eot
htdocsplayerjsdir = $(datadir)/owntone/htdocs/player/js
dist_htdocsplayerjs_DATA = \
player/js/app.js \
player/js/app.js.map \
player/js/chunk-vendors.js \
player/js/chunk-vendors.js.map \
player/js/app-legacy.js \
player/js/app-legacy.js.map \
player/js/chunk-vendors-legacy.js \
player/js/chunk-vendors-legacy.js.map
htdocsplayerimgdir = $(datadir)/owntone/htdocs/player/img
dist_htdocsplayerimg_DATA = \
player/img/materialdesignicons-webfont.svg
endif endif

1
htdocs/assets/index.css Normal file

File diff suppressed because one or more lines are too long

1
htdocs/assets/index.js Normal file

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

56
htdocs/assets/vendor.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -1 +1,27 @@
<!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>OwnTone Web</title><link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png?ver2.0"><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 lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png?ver=2.0"
/>
<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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OwnTone</title>
<script type="module" crossorigin src="/assets/index.js"></script>
<link rel="modulepreload" href="/assets/vendor.js">
<link rel="stylesheet" href="/assets/index.css">
</head>
<body>
<div id="app"></div>
</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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,19 +1,19 @@
{ {
"name": "OwnTone", "name": "OwnTone",
"short_name": "OwnTone", "short_name": "OwnTone",
"icons": [ "icons": [
{ {
"src": "/android-chrome-192x192.png", "src": "/android-chrome-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/android-chrome-512x512.png", "src": "/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
], ],
"theme_color": "#ffffff", "theme_color": "#ffffff",
"background_color": "#ffffff", "background_color": "#ffffff",
"display": "standalone" "display": "standalone"
} }

View File

@ -1,4 +0,0 @@
> 0.25%
not ie <= 8
not dead
not op_mini all

View File

@ -1,2 +0,0 @@
VUE_APP_JSON_API_SERVER='http://localhost:3689'
VUE_APP_WEBSOCKET_SERVER='ws://localhost:3688'

View File

@ -1,17 +1,14 @@
module.exports = { module.exports = {
root: true,
env: { env: {
node: true node: true
}, },
extends: [ extends: ['eslint:recommended', 'plugin:vue/vue3-recommended', 'prettier'],
'plugin:vue/essential',
'@vue/standard'
],
rules: { rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', // override/add rules settings here, such as:
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' // 'vue/no-unused-vars': 'error'
}, 'no-unused-vars': ['error', { args: 'none' }],
parserOptions: { 'vue/require-prop-types': 'off',
parser: 'babel-eslint' 'vue/require-default-prop': 'off',
'vue/prop-name-casing': ['warn', 'snake_case']
} }
} }

5
web-src/.prettierrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none"
}

View File

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

24
web-src/index.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png?ver=2.0"
/>
<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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OwnTone</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3
web-src/jsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"include": ["./src/**/*"]
}

35767
web-src/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,54 +1,42 @@
{ {
"name": "owntone-web", "name": "owntone-web",
"version": "1.2.0", "version": "2.0.0",
"private": true,
"description": "OwnTone web interface",
"author": "chme <christian.meffert@googlemail.com>",
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "dev": "vite",
"build": "vue-cli-service build --no-clean --modern", "serve": "vite",
"lint": "vue-cli-service lint", "build": "vite build",
"dev": "vue-cli-service serve" "preview": "vite preview",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src",
"format": "prettier . --write"
}, },
"dependencies": { "dependencies": {
"axios": "^0.25.0", "@aacassandra/vue3-progressbar": "^1.0.3",
"@ts-pro/vue-eternal-loading": "^1.2.0",
"@vueform/slider": "^2.0.9",
"axios": "^0.26.1",
"bulma": "^0.9.3", "bulma": "^0.9.3",
"bulma-switch": "^2.0.4", "bulma-switch": "^2.0.4",
"core-js": "^3.15.2",
"mdi": "^2.2.43", "mdi": "^2.2.43",
"moment": "^2.29.1", "moment": "^2.29.1",
"moment-duration-format": "^2.3.2", "moment-duration-format": "^2.3.2",
"npm": "^7.19.1",
"reconnectingwebsocket": "^1.0.0", "reconnectingwebsocket": "^1.0.0",
"spotify-web-api-js": "^1.5.2", "spotify-web-api-js": "^1.5.2",
"string-to-color": "^2.2.2", "string-to-color": "^2.2.2",
"v-click-outside": "^3.1.2", "vue": "^3.2.31",
"vue": "^2.6.14", "vue-router": "^4.0.14",
"vue-infinite-loading": "^2.4.5",
"vue-observe-visibility": "^1.0.0",
"vue-progressbar": "^0.7.5",
"vue-range-slider": "^0.6.0",
"vue-router": "^3.5.3",
"vue-scrollto": "^2.20.0", "vue-scrollto": "^2.20.0",
"vue-tiny-lazyload-img": "^0.1.0", "vue3-click-away": "^1.2.4",
"vuedraggable": "^2.24.3", "vue3-lazyload": "^0.2.5-beta",
"vuex": "^3.6.2" "vuedraggable": "^4.1.0",
"vuex": "^4.0.2"
}, },
"devDependencies": { "devDependencies": {
"@vue/cli-plugin-babel": "^4.5.15", "@vitejs/plugin-vue": "^2.2.4",
"@vue/cli-plugin-eslint": "^5.0.0-rc.1", "eslint": "^8.11.0",
"@vue/cli-service": "^4.5.15", "eslint-config-prettier": "^8.5.0",
"@vue/eslint-config-standard": "^6.1.0", "eslint-plugin-vue": "^8.5.0",
"babel-eslint": "^10.1.0", "prettier": "2.6.0",
"eslint": "^7.30.0", "sass": "^1.49.9",
"eslint-plugin-import": "^2.23.4", "vite": "^2.8.6"
"eslint-plugin-node": "^11.1.0", }
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^7.12.1",
"sass": "^1.35.1",
"sass-loader": "^10",
"vue-template-compiler": "^2.6.14"
},
"license": "GPL-2.0"
} }

View File

@ -1,5 +0,0 @@
module.exports = {
plugins: {
autoprefixer: {}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
web-src/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,19 +0,0 @@
<!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.0">
<title>OwnTone Web</title>
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png?ver2.0">
<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>
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

31
web-src/public/logo.svg Normal file
View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<style type="text/css">
.st0{fill:#107A66;}
.st1{fill:#479386;}
.st2{fill:#00D1B2;}
.st3{fill:#FFFFFF;}
</style>
<path class="st0" d="M390.5,193.9h30.3c18.5,49.6,13.9,104.8-12.7,150.6c-5.1,8.8-11,17.2-17.6,24.9V193.9z"/>
<path class="st1" d="M440.3,163.9h-79.8v269.6C453.8,378.5,488.6,260.8,440.3,163.9L440.3,163.9z"/>
<path class="st2" d="M256,432c-97,0-176-79-176-176S159,80,256,80c26.9-0.1,53.4,6.1,77.5,17.9H187.9v96h99.8v235.2
C277.2,431,266.6,432,256,432z"/>
<path class="st1" d="M157.9,147.8v76.1h99.8v178H256c-80.6,0-146-65.4-145.9-146.1c0-38.7,15.4-75.8,42.7-103.1
C154.4,151,156.1,149.4,157.9,147.8 M256,50C142.2,50,50,142.2,50,256s92.2,206,206,206c20.9,0,41.7-3.2,61.7-9.4V163.9h-99.8v-36
h199.4C378.3,78.6,318.8,49.9,256,50z"/>
<path class="st2" d="M429.7,183.5c19.7,46.8,19.7,99.6,0,146.4c-12.9,30.1-33.2,56.4-59.2,76.3v3.8c48.5-36,77-92.9,77-153.3
c0.1-28.4-6.3-56.4-18.5-82h-3.3C427.1,177.6,428.5,180.5,429.7,183.5z"/>
<path class="st2" d="M434,256.7c0.1-28.5-6.8-56.7-20-82h-3.4c2.4,4.6,4.7,9.3,6.7,14.1c18.3,43.4,18.3,92.4,0,135.8
c-8.8,20.7-21.5,39.6-37.4,55.5c-3,3-6.2,5.9-9.4,8.7v3.9C410.8,359,434,309.2,434,256.7z"/>
<path class="st2" d="M420.5,256.7c0-28.8-7.5-57.1-21.9-82h-3.5c3.7,6.2,6.9,12.7,9.8,19.3c25.6,60.3,11.9,130.1-34.4,176.4v4.2
C402.5,343.7,420.5,301.2,420.5,256.7z"/>
<path class="st2" d="M382.7,174.7h-3.5c5.2,7.8,9.6,16,13.2,24.6c21.3,50.5,12.9,108.7-21.9,151v4.7
C414.2,304.3,419.1,230.8,382.7,174.7z"/>
<path class="st2" d="M370.5,180.7v5.5c26.7,43.2,26.7,97.8,0,141v5.5C401.1,286.7,401.1,226.7,370.5,180.7z"/>
<g>
<path class="st3" d="M417.3,127.9H217.9v36h99.8v288.7c15.1-4.7,29.5-11.2,42.8-19.1V163.9h79.8C433.9,151.1,426.2,139,417.3,127.9
z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -0,0 +1,42 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M3173 6294 c-330 -40 -614 -124 -918 -274 -299 -147 -489 -284 -741
-535 -250 -249 -386 -439 -540 -750 -128 -260 -212 -532 -261 -845 -26 -164
-26 -616 0 -780 87 -555 303 -1035 647 -1435 450 -523 1045 -852 1740 -960
141 -22 515 -31 662 -16 159 17 324 46 461 83 l117 31 0 1973 0 1974 -680 0
-680 0 0 250 0 250 1356 0 1355 0 -22 33 c-12 17 -97 106 -188 197 -250 249
-439 385 -746 535 -310 152 -615 240 -954 274 -140 15 -470 12 -608 -5z m-333
-2354 l680 0 0 -1215 0 -1215 -79 0 c-261 0 -571 79 -836 212 -187 94 -331
197 -488 347 -391 375 -607 885 -607 1431 0 531 200 1020 572 1400 l73 75 3
-517 2 -518 680 0z"/>
<path d="M4930 2920 l0 -1841 23 12 c43 23 193 127 287 199 136 104 389 362
496 505 298 398 476 825 550 1317 21 145 30 505 15 654 -32 315 -117 629 -244
907 l-40 87 -543 0 -544 0 0 -1840z m318 1662 c38 -53 131 -237 168 -332 90
-231 133 -441 141 -686 9 -264 -21 -479 -103 -724 -74 -222 -168 -402 -313
-594 -60 -80 -66 -86 -69 -61 -3 18 8 41 36 78 518 678 555 1608 94 2325 -13
20 -13 22 6 22 11 0 30 -13 40 -28z m234 -34 c134 -260 208 -493 249 -788 18
-128 15 -432 -6 -575 -67 -464 -269 -887 -584 -1225 -63 -67 -70 -72 -71 -50
0 18 21 50 71 105 370 411 569 927 569 1473 0 343 -73 659 -223 962 -39 80
-73 148 -75 153 -2 4 6 7 17 7 15 0 29 -16 53 -62z m234 -58 c89 -197 151
-405 191 -645 25 -153 25 -542 0 -700 -87 -556 -349 -1057 -743 -1423 -74 -69
-90 -79 -92 -63 -2 15 27 51 105 128 366 365 593 798 684 1308 27 150 37 491
20 646 -32 283 -110 556 -222 781 -22 43 -39 80 -39 83 0 4 10 5 22 3 18 -2
33 -26 74 -118z m193 18 c382 -906 225 -1946 -407 -2698 -91 -109 -242 -257
-354 -348 -71 -58 -78 -61 -78 -40 0 18 27 47 112 119 177 150 371 375 505
586 85 136 207 394 257 543 49 150 86 307 113 480 23 154 23 527 0 683 -36
236 -101 471 -183 663 -24 57 -44 106 -44 109 0 3 8 5 18 5 13 0 29 -27 61
-102z m-775 -95 c107 -189 188 -424 222 -639 25 -165 23 -424 -5 -588 -40
-231 -111 -430 -219 -620 -50 -85 -57 -95 -60 -71 -3 19 16 65 56 144 110 212
168 393 197 617 49 376 -30 795 -211 1118 -24 43 -44 91 -44 107 0 16 2 29 4
29 3 0 30 -44 60 -97z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,19 @@
{
"name": "OwnTone",
"short_name": "OwnTone",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@ -2,27 +2,34 @@
<div id="app"> <div id="app">
<navbar-top /> <navbar-top />
<vue-progress-bar class="fd-progress-bar" /> <vue-progress-bar class="fd-progress-bar" />
<transition name="fade"> <router-view v-slot="{ Component }">
<!-- Setting v-show to true on the router-view tag avoids jumpiness during transitions --> <component :is="Component" class="fd-page" />
<router-view v-show="true" /> </router-view>
</transition>
<modal-dialog-remote-pairing :show="pairing_active" @close="pairing_active = false" /> <modal-dialog-remote-pairing
:show="pairing_active"
@close="pairing_active = false"
/>
<modal-dialog-update <modal-dialog-update
:show="show_update_dialog" :show="show_update_dialog"
@close="show_update_dialog = false" /> @close="show_update_dialog = false"
<notifications v-show="!show_burger_menu" /> />
<notification-list v-show="!show_burger_menu" />
<navbar-bottom /> <navbar-bottom />
<div class="fd-overlay-fullscreen" v-show="show_burger_menu || show_player_menu" <div
@click="show_burger_menu = show_player_menu = false"></div> v-show="show_burger_menu || show_player_menu"
class="fd-overlay-fullscreen"
@click="show_burger_menu = show_player_menu = false"
/>
</div> </div>
</template> </template>
<script> <script>
import NavbarTop from '@/components/NavbarTop' import NavbarTop from '@/components/NavbarTop.vue'
import NavbarBottom from '@/components/NavbarBottom' import NavbarBottom from '@/components/NavbarBottom.vue'
import Notifications from '@/components/Notifications' import NotificationList from '@/components/NotificationList.vue'
import ModalDialogRemotePairing from '@/components/ModalDialogRemotePairing' import ModalDialogRemotePairing from '@/components/ModalDialogRemotePairing.vue'
import ModalDialogUpdate from '@/components/ModalDialogUpdate' import ModalDialogUpdate from '@/components/ModalDialogUpdate.vue'
import webapi from '@/webapi' import webapi from '@/webapi'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
import ReconnectingWebSocket from 'reconnectingwebsocket' import ReconnectingWebSocket from 'reconnectingwebsocket'
@ -30,10 +37,15 @@ import moment from 'moment'
export default { export default {
name: 'App', name: 'App',
components: { NavbarTop, NavbarBottom, Notifications, ModalDialogRemotePairing, ModalDialogUpdate }, components: {
template: '<App/>', NavbarTop,
NavbarBottom,
NotificationList,
ModalDialogRemotePairing,
ModalDialogUpdate
},
data () { data() {
return { return {
token_timer_id: 0, token_timer_id: 0,
reconnect_attempts: 0, reconnect_attempts: 0,
@ -43,31 +55,40 @@ export default {
computed: { computed: {
show_burger_menu: { show_burger_menu: {
get () { get() {
return this.$store.state.show_burger_menu return this.$store.state.show_burger_menu
}, },
set (value) { set(value) {
this.$store.commit(types.SHOW_BURGER_MENU, value) this.$store.commit(types.SHOW_BURGER_MENU, value)
} }
}, },
show_player_menu: { show_player_menu: {
get () { get() {
return this.$store.state.show_player_menu return this.$store.state.show_player_menu
}, },
set (value) { set(value) {
this.$store.commit(types.SHOW_PLAYER_MENU, value) this.$store.commit(types.SHOW_PLAYER_MENU, value)
} }
}, },
show_update_dialog: { show_update_dialog: {
get () { get() {
return this.$store.state.show_update_dialog return this.$store.state.show_update_dialog
}, },
set (value) { set(value) {
this.$store.commit(types.SHOW_UPDATE_DIALOG, value) this.$store.commit(types.SHOW_UPDATE_DIALOG, value)
} }
} }
}, },
watch: {
show_burger_menu() {
this.update_is_clipped()
},
show_player_menu() {
this.update_is_clipped()
}
},
created: function () { created: function () {
moment.locale(navigator.language) moment.locale(navigator.language)
this.connect() this.connect()
@ -97,23 +118,40 @@ export default {
methods: { methods: {
connect: function () { connect: function () {
this.$store.dispatch('add_notification', { text: 'Connecting to OwnTone server', type: 'info', topic: 'connection', timeout: 2000 }) /*
this.$store.dispatch('add_notification', {
webapi.config().then(({ data }) => { text: 'Connecting to OwnTone server',
this.$store.commit(types.UPDATE_CONFIG, data) type: 'info',
this.$store.commit(types.HIDE_SINGLES, data.hide_singles) topic: 'connection',
document.title = data.library_name timeout: 2000
this.open_ws()
this.$Progress.finish()
}).catch(() => {
this.$store.dispatch('add_notification', { text: 'Failed to connect to OwnTone server', type: 'danger', topic: 'connection' })
}) })
*/
webapi
.config()
.then(({ data }) => {
this.$store.commit(types.UPDATE_CONFIG, data)
this.$store.commit(types.HIDE_SINGLES, data.hide_singles)
document.title = data.library_name
this.open_ws()
this.$Progress.finish()
})
.catch(() => {
this.$store.dispatch('add_notification', {
text: 'Failed to connect to OwnTone server',
type: 'danger',
topic: 'connection'
})
})
}, },
open_ws: function () { open_ws: function () {
if (this.$store.state.config.websocket_port <= 0) { if (this.$store.state.config.websocket_port <= 0) {
this.$store.dispatch('add_notification', { text: 'Missing websocket port', type: 'danger' }) this.$store.dispatch('add_notification', {
text: 'Missing websocket port',
type: 'danger'
})
return return
} }
@ -124,22 +162,54 @@ export default {
protocol = 'wss://' protocol = 'wss://'
} }
let wsUrl = protocol + window.location.hostname + ':' + vm.$store.state.config.websocket_port let wsUrl =
if (process.env.NODE_ENV === 'development' && process.env.VUE_APP_WEBSOCKET_SERVER) { protocol +
// If we are running in the development server, use the websocket url configured in .env.development window.location.hostname +
wsUrl = process.env.VUE_APP_WEBSOCKET_SERVER ':' +
vm.$store.state.config.websocket_port
if (import.meta.env.DEV && import.meta.env.VITE_OWNTONE_URL) {
// If we are running in development mode, construct the websocket url
// from the host of the environment variable VITE_OWNTONE_URL
const owntoneUrl = new URL(import.meta.env.VITE_OWNTONE_URL)
wsUrl =
protocol +
owntoneUrl.hostname +
':' +
vm.$store.state.config.websocket_port
} }
const socket = new ReconnectingWebSocket( const socket = new ReconnectingWebSocket(wsUrl, 'notify', {
wsUrl, reconnectInterval: 1000,
'notify', maxReconnectInterval: 2000
{ reconnectInterval: 3000 } })
)
socket.onopen = function () { socket.onopen = function () {
vm.$store.dispatch('add_notification', { text: 'Connection to server established', type: 'primary', topic: 'connection', timeout: 2000 }) /*
vm.$store.dispatch('add_notification', {
text: 'Connection to server established',
type: 'primary',
topic: 'connection',
timeout: 2000
})
*/
vm.reconnect_attempts = 0 vm.reconnect_attempts = 0
socket.send(JSON.stringify({ notify: ['update', 'database', 'player', 'options', 'outputs', 'volume', 'queue', 'spotify', 'lastfm', 'pairing'] })) socket.send(
JSON.stringify({
notify: [
'update',
'database',
'player',
'options',
'outputs',
'volume',
'queue',
'spotify',
'lastfm',
'pairing'
]
})
)
vm.update_outputs() vm.update_outputs()
vm.update_player_status() vm.update_player_status()
@ -153,16 +223,66 @@ export default {
socket.onclose = function () { socket.onclose = function () {
// vm.$store.dispatch('add_notification', { text: 'Connection closed', type: 'danger', timeout: 2000 }) // vm.$store.dispatch('add_notification', { text: 'Connection closed', type: 'danger', timeout: 2000 })
} }
/*
socket.onerror = function () { socket.onerror = function () {
vm.reconnect_attempts++ vm.reconnect_attempts++
vm.$store.dispatch('add_notification', { text: 'Connection lost. Reconnecting ... (' + vm.reconnect_attempts + ')', type: 'danger', topic: 'connection' }) vm.$store.dispatch('add_notification', {
text:
'Connection lost. Reconnecting ... (' + vm.reconnect_attempts + ')',
type: 'danger',
topic: 'connection'
})
} }
*/
// When the app becomes active, force an update of all information, because we
// may have missed notifications while the app was inactive.
// There are two relevant events (focus and visibilitychange), so we throttle
// the updates to avoid multiple redundant updates
var update_throttled = false
function update_info() {
if (update_throttled) {
return
}
vm.update_outputs()
vm.update_player_status()
vm.update_library_stats()
vm.update_settings()
vm.update_queue()
vm.update_spotify()
vm.update_lastfm()
vm.update_pairing()
update_throttled = true
setTimeout(function () {
update_throttled = false
}, 500)
}
// These events are fired when the window becomes active in different ways
// When this happens, we should update 'now playing' info etc
window.addEventListener('focus', update_info)
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
update_info()
}
})
socket.onmessage = function (response) { socket.onmessage = function (response) {
const data = JSON.parse(response.data) const data = JSON.parse(response.data)
if (data.notify.includes('update') || data.notify.includes('database')) { if (
data.notify.includes('update') ||
data.notify.includes('database')
) {
vm.update_library_stats() vm.update_library_stats()
} }
if (data.notify.includes('player') || data.notify.includes('options') || data.notify.includes('volume')) { if (
data.notify.includes('player') ||
data.notify.includes('options') ||
data.notify.includes('volume')
) {
vm.update_player_status() vm.update_player_status()
} }
if (data.notify.includes('outputs') || data.notify.includes('volume')) { if (data.notify.includes('outputs') || data.notify.includes('volume')) {
@ -237,7 +357,10 @@ export default {
this.token_timer_id = 0 this.token_timer_id = 0
} }
if (data.webapi_token_expires_in > 0 && data.webapi_token) { if (data.webapi_token_expires_in > 0 && data.webapi_token) {
this.token_timer_id = window.setTimeout(this.update_spotify, 1000 * data.webapi_token_expires_in) this.token_timer_id = window.setTimeout(
this.update_spotify,
1000 * data.webapi_token_expires_in
)
} }
}) })
}, },
@ -257,17 +380,8 @@ export default {
} }
} }
}, },
template: '<App/>'
watch: {
'show_burger_menu' () {
this.update_is_clipped()
},
'show_player_menu' () {
this.update_is_clipped()
}
}
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -9,7 +9,7 @@ export default {
_gain: null, _gain: null,
// setup audio routing // setup audio routing
setupAudio () { setupAudio() {
const AudioContext = window.AudioContext || window.webkitAudioContext const AudioContext = window.AudioContext || window.webkitAudioContext
this._context = new AudioContext() this._context = new AudioContext()
this._source = this._context.createMediaElementSource(this._audio) this._source = this._context.createMediaElementSource(this._audio)
@ -18,26 +18,26 @@ export default {
this._source.connect(this._gain) this._source.connect(this._gain)
this._gain.connect(this._context.destination) this._gain.connect(this._context.destination)
this._audio.addEventListener('canplaythrough', e => { this._audio.addEventListener('canplaythrough', (e) => {
this._audio.play() this._audio.play()
}) })
this._audio.addEventListener('canplay', e => { this._audio.addEventListener('canplay', (e) => {
this._audio.play() this._audio.play()
}) })
return this._audio return this._audio
}, },
// set audio volume // set audio volume
setVolume (volume) { setVolume(volume) {
if (!this._gain) return if (!this._gain) return
volume = parseFloat(volume) || 0.0 volume = parseFloat(volume) || 0.0
volume = (volume < 0) ? 0 : volume volume = volume < 0 ? 0 : volume
volume = (volume > 1) ? 1 : volume volume = volume > 1 ? 1 : volume
this._gain.gain.value = volume this._gain.gain.value = volume
}, },
// play audio source url // play audio source url
playSource (source) { playSource(source) {
this.stopAudio() this.stopAudio()
this._context.resume().then(() => { this._context.resume().then(() => {
this._audio.src = String(source || '') + '?x=' + Date.now() this._audio.src = String(source || '') + '?x=' + Date.now()
@ -47,9 +47,21 @@ export default {
}, },
// stop playing audio // stop playing audio
stopAudio () { stopAudio() {
try { this._audio.pause() } catch (e) {} try {
try { this._audio.stop() } catch (e) {} this._audio.pause()
try { this._audio.close() } catch (e) {} } catch (e) {
// continue regardless of error
}
try {
this._audio.stop()
} catch (e) {
// continue regardless of error
}
try {
this._audio.close()
} catch (e) {
// continue regardless of error
}
} }
} }

View File

@ -1,46 +1,53 @@
<template> <template>
<figure> <figure>
<img v-lazyload <img
:data-src="artwork_url_with_size" v-lazy="{ src: artwork_url_with_size, lifecycle: lazy_lifecycle }"
:data-err="dataURI" @click="$emit('click')"
:key="artwork_url_with_size" />
@click="$emit('click')">
</figure> </figure>
</template> </template>
<script> <script>
import webapi from '@/webapi' import webapi from '@/webapi'
import SVGRenderer from '@/lib/SVGRenderer' import { renderSVG } from '@/lib/SVGRenderer'
import stringToColor from 'string-to-color'
export default { export default {
name: 'CoverArtwork', name: 'CoverArtwork',
props: ['artist', 'album', 'artwork_url', 'maxwidth', 'maxheight'], props: ['artist', 'album', 'artwork_url', 'maxwidth', 'maxheight'],
emits: ['click'],
data () { data() {
return { return {
svg: new SVGRenderer(),
width: 600, width: 600,
height: 600, height: 600,
font_family: 'sans-serif', font_family: 'sans-serif',
font_size: 200, font_size: 200,
font_weight: 600 font_weight: 600,
lazy_lifecycle: {
error: (el) => {
el.src = this.dataURI()
}
}
} }
}, },
computed: { computed: {
artwork_url_with_size: function () { artwork_url_with_size: function () {
if (this.maxwidth > 0 && this.maxheight > 0) { 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,
this.maxwidth,
this.maxheight
)
} }
return webapi.artwork_url_append_size_params(this.artwork_url) return webapi.artwork_url_append_size_params(this.artwork_url)
}, },
alt_text () { alt_text() {
return this.artist + ' - ' + this.album return this.artist + ' - ' + this.album
}, },
caption () { caption() {
if (this.album) { if (this.album) {
return this.album.substring(0, 2) return this.album.substring(0, 2)
} }
@ -48,47 +55,18 @@ export default {
return this.artist.substring(0, 2) return this.artist.substring(0, 2)
} }
return '' return ''
}, }
},
background_color () { methods: {
return stringToColor(this.alt_text) dataURI: function () {
}, return renderSVG(this.caption, this.alt_text, {
is_background_light () {
// Based on https://stackoverflow.com/a/44615197
const hex = this.background_color.replace(/#/, '')
const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)
const luma = [
0.299 * r,
0.587 * g,
0.114 * b
].reduce((a, b) => a + b) / 255
return luma > 0.5
},
text_color () {
return this.is_background_light ? '#000000' : '#ffffff'
},
rendererParams () {
return {
width: this.width, width: this.width,
height: this.height, height: this.height,
textColor: this.text_color, font_family: this.font_family,
backgroundColor: this.background_color, font_size: this.font_size,
caption: this.caption, font_weight: this.font_weight
fontFamily: this.font_family, })
fontSize: this.font_size,
fontWeight: this.font_weight
}
},
dataURI () {
return this.svg.render(this.rendererParams)
} }
} }
} }

View File

@ -1,19 +1,31 @@
<template> <template>
<div class="dropdown" :class="{ 'is-active': is_active }" v-click-outside="onClickOutside"> <div
v-click-away="onClickOutside"
class="dropdown"
:class="{ 'is-active': is_active }"
>
<div class="dropdown-trigger"> <div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu" @click="is_active = !is_active"> <button
<span>{{ value }}</span> class="button"
aria-haspopup="true"
aria-controls="dropdown-menu"
@click="is_active = !is_active"
>
<span>{{ modelValue }}</span>
<span class="icon is-small"> <span class="icon is-small">
<i class="mdi mdi-chevron-down" aria-hidden="true"></i> <i class="mdi mdi-chevron-down" aria-hidden="true" />
</span> </span>
</button> </button>
</div> </div>
<div class="dropdown-menu" id="dropdown-menu" role="menu"> <div id="dropdown-menu" class="dropdown-menu" role="menu">
<div class="dropdown-content"> <div class="dropdown-content">
<a class="dropdown-item" <a
v-for="option in options" :key="option" v-for="option in options"
:class="{'is-active': value === option}" :key="option"
@click="select(option)"> class="dropdown-item"
:class="{ 'is-active': modelValue === option }"
@click="select(option)"
>
{{ option }} {{ option }}
</a> </a>
</div> </div>
@ -25,26 +37,27 @@
export default { export default {
name: 'DropdownMenu', name: 'DropdownMenu',
props: ['value', 'options'], // eslint-disable-next-line vue/prop-name-casing
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
data () { data() {
return { return {
is_active: false is_active: false
} }
}, },
methods: { methods: {
onClickOutside (event) { onClickOutside(event) {
this.is_active = false this.is_active = false
}, },
select (option) { select(option) {
this.is_active = false this.is_active = false
this.$emit('input', option) this.$emit('update:modelValue', option)
} }
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,7 +1,13 @@
<template> <template>
<section> <section>
<nav class="buttons is-centered fd-is-square" style="margin-bottom: 16px;"> <nav class="buttons is-centered fd-is-square" style="margin-bottom: 16px">
<a v-for="char in filtered_index" :key="char" class="button is-small" @click="nav(char)">{{ char }}</a> <a
v-for="char in filtered_index"
:key="char"
class="button is-small"
@click="nav(char)"
>{{ char }}</a
>
</nav> </nav>
</section> </section>
</template> </template>
@ -13,15 +19,18 @@ export default {
props: ['index'], props: ['index'],
computed: { computed: {
filtered_index () { filtered_index() {
if (!this.index) {
return []
}
const specialChars = '!"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~' const specialChars = '!"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~'
return this.index.filter(c => !specialChars.includes(c)) return this.index.filter((c) => !specialChars.includes(c))
} }
}, },
methods: { methods: {
nav: function (id) { nav: function (id) {
this.$router.push({ path: this.$router.currentRoute.path + '#index_' + id }) this.$router.push({ hash: '#index_' + id })
}, },
scroll_to_top: function () { scroll_to_top: function () {
@ -31,5 +40,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,115 +1,129 @@
<template> <template>
<div> <template v-for="album in albums" :key="album.itemId">
<div v-if="is_grouped"> <div v-if="!album.isItem && !hide_group_title" class="mt-6 mb-5 py-2">
<div v-for="idx in albums.indexList" :key="idx" class="mb-6"> <span
<span class="tag is-info is-light is-small has-text-weight-bold" :id="'index_' + idx">{{ idx }}</span> :id="'index_' + album.groupKey"
<list-item-album v-for="album in albums.grouped[idx]" class="tag is-info is-light is-small has-text-weight-bold"
:key="album.id" >{{ album.groupKey }}</span
:album="album" >
@click="open_album(album)"> </div>
<template slot="artwork" v-if="is_visible_artwork"> <div v-else-if="album.isItem" class="media" @click="open_album(album.item)">
<p class="image is-64x64 fd-has-shadow fd-has-action"> <div v-if="is_visible_artwork" class="media-left fd-has-action">
<cover-artwork <p class="image is-64x64 fd-has-shadow fd-has-action">
:artwork_url="album.artwork_url" <figure>
:artist="album.artist" <img
:album="album.name" v-lazy="{
:maxwidth="64" src: artwork_url_with_size(album.item.artwork_url),
:maxheight="64" /> lifecycle: artwork_options.lazy_lifecycle
</p> }"
</template> :album="album.item.name"
<template slot="actions"> :artist="album.item.artist"
<a @click="open_dialog(album)"> />
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> </figure>
</a> </p>
</template> </div>
</list-item-album> <div class="media-content fd-has-action is-clipped">
<div style="margin-top: 0.7rem">
<h1 class="title is-6">
{{ album.item.name }}
</h1>
<h2 class="subtitle is-7 has-text-grey">
<b>{{ album.item.artist }}</b>
</h2>
<h2
v-if="album.item.date_released && album.item.media_kind === 'music'"
class="subtitle is-7 has-text-grey has-text-weight-normal"
>
{{ $filters.time(album.item.date_released, 'L') }}
</h2>
</div>
</div>
<div class="media-right" style="padding-top: 0.7rem">
<a @click.prevent.stop="open_dialog(album.item)">
<span class="icon has-text-dark"
><i class="mdi mdi-dots-vertical mdi-18px"
/></span>
</a>
</div> </div>
</div> </div>
<div v-else> </template>
<list-item-album v-for="album in albums_list" <teleport to="#app">
: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 <modal-dialog-album
:show="show_details_modal" :show="show_details_modal"
:album="selected_album" :album="selected_album"
:media_kind="media_kind" :media_kind="media_kind"
@remove-podcast="open_remove_podcast_dialog()" @remove-podcast="open_remove_podcast_dialog()"
@play-count-changed="play_count_changed()" @play-count-changed="play_count_changed()"
@close="show_details_modal = false" /> @close="show_details_modal = false"
/>
<modal-dialog <modal-dialog
:show="show_remove_podcast_modal" :show="show_remove_podcast_modal"
title="Remove podcast" title="Remove podcast"
delete_action="Remove" delete_action="Remove"
@close="show_remove_podcast_modal = false" @close="show_remove_podcast_modal = false"
@delete="remove_podcast"> @delete="remove_podcast"
<template slot="modal-content"> >
<template #modal-content>
<p>Permanently remove this podcast from your library?</p> <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> <p class="is-size-7">
(This will also remove the RSS playlist
<b>{{ rss_playlist_to_remove.name }}</b
>.)
</p>
</template> </template>
</modal-dialog> </modal-dialog>
</div> </teleport>
</template> </template>
<script> <script>
import ListItemAlbum from '@/components/ListItemAlbum' import ModalDialogAlbum from '@/components/ModalDialogAlbum.vue'
import ModalDialogAlbum from '@/components/ModalDialogAlbum' import ModalDialog from '@/components/ModalDialog.vue'
import ModalDialog from '@/components/ModalDialog'
import CoverArtwork from '@/components/CoverArtwork'
import webapi from '@/webapi' import webapi from '@/webapi'
import Albums from '@/lib/Albums' import { renderSVG } from '@/lib/SVGRenderer'
export default { export default {
name: 'ListAlbums', name: 'ListAlbums',
components: { ListItemAlbum, ModalDialogAlbum, ModalDialog, CoverArtwork }, components: { ModalDialogAlbum, ModalDialog },
props: ['albums', 'media_kind'], props: ['albums', 'media_kind', 'hide_group_title'],
emits: ['play-count-changed', 'podcast-deleted'],
data () { data() {
return { return {
show_details_modal: false, show_details_modal: false,
selected_album: {}, selected_album: {},
show_remove_podcast_modal: false, show_remove_podcast_modal: false,
rss_playlist_to_remove: {} rss_playlist_to_remove: {},
artwork_options: {
width: 600,
height: 600,
font_family: 'sans-serif',
font_size: 200,
font_weight: 600,
lazy_lifecycle: {
error: (el) => {
el.src = this.dataURI(
el.attributes.album.value,
el.attributes.artist.value
)
}
}
}
} }
}, },
computed: { computed: {
is_visible_artwork () { is_visible_artwork() {
return this.$store.getters.settings_option('webinterface', 'show_cover_artwork_in_album_lists').value return this.$store.getters.settings_option(
'webinterface',
'show_cover_artwork_in_album_lists'
).value
}, },
media_kind_resolved: function () { media_kind_resolved: function () {
return this.media_kind ? this.media_kind : this.selected_album.media_kind 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)
} }
}, },
@ -131,19 +145,24 @@ export default {
}, },
open_remove_podcast_dialog: function () { open_remove_podcast_dialog: function () {
webapi.library_album_tracks(this.selected_album.id, { limit: 1 }).then(({ data }) => { webapi
webapi.library_track_playlists(data.items[0].id).then(({ data }) => { .library_album_tracks(this.selected_album.id, { limit: 1 })
const rssPlaylists = data.items.filter(pl => pl.type === 'rss') .then(({ data }) => {
if (rssPlaylists.length !== 1) { webapi.library_track_playlists(data.items[0].id).then(({ data }) => {
this.$store.dispatch('add_notification', { text: 'Podcast cannot be removed. Probably it was not added as an RSS playlist.', type: 'danger' }) const rssPlaylists = data.items.filter((pl) => pl.type === 'rss')
return 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.rss_playlist_to_remove = rssPlaylists[0]
this.show_remove_podcast_modal = true this.show_remove_podcast_modal = true
this.show_details_modal = false this.show_details_modal = false
})
}) })
})
}, },
play_count_changed: function () { play_count_changed: function () {
@ -152,13 +171,51 @@ export default {
remove_podcast: function () { remove_podcast: function () {
this.show_remove_podcast_modal = false this.show_remove_podcast_modal = false
webapi.library_playlist_delete(this.rss_playlist_to_remove.id).then(() => { webapi
this.$emit('podcast-deleted') .library_playlist_delete(this.rss_playlist_to_remove.id)
.then(() => {
this.$emit('podcast-deleted')
})
},
artwork_url_with_size: function (artwork_url) {
if (this.artwork_options.width > 0 && this.artwork_options.height > 0) {
return webapi.artwork_url_append_size_params(
artwork_url,
this.artwork_options.width,
this.artwork_options.height
)
}
return webapi.artwork_url_append_size_params(artwork_url)
},
alt_text(album, artist) {
return artist + ' - ' + album
},
caption(album, artist) {
if (album) {
return album.substring(0, 2)
}
if (artist) {
return artist.substring(0, 2)
}
return ''
},
dataURI: function (album, artist) {
const caption = this.caption(album, artist)
const alt_text = this.alt_text(album, artist)
return renderSVG(caption, alt_text, {
width: this.artwork_options.width,
height: this.artwork_options.height,
font_family: this.artwork_options.font_family,
font_size: this.artwork_options.font_size,
font_weight: this.artwork_options.font_weight
}) })
} }
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,48 +1,53 @@
<template> <template>
<div> <template v-for="artist in artists" :key="artist.itemId">
<div v-if="is_grouped"> <div v-if="!artist.isItem && !hide_group_title" class="mt-6 mb-5 py-2">
<div v-for="idx in artists.indexList" :key="idx" class="mb-6"> <div class="media-content is-clipped">
<span class="tag is-info is-light is-small has-text-weight-bold" :id="'index_' + idx">{{ idx }}</span> <span
<list-item-artist v-for="artist in artists.grouped[idx]" :id="'index_' + artist.groupKey"
:key="artist.id" class="tag is-info is-light is-small has-text-weight-bold"
:artist="artist" >{{ artist.groupKey }}</span
@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> </div>
<div v-else> <div
<list-item-artist v-for="artist in artists_list" v-else-if="artist.isItem"
:key="artist.id" class="media"
:artist="artist" @click="open_artist(artist.item)"
@click="open_artist(artist)"> >
<template slot="actions"> <div class="media-content fd-has-action is-clipped">
<a @click="open_dialog(artist)"> <h1 class="title is-6">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> {{ artist.item.name }}
</a> </h1>
</template> </div>
</list-item-artist> <div class="media-right">
<a @click.prevent.stop="open_dialog(artist.item)">
<span class="icon has-text-dark"
><i class="mdi mdi-dots-vertical mdi-18px"
/></span>
</a>
</div>
</div> </div>
<modal-dialog-artist :show="show_details_modal" :artist="selected_artist" :media_kind="media_kind" @close="show_details_modal = false" /> </template>
</div> <teleport to="#app">
<modal-dialog-artist
:show="show_details_modal"
:artist="selected_artist"
:media_kind="media_kind"
@close="show_details_modal = false"
/>
</teleport>
</template> </template>
<script> <script>
import ListItemArtist from '@/components/ListItemArtist' import ModalDialogArtist from '@/components/ModalDialogArtist.vue'
import ModalDialogArtist from '@/components/ModalDialogArtist'
import Artists from '@/lib/Artists'
export default { export default {
name: 'ListArtists', name: 'ListArtists',
components: { ListItemArtist, ModalDialogArtist }, components: { ModalDialogArtist },
props: ['artists', 'media_kind'], props: ['artists', 'media_kind', 'hide_group_title'],
data () { data() {
return { return {
show_details_modal: false, show_details_modal: false,
selected_artist: {} selected_artist: {}
@ -52,17 +57,6 @@ export default {
computed: { computed: {
media_kind_resolved: function () { media_kind_resolved: function () {
return this.media_kind ? this.media_kind : this.selected_artist.media_kind 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)
} }
}, },
@ -86,5 +80,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,48 +1,53 @@
<template> <template>
<div> <template v-for="composer in composers" :key="composer.itemId">
<div v-if="is_grouped"> <div v-if="!composer.isItem && !hide_group_title" class="mt-6 mb-5 py-2">
<div v-for="idx in composers.indexList" :key="idx" class="mb-6"> <div class="media-content is-clipped">
<span class="tag is-info is-light is-small has-text-weight-bold" :id="'index_' + idx">{{ idx }}</span> <span
<list-item-composer v-for="composer in composers.grouped[idx]" :id="'index_' + composer.groupKey"
:key="composer.id" class="tag is-info is-light is-small has-text-weight-bold"
:composer="composer" >{{ composer.groupKey }}</span
@click="open_composer(composer)"> >
<template slot="actions">
<a @click="open_dialog(composer)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-composer>
</div> </div>
</div> </div>
<div v-else> <div
<list-item-composer v-for="composer in composers_list" v-else-if="composer.isItem"
:key="composer.id" class="media"
:composer="composer" @click="open_composer(composer.item)"
@click="open_composer(composer)"> >
<template slot="actions"> <div class="media-content fd-has-action is-clipped">
<a @click="open_dialog(composer)"> <h1 class="title is-6">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> {{ composer.item.name }}
</a> </h1>
</template> </div>
</list-item-composer> <div class="media-right">
<a @click.prevent.stop="open_dialog(composer.item)">
<span class="icon has-text-dark"
><i class="mdi mdi-dots-vertical mdi-18px"
/></span>
</a>
</div>
</div> </div>
<modal-dialog-composer :show="show_details_modal" :composer="selected_composer" :media_kind="media_kind" @close="show_details_modal = false" /> </template>
</div> <teleport to="#app">
<modal-dialog-composer
:show="show_details_modal"
:composer="selected_composer"
:media_kind="media_kind"
@close="show_details_modal = false"
/>
</teleport>
</template> </template>
<script> <script>
import ListItemComposer from '@/components/ListItemComposer' import ModalDialogComposer from '@/components/ModalDialogComposer.vue'
import ModalDialogComposer from '@/components/ModalDialogComposer'
import Composers from '@/lib/Composers'
export default { export default {
name: 'ListComposers', name: 'ListComposers',
components: { ListItemComposer, ModalDialogComposer }, components: { ModalDialogComposer },
props: ['composers', 'media_kind'], props: ['composers', 'media_kind', 'hide_group_title'],
data () { data() {
return { return {
show_details_modal: false, show_details_modal: false,
selected_composer: {} selected_composer: {}
@ -51,25 +56,19 @@ export default {
computed: { computed: {
media_kind_resolved: function () { media_kind_resolved: function () {
return this.media_kind ? this.media_kind : this.selected_composer.media_kind return this.media_kind
}, ? this.media_kind
: this.selected_composer.media_kind
composers_list: function () {
if (Array.isArray(this.composers)) {
return this.composers
}
return this.composers.sortedAndFiltered
},
is_grouped: function () {
return (this.composers instanceof Composers && this.composers.options.group)
} }
}, },
methods: { methods: {
open_composer: function (composer) { open_composer: function (composer) {
this.selected_composer = composer this.selected_composer = composer
this.$router.push({ name: 'ComposerTracks', params: { composer: composer.name } }) this.$router.push({
name: 'ComposerTracks',
params: { composer: composer.name }
})
}, },
open_dialog: function (composer) { open_dialog: function (composer) {
@ -80,5 +79,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -0,0 +1,116 @@
<template>
<div
v-if="$route.query.directory"
class="media"
@click="open_parent_directory()"
>
<figure class="media-left fd-has-action">
<span class="icon">
<i class="mdi mdi-subdirectory-arrow-left" />
</span>
</figure>
<div class="media-content fd-has-action is-clipped">
<h1 class="title is-6">..</h1>
</div>
<div class="media-right">
<slot name="actions" />
</div>
</div>
<template v-for="directory in directories" :key="directory.path">
<div class="media" @click="open_directory(directory)">
<figure class="media-left fd-has-action">
<span class="icon">
<i class="mdi mdi-folder" />
</span>
</figure>
<div class="media-content fd-has-action is-clipped">
<h1 class="title is-6">
{{ directory.path.substring(directory.path.lastIndexOf('/') + 1) }}
</h1>
<h2 class="subtitle is-7 has-text-grey-light">
{{ directory.path }}
</h2>
</div>
<div class="media-right">
<a @click.prevent.stop="open_dialog(directory)">
<span class="icon has-text-dark"
><i class="mdi mdi-dots-vertical mdi-18px"
/></span>
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-directory
:show="show_details_modal"
:directory="selected_directory"
@close="show_details_modal = false"
/>
</teleport>
</template>
<script>
import ModalDialogDirectory from '@/components/ModalDialogDirectory.vue'
export default {
name: 'ListDirectories',
components: { ModalDialogDirectory },
props: ['directories'],
data() {
return {
show_details_modal: false,
selected_directory: {}
}
},
computed: {
current_directory() {
if (this.$route.query && this.$route.query.directory) {
return this.$route.query.directory
}
return '/'
}
},
methods: {
open_parent_directory: function () {
const parent = this.current_directory.slice(
0,
this.current_directory.lastIndexOf('/')
)
if (
parent === '' ||
this.$store.state.config.directories.includes(this.current_directory)
) {
this.$router.push({ path: '/files' })
} else {
this.$router.push({
path: '/files',
query: {
directory: this.current_directory.slice(
0,
this.current_directory.lastIndexOf('/')
)
}
})
}
},
open_directory: function (directory) {
this.$router.push({
path: '/files',
query: { directory: directory.path }
})
},
open_dialog: function (directory) {
this.selected_directory = directory
this.show_details_modal = true
}
}
}
</script>
<style></style>

View File

@ -0,0 +1,71 @@
<template>
<template v-for="genre in genres" :key="genre.itemId">
<div v-if="!genre.isItem && !hide_group_title" class="mt-6 mb-5 py-2">
<div class="media-content is-clipped">
<span
:id="'index_' + genre.groupKey"
class="tag is-info is-light is-small has-text-weight-bold"
>{{ genre.groupKey }}</span
>
</div>
</div>
<div v-else-if="genre.isItem" class="media" @click="open_genre(genre.item)">
<div class="media-content fd-has-action is-clipped">
<h1 class="title is-6">
{{ genre.item.name }}
</h1>
</div>
<div class="media-right">
<a @click.prevent.stop="open_dialog(genre.item)">
<span class="icon has-text-dark"
><i class="mdi mdi-dots-vertical mdi-18px"
/></span>
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-genre
:show="show_details_modal"
:genre="selected_genre"
@close="show_details_modal = false"
/>
</teleport>
</template>
<script>
import ModalDialogGenre from '@/components/ModalDialogGenre.vue'
export default {
name: 'ListGenres',
components: { ModalDialogGenre },
props: ['genres', 'media_kind', 'hide_group_title'],
data() {
return {
show_details_modal: false,
selected_genre: {}
}
},
computed: {
media_kind_resolved: function () {
return this.media_kind ? this.media_kind : this.selected_genre.media_kind
}
},
methods: {
open_genre: function (genre) {
this.$router.push({ name: 'Genre', params: { genre: genre.name } })
},
open_dialog: function (genre) {
this.selected_genre = genre
this.show_details_modal = true
}
}
}
</script>
<style></style>

View File

@ -1,32 +0,0 @@
<template functional>
<div class="media" :id="'index_' + props.album.name_sort.charAt(0).toUpperCase()">
<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>
<h2 class="subtitle is-7 has-text-grey"><b>{{ props.album.artist }}</b></h2>
<h2 class="subtitle is-7 has-text-grey has-text-weight-normal"
v-if="props.album.date_released && props.album.media_kind === 'music'">
{{ props.album.date_released | time('L') }}
</h2>
</div>
</div>
<div class="media-right" style="padding-top:0.7rem;">
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'ListItemAlbum',
props: ['album', 'media_kind']
}
</script>
<style>
</style>

View File

@ -1,20 +0,0 @@
<template functional>
<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>
<div class="media-right">
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'ListItemArtist',
props: ['artist']
}
</script>
<style>
</style>

View File

@ -1,20 +0,0 @@
<template functional>
<div class="media" :id="'index_' + props.composer.name.charAt(0).toUpperCase()">
<div class="media-content fd-has-action is-clipped" @click="listeners.click">
<h1 class="title is-6">{{ props.composer.name }}</h1>
</div>
<div class="media-right">
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'ListItemComposer',
props: ['composer']
}
</script>
<style>
</style>

View File

@ -1,26 +0,0 @@
<template functional>
<div class="media">
<figure class="media-left fd-has-action" @click="listeners.click">
<span class="icon">
<i class="mdi mdi-folder"></i>
</span>
</figure>
<div class="media-content fd-has-action is-clipped" @click="listeners.click">
<h1 class="title is-6">{{ props.directory.path.substring(props.directory.path.lastIndexOf('/') + 1) }}</h1>
<h2 class="subtitle is-7 has-text-grey-light">{{ props.directory.path }}</h2>
</div>
<div class="media-right">
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'ListItemDirectory',
props: ['directory']
}
</script>
<style>
</style>

View File

@ -1,20 +0,0 @@
<template functional>
<div class="media" :id="'index_' + props.genre.name.charAt(0).toUpperCase()">
<div class="media-content fd-has-action is-clipped" @click="listeners.click">
<h1 class="title is-6">{{ props.genre.name }}</h1>
</div>
<div class="media-right">
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'ListItemGenre',
props: ['genre']
}
</script>
<style>
</style>

View File

@ -1,23 +0,0 @@
<template functional>
<div class="media">
<figure class="media-left fd-has-action" v-if="slots().icon" @click="listeners.click">
<slot name="icon"></slot>
</figure>
<div class="media-content fd-has-action is-clipped" @click="listeners.click">
<h1 class="title is-6">{{ props.playlist.name }}</h1>
</div>
<div class="media-right">
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'ListItemPlaylist',
props: ['playlist']
}
</script>
<style>
</style>

View File

@ -1,16 +1,44 @@
<template> <template>
<div class="media" v-if="is_next || !show_only_next_items"> <div v-if="is_next || !show_only_next_items" class="media">
<div class="media-left" v-if="edit_mode"> <div v-if="edit_mode" class="media-left">
<span class="icon has-text-grey fd-is-movable handle"><i class="mdi mdi-drag-horizontal mdi-18px"></i></span> <span class="icon has-text-grey fd-is-movable handle"
><i class="mdi mdi-drag-horizontal mdi-18px"
/></span>
</div> </div>
<div class="media-content fd-has-action is-clipped" v-on:click="play"> <div class="media-content fd-has-action is-clipped" @click="play">
<h1 class="title is-6" :class="{ 'has-text-primary': item.id === state.item_id, 'has-text-grey-light': !is_next }">{{ item.title }}</h1> <h1
<h2 class="subtitle is-7" :class="{ 'has-text-primary': item.id === state.item_id, 'has-text-grey-light': !is_next, 'has-text-grey': is_next && item.id !== state.item_id }"><b>{{ item.artist }}</b></h2> class="title is-6"
<h2 class="subtitle is-7" :class="{ 'has-text-primary': item.id === state.item_id, 'has-text-grey-light': !is_next, 'has-text-grey': is_next && item.id !== state.item_id }">{{ item.album }}</h2> :class="{
'has-text-primary': item.id === state.item_id,
'has-text-grey-light': !is_next
}"
>
{{ item.title }}
</h1>
<h2
class="subtitle is-7"
:class="{
'has-text-primary': item.id === state.item_id,
'has-text-grey-light': !is_next,
'has-text-grey': is_next && item.id !== state.item_id
}"
>
<b>{{ item.artist }}</b>
</h2>
<h2
class="subtitle is-7"
:class="{
'has-text-primary': item.id === state.item_id,
'has-text-grey-light': !is_next,
'has-text-grey': is_next && item.id !== state.item_id
}"
>
{{ item.album }}
</h2>
</div> </div>
<div class="media-right"> <div class="media-right">
<slot name="actions"></slot> <slot name="actions" />
</div> </div>
</div> </div>
</template> </template>
@ -20,14 +48,20 @@ import webapi from '@/webapi'
export default { export default {
name: 'ListItemQueueItem', name: 'ListItemQueueItem',
props: ['item', 'position', 'current_position', 'show_only_next_items', 'edit_mode'], props: [
'item',
'position',
'current_position',
'show_only_next_items',
'edit_mode'
],
computed: { computed: {
state () { state() {
return this.$store.state.player return this.$store.state.player
}, },
is_next () { is_next() {
return this.current_position < 0 || this.position >= this.current_position return this.current_position < 0 || this.position >= this.current_position
} }
}, },
@ -40,5 +74,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,26 +0,0 @@
<template functional>
<div class="media" :id="'index_' + props.track.title_sort.charAt(0).toUpperCase()" :class="{ 'with-progress': slots().progress }">
<figure class="media-left fd-has-action" v-if="slots().icon" @click="listeners.click">
<slot name="icon"></slot>
</figure>
<div class="media-content fd-has-action is-clipped" @click="listeners.click">
<h1 class="title is-6" :class="{ 'has-text-grey': props.track.media_kind === 'podcast' && props.track.play_count > 0 }">{{ props.track.title }}</h1>
<h2 class="subtitle is-7 has-text-grey"><b>{{ props.track.artist }}</b></h2>
<h2 class="subtitle is-7 has-text-grey">{{ props.track.album }}</h2>
<slot name="progress"></slot>
</div>
<div class="media-right">
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'ListItemTrack',
props: ['track']
}
</script>
<style>
</style>

View File

@ -1,32 +1,55 @@
<template> <template>
<div> <div
<list-item-playlist v-for="playlist in playlists" :key="playlist.id" :playlist="playlist" @click="open_playlist(playlist)"> v-for="playlist in playlists"
<template slot="icon"> :key="playlist.id"
<span class="icon"> class="media"
<i class="mdi" :class="{ 'mdi-library-music': playlist.type !== 'folder', 'mdi-rss': playlist.type === 'rss', 'mdi-folder': playlist.type === 'folder' }"></i> :playlist="playlist"
</span> @click="open_playlist(playlist)"
</template> >
<template slot="actions"> <figure class="media-left fd-has-action">
<a @click="open_dialog(playlist)"> <span class="icon">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> <i
</a> class="mdi"
</template> :class="{
</list-item-playlist> 'mdi-library-music': playlist.type !== 'folder',
<modal-dialog-playlist :show="show_details_modal" :playlist="selected_playlist" @close="show_details_modal = false" /> 'mdi-rss': playlist.type === 'rss',
'mdi-folder': playlist.type === 'folder'
}"
/>
</span>
</figure>
<div class="media-content fd-has-action is-clipped">
<h1 class="title is-6">
{{ playlist.name }}
</h1>
</div>
<div class="media-right">
<a @click.prevent.stop="open_dialog(playlist)">
<span class="icon has-text-dark"
><i class="mdi mdi-dots-vertical mdi-18px"
/></span>
</a>
</div>
</div> </div>
<teleport to="#app">
<modal-dialog-playlist
:show="show_details_modal"
:playlist="selected_playlist"
@close="show_details_modal = false"
/>
</teleport>
</template> </template>
<script> <script>
import ListItemPlaylist from '@/components/ListItemPlaylist' import ModalDialogPlaylist from '@/components/ModalDialogPlaylist.vue'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
export default { export default {
name: 'ListPlaylists', name: 'ListPlaylists',
components: { ListItemPlaylist, ModalDialogPlaylist }, components: { ModalDialogPlaylist },
props: ['playlists'], props: ['playlists'],
data () { data() {
return { return {
show_details_modal: false, show_details_modal: false,
selected_playlist: {} selected_playlist: {}
@ -50,5 +73,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,28 +1,71 @@
<template> <template>
<div> <div
<list-item-track v-for="(track, index) in tracks" :key="track.id" :track="track" @click="play_track(index, track)"> v-for="(track, index) in tracks"
<template slot="actions"> :id="'index_' + track.title_sort.charAt(0).toUpperCase()"
<a @click="open_dialog(track)"> :key="track.id"
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> class="media"
</a> :class="{ 'with-progress': show_progress }"
</template> @click="play_track(index, track)"
</list-item-track> >
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" /> <figure v-if="show_icon" class="media-left fd-has-action">
<span class="icon">
<i class="mdi mdi-file-outline" />
</span>
</figure>
<div class="media-content fd-has-action is-clipped">
<h1
class="title is-6"
:class="{
'has-text-grey':
track.media_kind === 'podcast' && track.play_count > 0
}"
>
{{ track.title }}
</h1>
<h2 class="subtitle is-7 has-text-grey">
<b>{{ track.artist }}</b>
</h2>
<h2 class="subtitle is-7 has-text-grey">
{{ track.album }}
</h2>
<progress-bar
v-if="show_progress"
:max="track.length_ms"
:value="track.seek_ms"
/>
</div>
<div class="media-right">
<a @click.prevent.stop="open_dialog(track)">
<span class="icon has-text-dark"
><i class="mdi mdi-dots-vertical mdi-18px"
/></span>
</a>
</div>
</div> </div>
<teleport to="#app">
<modal-dialog-track
:show="show_details_modal"
:track="selected_track"
@close="show_details_modal = false"
@play-count-changed="$emit('play-count-changed')"
/>
</teleport>
</template> </template>
<script> <script>
import ListItemTrack from '@/components/ListItemTrack' import ModalDialogTrack from '@/components/ModalDialogTrack.vue'
import ModalDialogTrack from '@/components/ModalDialogTrack' import ProgressBar from '@/components/ProgressBar.vue'
import webapi from '@/webapi' import webapi from '@/webapi'
export default { export default {
name: 'ListTracks', name: 'ListTracks',
components: { ListItemTrack, ModalDialogTrack }, components: { ModalDialogTrack, ProgressBar },
props: ['tracks', 'uris', 'expression'], props: ['tracks', 'uris', 'expression', 'show_progress', 'show_icon'],
emits: ['play-count-changed'],
data () { data() {
return { return {
show_details_modal: false, show_details_modal: false,
selected_track: {} selected_track: {}
@ -48,5 +91,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,30 +1,47 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<p class="title is-4" v-if="title"> <p v-if="title" class="title is-4">
{{ title }} {{ title }}
</p> </p>
<slot name="modal-content"></slot> <slot name="modal-content" />
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<a class="card-footer-item has-text-dark" @click="$emit('close')"> <a class="card-footer-item has-text-dark" @click="$emit('close')">
<span class="icon"><i class="mdi mdi-cancel"></i></span> <span class="is-size-7">{{ close_action ? close_action : 'Cancel' }}</span> <span class="icon"><i class="mdi mdi-cancel" /></span>
<span class="is-size-7">{{
close_action ? close_action : 'Cancel'
}}</span>
</a> </a>
<a v-if="delete_action" class="card-footer-item has-background-danger has-text-white has-text-weight-bold" @click="$emit('delete')"> <a
<span class="icon"><i class="mdi mdi-delete"></i></span> <span class="is-size-7">{{ delete_action }}</span> v-if="delete_action"
class="card-footer-item has-background-danger has-text-white has-text-weight-bold"
@click="$emit('delete')"
>
<span class="icon"><i class="mdi mdi-delete" /></span>
<span class="is-size-7">{{ delete_action }}</span>
</a> </a>
<a v-if="ok_action" class="card-footer-item has-background-info has-text-white has-text-weight-bold" @click="$emit('ok')"> <a
<span class="icon"><i class="mdi mdi-check"></i></span> <span class="is-size-7">{{ ok_action }}</span> v-if="ok_action"
class="card-footer-item has-background-info has-text-white has-text-weight-bold"
@click="$emit('ok')"
>
<span class="icon"><i class="mdi mdi-check" /></span>
<span class="is-size-7">{{ ok_action }}</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -33,9 +50,9 @@
<script> <script>
export default { export default {
name: 'ModalDialog', name: 'ModalDialog',
props: ['show', 'title', 'ok_action', 'delete_action', 'close_action'] props: ['show', 'title', 'ok_action', 'delete_action', 'close_action'],
emits: ['delete', 'close', 'ok']
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,8 +1,8 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
@ -10,32 +10,54 @@
<form @submit.prevent="add_stream"> <form @submit.prevent="add_stream">
<div class="field"> <div class="field">
<p class="control is-expanded has-icons-left"> <p class="control is-expanded has-icons-left">
<input class="input is-shadowless" type="text" placeholder="http://url-to-rss" v-model="url" :disabled="loading" ref="url_field"> <input
ref="url_field"
v-model="url"
class="input is-shadowless"
type="text"
placeholder="http://url-to-rss"
:disabled="loading"
/>
<span class="icon is-left"> <span class="icon is-left">
<i class="mdi mdi-rss"></i> <i class="mdi mdi-rss" />
</span> </span>
</p> </p>
<p class="help">Adding a podcast includes creating an RSS playlist, that will allow OwnTone to manage the podcast subscription. <p class="help">
Adding a podcast includes creating an RSS playlist, that
will allow OwnTone to manage the podcast subscription.
</p> </p>
</div> </div>
</form> </form>
</div> </div>
<footer class="card-footer" v-if="loading"> <footer v-if="loading" class="card-footer">
<a class="card-footer-item button is-loading"> <a class="card-footer-item button is-loading">
<span class="icon"><i class="mdi mdi-web"></i></span> <span class="is-size-7">Processing ...</span> <span class="icon"><i class="mdi mdi-web" /></span>
<span class="is-size-7">Processing ...</span>
</a> </a>
</footer> </footer>
<footer class="card-footer" v-else> <footer v-else class="card-footer">
<a class="card-footer-item has-text-danger" @click="$emit('close')"> <a
<span class="icon"><i class="mdi mdi-cancel"></i></span> <span class="is-size-7">Cancel</span> class="card-footer-item has-text-danger"
@click="$emit('close')"
>
<span class="icon"><i class="mdi mdi-cancel" /></span>
<span class="is-size-7">Cancel</span>
</a> </a>
<a class="card-footer-item has-background-info has-text-white has-text-weight-bold" @click="add_stream"> <a
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span> class="card-footer-item has-background-info has-text-white has-text-weight-bold"
@click="add_stream"
>
<span class="icon"><i class="mdi mdi-playlist-plus" /></span>
<span class="is-size-7">Add</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -47,29 +69,17 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogAddRss', name: 'ModalDialogAddRss',
props: ['show'], props: ['show'],
emits: ['close', 'podcast-added'],
data () { data() {
return { return {
url: '', url: '',
loading: false loading: false
} }
}, },
methods: {
add_stream: function () {
this.loading = true
webapi.library_add(this.url).then(() => {
this.$emit('close')
this.$emit('podcast-added')
this.url = ''
}).catch(() => {
this.loading = false
})
}
},
watch: { watch: {
'show' () { show() {
if (this.show) { if (this.show) {
this.loading = false this.loading = false
@ -79,9 +89,24 @@ export default {
}, 10) }, 10)
} }
} }
},
methods: {
add_stream: function () {
this.loading = true
webapi
.library_add(this.url)
.then(() => {
this.$emit('close')
this.$emit('podcast-added')
this.url = ''
})
.catch(() => {
this.loading = false
})
}
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,44 +1,63 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<p class="title is-4"> <p class="title is-4">Add stream URL</p>
Add stream URL <form class="fd-has-margin-bottom" @submit.prevent="play">
</p>
<form v-on:submit.prevent="play" class="fd-has-margin-bottom">
<div class="field"> <div class="field">
<p class="control is-expanded has-icons-left"> <p class="control is-expanded has-icons-left">
<input class="input is-shadowless" type="text" placeholder="http://url-to-stream" v-model="url" :disabled="loading" ref="url_field"> <input
ref="url_field"
v-model="url"
class="input is-shadowless"
type="text"
placeholder="http://url-to-stream"
:disabled="loading"
/>
<span class="icon is-left"> <span class="icon is-left">
<i class="mdi mdi-web"></i> <i class="mdi mdi-web" />
</span> </span>
</p> </p>
</div> </div>
</form> </form>
</div> </div>
<footer class="card-footer" v-if="loading"> <footer v-if="loading" class="card-footer">
<a class="card-footer-item has-text-dark"> <a class="card-footer-item has-text-dark">
<span class="icon"><i class="mdi mdi-web"></i></span> <span class="is-size-7">Loading ...</span> <span class="icon"><i class="mdi mdi-web" /></span>
<span class="is-size-7">Loading ...</span>
</a> </a>
</footer> </footer>
<footer class="card-footer" v-else> <footer v-else class="card-footer">
<a class="card-footer-item has-text-danger" @click="$emit('close')"> <a
<span class="icon"><i class="mdi mdi-cancel"></i></span> <span class="is-size-7">Cancel</span> class="card-footer-item has-text-danger"
@click="$emit('close')"
>
<span class="icon"><i class="mdi mdi-cancel" /></span>
<span class="is-size-7">Cancel</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="add_stream"> <a class="card-footer-item has-text-dark" @click="add_stream">
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span> <span class="icon"><i class="mdi mdi-playlist-plus" /></span>
<span class="is-size-7">Add</span>
</a> </a>
<a class="card-footer-item has-background-info has-text-white has-text-weight-bold" @click="play"> <a
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span> class="card-footer-item has-background-info has-text-white has-text-weight-bold"
@click="play"
>
<span class="icon"><i class="mdi mdi-play" /></span>
<span class="is-size-7">Play</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -50,38 +69,17 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogAddUrlStream', name: 'ModalDialogAddUrlStream',
props: ['show'], props: ['show'],
emits: ['close'],
data () { data() {
return { return {
url: '', url: '',
loading: false loading: false
} }
}, },
methods: {
add_stream: function () {
this.loading = true
webapi.queue_add(this.url).then(() => {
this.$emit('close')
this.url = ''
}).catch(() => {
this.loading = false
})
},
play: function () {
this.loading = true
webapi.player_play_uri(this.url, false).then(() => {
this.$emit('close')
this.url = ''
}).catch(() => {
this.loading = false
})
}
},
watch: { watch: {
'show' () { show() {
if (this.show) { if (this.show) {
this.loading = false this.loading = false
@ -91,9 +89,36 @@ export default {
}, 10) }, 10)
} }
} }
},
methods: {
add_stream: function () {
this.loading = true
webapi
.queue_add(this.url)
.then(() => {
this.$emit('close')
this.url = ''
})
.catch(() => {
this.loading = false
})
},
play: function () {
this.loading = true
webapi
.player_play_uri(this.url, false)
.then(() => {
this.$emit('close')
this.url = ''
})
.catch(() => {
this.loading = false
})
}
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,8 +1,8 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
@ -10,22 +10,33 @@
:artwork_url="album.artwork_url" :artwork_url="album.artwork_url"
:artist="album.artist" :artist="album.artist"
:album="album.name" :album="album.name"
class="image is-square fd-has-margin-bottom fd-has-shadow" /> class="image is-square fd-has-margin-bottom fd-has-shadow"
/>
<p class="title is-4"> <p class="title is-4">
<a class="has-text-link" @click="open_album">{{ album.name }}</a> <a class="has-text-link" @click="open_album">{{
album.name
}}</a>
</p> </p>
<div class="buttons" v-if="media_kind_resolved === 'podcast'"> <div v-if="media_kind_resolved === 'podcast'" class="buttons">
<a class="button is-small" @click="mark_played">Mark as played</a> <a class="button is-small" @click="mark_played"
<a class="button is-small" @click="$emit('remove-podcast')">Remove podcast</a> >Mark as played</a
>
<a class="button is-small" @click="$emit('remove-podcast')"
>Remove podcast</a
>
</div> </div>
<div class="content is-small"> <div class="content is-small">
<p v-if="album.artist"> <p v-if="album.artist">
<span class="heading">Album artist</span> <span class="heading">Album artist</span>
<a class="title is-6 has-text-link" @click="open_artist">{{ album.artist }}</a> <a class="title is-6 has-text-link" @click="open_artist">{{
album.artist
}}</a>
</p> </p>
<p v-if="album.date_released"> <p v-if="album.date_released">
<span class="heading">Release date</span> <span class="heading">Release date</span>
<span class="title is-6">{{ album.date_released | time('L') }}</span> <span class="title is-6">{{
$filters.time(album.date_released, 'L')
}}</span>
</p> </p>
<p v-else-if="album.year > 0"> <p v-else-if="album.year > 0">
<span class="heading">Year</span> <span class="heading">Year</span>
@ -37,47 +48,61 @@
</p> </p>
<p> <p>
<span class="heading">Length</span> <span class="heading">Length</span>
<span class="title is-6">{{ album.length_ms | duration }}</span> <span class="title is-6">{{
$filters.duration(album.length_ms)
}}</span>
</p> </p>
<p> <p>
<span class="heading">Type</span> <span class="heading">Type</span>
<span class="title is-6">{{ album.media_kind }} - {{ album.data_kind }}</span> <span class="title is-6"
>{{ album.media_kind }} - {{ album.data_kind }}</span
>
</p> </p>
<p> <p>
<span class="heading">Added at</span> <span class="heading">Added at</span>
<span class="title is-6">{{ album.time_added | time('L LT') }}</span> <span class="title is-6">{{
$filters.time(album.time_added, 'L LT')
}}</span>
</p> </p>
</div> </div>
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<a class="card-footer-item has-text-dark" @click="queue_add"> <a class="card-footer-item has-text-dark" @click="queue_add">
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span> <span class="icon"><i class="mdi mdi-playlist-plus" /></span>
<span class="is-size-7">Add</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="queue_add_next"> <a class="card-footer-item has-text-dark" @click="queue_add_next">
<span class="icon"><i class="mdi mdi-playlist-play"></i></span> <span class="is-size-7">Add Next</span> <span class="icon"><i class="mdi mdi-playlist-play" /></span>
<span class="is-size-7">Add Next</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="play"> <a class="card-footer-item has-text-dark" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span> <span class="icon"><i class="mdi mdi-play" /></span>
<span class="is-size-7">Play</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
</template> </template>
<script> <script>
import CoverArtwork from '@/components/CoverArtwork' import CoverArtwork from '@/components/CoverArtwork.vue'
import webapi from '@/webapi' import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogAlbum', name: 'ModalDialogAlbum',
components: { CoverArtwork }, components: { CoverArtwork },
props: ['show', 'album', 'media_kind', 'new_tracks'], props: ['show', 'album', 'media_kind', 'new_tracks'],
emits: ['close', 'remove-podcast', 'play-count-changed'],
data () { data() {
return { return {
artwork_visible: false artwork_visible: false
} }
@ -123,17 +148,21 @@ export default {
if (this.media_kind_resolved === 'podcast') { if (this.media_kind_resolved === 'podcast') {
// No artist page for podcasts // No artist page for podcasts
} else if (this.media_kind_resolved === 'audiobook') { } else if (this.media_kind_resolved === 'audiobook') {
this.$router.push({ path: '/audiobooks/artists/' + this.album.artist_id }) this.$router.push({
path: '/audiobooks/artists/' + this.album.artist_id
})
} else { } else {
this.$router.push({ path: '/music/artists/' + this.album.artist_id }) this.$router.push({ path: '/music/artists/' + this.album.artist_id })
} }
}, },
mark_played: function () { mark_played: function () {
webapi.library_album_track_update(this.album.id, { play_count: 'played' }).then(({ data }) => { webapi
this.$emit('play-count-changed') .library_album_track_update(this.album.id, { play_count: 'played' })
this.$emit('close') .then(({ data }) => {
}) this.$emit('play-count-changed')
this.$emit('close')
})
}, },
artwork_loaded: function () { artwork_loaded: function () {
@ -147,5 +176,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,13 +1,15 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<p class="title is-4"> <p class="title is-4">
<a class="has-text-link" @click="open_artist">{{ artist.name }}</a> <a class="has-text-link" @click="open_artist">{{
artist.name
}}</a>
</p> </p>
<div class="content is-small"> <div class="content is-small">
<p> <p>
@ -24,24 +26,33 @@
</p> </p>
<p> <p>
<span class="heading">Added at</span> <span class="heading">Added at</span>
<span class="title is-6">{{ artist.time_added | time('L LT') }}</span> <span class="title is-6">{{
$filters.time(artist.time_added, 'L LT')
}}</span>
</p> </p>
</div> </div>
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<a class="card-footer-item has-text-dark" @click="queue_add"> <a class="card-footer-item has-text-dark" @click="queue_add">
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span> <span class="icon"><i class="mdi mdi-playlist-plus" /></span>
<span class="is-size-7">Add</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="queue_add_next"> <a class="card-footer-item has-text-dark" @click="queue_add_next">
<span class="icon"><i class="mdi mdi-playlist-play"></i></span> <span class="is-size-7">Add Next</span> <span class="icon"><i class="mdi mdi-playlist-play" /></span>
<span class="is-size-7">Add Next</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="play"> <a class="card-footer-item has-text-dark" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span> <span class="icon"><i class="mdi mdi-play" /></span>
<span class="is-size-7">Play</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -53,6 +64,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogArtist', name: 'ModalDialogArtist',
props: ['show', 'artist'], props: ['show', 'artist'],
emits: ['close'],
methods: { methods: {
play: function () { play: function () {
@ -78,5 +90,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,37 +1,50 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<p class="title is-4"> <p class="title is-4">
<a class="has-text-link" @click="open_albums">{{ composer.name }}</a> <a class="has-text-link" @click="open_albums">{{
composer.name
}}</a>
</p> </p>
<p> <p>
<span class="heading">Albums</span> <span class="heading">Albums</span>
<a class="has-text-link is-6" @click="open_albums">{{ composer.album_count }}</a> <a class="has-text-link is-6" @click="open_albums">{{
composer.album_count
}}</a>
</p> </p>
<p> <p>
<span class="heading">Tracks</span> <span class="heading">Tracks</span>
<a class="has-text-link is-6" @click="open_tracks">{{ composer.track_count }}</a> <a class="has-text-link is-6" @click="open_tracks">{{
composer.track_count
}}</a>
</p> </p>
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<a class="card-footer-item has-text-dark" @click="queue_add"> <a class="card-footer-item has-text-dark" @click="queue_add">
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span> <span class="icon"><i class="mdi mdi-playlist-plus" /></span>
<span class="is-size-7">Add</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="queue_add_next"> <a class="card-footer-item has-text-dark" @click="queue_add_next">
<span class="icon"><i class="mdi mdi-playlist-play"></i></span> <span class="is-size-7">Add Next</span> <span class="icon"><i class="mdi mdi-playlist-play" /></span>
<span class="is-size-7">Add Next</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="play"> <a class="card-footer-item has-text-dark" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span> <span class="icon"><i class="mdi mdi-play" /></span>
<span class="is-size-7">Play</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -43,35 +56,48 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogComposer', name: 'ModalDialogComposer',
props: ['show', 'composer'], props: ['show', 'composer'],
emits: ['close'],
methods: { methods: {
play: function () { play: function () {
this.$emit('close') this.$emit('close')
webapi.player_play_expression('composer is "' + this.composer.name + '" and media_kind is music', false) webapi.player_play_expression(
'composer is "' + this.composer.name + '" and media_kind is music',
false
)
}, },
queue_add: function () { queue_add: function () {
this.$emit('close') this.$emit('close')
webapi.queue_expression_add('composer is "' + this.composer.name + '" and media_kind is music') webapi.queue_expression_add(
'composer is "' + this.composer.name + '" and media_kind is music'
)
}, },
queue_add_next: function () { queue_add_next: function () {
this.$emit('close') this.$emit('close')
webapi.queue_expression_add_next('composer is "' + this.composer.name + '" and media_kind is music') webapi.queue_expression_add_next(
'composer is "' + this.composer.name + '" and media_kind is music'
)
}, },
open_albums: function () { open_albums: function () {
this.$emit('close') this.$emit('close')
this.$router.push({ name: 'ComposerAlbums', params: { composer: this.composer.name } }) this.$router.push({
name: 'ComposerAlbums',
params: { composer: this.composer.name }
})
}, },
open_tracks: function () { open_tracks: function () {
this.show_details_modal = false this.show_details_modal = false
this.$router.push({ name: 'ComposerTracks', params: { composer: this.composer.name } }) this.$router.push({
name: 'ComposerTracks',
params: { composer: this.composer.name }
})
} }
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,8 +1,8 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
@ -12,18 +12,25 @@
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<a class="card-footer-item has-text-dark" @click="queue_add"> <a class="card-footer-item has-text-dark" @click="queue_add">
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span> <span class="icon"><i class="mdi mdi-playlist-plus" /></span>
<span class="is-size-7">Add</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="queue_add_next"> <a class="card-footer-item has-text-dark" @click="queue_add_next">
<span class="icon"><i class="mdi mdi-playlist-play"></i></span> <span class="is-size-7">Add Next</span> <span class="icon"><i class="mdi mdi-playlist-play" /></span>
<span class="is-size-7">Add Next</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="play"> <a class="card-footer-item has-text-dark" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span> <span class="icon"><i class="mdi mdi-play" /></span>
<span class="is-size-7">Play</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -35,25 +42,32 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogDirectory', name: 'ModalDialogDirectory',
props: ['show', 'directory'], props: ['show', 'directory'],
emits: ['close'],
methods: { methods: {
play: function () { play: function () {
this.$emit('close') this.$emit('close')
webapi.player_play_expression('path starts with "' + this.directory.path + '" order by path asc', false) webapi.player_play_expression(
'path starts with "' + this.directory.path + '" order by path asc',
false
)
}, },
queue_add: function () { queue_add: function () {
this.$emit('close') this.$emit('close')
webapi.queue_expression_add('path starts with "' + this.directory.path + '" order by path asc') webapi.queue_expression_add(
'path starts with "' + this.directory.path + '" order by path asc'
)
}, },
queue_add_next: function () { queue_add_next: function () {
this.$emit('close') this.$emit('close')
webapi.queue_expression_add_next('path starts with "' + this.directory.path + '" order by path asc') webapi.queue_expression_add_next(
'path starts with "' + this.directory.path + '" order by path asc'
)
} }
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,29 +1,38 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<p class="title is-4"> <p class="title is-4">
<a class="has-text-link" @click="open_genre">{{ genre.name }}</a> <a class="has-text-link" @click="open_genre">{{
genre.name
}}</a>
</p> </p>
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<a class="card-footer-item has-text-dark" @click="queue_add"> <a class="card-footer-item has-text-dark" @click="queue_add">
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span> <span class="icon"><i class="mdi mdi-playlist-plus" /></span>
<span class="is-size-7">Add</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="queue_add_next"> <a class="card-footer-item has-text-dark" @click="queue_add_next">
<span class="icon"><i class="mdi mdi-playlist-play"></i></span> <span class="is-size-7">Add Next</span> <span class="icon"><i class="mdi mdi-playlist-play" /></span>
<span class="is-size-7">Add Next</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="play"> <a class="card-footer-item has-text-dark" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span> <span class="icon"><i class="mdi mdi-play" /></span>
<span class="is-size-7">Play</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -35,21 +44,29 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogGenre', name: 'ModalDialogGenre',
props: ['show', 'genre'], props: ['show', 'genre'],
emits: ['close'],
methods: { methods: {
play: function () { play: function () {
this.$emit('close') this.$emit('close')
webapi.player_play_expression('genre is "' + this.genre.name + '" and media_kind is music', false) webapi.player_play_expression(
'genre is "' + this.genre.name + '" and media_kind is music',
false
)
}, },
queue_add: function () { queue_add: function () {
this.$emit('close') this.$emit('close')
webapi.queue_expression_add('genre is "' + this.genre.name + '" and media_kind is music') webapi.queue_expression_add(
'genre is "' + this.genre.name + '" and media_kind is music'
)
}, },
queue_add_next: function () { queue_add_next: function () {
this.$emit('close') this.$emit('close')
webapi.queue_expression_add_next('genre is "' + this.genre.name + '" and media_kind is music') webapi.queue_expression_add_next(
'genre is "' + this.genre.name + '" and media_kind is music'
)
}, },
open_genre: function () { open_genre: function () {
@ -60,5 +77,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,13 +1,15 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<p class="title is-4"> <p class="title is-4">
<a class="has-text-link" @click="open_playlist">{{ playlist.name }}</a> <a class="has-text-link" @click="open_playlist">{{
playlist.name
}}</a>
</p> </p>
<div class="content is-small"> <div class="content is-small">
<p> <p>
@ -20,20 +22,27 @@
</p> </p>
</div> </div>
</div> </div>
<footer class="card-footer" v-if="!playlist.folder"> <footer v-if="!playlist.folder" class="card-footer">
<a class="card-footer-item has-text-dark" @click="queue_add"> <a class="card-footer-item has-text-dark" @click="queue_add">
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span> <span class="icon"><i class="mdi mdi-playlist-plus" /></span>
<span class="is-size-7">Add</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="queue_add_next"> <a class="card-footer-item has-text-dark" @click="queue_add_next">
<span class="icon"><i class="mdi mdi-playlist-play"></i></span> <span class="is-size-7">Add Next</span> <span class="icon"><i class="mdi mdi-playlist-play" /></span>
<span class="is-size-7">Add Next</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="play"> <a class="card-footer-item has-text-dark" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span> <span class="icon"><i class="mdi mdi-play" /></span>
<span class="is-size-7">Play</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -45,6 +54,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogPlaylist', name: 'ModalDialogPlaylist',
props: ['show', 'playlist', 'uris'], props: ['show', 'playlist', 'uris'],
emits: ['close'],
methods: { methods: {
play: function () { play: function () {
@ -70,5 +80,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,41 +1,59 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<p class="title is-4"> <p class="title is-4">Save queue to playlist</p>
Save queue to playlist <form class="fd-has-margin-bottom" @submit.prevent="save">
</p>
<form v-on:submit.prevent="save" class="fd-has-margin-bottom">
<div class="field"> <div class="field">
<p class="control is-expanded has-icons-left"> <p class="control is-expanded has-icons-left">
<input class="input is-shadowless" type="text" placeholder="Playlist name" v-model="playlist_name" :disabled="loading" ref="playlist_name_field"> <input
ref="playlist_name_field"
v-model="playlist_name"
class="input is-shadowless"
type="text"
placeholder="Playlist name"
:disabled="loading"
/>
<span class="icon is-left"> <span class="icon is-left">
<i class="mdi mdi-file-music"></i> <i class="mdi mdi-file-music" />
</span> </span>
</p> </p>
</div> </div>
</form> </form>
</div> </div>
<footer class="card-footer" v-if="loading"> <footer v-if="loading" class="card-footer">
<a class="card-footer-item has-text-dark"> <a class="card-footer-item has-text-dark">
<span class="icon"><i class="mdi mdi-web"></i></span> <span class="is-size-7">Saving ...</span> <span class="icon"><i class="mdi mdi-web" /></span>
<span class="is-size-7">Saving ...</span>
</a> </a>
</footer> </footer>
<footer class="card-footer" v-else> <footer v-else class="card-footer">
<a class="card-footer-item has-text-danger" @click="$emit('close')"> <a
<span class="icon"><i class="mdi mdi-cancel"></i></span> <span class="is-size-7">Cancel</span> class="card-footer-item has-text-danger"
@click="$emit('close')"
>
<span class="icon"><i class="mdi mdi-cancel" /></span>
<span class="is-size-7">Cancel</span>
</a> </a>
<a class="card-footer-item has-background-info has-text-white has-text-weight-bold" @click="save"> <a
<span class="icon"><i class="mdi mdi-content-save"></i></span> <span class="is-size-7">Save</span> class="card-footer-item has-background-info has-text-white has-text-weight-bold"
@click="save"
>
<span class="icon"><i class="mdi mdi-content-save" /></span>
<span class="is-size-7">Save</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -47,14 +65,28 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogPlaylistSave', name: 'ModalDialogPlaylistSave',
props: ['show'], props: ['show'],
emits: ['close'],
data () { data() {
return { return {
playlist_name: '', playlist_name: '',
loading: false loading: false
} }
}, },
watch: {
show() {
if (this.show) {
this.loading = false
// We need to delay setting the focus to the input field until the field is part of the dom and visible
setTimeout(() => {
this.$refs.playlist_name_field.focus()
}, 10)
}
}
},
methods: { methods: {
save: function () { save: function () {
if (this.playlist_name.length < 1) { if (this.playlist_name.length < 1) {
@ -62,29 +94,18 @@ export default {
} }
this.loading = true this.loading = true
webapi.queue_save_playlist(this.playlist_name).then(() => { webapi
this.$emit('close') .queue_save_playlist(this.playlist_name)
this.playlist_name = '' .then(() => {
}).catch(() => { this.$emit('close')
this.loading = false this.playlist_name = ''
}) })
} .catch(() => {
}, this.loading = false
})
watch: {
'show' () {
if (this.show) {
this.loading = false
// We need to delay setting the focus to the input field until the field is part of the dom and visible
setTimeout(() => {
this.$refs.playlist_name_field.focus()
}, 10)
}
} }
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,8 +1,8 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
@ -15,12 +15,22 @@
<div class="content is-small"> <div class="content is-small">
<p> <p>
<span class="heading">Album</span> <span class="heading">Album</span>
<a v-if="item.album_id" class="title is-6 has-text-link" @click="open_album">{{ item.album }}</a> <a
v-if="item.album_id"
class="title is-6 has-text-link"
@click="open_album"
>{{ item.album }}</a
>
<span v-else class="title is-6">{{ item.album }}</span> <span v-else class="title is-6">{{ item.album }}</span>
</p> </p>
<p v-if="item.album_artist"> <p v-if="item.album_artist">
<span class="heading">Album artist</span> <span class="heading">Album artist</span>
<a v-if="item.album_artist_id" class="title is-6 has-text-link" @click="open_album_artist">{{ item.album_artist }}</a> <a
v-if="item.album_artist_id"
class="title is-6 has-text-link"
@click="open_album_artist"
>{{ item.album_artist }}</a
>
<span v-else class="title is-6">{{ item.album_artist }}</span> <span v-else class="title is-6">{{ item.album_artist }}</span>
</p> </p>
<p v-if="item.composer"> <p v-if="item.composer">
@ -33,15 +43,21 @@
</p> </p>
<p v-if="item.genre"> <p v-if="item.genre">
<span class="heading">Genre</span> <span class="heading">Genre</span>
<a class="title is-6 has-text-link" @click="open_genre">{{ item.genre }}</a> <a class="title is-6 has-text-link" @click="open_genre">{{
item.genre
}}</a>
</p> </p>
<p> <p>
<span class="heading">Track / Disc</span> <span class="heading">Track / Disc</span>
<span class="title is-6">{{ item.track_number }} / {{ item.disc_number }}</span> <span class="title is-6"
>{{ item.track_number }} / {{ item.disc_number }}</span
>
</p> </p>
<p> <p>
<span class="heading">Length</span> <span class="heading">Length</span>
<span class="title is-6">{{ item.length_ms | duration }}</span> <span class="title is-6">{{
$filters.duration(item.length_ms)
}}</span>
</p> </p>
<p> <p>
<span class="heading">Path</span> <span class="heading">Path</span>
@ -49,14 +65,26 @@
</p> </p>
<p> <p>
<span class="heading">Type</span> <span class="heading">Type</span>
<span class="title is-6">{{ item.media_kind }} - {{ item.data_kind }} <span class="has-text-weight-normal" v-if="item.data_kind === 'spotify'">(<a @click="open_spotify_artist">artist</a>, <a @click="open_spotify_album">album</a>)</span></span> <span class="title is-6"
>{{ item.media_kind }} - {{ item.data_kind }}
<span
v-if="item.data_kind === 'spotify'"
class="has-text-weight-normal"
>(<a @click="open_spotify_artist">artist</a>,
<a @click="open_spotify_album">album</a>)</span
></span
>
</p> </p>
<p> <p>
<span class="heading">Quality</span> <span class="heading">Quality</span>
<span class="title is-6"> <span class="title is-6">
{{ item.type }} {{ item.type }}
<span v-if="item.samplerate"> | {{ item.samplerate }} Hz</span> <span v-if="item.samplerate">
<span v-if="item.channels"> | {{ item.channels | channels }}</span> | {{ item.samplerate }} Hz</span
>
<span v-if="item.channels">
| {{ $filters.channels(item.channels) }}</span
>
<span v-if="item.bitrate"> | {{ item.bitrate }} Kb/s</span> <span v-if="item.bitrate"> | {{ item.bitrate }} Kb/s</span>
</span> </span>
</p> </p>
@ -64,15 +92,21 @@
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<a class="card-footer-item has-text-dark" @click="remove"> <a class="card-footer-item has-text-dark" @click="remove">
<span class="icon"><i class="mdi mdi-delete"></i></span> <span class="is-size-7">Remove</span> <span class="icon"><i class="mdi mdi-delete" /></span>
<span class="is-size-7">Remove</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="play"> <a class="card-footer-item has-text-dark" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span> <span class="icon"><i class="mdi mdi-play" /></span>
<span class="is-size-7">Play</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -85,13 +119,30 @@ import SpotifyWebApi from 'spotify-web-api-js'
export default { export default {
name: 'ModalDialogQueueItem', name: 'ModalDialogQueueItem',
props: ['show', 'item'], props: ['show', 'item'],
emits: ['close'],
data () { data() {
return { return {
spotify_track: {} spotify_track: {}
} }
}, },
watch: {
item() {
if (this.item && this.item.data_kind === 'spotify') {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(this.$store.state.spotify.webapi_token)
spotifyApi
.getTrack(this.item.path.slice(this.item.path.lastIndexOf(':') + 1))
.then((response) => {
this.spotify_track = response
})
} else {
this.spotify_track = {}
}
}
},
methods: { methods: {
remove: function () { remove: function () {
this.$emit('close') this.$emit('close')
@ -123,30 +174,19 @@ export default {
open_spotify_artist: function () { open_spotify_artist: function () {
this.$emit('close') this.$emit('close')
this.$router.push({ path: '/music/spotify/artists/' + this.spotify_track.artists[0].id }) this.$router.push({
path: '/music/spotify/artists/' + this.spotify_track.artists[0].id
})
}, },
open_spotify_album: function () { open_spotify_album: function () {
this.$emit('close') this.$emit('close')
this.$router.push({ path: '/music/spotify/albums/' + this.spotify_track.album.id }) this.$router.push({
} path: '/music/spotify/albums/' + this.spotify_track.album.id
}, })
watch: {
'item' () {
if (this.item && this.item.data_kind === 'spotify') {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(this.$store.state.spotify.webapi_token)
spotifyApi.getTrack(this.item.path.slice(this.item.path.lastIndexOf(':') + 1)).then((response) => {
this.spotify_track = response
})
} else {
this.spotify_track = {}
}
} }
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,36 +1,52 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<p class="title is-4"> <p class="title is-4">Remote pairing request</p>
Remote pairing request <form @submit.prevent="kickoff_pairing">
</p>
<form v-on:submit.prevent="kickoff_pairing">
<label class="label"> <label class="label">
{{ pairing.remote }} {{ pairing.remote }}
</label> </label>
<div class="field"> <div class="field">
<div class="control"> <div class="control">
<input class="input" type="text" placeholder="Enter pairing code" v-model="pairing_req.pin" ref="pin_field"> <input
ref="pin_field"
v-model="pairing_req.pin"
class="input"
type="text"
placeholder="Enter pairing code"
/>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<a class="card-footer-item has-text-danger" @click="$emit('close')"> <a
<span class="icon"><i class="mdi mdi-cancel"></i></span> <span class="is-size-7">Cancel</span> class="card-footer-item has-text-danger"
@click="$emit('close')"
>
<span class="icon"><i class="mdi mdi-cancel" /></span>
<span class="is-size-7">Cancel</span>
</a> </a>
<a class="card-footer-item has-background-info has-text-white has-text-weight-bold" @click="kickoff_pairing"> <a
<span class="icon"><i class="mdi mdi-cellphone-iphone"></i></span> <span class="is-size-7">Pair Remote</span> class="card-footer-item has-background-info has-text-white has-text-weight-bold"
@click="kickoff_pairing"
>
<span class="icon"><i class="mdi mdi-cellphone-iphone" /></span>
<span class="is-size-7">Pair Remote</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -42,29 +58,22 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogRemotePairing', name: 'ModalDialogRemotePairing',
props: ['show'], props: ['show'],
emits: ['close'],
data () { data() {
return { return {
pairing_req: { pin: '' } pairing_req: { pin: '' }
} }
}, },
computed: { computed: {
pairing () { pairing() {
return this.$store.state.pairing return this.$store.state.pairing
} }
}, },
methods: {
kickoff_pairing () {
webapi.pairing_kickoff(this.pairing_req).then(() => {
this.pairing_req.pin = ''
})
}
},
watch: { watch: {
'show' () { show() {
if (this.show) { if (this.show) {
this.loading = false this.loading = false
@ -74,9 +83,16 @@ export default {
}, 10) }, 10)
} }
} }
},
methods: {
kickoff_pairing() {
webapi.pairing_kickoff(this.pairing_req).then(() => {
this.pairing_req.pin = ''
})
}
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,8 +1,8 @@
<template> <template>
<div> <div>
<transition name="fade"> <transition name="fade">
<div class="modal is-active" v-if="show"> <div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
@ -12,18 +12,34 @@
<p class="subtitle"> <p class="subtitle">
{{ track.artist }} {{ track.artist }}
</p> </p>
<div class="buttons" v-if="track.media_kind === 'podcast'"> <div v-if="track.media_kind === 'podcast'" class="buttons">
<a class="button is-small" v-if="track.play_count > 0" @click="mark_new">Mark as new</a> <a
<a class="button is-small" v-if="track.play_count === 0" @click="mark_played">Mark as played</a> v-if="track.play_count > 0"
class="button is-small"
@click="mark_new"
>Mark as new</a
>
<a
v-if="track.play_count === 0"
class="button is-small"
@click="mark_played"
>Mark as played</a
>
</div> </div>
<div class="content is-small"> <div class="content is-small">
<p> <p>
<span class="heading">Album</span> <span class="heading">Album</span>
<a class="title is-6 has-text-link" @click="open_album">{{ track.album }}</a> <a class="title is-6 has-text-link" @click="open_album">{{
track.album
}}</a>
</p> </p>
<p v-if="track.album_artist && track.media_kind !== 'audiobook'"> <p
v-if="track.album_artist && track.media_kind !== 'audiobook'"
>
<span class="heading">Album artist</span> <span class="heading">Album artist</span>
<a class="title is-6 has-text-link" @click="open_artist">{{ track.album_artist }}</a> <a class="title is-6 has-text-link" @click="open_artist">{{
track.album_artist
}}</a>
</p> </p>
<p v-if="track.composer"> <p v-if="track.composer">
<span class="heading">Composer</span> <span class="heading">Composer</span>
@ -31,7 +47,9 @@
</p> </p>
<p v-if="track.date_released"> <p v-if="track.date_released">
<span class="heading">Release date</span> <span class="heading">Release date</span>
<span class="title is-6">{{ track.date_released | time('L') }}</span> <span class="title is-6">{{
$filters.time(track.date_released, 'L')
}}</span>
</p> </p>
<p v-else-if="track.year > 0"> <p v-else-if="track.year > 0">
<span class="heading">Year</span> <span class="heading">Year</span>
@ -39,15 +57,21 @@
</p> </p>
<p v-if="track.genre"> <p v-if="track.genre">
<span class="heading">Genre</span> <span class="heading">Genre</span>
<a class="title is-6 has-text-link" @click="open_genre">{{ track.genre }}</a> <a class="title is-6 has-text-link" @click="open_genre">{{
track.genre
}}</a>
</p> </p>
<p> <p>
<span class="heading">Track / Disc</span> <span class="heading">Track / Disc</span>
<span class="title is-6">{{ track.track_number }} / {{ track.disc_number }}</span> <span class="title is-6"
>{{ track.track_number }} / {{ track.disc_number }}</span
>
</p> </p>
<p> <p>
<span class="heading">Length</span> <span class="heading">Length</span>
<span class="title is-6">{{ track.length_ms | duration }}</span> <span class="title is-6">{{
$filters.duration(track.length_ms)
}}</span>
</p> </p>
<p> <p>
<span class="heading">Path</span> <span class="heading">Path</span>
@ -55,24 +79,42 @@
</p> </p>
<p> <p>
<span class="heading">Type</span> <span class="heading">Type</span>
<span class="title is-6">{{ track.media_kind }} - {{ track.data_kind }} <span class="has-text-weight-normal" v-if="track.data_kind === 'spotify'">(<a @click="open_spotify_artist">artist</a>, <a @click="open_spotify_album">album</a>)</span></span> <span class="title is-6"
>{{ track.media_kind }} - {{ track.data_kind }}
<span
v-if="track.data_kind === 'spotify'"
class="has-text-weight-normal"
>(<a @click="open_spotify_artist">artist</a>,
<a @click="open_spotify_album">album</a>)</span
></span
>
</p> </p>
<p> <p>
<span class="heading">Quality</span> <span class="heading">Quality</span>
<span class="title is-6"> <span class="title is-6">
{{ track.type }} {{ track.type }}
<span v-if="track.samplerate"> | {{ track.samplerate }} Hz</span> <span v-if="track.samplerate">
<span v-if="track.channels"> | {{ track.channels | channels }}</span> | {{ track.samplerate }} Hz</span
<span v-if="track.bitrate"> | {{ track.bitrate }} Kb/s</span> >
<span v-if="track.channels">
| {{ $filters.channels(track.channels) }}</span
>
<span v-if="track.bitrate">
| {{ track.bitrate }} Kb/s</span
>
</span> </span>
</p> </p>
<p> <p>
<span class="heading">Added at</span> <span class="heading">Added at</span>
<span class="title is-6">{{ track.time_added | time('L LT') }}</span> <span class="title is-6">{{
$filters.time(track.time_added, 'L LT')
}}</span>
</p> </p>
<p> <p>
<span class="heading">Rating</span> <span class="heading">Rating</span>
<span class="title is-6">{{ Math.floor(track.rating / 10) }} / 10</span> <span class="title is-6"
>{{ Math.floor(track.rating / 10) }} / 10</span
>
</p> </p>
<p v-if="track.comment"> <p v-if="track.comment">
<span class="heading">Comment</span> <span class="heading">Comment</span>
@ -82,18 +124,25 @@
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<a class="card-footer-item has-text-dark" @click="queue_add"> <a class="card-footer-item has-text-dark" @click="queue_add">
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span> <span class="icon"><i class="mdi mdi-playlist-plus" /></span>
<span class="is-size-7">Add</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="queue_add_next"> <a class="card-footer-item has-text-dark" @click="queue_add_next">
<span class="icon"><i class="mdi mdi-playlist-play"></i></span> <span class="is-size-7">Add Next</span> <span class="icon"><i class="mdi mdi-playlist-play" /></span>
<span class="is-size-7">Add Next</span>
</a> </a>
<a class="card-footer-item has-text-dark" @click="play_track"> <a class="card-footer-item has-text-dark" @click="play_track">
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span> <span class="icon"><i class="mdi mdi-play" /></span>
<span class="is-size-7">Play</span>
</a> </a>
</footer> </footer>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="$emit('close')"
/>
</div> </div>
</transition> </transition>
</div> </div>
@ -107,13 +156,30 @@ export default {
name: 'ModalDialogTrack', name: 'ModalDialogTrack',
props: ['show', 'track'], props: ['show', 'track'],
emits: ['close', 'play-count-changed'],
data () { data() {
return { return {
spotify_track: {} spotify_track: {}
} }
}, },
watch: {
track() {
if (this.track && this.track.data_kind === 'spotify') {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(this.$store.state.spotify.webapi_token)
spotifyApi
.getTrack(this.track.path.slice(this.track.path.lastIndexOf(':') + 1))
.then((response) => {
this.spotify_track = response
})
} else {
this.spotify_track = {}
}
}
},
methods: { methods: {
play_track: function () { play_track: function () {
this.$emit('close') this.$emit('close')
@ -143,7 +209,9 @@ export default {
open_artist: function () { open_artist: function () {
this.$emit('close') this.$emit('close')
this.$router.push({ path: '/music/artists/' + this.track.album_artist_id }) this.$router.push({
path: '/music/artists/' + this.track.album_artist_id
})
}, },
open_genre: function () { open_genre: function () {
@ -152,44 +220,37 @@ export default {
open_spotify_artist: function () { open_spotify_artist: function () {
this.$emit('close') this.$emit('close')
this.$router.push({ path: '/music/spotify/artists/' + this.spotify_track.artists[0].id }) this.$router.push({
path: '/music/spotify/artists/' + this.spotify_track.artists[0].id
})
}, },
open_spotify_album: function () { open_spotify_album: function () {
this.$emit('close') this.$emit('close')
this.$router.push({ path: '/music/spotify/albums/' + this.spotify_track.album.id }) this.$router.push({
path: '/music/spotify/albums/' + this.spotify_track.album.id
})
}, },
mark_new: function () { mark_new: function () {
webapi.library_track_update(this.track.id, { play_count: 'reset' }).then(() => { webapi
this.$emit('play-count-changed') .library_track_update(this.track.id, { play_count: 'reset' })
this.$emit('close') .then(() => {
}) this.$emit('play-count-changed')
this.$emit('close')
})
}, },
mark_played: function () { mark_played: function () {
webapi.library_track_update(this.track.id, { play_count: 'increment' }).then(() => { webapi
this.$emit('play-count-changed') .library_track_update(this.track.id, { play_count: 'increment' })
this.$emit('close') .then(() => {
}) this.$emit('play-count-changed')
} this.$emit('close')
},
watch: {
'track' () {
if (this.track && this.track.data_kind === 'spotify') {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(this.$store.state.spotify.webapi_token)
spotifyApi.getTrack(this.track.path.slice(this.track.path.lastIndexOf(':') + 1)).then((response) => {
this.spotify_track = response
}) })
} else {
this.spotify_track = {}
}
} }
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,29 +1,34 @@
<template> <template>
<modal-dialog <modal-dialog
:show="show" :show="show"
title="Update library" title="Update library"
:ok_action="library.updating ? '' : 'Rescan'" :ok_action="library.updating ? '' : 'Rescan'"
close_action="Close" close_action="Close"
@ok="update_library" @ok="update_library"
@close="close()"> @close="close()"
<template slot="modal-content"> >
<template #modal-content>
<div v-if="!library.updating"> <div v-if="!library.updating">
<p class="mb-3">Scan for new, deleted and modified files</p> <p class="mb-3">Scan for new, deleted and modified files</p>
<div class="field" v-if="spotify_enabled || rss.tracks > 0"> <div v-if="spotify_enabled || rss.tracks > 0" class="field">
<div class="control"> <div class="control">
<div class="select is-small"> <div class="select is-small">
<select v-model="update_dialog_scan_kind"> <select v-model="update_dialog_scan_kind">
<option value="">Update everything</option> <option value="">Update everything</option>
<option value="files">Only update local library</option> <option value="files">Only update local library</option>
<option value="spotify" v-if="spotify_enabled">Only update Spotify</option> <option v-if="spotify_enabled" value="spotify">
<option value="rss" v-if="rss.tracks > 0">Only update RSS feeds</option> Only update Spotify
</option>
<option v-if="rss.tracks > 0" value="rss">
Only update RSS feeds
</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="checkbox is-size-7 is-small"> <label class="checkbox is-size-7 is-small">
<input type="checkbox" v-model="rescan_metadata"> <input v-model="rescan_metadata" type="checkbox" />
Rescan metadata for unmodified files Rescan metadata for unmodified files
</label> </label>
</div> </div>
@ -36,7 +41,7 @@
</template> </template>
<script> <script>
import ModalDialog from '@/components/ModalDialog' import ModalDialog from '@/components/ModalDialog.vue'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
import webapi from '@/webapi' import webapi from '@/webapi'
@ -44,38 +49,39 @@ export default {
name: 'ModalDialogUpdate', name: 'ModalDialogUpdate',
components: { ModalDialog }, components: { ModalDialog },
props: ['show'], props: ['show'],
emits: ['close'],
data () { data() {
return { return {
rescan_metadata: false rescan_metadata: false
} }
}, },
computed: { computed: {
library () { library() {
return this.$store.state.library return this.$store.state.library
}, },
rss () { rss() {
return this.$store.state.rss_count return this.$store.state.rss_count
}, },
spotify_enabled () { spotify_enabled() {
return this.$store.state.spotify.webapi_token_valid return this.$store.state.spotify.webapi_token_valid
}, },
update_dialog_scan_kind: { update_dialog_scan_kind: {
get () { get() {
return this.$store.state.update_dialog_scan_kind return this.$store.state.update_dialog_scan_kind
}, },
set (value) { set(value) {
this.$store.commit(types.UPDATE_DIALOG_SCAN_KIND, value) this.$store.commit(types.UPDATE_DIALOG_SCAN_KIND, value)
} }
} }
}, },
methods: { methods: {
update_library () { update_library() {
if (this.rescan_metadata) { if (this.rescan_metadata) {
webapi.library_rescan(this.update_dialog_scan_kind) webapi.library_rescan(this.update_dialog_scan_kind)
} else { } else {
@ -83,7 +89,7 @@ export default {
} }
}, },
close () { close() {
this.update_dialog_scan_kind = '' this.update_dialog_scan_kind = ''
this.$emit('close') this.$emit('close')
} }
@ -91,5 +97,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,66 +1,148 @@
<template> <template>
<nav class="fd-bottom-navbar navbar is-white is-fixed-bottom" :style="zindex" :class="{ 'is-transparent': is_now_playing_page, 'is-dark': !is_now_playing_page }" role="navigation" aria-label="player controls"> <nav
class="fd-bottom-navbar navbar is-white is-fixed-bottom"
:style="zindex"
:class="{
'is-transparent': is_now_playing_page,
'is-dark': !is_now_playing_page
}"
role="navigation"
aria-label="player controls"
>
<div class="navbar-brand fd-expanded"> <div class="navbar-brand fd-expanded">
<!-- Link to queue --> <!-- Link to queue -->
<navbar-item-link to="/" exact> <navbar-item-link to="/" exact>
<span class="icon"><i class="mdi mdi-24px mdi-playlist-play"></i></span> <span class="icon"><i class="mdi mdi-24px mdi-playlist-play" /></span>
</navbar-item-link> </navbar-item-link>
<!-- Now playing artist/title (not visible on "now playing" page) --> <!-- Now playing artist/title (not visible on "now playing" page) -->
<router-link to="/now-playing" v-if="!is_now_playing_page" class="navbar-item is-expanded is-clipped" active-class="is-active" exact> <router-link
v-if="!is_now_playing_page"
to="/now-playing"
class="navbar-item is-expanded is-clipped"
active-class="is-active"
exact
>
<div class="is-clipped"> <div class="is-clipped">
<p class="is-size-7 fd-is-text-clipped"> <p class="is-size-7 fd-is-text-clipped">
<strong>{{ now_playing.title }}</strong><br> <strong>{{ now_playing.title }}</strong
{{ now_playing.artist }}<span v-if="now_playing.data_kind === 'url'"> - {{ now_playing.album }}</span> ><br />
{{ now_playing.artist
}}<span v-if="now_playing.data_kind === 'url'">
- {{ now_playing.album }}</span
>
</p> </p>
</div> </div>
</router-link> </router-link>
<!-- Skip previous (not visible on "now playing" page) --> <!-- Skip previous (not visible on "now playing" page) -->
<player-button-previous v-if="is_now_playing_page" class="navbar-item fd-margin-left-auto" icon_style="mdi-24px"></player-button-previous> <player-button-previous
<player-button-seek-back v-if="is_now_playing_page" seek_ms="10000" class="navbar-item" icon_style="mdi-24px"></player-button-seek-back> v-if="is_now_playing_page"
class="navbar-item fd-margin-left-auto"
icon_style="mdi-24px"
/>
<player-button-seek-back
v-if="is_now_playing_page"
seek_ms="10000"
class="navbar-item"
icon_style="mdi-24px"
/>
<!-- Play/pause --> <!-- Play/pause -->
<player-button-play-pause class="navbar-item" icon_style="mdi-36px" show_disabled_message></player-button-play-pause> <player-button-play-pause
<player-button-seek-forward v-if="is_now_playing_page" seek_ms="30000" class="navbar-item" icon_style="mdi-24px"></player-button-seek-forward> class="navbar-item"
icon_style="mdi-36px"
show_disabled_message
/>
<player-button-seek-forward
v-if="is_now_playing_page"
seek_ms="30000"
class="navbar-item"
icon_style="mdi-24px"
/>
<!-- Skip next (not visible on "now playing" page) --> <!-- Skip next (not visible on "now playing" page) -->
<player-button-next v-if="is_now_playing_page" class="navbar-item" icon_style="mdi-24px"></player-button-next> <player-button-next
v-if="is_now_playing_page"
class="navbar-item"
icon_style="mdi-24px"
/>
<!-- Player menu button (only visible on mobile and tablet) --> <!-- Player menu button (only visible on mobile and tablet) -->
<a class="navbar-item fd-margin-left-auto is-hidden-desktop" @click="show_player_menu = !show_player_menu"> <a
<span class="icon"><i class="mdi mdi-18px" :class="{ 'mdi-chevron-up': !show_player_menu, 'mdi-chevron-down': show_player_menu }"></i></span> class="navbar-item fd-margin-left-auto is-hidden-desktop"
@click="show_player_menu = !show_player_menu"
>
<span class="icon"
><i
class="mdi mdi-18px"
:class="{
'mdi-chevron-up': !show_player_menu,
'mdi-chevron-down': show_player_menu
}"
/></span>
</a> </a>
<!-- Player menu dropup menu (only visible on desktop) --> <!-- Player menu dropup menu (only visible on desktop) -->
<div class="navbar-item has-dropdown has-dropdown-up fd-margin-left-auto is-hidden-touch" <div
:class="{ 'is-active': show_player_menu }"> class="navbar-item has-dropdown has-dropdown-up fd-margin-left-auto is-hidden-touch"
<a class="navbar-link is-arrowless" :class="{ 'is-active': show_player_menu }"
@click="show_player_menu = !show_player_menu"> >
<span class="icon"><i class="mdi mdi-18px" <a
:class="{ 'mdi-chevron-up': !show_player_menu, 'mdi-chevron-down': show_player_menu }"></i></span> class="navbar-link is-arrowless"
@click="show_player_menu = !show_player_menu"
>
<span class="icon"
><i
class="mdi mdi-18px"
:class="{
'mdi-chevron-up': !show_player_menu,
'mdi-chevron-down': show_player_menu
}"
/></span>
</a> </a>
<div class="navbar-dropdown is-right is-boxed" style="margin-right: 6px; margin-bottom: 6px; border-radius: 6px;"> <div
class="navbar-dropdown is-right is-boxed"
style="margin-right: 6px; margin-bottom: 6px; border-radius: 6px"
>
<div class="navbar-item"> <div class="navbar-item">
<!-- Outputs: master volume --> <!-- Outputs: master volume -->
<div class="level is-mobile"> <div class="level is-mobile">
<div class="level-left fd-expanded"> <div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;"> <div class="level-item" style="flex-grow: 0">
<a class="button is-white is-small" @click="toggle_mute_volume"> <a
<span class="icon"><i class="mdi mdi-18px" :class="{ 'mdi-volume-off': player.volume <= 0, 'mdi-volume-high': player.volume > 0 }"></i></span> class="button is-white is-small"
@click="toggle_mute_volume"
>
<span class="icon"
><i
class="mdi mdi-18px"
:class="{
'mdi-volume-off': player.volume <= 0,
'mdi-volume-high': player.volume > 0
}"
/></span>
</a> </a>
</div> </div>
<div class="level-item fd-expanded"> <div class="level-item fd-expanded">
<div class="fd-expanded"> <div class="fd-expanded">
<p class="heading">Volume</p> <p class="heading">Volume</p>
<range-slider <Slider
v-model="player.volume"
:min="0"
:max="100"
:step="1"
:tooltips="false"
:classes="{ target: 'slider' }"
@change="set_volume"
/>
<!--range-slider
class="slider fd-has-action" class="slider fd-has-action"
min="0" min="0"
max="100" max="100"
step="1" step="1"
:value="player.volume" :value="player.volume"
@change="set_volume"> @change="set_volume">
</range-slider> </range-slider-->
</div> </div>
</div> </div>
</div> </div>
@ -68,21 +150,54 @@
</div> </div>
<!-- Outputs: master volume --> <!-- Outputs: master volume -->
<hr class="fd-navbar-divider"> <hr class="fd-navbar-divider" />
<navbar-item-output v-for="output in outputs" :key="output.id" :output="output"></navbar-item-output> <navbar-item-output
v-for="output in outputs"
:key="output.id"
:output="output"
/>
<!-- Outputs: stream volume --> <!-- Outputs: stream volume -->
<hr class="fd-navbar-divider"> <hr class="fd-navbar-divider" />
<div class="navbar-item"> <div class="navbar-item">
<div class="level is-mobile"> <div class="level is-mobile">
<div class="level-left fd-expanded"> <div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;"> <div class="level-item" style="flex-grow: 0">
<a class="button is-white is-small" :class="{ 'is-loading': loading }"><span class="icon fd-has-action" :class="{ 'has-text-grey-light': !playing && !loading, 'is-loading': loading }" @click="togglePlay"><i class="mdi mdi-18px mdi-radio-tower"></i></span></a> <a
class="button is-white is-small"
:class="{ 'is-loading': loading }"
><span
class="icon fd-has-action"
:class="{
'has-text-grey-light': !playing && !loading,
'is-loading': loading
}"
@click="togglePlay"
><i class="mdi mdi-18px mdi-radio-tower" /></span
></a>
</div> </div>
<div class="level-item fd-expanded"> <div class="level-item fd-expanded">
<div class="fd-expanded"> <div class="fd-expanded">
<p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p> <p
<range-slider class="heading"
:class="{ 'has-text-grey-light': !playing }"
>
HTTP stream
<a href="stream.mp3"
><span class="is-lowercase">(stream.mp3)</span></a
>
</p>
<Slider
v-model="stream_volume"
:min="0"
:max="100"
:step="1"
:tooltips="false"
:disabled="!playing"
:classes="{ target: 'slider' }"
@change="set_stream_volume"
/>
<!--range-slider
class="slider fd-has-action" class="slider fd-has-action"
min="0" min="0"
max="100" max="100"
@ -90,7 +205,7 @@
:disabled="!playing" :disabled="!playing"
:value="stream_volume" :value="stream_volume"
@change="set_stream_volume"> @change="set_stream_volume">
</range-slider> </range-slider-->
</div> </div>
</div> </div>
</div> </div>
@ -98,14 +213,14 @@
</div> </div>
<!-- Playback controls --> <!-- Playback controls -->
<hr class="fd-navbar-divider"> <hr class="fd-navbar-divider" />
<div class="navbar-item"> <div class="navbar-item">
<div class="level is-mobile fd-expanded"> <div class="level is-mobile fd-expanded">
<div class="level-item"> <div class="level-item">
<div class="buttons has-addons"> <div class="buttons has-addons">
<player-button-repeat class="button"></player-button-repeat> <player-button-repeat class="button" />
<player-button-shuffle class="button"></player-button-shuffle> <player-button-shuffle class="button" />
<player-button-consume class="button"></player-button-consume> <player-button-consume class="button" />
</div> </div>
</div> </div>
</div> </div>
@ -115,41 +230,59 @@
</div> </div>
<!-- Player menu (only visible on mobile and tablet) --> <!-- Player menu (only visible on mobile and tablet) -->
<div class="navbar-menu is-hidden-desktop" :class="{ 'is-active': show_player_menu }"> <div
<div class="navbar-start"> class="navbar-menu is-hidden-desktop"
</div> :class="{ 'is-active': show_player_menu }"
>
<div class="navbar-start" />
<div class="navbar-end"> <div class="navbar-end">
<!-- Repeat/shuffle/consume --> <!-- Repeat/shuffle/consume -->
<div class="navbar-item"> <div class="navbar-item">
<div class="buttons is-centered"> <div class="buttons is-centered">
<player-button-repeat class="button" icon_style="mdi-18px"></player-button-repeat> <player-button-repeat class="button" icon_style="mdi-18px" />
<player-button-shuffle class="button" icon_style="mdi-18px"></player-button-shuffle> <player-button-shuffle class="button" icon_style="mdi-18px" />
<player-button-consume class="button" icon_style="mdi-18px"></player-button-consume> <player-button-consume class="button" icon_style="mdi-18px" />
</div> </div>
</div> </div>
<hr class="fd-navbar-divider"> <hr class="fd-navbar-divider" />
<!-- Outputs: master volume --> <!-- Outputs: master volume -->
<div class="navbar-item"> <div class="navbar-item">
<div class="level is-mobile"> <div class="level is-mobile">
<div class="level-left fd-expanded"> <div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;"> <div class="level-item" style="flex-grow: 0">
<a class="button is-white is-small" @click="toggle_mute_volume"> <a class="button is-white is-small" @click="toggle_mute_volume">
<span class="icon"><i class="mdi mdi-18px" :class="{ 'mdi-volume-off': player.volume <= 0, 'mdi-volume-high': player.volume > 0 }"></i></span> <span class="icon"
><i
class="mdi mdi-18px"
:class="{
'mdi-volume-off': player.volume <= 0,
'mdi-volume-high': player.volume > 0
}"
/></span>
</a> </a>
</div> </div>
<div class="level-item fd-expanded"> <div class="level-item fd-expanded">
<div class="fd-expanded"> <div class="fd-expanded">
<p class="heading">Volume</p> <p class="heading">Volume</p>
<range-slider <Slider
v-model="player.volume"
:min="0"
:max="100"
:step="1"
:tooltips="false"
:classes="{ target: 'slider' }"
@change="set_volume"
/>
<!--range-slider
class="slider fd-has-action" class="slider fd-has-action"
min="0" min="0"
max="100" max="100"
step="1" step="1"
:value="player.volume" :value="player.volume"
@change="set_volume"> @change="set_volume">
</range-slider> </range-slider-->
</div> </div>
</div> </div>
</div> </div>
@ -157,25 +290,55 @@
</div> </div>
<!-- Outputs: speaker volumes --> <!-- Outputs: speaker volumes -->
<navbar-item-output v-for="output in outputs" :key="output.id" :output="output"></navbar-item-output> <navbar-item-output
v-for="output in outputs"
:key="output.id"
:output="output"
/>
<!-- Outputs: stream volume --> <!-- Outputs: stream volume -->
<hr class="fd-navbar-divider"> <hr class="fd-navbar-divider" />
<div class="navbar-item fd-has-margin-bottom"> <div class="navbar-item fd-has-margin-bottom">
<div class="level is-mobile"> <div class="level is-mobile">
<div class="level-left fd-expanded"> <div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;"> <div class="level-item" style="flex-grow: 0">
<a class="button is-white is-small" :class="{ 'is-loading': loading }"> <a
<span class="icon fd-has-action" class="button is-white is-small"
:class="{ 'has-text-grey-light': !playing && !loading, 'is-loading': loading }" :class="{ 'is-loading': loading }"
@click="togglePlay"><i class="mdi mdi-18px mdi-radio-tower"></i> >
<span
class="icon fd-has-action"
:class="{
'has-text-grey-light': !playing && !loading,
'is-loading': loading
}"
@click="togglePlay"
><i class="mdi mdi-18px mdi-radio-tower" />
</span> </span>
</a> </a>
</div> </div>
<div class="level-item fd-expanded"> <div class="level-item fd-expanded">
<div class="fd-expanded"> <div class="fd-expanded">
<p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p> <p
<range-slider class="heading"
:class="{ 'has-text-grey-light': !playing }"
>
HTTP stream
<a href="stream.mp3"
><span class="is-lowercase">(stream.mp3)</span></a
>
</p>
<Slider
v-model="stream_volume"
:min="0"
:max="100"
:step="1"
:tooltips="false"
:disabled="!playing"
:classes="{ target: 'slider' }"
@change="set_stream_volume"
/>
<!-- range-slider
class="slider fd-has-action" class="slider fd-has-action"
min="0" min="0"
max="100" max="100"
@ -183,7 +346,7 @@
:disabled="!playing" :disabled="!playing"
:value="stream_volume" :value="stream_volume"
@change="set_stream_volume"> @change="set_stream_volume">
</range-slider> </range-slider-->
</div> </div>
</div> </div>
</div> </div>
@ -197,17 +360,18 @@
<script> <script>
import webapi from '@/webapi' import webapi from '@/webapi'
import _audio from '@/audio' import _audio from '@/audio'
import NavbarItemLink from './NavbarItemLink' import NavbarItemLink from './NavbarItemLink.vue'
import NavbarItemOutput from './NavbarItemOutput' import NavbarItemOutput from './NavbarItemOutput.vue'
import PlayerButtonPlayPause from '@/components/PlayerButtonPlayPause' import PlayerButtonPlayPause from '@/components/PlayerButtonPlayPause.vue'
import PlayerButtonNext from '@/components/PlayerButtonNext' import PlayerButtonNext from '@/components/PlayerButtonNext.vue'
import PlayerButtonPrevious from '@/components/PlayerButtonPrevious' import PlayerButtonPrevious from '@/components/PlayerButtonPrevious.vue'
import PlayerButtonShuffle from '@/components/PlayerButtonShuffle' import PlayerButtonShuffle from '@/components/PlayerButtonShuffle.vue'
import PlayerButtonConsume from '@/components/PlayerButtonConsume' import PlayerButtonConsume from '@/components/PlayerButtonConsume.vue'
import PlayerButtonRepeat from '@/components/PlayerButtonRepeat' import PlayerButtonRepeat from '@/components/PlayerButtonRepeat.vue'
import PlayerButtonSeekBack from '@/components/PlayerButtonSeekBack' import PlayerButtonSeekBack from '@/components/PlayerButtonSeekBack.vue'
import PlayerButtonSeekForward from '@/components/PlayerButtonSeekForward' import PlayerButtonSeekForward from '@/components/PlayerButtonSeekForward.vue'
import RangeSlider from 'vue-range-slider' //import RangeSlider from 'vue-range-slider'
import Slider from '@vueform/slider'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
export default { export default {
@ -215,7 +379,8 @@ export default {
components: { components: {
NavbarItemLink, NavbarItemLink,
NavbarItemOutput, NavbarItemOutput,
RangeSlider, //RangeSlider,
Slider,
PlayerButtonPlayPause, PlayerButtonPlayPause,
PlayerButtonNext, PlayerButtonNext,
PlayerButtonPrevious, PlayerButtonPrevious,
@ -226,7 +391,7 @@ export default {
PlayerButtonSeekBack PlayerButtonSeekBack
}, },
data () { data() {
return { return {
old_volume: 0, old_volume: 0,
@ -241,49 +406,67 @@ export default {
computed: { computed: {
show_player_menu: { show_player_menu: {
get () { get() {
return this.$store.state.show_player_menu return this.$store.state.show_player_menu
}, },
set (value) { set(value) {
this.$store.commit(types.SHOW_PLAYER_MENU, value) this.$store.commit(types.SHOW_PLAYER_MENU, value)
} }
}, },
show_burger_menu () { show_burger_menu() {
return this.$store.state.show_burger_menu return this.$store.state.show_burger_menu
}, },
zindex () { zindex() {
if (this.show_burger_menu) { if (this.show_burger_menu) {
return 'z-index: 20' return 'z-index: 20'
} }
return '' return ''
}, },
state () { state() {
return this.$store.state.player return this.$store.state.player
}, },
now_playing () { now_playing() {
return this.$store.getters.now_playing return this.$store.getters.now_playing
}, },
is_now_playing_page () { is_now_playing_page() {
return this.$route.path === '/now-playing' return this.$route.path === '/now-playing'
}, },
outputs () { outputs() {
return this.$store.state.outputs return this.$store.state.outputs
}, },
player () { player() {
return this.$store.state.player return this.$store.state.player
}, },
config () { config() {
return this.$store.state.config return this.$store.state.config
} }
}, },
watch: {
'$store.state.player.volume'() {
if (this.player.volume > 0) {
this.old_volume = this.player.volume
}
}
},
// on app mounted
mounted() {
this.setupAudio()
},
// on app destroyed
unmounted() {
this.closeAudio()
},
methods: { methods: {
on_click_outside_outputs () { on_click_outside_outputs() {
this.show_outputs_menu = false this.show_outputs_menu = false
}, },
@ -302,21 +485,24 @@ export default {
setupAudio: function () { setupAudio: function () {
const a = _audio.setupAudio() const a = _audio.setupAudio()
a.addEventListener('waiting', e => { a.addEventListener('waiting', (e) => {
this.playing = false this.playing = false
this.loading = true this.loading = true
}) })
a.addEventListener('playing', e => { a.addEventListener('playing', (e) => {
this.playing = true this.playing = true
this.loading = false this.loading = false
}) })
a.addEventListener('ended', e => { a.addEventListener('ended', (e) => {
this.playing = false this.playing = false
this.loading = false this.loading = false
}) })
a.addEventListener('error', e => { a.addEventListener('error', (e) => {
this.closeAudio() this.closeAudio()
this.$store.dispatch('add_notification', { text: 'HTTP stream error: failed to load stream or stopped loading due to network problem', type: 'danger' }) this.$store.dispatch('add_notification', {
text: 'HTTP stream error: failed to load stream or stopped loading due to network problem',
type: 'danger'
})
this.playing = false this.playing = false
this.loading = false this.loading = false
}) })
@ -353,27 +539,8 @@ export default {
this.stream_volume = newVolume this.stream_volume = newVolume
_audio.setVolume(this.stream_volume / 100) _audio.setVolume(this.stream_volume / 100)
} }
},
watch: {
'$store.state.player.volume' () {
if (this.player.volume > 0) {
this.old_volume = this.player.volume
}
}
},
// on app mounted
mounted () {
this.setupAudio()
},
// on app destroyed
destroyed () {
this.closeAudio()
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,6 +1,11 @@
<template> <template>
<a class="navbar-item" :class="{ 'is-active': is_active }" @click.stop.prevent="open_link()" :href="full_path()"> <a
<slot></slot> class="navbar-item"
:class="{ 'is-active': is_active }"
:href="full_path()"
@click.stop.prevent="open_link()"
>
<slot />
</a> </a>
</template> </template>
@ -15,7 +20,7 @@ export default {
}, },
computed: { computed: {
is_active () { is_active() {
if (this.exact) { if (this.exact) {
return this.$route.path === this.to return this.$route.path === this.to
} }
@ -23,19 +28,19 @@ export default {
}, },
show_player_menu: { show_player_menu: {
get () { get() {
return this.$store.state.show_player_menu return this.$store.state.show_player_menu
}, },
set (value) { set(value) {
this.$store.commit(types.SHOW_PLAYER_MENU, value) this.$store.commit(types.SHOW_PLAYER_MENU, value)
} }
}, },
show_burger_menu: { show_burger_menu: {
get () { get() {
return this.$store.state.show_burger_menu return this.$store.state.show_burger_menu
}, },
set (value) { set(value) {
this.$store.commit(types.SHOW_BURGER_MENU, value) this.$store.commit(types.SHOW_BURGER_MENU, value)
} }
} }

View File

@ -2,19 +2,40 @@
<div class="navbar-item"> <div class="navbar-item">
<div class="level is-mobile"> <div class="level is-mobile">
<div class="level-left fd-expanded"> <div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;"> <div class="level-item" style="flex-grow: 0">
<a class="button is-white is-small"> <a class="button is-white is-small">
<span class="icon fd-has-action" <span
:class="{ 'has-text-grey-light': !output.selected }" class="icon fd-has-action"
v-on:click="set_enabled"> :class="{ 'has-text-grey-light': !output.selected }"
<i class="mdi mdi-18px" :class="type_class" :title="output.type"></i> @click="set_enabled"
>
<i
class="mdi mdi-18px"
:class="type_class"
:title="output.type"
/>
</span> </span>
</a> </a>
</div> </div>
<div class="level-item fd-expanded"> <div class="level-item fd-expanded">
<div class="fd-expanded"> <div class="fd-expanded">
<p class="heading" :class="{ 'has-text-grey-light': !output.selected }">{{ output.name }}</p> <p
<range-slider class="heading"
:class="{ 'has-text-grey-light': !output.selected }"
>
{{ output.name }}
</p>
<Slider
v-model="volume"
:min="0"
:max="100"
:step="1"
:tooltips="false"
:disabled="!output.selected"
:classes="{ target: 'slider' }"
@change="set_volume"
/>
<!--range-slider
class="slider fd-has-action" class="slider fd-has-action"
min="0" min="0"
max="100" max="100"
@ -22,7 +43,7 @@
:disabled="!output.selected" :disabled="!output.selected"
:value="volume" :value="volume"
@change="set_volume" > @change="set_volume" >
</range-slider> </range-slider-->
</div> </div>
</div> </div>
</div> </div>
@ -31,17 +52,21 @@
</template> </template>
<script> <script>
import RangeSlider from 'vue-range-slider' //import RangeSlider from 'vue-range-slider'
import Slider from '@vueform/slider'
import webapi from '@/webapi' import webapi from '@/webapi'
export default { export default {
name: 'NavbarItemOutput', name: 'NavbarItemOutput',
components: { RangeSlider }, components: {
// RangeSlider
Slider
},
props: ['output'], props: ['output'],
computed: { computed: {
type_class () { type_class() {
if (this.output.type.startsWith('AirPlay')) { if (this.output.type.startsWith('AirPlay')) {
return 'mdi-airplay' return 'mdi-airplay'
} else if (this.output.type === 'Chromecast') { } else if (this.output.type === 'Chromecast') {
@ -53,7 +78,7 @@ export default {
} }
}, },
volume () { volume() {
return this.output.selected ? this.output.volume : 0 return this.output.selected ? this.output.volume : 0
} }
}, },
@ -77,5 +102,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,167 +1,235 @@
<template> <template>
<nav class="fd-top-navbar navbar is-light is-fixed-top" :style="zindex" role="navigation" aria-label="main navigation"> <nav
class="fd-top-navbar navbar is-light is-fixed-top"
:style="zindex"
role="navigation"
aria-label="main navigation"
>
<div class="navbar-brand"> <div class="navbar-brand">
<navbar-item-link to="/playlists" v-if="is_visible_playlists"> <navbar-item-link v-if="is_visible_playlists" to="/playlists">
<span class="icon"><i class="mdi mdi-library-music"></i></span> <span class="icon"><i class="mdi mdi-library-music" /></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/music" v-if="is_visible_music"> <navbar-item-link v-if="is_visible_music" to="/music">
<span class="icon"><i class="mdi mdi-music"></i></span> <span class="icon"><i class="mdi mdi-music" /></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/podcasts" v-if="is_visible_podcasts"> <navbar-item-link v-if="is_visible_podcasts" to="/podcasts">
<span class="icon"><i class="mdi mdi-microphone"></i></span> <span class="icon"><i class="mdi mdi-microphone" /></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/audiobooks" v-if="is_visible_audiobooks"> <navbar-item-link v-if="is_visible_audiobooks" to="/audiobooks">
<span class="icon"><i class="mdi mdi-book-open-variant"></i></span> <span class="icon"><i class="mdi mdi-book-open-variant" /></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/radio" v-if="is_visible_radio"> <navbar-item-link v-if="is_visible_radio" to="/radio">
<span class="icon"><i class="mdi mdi-radio"></i></span> <span class="icon"><i class="mdi mdi-radio" /></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/files" v-if="is_visible_files"> <navbar-item-link v-if="is_visible_files" to="/files">
<span class="icon"><i class="mdi mdi-folder-open"></i></span> <span class="icon"><i class="mdi mdi-folder-open" /></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/search" v-if="is_visible_search"> <navbar-item-link v-if="is_visible_search" to="/search">
<span class="icon"><i class="mdi mdi-magnify"></i></span> <span class="icon"><i class="mdi mdi-magnify" /></span>
</navbar-item-link> </navbar-item-link>
<div class="navbar-burger" @click="show_burger_menu = !show_burger_menu" :class="{ 'is-active': show_burger_menu }"> <div
<span></span> class="navbar-burger"
<span></span> :class="{ 'is-active': show_burger_menu }"
<span></span> @click="show_burger_menu = !show_burger_menu"
>
<span />
<span />
<span />
</div> </div>
</div> </div>
<div class="navbar-menu" :class="{ 'is-active': show_burger_menu }"> <div class="navbar-menu" :class="{ 'is-active': show_burger_menu }">
<div class="navbar-start"> <div class="navbar-start" />
</div>
<div class="navbar-end"> <div class="navbar-end">
<!-- Burger menu entries --> <!-- Burger menu entries -->
<div class="navbar-item has-dropdown is-hoverable" <div
:class="{ 'is-active': show_settings_menu }" class="navbar-item has-dropdown is-hoverable"
@click="on_click_outside_settings"> :class="{ 'is-active': show_settings_menu }"
@click="on_click_outside_settings"
>
<a class="navbar-link is-arrowless"> <a class="navbar-link is-arrowless">
<span class="icon is-hidden-touch"><i class="mdi mdi-24px mdi-menu"></i></span> <span class="icon is-hidden-touch"
><i class="mdi mdi-24px mdi-menu"
/></span>
<span class="is-hidden-desktop has-text-weight-bold">OwnTone</span> <span class="is-hidden-desktop has-text-weight-bold">OwnTone</span>
</a> </a>
<div class="navbar-dropdown is-right"> <div class="navbar-dropdown is-right">
<navbar-item-link to="/playlists">
<span class="icon"><i class="mdi mdi-library-music" /></span>
<b>Playlists</b>
</navbar-item-link>
<navbar-item-link to="/music" exact>
<span class="icon"><i class="mdi mdi-music" /></span>
<b>Music</b>
</navbar-item-link>
<navbar-item-link to="/music/artists">
<span class="fd-navbar-item-level2">Artists</span>
</navbar-item-link>
<navbar-item-link to="/music/albums">
<span class="fd-navbar-item-level2">Albums</span>
</navbar-item-link>
<navbar-item-link to="/music/genres">
<span class="fd-navbar-item-level2">Genres</span>
</navbar-item-link>
<navbar-item-link v-if="spotify_enabled" to="/music/spotify">
<span class="fd-navbar-item-level2">Spotify</span>
</navbar-item-link>
<navbar-item-link to="/podcasts">
<span class="icon"><i class="mdi mdi-microphone" /></span>
<b>Podcasts</b>
</navbar-item-link>
<navbar-item-link to="/audiobooks">
<span class="icon"><i class="mdi mdi-book-open-variant" /></span>
<b>Audiobooks</b>
</navbar-item-link>
<navbar-item-link to="/radio">
<span class="icon"><i class="mdi mdi-radio" /></span>
<b>Radio</b>
</navbar-item-link>
<navbar-item-link to="/files">
<span class="icon"><i class="mdi mdi-folder-open" /></span>
<b>Files</b>
</navbar-item-link>
<navbar-item-link to="/search">
<span class="icon"><i class="mdi mdi-magnify" /></span>
<b>Search</b>
</navbar-item-link>
<hr class="fd-navbar-divider" />
<navbar-item-link to="/playlists"><span class="icon"><i class="mdi mdi-library-music"></i></span> <b>Playlists</b></navbar-item-link> <navbar-item-link to="/settings/webinterface">
<navbar-item-link to="/music" exact><span class="icon"><i class="mdi mdi-music"></i></span> <b>Music</b></navbar-item-link> Settings
<navbar-item-link to="/music/artists"><span class="fd-navbar-item-level2">Artists</span></navbar-item-link> </navbar-item-link>
<navbar-item-link to="/music/albums"><span class="fd-navbar-item-level2">Albums</span></navbar-item-link> <a class="navbar-item" @click.stop.prevent="open_update_dialog()">
<navbar-item-link to="/music/genres"><span class="fd-navbar-item-level2">Genres</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>
<a class="navbar-item" @click.stop.prevent="show_update_dialog = true; show_settings_menu = false; show_burger_menu = false">
Update Library Update Library
</a> </a>
<navbar-item-link to="/about">About</navbar-item-link> <navbar-item-link to="/about"> About </navbar-item-link>
<div class="navbar-item is-hidden-desktop" style="margin-bottom: 2.5rem;"></div> <div
class="navbar-item is-hidden-desktop"
style="margin-bottom: 2.5rem"
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="is-overlay" v-show="show_settings_menu" <div
style="z-index:10; width: 100vw; height:100vh;" v-show="show_settings_menu"
@click="show_settings_menu = false"></div> class="is-overlay"
style="z-index: 10; width: 100vw; height: 100vh"
@click="show_settings_menu = false"
/>
</nav> </nav>
</template> </template>
<script> <script>
import NavbarItemLink from './NavbarItemLink' import NavbarItemLink from './NavbarItemLink.vue'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
export default { export default {
name: 'NavbarTop', name: 'NavbarTop',
components: { NavbarItemLink }, components: { NavbarItemLink },
data () { data() {
return { return {
show_settings_menu: false show_settings_menu: false
} }
}, },
computed: { computed: {
is_visible_playlists () { is_visible_playlists() {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_playlists').value return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_playlists'
).value
}, },
is_visible_music () { is_visible_music() {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_music').value return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_music'
).value
}, },
is_visible_podcasts () { is_visible_podcasts() {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_podcasts').value return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_podcasts'
).value
}, },
is_visible_audiobooks () { is_visible_audiobooks() {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_audiobooks').value return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_audiobooks'
).value
}, },
is_visible_radio () { is_visible_radio() {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_radio').value return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_radio'
).value
}, },
is_visible_files () { is_visible_files() {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_files').value return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_files'
).value
}, },
is_visible_search () { is_visible_search() {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_search').value return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_search'
).value
}, },
player () { player() {
return this.$store.state.player return this.$store.state.player
}, },
config () { config() {
return this.$store.state.config return this.$store.state.config
}, },
library () { library() {
return this.$store.state.library return this.$store.state.library
}, },
audiobooks () { audiobooks() {
return this.$store.state.audiobooks_count return this.$store.state.audiobooks_count
}, },
podcasts () { podcasts() {
return this.$store.state.podcasts_count return this.$store.state.podcasts_count
}, },
spotify_enabled () { spotify_enabled() {
return this.$store.state.spotify.webapi_token_valid return this.$store.state.spotify.webapi_token_valid
}, },
show_burger_menu: { show_burger_menu: {
get () { get() {
return this.$store.state.show_burger_menu return this.$store.state.show_burger_menu
}, },
set (value) { set(value) {
this.$store.commit(types.SHOW_BURGER_MENU, value) this.$store.commit(types.SHOW_BURGER_MENU, value)
} }
}, },
show_player_menu () { show_player_menu() {
return this.$store.state.show_player_menu return this.$store.state.show_player_menu
}, },
show_update_dialog: { show_update_dialog: {
get () { get() {
return this.$store.state.show_update_dialog return this.$store.state.show_update_dialog
}, },
set (value) { set(value) {
this.$store.commit(types.SHOW_UPDATE_DIALOG, value) this.$store.commit(types.SHOW_UPDATE_DIALOG, value)
} }
}, },
zindex () { zindex() {
if (this.show_player_menu) { if (this.show_player_menu) {
return 'z-index: 20' return 'z-index: 20'
} }
@ -169,19 +237,24 @@ export default {
} }
}, },
methods: { watch: {
on_click_outside_settings () { $route(to, from) {
this.show_settings_menu = !this.show_settings_menu this.show_settings_menu = false
} }
}, },
watch: { methods: {
$route (to, from) { on_click_outside_settings() {
this.show_settings_menu = !this.show_settings_menu
},
open_update_dialog() {
this.show_update_dialog = true
this.show_settings_menu = false this.show_settings_menu = false
this.show_burger_menu = false
} }
} }
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,9 +1,17 @@
<template> <template>
<section class="fd-notifications" v-if="notifications.length > 0"> <section v-if="notifications.length > 0" class="fd-notifications">
<div class="columns is-centered"> <div class="columns is-centered">
<div class="column is-half"> <div class="column is-half">
<div class="notification has-shadow " v-for="notification in notifications" :key="notification.id" :class="['notification', notification.type ? `is-${notification.type}` : '']"> <div
<button class="delete" v-on:click="remove(notification)"></button> v-for="notification in notifications"
:key="notification.id"
class="notification has-shadow"
:class="[
'notification',
notification.type ? `is-${notification.type}` : ''
]"
>
<button class="delete" @click="remove(notification)" />
{{ notification.text }} {{ notification.text }}
</div> </div>
</div> </div>
@ -15,15 +23,15 @@
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
export default { export default {
name: 'Notifications', name: 'NotificationList',
components: { }, components: {},
data () { data() {
return { showNav: false } return { showNav: false }
}, },
computed: { computed: {
notifications () { notifications() {
return this.$store.state.notifications.list return this.$store.state.notifications.list
} }
}, },

View File

@ -1,6 +1,6 @@
<template> <template>
<a @click="toggle_consume_mode" :class="{ 'is-warning': is_consume }"> <a :class="{ 'is-warning': is_consume }" @click="toggle_consume_mode">
<span class="icon"><i class="mdi mdi-fire" :class="icon_style"></i></span> <span class="icon"><i class="mdi mdi-fire" :class="icon_style" /></span>
</a> </a>
</template> </template>
@ -15,7 +15,7 @@ export default {
}, },
computed: { computed: {
is_consume () { is_consume() {
return this.$store.state.player.consume return this.$store.state.player.consume
} }
}, },
@ -28,5 +28,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,6 +1,8 @@
<template> <template>
<a @click="play_next" :disabled="disabled"> <a :disabled="disabled" @click="play_next">
<span class="icon"><i class="mdi mdi-skip-forward" :class="icon_style"></i></span> <span class="icon"
><i class="mdi mdi-skip-forward" :class="icon_style"
/></span>
</a> </a>
</template> </template>
@ -15,7 +17,7 @@ export default {
}, },
computed: { computed: {
disabled () { disabled() {
return !this.$store.state.queue || this.$store.state.queue.count <= 0 return !this.$store.state.queue || this.$store.state.queue.count <= 0
} }
}, },
@ -32,5 +34,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,6 +1,17 @@
<template> <template>
<a @click="toggle_play_pause" :disabled="disabled"> <a :disabled="disabled" @click="toggle_play_pause">
<span class="icon"><i class="mdi" :class="[icon_style, { 'mdi-play': !is_playing, 'mdi-pause': is_playing && is_pause_allowed, 'mdi-stop': is_playing && !is_pause_allowed }]"></i></span> <span class="icon"
><i
class="mdi"
:class="[
icon_style,
{
'mdi-play': !is_playing,
'mdi-pause': is_playing && is_pause_allowed,
'mdi-stop': is_playing && !is_pause_allowed
}
]"
/></span>
</a> </a>
</template> </template>
@ -16,16 +27,18 @@ export default {
}, },
computed: { computed: {
is_playing () { is_playing() {
return this.$store.state.player.state === 'play' return this.$store.state.player.state === 'play'
}, },
is_pause_allowed () { is_pause_allowed() {
return (this.$store.getters.now_playing && return (
this.$store.getters.now_playing.data_kind !== 'pipe') this.$store.getters.now_playing &&
this.$store.getters.now_playing.data_kind !== 'pipe'
)
}, },
disabled () { disabled() {
return !this.$store.state.queue || this.$store.state.queue.count <= 0 return !this.$store.state.queue || this.$store.state.queue.count <= 0
} }
}, },
@ -34,7 +47,12 @@ export default {
toggle_play_pause: function () { toggle_play_pause: function () {
if (this.disabled) { if (this.disabled) {
if (this.show_disabled_message) { if (this.show_disabled_message) {
this.$store.dispatch('add_notification', { text: 'Queue is empty', type: 'info', topic: 'connection', timeout: 2000 }) this.$store.dispatch('add_notification', {
text: 'Queue is empty',
type: 'info',
topic: 'connection',
timeout: 2000
})
} }
return return
} }
@ -51,5 +69,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,6 +1,8 @@
<template> <template>
<a @click="play_previous" :disabled="disabled"> <a :disabled="disabled" @click="play_previous">
<span class="icon"><i class="mdi mdi-skip-backward" :class="icon_style"></i></span> <span class="icon"
><i class="mdi mdi-skip-backward" :class="icon_style"
/></span>
</a> </a>
</template> </template>
@ -15,7 +17,7 @@ export default {
}, },
computed: { computed: {
disabled () { disabled() {
return !this.$store.state.queue || this.$store.state.queue.count <= 0 return !this.$store.state.queue || this.$store.state.queue.count <= 0
} }
}, },
@ -32,5 +34,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,6 +1,17 @@
<template> <template>
<a @click="toggle_repeat_mode" :class="{ 'is-warning': !is_repeat_off }"> <a :class="{ 'is-warning': !is_repeat_off }" @click="toggle_repeat_mode">
<span class="icon"><i class="mdi" :class="[icon_style, { 'mdi-repeat': is_repeat_all, 'mdi-repeat-once': is_repeat_single, 'mdi-repeat-off': is_repeat_off }]"></i></span> <span class="icon"
><i
class="mdi"
:class="[
icon_style,
{
'mdi-repeat': is_repeat_all,
'mdi-repeat-once': is_repeat_single,
'mdi-repeat-off': is_repeat_off
}
]"
/></span>
</a> </a>
</template> </template>
@ -15,13 +26,13 @@ export default {
}, },
computed: { computed: {
is_repeat_all () { is_repeat_all() {
return this.$store.state.player.repeat === 'all' return this.$store.state.player.repeat === 'all'
}, },
is_repeat_single () { is_repeat_single() {
return this.$store.state.player.repeat === 'single' return this.$store.state.player.repeat === 'single'
}, },
is_repeat_off () { is_repeat_off() {
return !this.is_repeat_all && !this.is_repeat_single return !this.is_repeat_all && !this.is_repeat_single
} }
}, },
@ -40,5 +51,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,6 +1,6 @@
<template> <template>
<a @click="seek" :disabled="disabled" v-if="visible"> <a v-if="visible" :disabled="disabled" @click="seek">
<span class="icon"><i class="mdi mdi-rewind" :class="icon_style"></i></span> <span class="icon"><i class="mdi mdi-rewind" :class="icon_style" /></span>
</a> </a>
</template> </template>
@ -12,17 +12,21 @@ export default {
props: ['seek_ms', 'icon_style'], props: ['seek_ms', 'icon_style'],
computed: { computed: {
now_playing () { now_playing() {
return this.$store.getters.now_playing return this.$store.getters.now_playing
}, },
is_stopped () { is_stopped() {
return this.$store.state.player.state === 'stop' return this.$store.state.player.state === 'stop'
}, },
disabled () { disabled() {
return !this.$store.state.queue || this.$store.state.queue.count <= 0 || this.is_stopped || return (
this.now_playing.data_kind === 'pipe' !this.$store.state.queue ||
this.$store.state.queue.count <= 0 ||
this.is_stopped ||
this.now_playing.data_kind === 'pipe'
)
}, },
visible () { visible() {
return ['podcast', 'audiobook'].includes(this.now_playing.media_kind) return ['podcast', 'audiobook'].includes(this.now_playing.media_kind)
} }
}, },

View File

@ -1,6 +1,8 @@
<template> <template>
<a @click="seek" :disabled="disabled" v-if="visible"> <a v-if="visible" :disabled="disabled" @click="seek">
<span class="icon"><i class="mdi mdi-fast-forward" :class="icon_style"></i></span> <span class="icon"
><i class="mdi mdi-fast-forward" :class="icon_style"
/></span>
</a> </a>
</template> </template>
@ -12,17 +14,21 @@ export default {
props: ['seek_ms', 'icon_style'], props: ['seek_ms', 'icon_style'],
computed: { computed: {
now_playing () { now_playing() {
return this.$store.getters.now_playing return this.$store.getters.now_playing
}, },
is_stopped () { is_stopped() {
return this.$store.state.player.state === 'stop' return this.$store.state.player.state === 'stop'
}, },
disabled () { disabled() {
return !this.$store.state.queue || this.$store.state.queue.count <= 0 || this.is_stopped || return (
this.now_playing.data_kind === 'pipe' !this.$store.state.queue ||
this.$store.state.queue.count <= 0 ||
this.is_stopped ||
this.now_playing.data_kind === 'pipe'
)
}, },
visible () { visible() {
return ['podcast', 'audiobook'].includes(this.now_playing.media_kind) return ['podcast', 'audiobook'].includes(this.now_playing.media_kind)
} }
}, },

View File

@ -1,6 +1,13 @@
<template> <template>
<a @click="toggle_shuffle_mode" :class="{ 'is-warning': is_shuffle }"> <a :class="{ 'is-warning': is_shuffle }" @click="toggle_shuffle_mode">
<span class="icon"><i class="mdi" :class="[icon_style, { 'mdi-shuffle': is_shuffle, 'mdi-shuffle-disabled': !is_shuffle }]"></i></span> <span class="icon"
><i
class="mdi"
:class="[
icon_style,
{ 'mdi-shuffle': is_shuffle, 'mdi-shuffle-disabled': !is_shuffle }
]"
/></span>
</a> </a>
</template> </template>
@ -15,7 +22,7 @@ export default {
}, },
computed: { computed: {
is_shuffle () { is_shuffle() {
return this.$store.state.player.shuffle return this.$store.state.player.shuffle
} }
}, },
@ -28,5 +35,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -0,0 +1,26 @@
<template>
<div
v-if="width > 0"
class="progress-bar mt-2"
:style="{ width: width_percent }"
/>
</template>
<script>
export default {
name: 'ProgressBar',
props: ['max', 'value'],
computed: {
width() {
if (this.value > 0 && this.max > 0) {
return parseInt((this.value * 100) / this.max)
}
return 0
},
width_percent() {
return this.width + '%'
}
}
}
</script>

View File

@ -1,19 +1,25 @@
<template> <template>
<div class="field"> <div class="field">
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" <input
:checked="value" ref="settings_checkbox"
@change="set_update_timer" type="checkbox"
ref="settings_checkbox"> :checked="value"
<slot name="label"></slot> @change="set_update_timer"
<i class="is-size-7" />
:class="{ <slot name="label" />
'has-text-info': statusUpdate === 'success', <i
'has-text-danger': statusUpdate === 'error' class="is-size-7"
}"> {{ info }}</i> :class="{
'has-text-info': statusUpdate === 'success',
'has-text-danger': statusUpdate === 'error'
}"
>
{{ info }}</i
>
</label> </label>
<p class="help" v-if="$slots['info']"> <p v-if="$slots['info']" class="help">
<slot name="info"></slot> <slot name="info" />
</p> </p>
</div> </div>
</template> </template>
@ -27,7 +33,7 @@ export default {
props: ['category_name', 'option_name'], props: ['category_name', 'option_name'],
data () { data() {
return { return {
timerDelay: 2000, timerDelay: 2000,
timerId: -1, timerId: -1,
@ -38,22 +44,26 @@ export default {
}, },
computed: { computed: {
category () { category() {
return this.$store.state.settings.categories.find(elem => elem.name === this.category_name) return this.$store.state.settings.categories.find(
(elem) => elem.name === this.category_name
)
}, },
option () { option() {
if (!this.category) { if (!this.category) {
return {} return {}
} }
return this.category.options.find(elem => elem.name === this.option_name) return this.category.options.find(
(elem) => elem.name === this.option_name
)
}, },
value () { value() {
return this.option.value return this.option.value
}, },
info () { info() {
if (this.statusUpdate === 'success') { if (this.statusUpdate === 'success') {
return '(setting saved)' return '(setting saved)'
} else if (this.statusUpdate === 'error') { } else if (this.statusUpdate === 'error') {
@ -64,7 +74,7 @@ export default {
}, },
methods: { methods: {
set_update_timer () { set_update_timer() {
if (this.timerId > 0) { if (this.timerId > 0) {
window.clearTimeout(this.timerId) window.clearTimeout(this.timerId)
this.timerId = -1 this.timerId = -1
@ -77,10 +87,11 @@ export default {
} }
}, },
update_setting () { update_setting() {
this.timerId = -1 this.timerId = -1
const newValue = this.$refs.settings_checkbox.checked const newValue = this.$refs.settings_checkbox.checked
console.log(this.$refs.settings_checkbox)
if (newValue === this.value) { if (newValue === this.value) {
this.statusUpdate = '' this.statusUpdate = ''
return return
@ -91,15 +102,19 @@ export default {
name: this.option_name, name: this.option_name,
value: newValue value: newValue
} }
webapi.settings_update(this.category.name, option).then(() => { webapi
this.$store.commit(types.UPDATE_SETTINGS_OPTION, option) .settings_update(this.category.name, option)
this.statusUpdate = 'success' .then(() => {
}).catch(() => { this.$store.commit(types.UPDATE_SETTINGS_OPTION, option)
this.statusUpdate = 'error' this.statusUpdate = 'success'
this.$refs.settings_checkbox.checked = this.value })
}).finally(() => { .catch(() => {
this.timerId = window.setTimeout(this.clear_status, this.timerDelay) this.statusUpdate = 'error'
}) this.$refs.settings_checkbox.checked = this.value
})
.finally(() => {
this.timerId = window.setTimeout(this.clear_status, this.timerDelay)
})
}, },
clear_status: function () { clear_status: function () {
@ -109,5 +124,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -2,25 +2,31 @@
<fieldset :disabled="disabled"> <fieldset :disabled="disabled">
<div class="field"> <div class="field">
<label class="label has-text-weight-normal"> <label class="label has-text-weight-normal">
<slot name="label"></slot> <slot name="label" />
<i class="is-size-7" <i
:class="{ class="is-size-7"
'has-text-info': statusUpdate === 'success', :class="{
'has-text-danger': statusUpdate === 'error' 'has-text-info': statusUpdate === 'success',
}"> {{ info }}</i> 'has-text-danger': statusUpdate === 'error'
}"
>
{{ info }}</i
>
</label> </label>
<div class="control"> <div class="control">
<input class="input" <input
type="number" ref="settings_number"
min="0" class="input"
style="width: 10em;" type="number"
:placeholder="placeholder" min="0"
:value="value" style="width: 10em"
@input="set_update_timer" :placeholder="placeholder"
ref="settings_number"> :value="value"
@input="set_update_timer"
/>
</div> </div>
<p class="help" v-if="$slots['info']"> <p v-if="$slots['info']" class="help">
<slot name="info"></slot> <slot name="info" />
</p> </p>
</div> </div>
</fieldset> </fieldset>
@ -35,7 +41,7 @@ export default {
props: ['category_name', 'option_name', 'placeholder', 'disabled'], props: ['category_name', 'option_name', 'placeholder', 'disabled'],
data () { data() {
return { return {
timerDelay: 2000, timerDelay: 2000,
timerId: -1, timerId: -1,
@ -45,22 +51,26 @@ export default {
}, },
computed: { computed: {
category () { category() {
return this.$store.state.settings.categories.find(elem => elem.name === this.category_name) return this.$store.state.settings.categories.find(
(elem) => elem.name === this.category_name
)
}, },
option () { option() {
if (!this.category) { if (!this.category) {
return {} return {}
} }
return this.category.options.find(elem => elem.name === this.option_name) return this.category.options.find(
(elem) => elem.name === this.option_name
)
}, },
value () { value() {
return this.option.value return this.option.value
}, },
info () { info() {
if (this.statusUpdate === 'success') { if (this.statusUpdate === 'success') {
return '(setting saved)' return '(setting saved)'
} else if (this.statusUpdate === 'error') { } else if (this.statusUpdate === 'error') {
@ -71,7 +81,7 @@ export default {
}, },
methods: { methods: {
set_update_timer () { set_update_timer() {
if (this.timerId > 0) { if (this.timerId > 0) {
window.clearTimeout(this.timerId) window.clearTimeout(this.timerId)
this.timerId = -1 this.timerId = -1
@ -84,7 +94,7 @@ export default {
} }
}, },
update_setting () { update_setting() {
this.timerId = -1 this.timerId = -1
const newValue = this.$refs.settings_number.value const newValue = this.$refs.settings_number.value
@ -98,15 +108,19 @@ export default {
name: this.option_name, name: this.option_name,
value: parseInt(newValue, 10) value: parseInt(newValue, 10)
} }
webapi.settings_update(this.category.name, option).then(() => { webapi
this.$store.commit(types.UPDATE_SETTINGS_OPTION, option) .settings_update(this.category.name, option)
this.statusUpdate = 'success' .then(() => {
}).catch(() => { this.$store.commit(types.UPDATE_SETTINGS_OPTION, option)
this.statusUpdate = 'error' this.statusUpdate = 'success'
this.$refs.settings_number.value = this.value })
}).finally(() => { .catch(() => {
this.timerId = window.setTimeout(this.clear_status, this.timerDelay) this.statusUpdate = 'error'
}) this.$refs.settings_number.value = this.value
})
.finally(() => {
this.timerId = window.setTimeout(this.clear_status, this.timerDelay)
})
}, },
clear_status: function () { clear_status: function () {
@ -116,5 +130,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -2,21 +2,29 @@
<fieldset :disabled="disabled"> <fieldset :disabled="disabled">
<div class="field"> <div class="field">
<label class="label has-text-weight-normal"> <label class="label has-text-weight-normal">
<slot name="label"></slot> <slot name="label" />
<i class="is-size-7" <i
:class="{ class="is-size-7"
'has-text-info': statusUpdate === 'success', :class="{
'has-text-danger': statusUpdate === 'error' 'has-text-info': statusUpdate === 'success',
}"> {{ info }}</i> 'has-text-danger': statusUpdate === 'error'
}"
>
{{ info }}</i
>
</label> </label>
<div class="control"> <div class="control">
<input class="input" type="text" :placeholder="placeholder" <input
:value="value" ref="settings_text"
@input="set_update_timer" class="input"
ref="settings_text"> type="text"
:placeholder="placeholder"
:value="value"
@input="set_update_timer"
/>
</div> </div>
<p class="help" v-if="$slots['info']"> <p v-if="$slots['info']" class="help">
<slot name="info"></slot> <slot name="info" />
</p> </p>
</div> </div>
</fieldset> </fieldset>
@ -31,7 +39,7 @@ export default {
props: ['category_name', 'option_name', 'placeholder', 'disabled'], props: ['category_name', 'option_name', 'placeholder', 'disabled'],
data () { data() {
return { return {
timerDelay: 2000, timerDelay: 2000,
timerId: -1, timerId: -1,
@ -42,22 +50,26 @@ export default {
}, },
computed: { computed: {
category () { category() {
return this.$store.state.settings.categories.find(elem => elem.name === this.category_name) return this.$store.state.settings.categories.find(
(elem) => elem.name === this.category_name
)
}, },
option () { option() {
if (!this.category) { if (!this.category) {
return {} return {}
} }
return this.category.options.find(elem => elem.name === this.option_name) return this.category.options.find(
(elem) => elem.name === this.option_name
)
}, },
value () { value() {
return this.option.value return this.option.value
}, },
info () { info() {
if (this.statusUpdate === 'success') { if (this.statusUpdate === 'success') {
return '(setting saved)' return '(setting saved)'
} else if (this.statusUpdate === 'error') { } else if (this.statusUpdate === 'error') {
@ -68,7 +80,7 @@ export default {
}, },
methods: { methods: {
set_update_timer () { set_update_timer() {
if (this.timerId > 0) { if (this.timerId > 0) {
window.clearTimeout(this.timerId) window.clearTimeout(this.timerId)
this.timerId = -1 this.timerId = -1
@ -81,7 +93,7 @@ export default {
} }
}, },
update_setting () { update_setting() {
this.timerId = -1 this.timerId = -1
const newValue = this.$refs.settings_text.value const newValue = this.$refs.settings_text.value
@ -95,15 +107,19 @@ export default {
name: this.option_name, name: this.option_name,
value: newValue value: newValue
} }
webapi.settings_update(this.category.name, option).then(() => { webapi
this.$store.commit(types.UPDATE_SETTINGS_OPTION, option) .settings_update(this.category.name, option)
this.statusUpdate = 'success' .then(() => {
}).catch(() => { this.$store.commit(types.UPDATE_SETTINGS_OPTION, option)
this.statusUpdate = 'error' this.statusUpdate = 'success'
this.$refs.settings_text.value = this.value })
}).finally(() => { .catch(() => {
this.timerId = window.setTimeout(this.clear_status, this.timerDelay) this.statusUpdate = 'error'
}) this.$refs.settings_text.value = this.value
})
.finally(() => {
this.timerId = window.setTimeout(this.clear_status, this.timerDelay)
})
}, },
clear_status: function () { clear_status: function () {
@ -113,5 +129,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,17 +1,21 @@
<template functional> <template>
<div class="media"> <div class="media">
<div class="media-left fd-has-action" <div v-if="$slots['artwork']" class="media-left fd-has-action">
v-if="$slots['artwork']" <slot name="artwork" />
@click="listeners.click">
<slot name="artwork"></slot>
</div> </div>
<div class="media-content fd-has-action is-clipped" @click="listeners.click"> <div class="media-content fd-has-action is-clipped">
<h1 class="title is-6">{{ props.album.name }}</h1> <h1 class="title is-6">
<h2 class="subtitle is-7 has-text-grey"><b>{{ props.album.artists[0].name }}</b></h2> {{ album.name }}
<h2 class="subtitle is-7 has-text-grey has-text-weight-normal">({{ props.album.album_type }}, {{ props.album.release_date | time('L') }})</h2> </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 }}, {{ $filters.time(album.release_date, 'L') }})
</h2>
</div> </div>
<div class="media-right"> <div class="media-right">
<slot name="actions"></slot> <slot name="actions" />
</div> </div>
</div> </div>
</template> </template>
@ -23,5 +27,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,10 +1,12 @@
<template> <template>
<div class="media"> <div class="media">
<div class="media-content fd-has-action is-clipped" v-on:click="open_artist"> <div class="media-content fd-has-action is-clipped" @click="open_artist">
<h1 class="title is-6">{{ artist.name }}</h1> <h1 class="title is-6">
{{ artist.name }}
</h1>
</div> </div>
<div class="media-right"> <div class="media-right">
<slot name="actions"></slot> <slot name="actions" />
</div> </div>
</div> </div>
</template> </template>
@ -22,5 +24,4 @@ export default {
} }
</script> </script>
<style> <style></style>
</style>

View File

@ -1,11 +1,15 @@
<template> <template>
<div class="media"> <div class="media">
<div class="media-content fd-has-action is-clipped" v-on:click="open_playlist"> <div class="media-content fd-has-action is-clipped" @click="open_playlist">
<h1 class="title is-6">{{ playlist.name }}</h1> <h1 class="title is-6">
<h2 class="subtitle is-7">{{ playlist.owner.display_name }}</h2> {{ playlist.name }}
</h1>
<h2 class="subtitle is-7">
{{ playlist.owner.display_name }}
</h2>
</div> </div>
<div class="media-right"> <div class="media-right">
<slot name="actions"></slot> <slot name="actions" />
</div> </div>
</div> </div>
</template> </template>
@ -17,11 +21,12 @@ export default {
methods: { methods: {
open_playlist: function () { open_playlist: function () {
this.$router.push({ path: '/music/spotify/playlists/' + this.playlist.id }) this.$router.push({
path: '/music/spotify/playlists/' + this.playlist.id
})
} }
} }
} }
</script> </script>
<style> <style></style>
</style>

Some files were not shown because too many files have changed in this diff Show More