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
@ -21,20 +22,34 @@ The source is located in the `web-src` folder.
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
# install dependencies
npm install
# serve with hot reload at localhost:8080
npm run dev
# Serve with hot reload at localhost:3000
# (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
# build for production and view the bundle analyzer report
npm run build --report
# Format code
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
if COND_WEBINTERFACE
htdocsplayercssdir = $(datadir)/owntone/htdocs/player/css
htdocsassetsdir = $(datadir)/owntone/htdocs/assets
dist_htdocsplayercss_DATA = \
player/css/app.css \
player/css/app.css.map \
player/css/chunk-vendors.css \
player/css/chunk-vendors.css.map
htdocsplayerfontsdir = $(datadir)/owntone/htdocs/player/fonts
dist_htdocsplayerfonts_DATA = \
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
dist_htdocsassets_DATA = \
assets/index.css \
assets/index.js \
assets/vendor.js \
assets/materialdesignicons-webfont.svg \
assets/materialdesignicons-webfont.ttf \
assets/materialdesignicons-webfont.woff2 \
assets/materialdesignicons-webfont.woff \
assets/materialdesignicons-webfont.eot
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",
"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"
"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

@ -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 = {
root: true,
env: {
node: true
},
extends: [
'plugin:vue/essential',
'@vue/standard'
],
extends: ['eslint:recommended', 'plugin:vue/vue3-recommended', 'prettier'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
},
parserOptions: {
parser: 'babel-eslint'
// override/add rules settings here, such as:
// 'vue/no-unused-vars': 'error'
'no-unused-vars': ['error', { args: 'none' }],
'vue/require-prop-types': 'off',
'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",
"version": "1.2.0",
"private": true,
"description": "OwnTone web interface",
"author": "chme <christian.meffert@googlemail.com>",
"version": "2.0.0",
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build --no-clean --modern",
"lint": "vue-cli-service lint",
"dev": "vue-cli-service serve"
"dev": "vite",
"serve": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src",
"format": "prettier . --write"
},
"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-switch": "^2.0.4",
"core-js": "^3.15.2",
"mdi": "^2.2.43",
"moment": "^2.29.1",
"moment-duration-format": "^2.3.2",
"npm": "^7.19.1",
"reconnectingwebsocket": "^1.0.0",
"spotify-web-api-js": "^1.5.2",
"string-to-color": "^2.2.2",
"v-click-outside": "^3.1.2",
"vue": "^2.6.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": "^3.2.31",
"vue-router": "^4.0.14",
"vue-scrollto": "^2.20.0",
"vue-tiny-lazyload-img": "^0.1.0",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2"
"vue3-click-away": "^1.2.4",
"vue3-lazyload": "^0.2.5-beta",
"vuedraggable": "^4.1.0",
"vuex": "^4.0.2"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^4.5.15",
"@vue/cli-plugin-eslint": "^5.0.0-rc.1",
"@vue/cli-service": "^4.5.15",
"@vue/eslint-config-standard": "^6.1.0",
"babel-eslint": "^10.1.0",
"eslint": "^7.30.0",
"eslint-plugin-import": "^2.23.4",
"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"
"@vitejs/plugin-vue": "^2.2.4",
"eslint": "^8.11.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-vue": "^8.5.0",
"prettier": "2.6.0",
"sass": "^1.49.9",
"vite": "^2.8.6"
}
}

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">
<navbar-top />
<vue-progress-bar class="fd-progress-bar" />
<transition name="fade">
<!-- Setting v-show to true on the router-view tag avoids jumpiness during transitions -->
<router-view v-show="true" />
</transition>
<modal-dialog-remote-pairing :show="pairing_active" @close="pairing_active = false" />
<router-view v-slot="{ Component }">
<component :is="Component" class="fd-page" />
</router-view>
<modal-dialog-remote-pairing
:show="pairing_active"
@close="pairing_active = false"
/>
<modal-dialog-update
:show="show_update_dialog"
@close="show_update_dialog = false" />
<notifications v-show="!show_burger_menu" />
:show="show_update_dialog"
@close="show_update_dialog = false"
/>
<notification-list v-show="!show_burger_menu" />
<navbar-bottom />
<div class="fd-overlay-fullscreen" v-show="show_burger_menu || show_player_menu"
@click="show_burger_menu = show_player_menu = false"></div>
<div
v-show="show_burger_menu || show_player_menu"
class="fd-overlay-fullscreen"
@click="show_burger_menu = show_player_menu = false"
/>
</div>
</template>
<script>
import NavbarTop from '@/components/NavbarTop'
import NavbarBottom from '@/components/NavbarBottom'
import Notifications from '@/components/Notifications'
import ModalDialogRemotePairing from '@/components/ModalDialogRemotePairing'
import ModalDialogUpdate from '@/components/ModalDialogUpdate'
import NavbarTop from '@/components/NavbarTop.vue'
import NavbarBottom from '@/components/NavbarBottom.vue'
import NotificationList from '@/components/NotificationList.vue'
import ModalDialogRemotePairing from '@/components/ModalDialogRemotePairing.vue'
import ModalDialogUpdate from '@/components/ModalDialogUpdate.vue'
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
import ReconnectingWebSocket from 'reconnectingwebsocket'
@ -30,10 +37,15 @@ import moment from 'moment'
export default {
name: 'App',
components: { NavbarTop, NavbarBottom, Notifications, ModalDialogRemotePairing, ModalDialogUpdate },
template: '<App/>',
components: {
NavbarTop,
NavbarBottom,
NotificationList,
ModalDialogRemotePairing,
ModalDialogUpdate
},
data () {
data() {
return {
token_timer_id: 0,
reconnect_attempts: 0,
@ -43,31 +55,40 @@ export default {
computed: {
show_burger_menu: {
get () {
get() {
return this.$store.state.show_burger_menu
},
set (value) {
set(value) {
this.$store.commit(types.SHOW_BURGER_MENU, value)
}
},
show_player_menu: {
get () {
get() {
return this.$store.state.show_player_menu
},
set (value) {
set(value) {
this.$store.commit(types.SHOW_PLAYER_MENU, value)
}
},
show_update_dialog: {
get () {
get() {
return this.$store.state.show_update_dialog
},
set (value) {
set(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 () {
moment.locale(navigator.language)
this.connect()
@ -97,23 +118,40 @@ export default {
methods: {
connect: function () {
this.$store.dispatch('add_notification', { text: 'Connecting to OwnTone server', type: 'info', topic: 'connection', timeout: 2000 })
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' })
/*
this.$store.dispatch('add_notification', {
text: 'Connecting to OwnTone server',
type: 'info',
topic: 'connection',
timeout: 2000
})
*/
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 () {
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
}
@ -124,22 +162,54 @@ export default {
protocol = 'wss://'
}
let wsUrl = protocol + window.location.hostname + ':' + vm.$store.state.config.websocket_port
if (process.env.NODE_ENV === 'development' && process.env.VUE_APP_WEBSOCKET_SERVER) {
// If we are running in the development server, use the websocket url configured in .env.development
wsUrl = process.env.VUE_APP_WEBSOCKET_SERVER
let wsUrl =
protocol +
window.location.hostname +
':' +
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(
wsUrl,
'notify',
{ reconnectInterval: 3000 }
)
const socket = new ReconnectingWebSocket(wsUrl, 'notify', {
reconnectInterval: 1000,
maxReconnectInterval: 2000
})
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
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_player_status()
@ -153,16 +223,66 @@ export default {
socket.onclose = function () {
// vm.$store.dispatch('add_notification', { text: 'Connection closed', type: 'danger', timeout: 2000 })
}
/*
socket.onerror = function () {
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) {
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()
}
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()
}
if (data.notify.includes('outputs') || data.notify.includes('volume')) {
@ -237,7 +357,10 @@ export default {
this.token_timer_id = 0
}
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 {
}
}
},
watch: {
'show_burger_menu' () {
this.update_is_clipped()
},
'show_player_menu' () {
this.update_is_clipped()
}
}
template: '<App/>'
}
</script>
<style>
</style>
<style></style>

View File

@ -9,7 +9,7 @@ export default {
_gain: null,
// setup audio routing
setupAudio () {
setupAudio() {
const AudioContext = window.AudioContext || window.webkitAudioContext
this._context = new AudioContext()
this._source = this._context.createMediaElementSource(this._audio)
@ -18,26 +18,26 @@ export default {
this._source.connect(this._gain)
this._gain.connect(this._context.destination)
this._audio.addEventListener('canplaythrough', e => {
this._audio.addEventListener('canplaythrough', (e) => {
this._audio.play()
})
this._audio.addEventListener('canplay', e => {
this._audio.addEventListener('canplay', (e) => {
this._audio.play()
})
return this._audio
},
// set audio volume
setVolume (volume) {
setVolume(volume) {
if (!this._gain) return
volume = parseFloat(volume) || 0.0
volume = (volume < 0) ? 0 : volume
volume = (volume > 1) ? 1 : volume
volume = volume < 0 ? 0 : volume
volume = volume > 1 ? 1 : volume
this._gain.gain.value = volume
},
// play audio source url
playSource (source) {
playSource(source) {
this.stopAudio()
this._context.resume().then(() => {
this._audio.src = String(source || '') + '?x=' + Date.now()
@ -47,9 +47,21 @@ export default {
},
// stop playing audio
stopAudio () {
try { this._audio.pause() } catch (e) {}
try { this._audio.stop() } catch (e) {}
try { this._audio.close() } catch (e) {}
stopAudio() {
try {
this._audio.pause()
} 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>
<figure>
<img v-lazyload
:data-src="artwork_url_with_size"
:data-err="dataURI"
:key="artwork_url_with_size"
@click="$emit('click')">
<img
v-lazy="{ src: artwork_url_with_size, lifecycle: lazy_lifecycle }"
@click="$emit('click')"
/>
</figure>
</template>
<script>
import webapi from '@/webapi'
import SVGRenderer from '@/lib/SVGRenderer'
import stringToColor from 'string-to-color'
import { renderSVG } from '@/lib/SVGRenderer'
export default {
name: 'CoverArtwork',
props: ['artist', 'album', 'artwork_url', 'maxwidth', 'maxheight'],
emits: ['click'],
data () {
data() {
return {
svg: new SVGRenderer(),
width: 600,
height: 600,
font_family: 'sans-serif',
font_size: 200,
font_weight: 600
font_weight: 600,
lazy_lifecycle: {
error: (el) => {
el.src = this.dataURI()
}
}
}
},
computed: {
artwork_url_with_size: function () {
if (this.maxwidth > 0 && this.maxheight > 0) {
return webapi.artwork_url_append_size_params(this.artwork_url, this.maxwidth, this.maxheight)
return webapi.artwork_url_append_size_params(
this.artwork_url,
this.maxwidth,
this.maxheight
)
}
return webapi.artwork_url_append_size_params(this.artwork_url)
},
alt_text () {
alt_text() {
return this.artist + ' - ' + this.album
},
caption () {
caption() {
if (this.album) {
return this.album.substring(0, 2)
}
@ -48,47 +55,18 @@ export default {
return this.artist.substring(0, 2)
}
return ''
},
}
},
background_color () {
return stringToColor(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 {
methods: {
dataURI: function () {
return renderSVG(this.caption, this.alt_text, {
width: this.width,
height: this.height,
textColor: this.text_color,
backgroundColor: this.background_color,
caption: this.caption,
fontFamily: this.font_family,
fontSize: this.font_size,
fontWeight: this.font_weight
}
},
dataURI () {
return this.svg.render(this.rendererParams)
font_family: this.font_family,
font_size: this.font_size,
font_weight: this.font_weight
})
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,48 +1,53 @@
<template>
<div>
<div v-if="is_grouped">
<div v-for="idx in composers.indexList" :key="idx" class="mb-6">
<span class="tag is-info is-light is-small has-text-weight-bold" :id="'index_' + idx">{{ idx }}</span>
<list-item-composer v-for="composer in composers.grouped[idx]"
:key="composer.id"
:composer="composer"
@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>
<template v-for="composer in composers" :key="composer.itemId">
<div v-if="!composer.isItem && !hide_group_title" class="mt-6 mb-5 py-2">
<div class="media-content is-clipped">
<span
:id="'index_' + composer.groupKey"
class="tag is-info is-light is-small has-text-weight-bold"
>{{ composer.groupKey }}</span
>
</div>
</div>
<div v-else>
<list-item-composer v-for="composer in composers_list"
:key="composer.id"
:composer="composer"
@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
v-else-if="composer.isItem"
class="media"
@click="open_composer(composer.item)"
>
<div class="media-content fd-has-action is-clipped">
<h1 class="title is-6">
{{ composer.item.name }}
</h1>
</div>
<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>
<modal-dialog-composer :show="show_details_modal" :composer="selected_composer" :media_kind="media_kind" @close="show_details_modal = false" />
</div>
</template>
<teleport to="#app">
<modal-dialog-composer
:show="show_details_modal"
:composer="selected_composer"
:media_kind="media_kind"
@close="show_details_modal = false"
/>
</teleport>
</template>
<script>
import ListItemComposer from '@/components/ListItemComposer'
import ModalDialogComposer from '@/components/ModalDialogComposer'
import Composers from '@/lib/Composers'
import ModalDialogComposer from '@/components/ModalDialogComposer.vue'
export default {
name: 'ListComposers',
components: { ListItemComposer, ModalDialogComposer },
components: { ModalDialogComposer },
props: ['composers', 'media_kind'],
props: ['composers', 'media_kind', 'hide_group_title'],
data () {
data() {
return {
show_details_modal: false,
selected_composer: {}
@ -51,25 +56,19 @@ export default {
computed: {
media_kind_resolved: function () {
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)
return this.media_kind
? this.media_kind
: this.selected_composer.media_kind
}
},
methods: {
open_composer: function (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) {
@ -80,5 +79,4 @@ export default {
}
</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>
<div class="media" v-if="is_next || !show_only_next_items">
<div class="media-left" v-if="edit_mode">
<span class="icon has-text-grey fd-is-movable handle"><i class="mdi mdi-drag-horizontal mdi-18px"></i></span>
<div v-if="is_next || !show_only_next_items" class="media">
<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"
/></span>
</div>
<div class="media-content fd-has-action is-clipped" v-on: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>
<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 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>
<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 class="media-right">
<slot name="actions"></slot>
<slot name="actions" />
</div>
</div>
</template>
@ -20,14 +48,20 @@ import webapi from '@/webapi'
export default {
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: {
state () {
state() {
return this.$store.state.player
},
is_next () {
is_next() {
return this.current_position < 0 || this.position >= this.current_position
}
},
@ -40,5 +74,4 @@ export default {
}
</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>
<div>
<list-item-playlist v-for="playlist in playlists" :key="playlist.id" :playlist="playlist" @click="open_playlist(playlist)">
<template slot="icon">
<span class="icon">
<i class="mdi" :class="{ 'mdi-library-music': playlist.type !== 'folder', 'mdi-rss': playlist.type === 'rss', 'mdi-folder': playlist.type === 'folder' }"></i>
</span>
</template>
<template slot="actions">
<a @click="open_dialog(playlist)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-playlist>
<modal-dialog-playlist :show="show_details_modal" :playlist="selected_playlist" @close="show_details_modal = false" />
<div
v-for="playlist in playlists"
:key="playlist.id"
class="media"
:playlist="playlist"
@click="open_playlist(playlist)"
>
<figure class="media-left fd-has-action">
<span class="icon">
<i
class="mdi"
:class="{
'mdi-library-music': playlist.type !== 'folder',
'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>
<teleport to="#app">
<modal-dialog-playlist
:show="show_details_modal"
:playlist="selected_playlist"
@close="show_details_modal = false"
/>
</teleport>
</template>
<script>
import ListItemPlaylist from '@/components/ListItemPlaylist'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist.vue'
export default {
name: 'ListPlaylists',
components: { ListItemPlaylist, ModalDialogPlaylist },
components: { ModalDialogPlaylist },
props: ['playlists'],
data () {
data() {
return {
show_details_modal: false,
selected_playlist: {}
@ -50,5 +73,4 @@ export default {
}
</script>
<style>
</style>
<style></style>

View File

@ -1,28 +1,71 @@
<template>
<div>
<list-item-track v-for="(track, index) in tracks" :key="track.id" :track="track" @click="play_track(index, track)">
<template slot="actions">
<a @click="open_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
<div
v-for="(track, index) in tracks"
:id="'index_' + track.title_sort.charAt(0).toUpperCase()"
:key="track.id"
class="media"
:class="{ 'with-progress': show_progress }"
@click="play_track(index, track)"
>
<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>
<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>
<script>
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import webapi from '@/webapi'
export default {
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 {
show_details_modal: false,
selected_track: {}
@ -48,5 +91,4 @@ export default {
}
</script>
<style>
</style>
<style></style>

View File

@ -1,30 +1,47 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
<p class="title is-4" v-if="title">
<p v-if="title" class="title is-4">
{{ title }}
</p>
<slot name="modal-content"></slot>
<slot name="modal-content" />
</div>
<footer class="card-footer">
<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 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"></i></span> <span class="is-size-7">{{ delete_action }}</span>
<a
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 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"></i></span> <span class="is-size-7">{{ ok_action }}</span>
<a
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>
</footer>
</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>
</transition>
</div>
@ -33,9 +50,9 @@
<script>
export default {
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>
<style>
</style>
<style></style>

View File

@ -1,8 +1,8 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
@ -10,32 +10,54 @@
<form @submit.prevent="add_stream">
<div class="field">
<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">
<i class="mdi mdi-rss"></i>
<i class="mdi mdi-rss" />
</span>
</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>
</div>
</form>
</div>
<footer class="card-footer" v-if="loading">
<footer v-if="loading" class="card-footer">
<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>
</footer>
<footer class="card-footer" v-else>
<a class="card-footer-item has-text-danger" @click="$emit('close')">
<span class="icon"><i class="mdi mdi-cancel"></i></span> <span class="is-size-7">Cancel</span>
<footer v-else class="card-footer">
<a
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 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"></i></span> <span class="is-size-7">Add</span>
<a
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>
</footer>
</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>
</transition>
</div>
@ -47,29 +69,17 @@ import webapi from '@/webapi'
export default {
name: 'ModalDialogAddRss',
props: ['show'],
emits: ['close', 'podcast-added'],
data () {
data() {
return {
url: '',
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: {
'show' () {
show() {
if (this.show) {
this.loading = false
@ -79,9 +89,24 @@ export default {
}, 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>
<style>
</style>
<style></style>

View File

@ -1,44 +1,63 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
<p class="title is-4">
Add stream URL
</p>
<form v-on:submit.prevent="play" class="fd-has-margin-bottom">
<p class="title is-4">Add stream URL</p>
<form class="fd-has-margin-bottom" @submit.prevent="play">
<div class="field">
<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">
<i class="mdi mdi-web"></i>
<i class="mdi mdi-web" />
</span>
</p>
</div>
</form>
</div>
<footer class="card-footer" v-if="loading">
<footer v-if="loading" class="card-footer">
<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>
</footer>
<footer class="card-footer" v-else>
<a class="card-footer-item has-text-danger" @click="$emit('close')">
<span class="icon"><i class="mdi mdi-cancel"></i></span> <span class="is-size-7">Cancel</span>
<footer v-else class="card-footer">
<a
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 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 class="card-footer-item has-background-info has-text-white has-text-weight-bold" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span>
<a
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>
</footer>
</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>
</transition>
</div>
@ -50,38 +69,17 @@ import webapi from '@/webapi'
export default {
name: 'ModalDialogAddUrlStream',
props: ['show'],
emits: ['close'],
data () {
data() {
return {
url: '',
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: {
'show' () {
show() {
if (this.show) {
this.loading = false
@ -91,9 +89,36 @@ export default {
}, 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>
<style>
</style>
<style></style>

View File

@ -1,8 +1,8 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
@ -10,22 +10,33 @@
:artwork_url="album.artwork_url"
:artist="album.artist"
: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">
<a class="has-text-link" @click="open_album">{{ album.name }}</a>
<a class="has-text-link" @click="open_album">{{
album.name
}}</a>
</p>
<div class="buttons" v-if="media_kind_resolved === 'podcast'">
<a class="button is-small" @click="mark_played">Mark as played</a>
<a class="button is-small" @click="$emit('remove-podcast')">Remove podcast</a>
<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="$emit('remove-podcast')"
>Remove podcast</a
>
</div>
<div class="content is-small">
<p v-if="album.artist">
<span class="heading">Album artist</span>
<a class="title is-6 has-text-link" @click="open_artist">{{ album.artist }}</a>
<a class="title is-6 has-text-link" @click="open_artist">{{
album.artist
}}</a>
</p>
<p v-if="album.date_released">
<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 v-else-if="album.year > 0">
<span class="heading">Year</span>
@ -37,47 +48,61 @@
</p>
<p>
<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>
<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>
<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>
</div>
</div>
<footer class="card-footer">
<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 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 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>
</footer>
</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>
</transition>
</div>
</template>
<script>
import CoverArtwork from '@/components/CoverArtwork'
import CoverArtwork from '@/components/CoverArtwork.vue'
import webapi from '@/webapi'
export default {
name: 'ModalDialogAlbum',
components: { CoverArtwork },
props: ['show', 'album', 'media_kind', 'new_tracks'],
emits: ['close', 'remove-podcast', 'play-count-changed'],
data () {
data() {
return {
artwork_visible: false
}
@ -123,17 +148,21 @@ export default {
if (this.media_kind_resolved === 'podcast') {
// No artist page for podcasts
} else if (this.media_kind_resolved === 'audiobook') {
this.$router.push({ path: '/audiobooks/artists/' + this.album.artist_id })
this.$router.push({
path: '/audiobooks/artists/' + this.album.artist_id
})
} else {
this.$router.push({ path: '/music/artists/' + this.album.artist_id })
}
},
mark_played: function () {
webapi.library_album_track_update(this.album.id, { play_count: 'played' }).then(({ data }) => {
this.$emit('play-count-changed')
this.$emit('close')
})
webapi
.library_album_track_update(this.album.id, { play_count: 'played' })
.then(({ data }) => {
this.$emit('play-count-changed')
this.$emit('close')
})
},
artwork_loaded: function () {
@ -147,5 +176,4 @@ export default {
}
</script>
<style>
</style>
<style></style>

View File

@ -1,13 +1,15 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
<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>
<div class="content is-small">
<p>
@ -24,24 +26,33 @@
</p>
<p>
<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>
</div>
</div>
<footer class="card-footer">
<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 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 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>
</footer>
</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>
</transition>
</div>
@ -53,6 +64,7 @@ import webapi from '@/webapi'
export default {
name: 'ModalDialogArtist',
props: ['show', 'artist'],
emits: ['close'],
methods: {
play: function () {
@ -78,5 +90,4 @@ export default {
}
</script>
<style>
</style>
<style></style>

View File

@ -1,37 +1,50 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
<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>
<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>
<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>
</div>
<footer class="card-footer">
<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 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 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>
</footer>
</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>
</transition>
</div>
@ -43,35 +56,48 @@ import webapi from '@/webapi'
export default {
name: 'ModalDialogComposer',
props: ['show', 'composer'],
emits: ['close'],
methods: {
play: function () {
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 () {
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 () {
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 () {
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 () {
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>
<style>
</style>
<style></style>

View File

@ -1,8 +1,8 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
@ -12,18 +12,25 @@
</div>
<footer class="card-footer">
<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 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 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>
</footer>
</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>
</transition>
</div>
@ -35,25 +42,32 @@ import webapi from '@/webapi'
export default {
name: 'ModalDialogDirectory',
props: ['show', 'directory'],
emits: ['close'],
methods: {
play: function () {
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 () {
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 () {
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>
<style>
</style>
<style></style>

View File

@ -1,29 +1,38 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
<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>
</div>
<footer class="card-footer">
<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 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 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>
</footer>
</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>
</transition>
</div>
@ -35,21 +44,29 @@ import webapi from '@/webapi'
export default {
name: 'ModalDialogGenre',
props: ['show', 'genre'],
emits: ['close'],
methods: {
play: function () {
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 () {
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 () {
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 () {
@ -60,5 +77,4 @@ export default {
}
</script>
<style>
</style>
<style></style>

View File

@ -1,13 +1,15 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
<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>
<div class="content is-small">
<p>
@ -20,20 +22,27 @@
</p>
</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">
<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 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 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>
</footer>
</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>
</transition>
</div>
@ -45,6 +54,7 @@ import webapi from '@/webapi'
export default {
name: 'ModalDialogPlaylist',
props: ['show', 'playlist', 'uris'],
emits: ['close'],
methods: {
play: function () {
@ -70,5 +80,4 @@ export default {
}
</script>
<style>
</style>
<style></style>

View File

@ -1,41 +1,59 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
<p class="title is-4">
Save queue to playlist
</p>
<form v-on:submit.prevent="save" class="fd-has-margin-bottom">
<p class="title is-4">Save queue to playlist</p>
<form class="fd-has-margin-bottom" @submit.prevent="save">
<div class="field">
<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">
<i class="mdi mdi-file-music"></i>
<i class="mdi mdi-file-music" />
</span>
</p>
</div>
</form>
</div>
<footer class="card-footer" v-if="loading">
<footer v-if="loading" class="card-footer">
<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>
</footer>
<footer class="card-footer" v-else>
<a class="card-footer-item has-text-danger" @click="$emit('close')">
<span class="icon"><i class="mdi mdi-cancel"></i></span> <span class="is-size-7">Cancel</span>
<footer v-else class="card-footer">
<a
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 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"></i></span> <span class="is-size-7">Save</span>
<a
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>
</footer>
</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>
</transition>
</div>
@ -47,14 +65,28 @@ import webapi from '@/webapi'
export default {
name: 'ModalDialogPlaylistSave',
props: ['show'],
emits: ['close'],
data () {
data() {
return {
playlist_name: '',
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: {
save: function () {
if (this.playlist_name.length < 1) {
@ -62,29 +94,18 @@ export default {
}
this.loading = true
webapi.queue_save_playlist(this.playlist_name).then(() => {
this.$emit('close')
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)
}
webapi
.queue_save_playlist(this.playlist_name)
.then(() => {
this.$emit('close')
this.playlist_name = ''
})
.catch(() => {
this.loading = false
})
}
}
}
</script>
<style>
</style>
<style></style>

View File

@ -1,8 +1,8 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
@ -15,12 +15,22 @@
<div class="content is-small">
<p>
<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>
</p>
<p v-if="item.album_artist">
<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>
</p>
<p v-if="item.composer">
@ -33,15 +43,21 @@
</p>
<p v-if="item.genre">
<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>
<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>
<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>
<span class="heading">Path</span>
@ -49,14 +65,26 @@
</p>
<p>
<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>
<span class="heading">Quality</span>
<span class="title is-6">
{{ item.type }}
<span v-if="item.samplerate"> | {{ item.samplerate }} Hz</span>
<span v-if="item.channels"> | {{ item.channels | channels }}</span>
<span v-if="item.samplerate">
| {{ 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>
</p>
@ -64,15 +92,21 @@
</div>
<footer class="card-footer">
<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 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>
</footer>
</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>
</transition>
</div>
@ -85,13 +119,30 @@ import SpotifyWebApi from 'spotify-web-api-js'
export default {
name: 'ModalDialogQueueItem',
props: ['show', 'item'],
emits: ['close'],
data () {
data() {
return {
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: {
remove: function () {
this.$emit('close')
@ -123,30 +174,19 @@ export default {
open_spotify_artist: function () {
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 () {
this.$emit('close')
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 = {}
}
this.$router.push({
path: '/music/spotify/albums/' + this.spotify_track.album.id
})
}
}
}
</script>
<style>
</style>
<style></style>

View File

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

View File

@ -1,8 +1,8 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
@ -12,18 +12,34 @@
<p class="subtitle">
{{ track.artist }}
</p>
<div class="buttons" v-if="track.media_kind === 'podcast'">
<a class="button is-small" v-if="track.play_count > 0" @click="mark_new">Mark as new</a>
<a class="button is-small" v-if="track.play_count === 0" @click="mark_played">Mark as played</a>
<div v-if="track.media_kind === 'podcast'" class="buttons">
<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 class="content is-small">
<p>
<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 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>
<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 v-if="track.composer">
<span class="heading">Composer</span>
@ -31,7 +47,9 @@
</p>
<p v-if="track.date_released">
<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 v-else-if="track.year > 0">
<span class="heading">Year</span>
@ -39,15 +57,21 @@
</p>
<p v-if="track.genre">
<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>
<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>
<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>
<span class="heading">Path</span>
@ -55,24 +79,42 @@
</p>
<p>
<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>
<span class="heading">Quality</span>
<span class="title is-6">
{{ track.type }}
<span v-if="track.samplerate"> | {{ track.samplerate }} Hz</span>
<span v-if="track.channels"> | {{ track.channels | channels }}</span>
<span v-if="track.bitrate"> | {{ track.bitrate }} Kb/s</span>
<span v-if="track.samplerate">
| {{ track.samplerate }} Hz</span
>
<span v-if="track.channels">
| {{ $filters.channels(track.channels) }}</span
>
<span v-if="track.bitrate">
| {{ track.bitrate }} Kb/s</span
>
</span>
</p>
<p>
<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>
<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 v-if="track.comment">
<span class="heading">Comment</span>
@ -82,18 +124,25 @@
</div>
<footer class="card-footer">
<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 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 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>
</footer>
</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>
</transition>
</div>
@ -107,13 +156,30 @@ export default {
name: 'ModalDialogTrack',
props: ['show', 'track'],
emits: ['close', 'play-count-changed'],
data () {
data() {
return {
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: {
play_track: function () {
this.$emit('close')
@ -143,7 +209,9 @@ export default {
open_artist: function () {
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 () {
@ -152,44 +220,37 @@ export default {
open_spotify_artist: function () {
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 () {
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 () {
webapi.library_track_update(this.track.id, { play_count: 'reset' }).then(() => {
this.$emit('play-count-changed')
this.$emit('close')
})
webapi
.library_track_update(this.track.id, { play_count: 'reset' })
.then(() => {
this.$emit('play-count-changed')
this.$emit('close')
})
},
mark_played: function () {
webapi.library_track_update(this.track.id, { play_count: 'increment' }).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
webapi
.library_track_update(this.track.id, { play_count: 'increment' })
.then(() => {
this.$emit('play-count-changed')
this.$emit('close')
})
} else {
this.spotify_track = {}
}
}
}
}
</script>
<style>
</style>
<style></style>

View File

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

View File

@ -1,66 +1,148 @@
<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">
<!-- Link to queue -->
<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>
<!-- 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">
<p class="is-size-7 fd-is-text-clipped">
<strong>{{ now_playing.title }}</strong><br>
{{ now_playing.artist }}<span v-if="now_playing.data_kind === 'url'"> - {{ now_playing.album }}</span>
<strong>{{ now_playing.title }}</strong
><br />
{{ now_playing.artist
}}<span v-if="now_playing.data_kind === 'url'">
- {{ now_playing.album }}</span
>
</p>
</div>
</router-link>
<!-- 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-seek-back v-if="is_now_playing_page" seek_ms="10000" class="navbar-item" icon_style="mdi-24px"></player-button-seek-back>
<player-button-previous
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 -->
<player-button-play-pause class="navbar-item" icon_style="mdi-36px" show_disabled_message></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>
<player-button-play-pause
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) -->
<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) -->
<a 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 }"></i></span>
<a
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>
<!-- Player menu dropup menu (only visible on desktop) -->
<div class="navbar-item has-dropdown has-dropdown-up fd-margin-left-auto is-hidden-touch"
:class="{ 'is-active': show_player_menu }">
<a 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 }"></i></span>
<div
class="navbar-item has-dropdown has-dropdown-up fd-margin-left-auto is-hidden-touch"
:class="{ 'is-active': show_player_menu }"
>
<a
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>
<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">
<!-- Outputs: master volume -->
<div class="level is-mobile">
<div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;">
<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>
<div class="level-item" style="flex-grow: 0">
<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
}"
/></span>
</a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
<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"
min="0"
max="100"
step="1"
:value="player.volume"
@change="set_volume">
</range-slider>
</range-slider-->
</div>
</div>
</div>
@ -68,21 +150,54 @@
</div>
<!-- Outputs: master volume -->
<hr class="fd-navbar-divider">
<navbar-item-output v-for="output in outputs" :key="output.id" :output="output"></navbar-item-output>
<hr class="fd-navbar-divider" />
<navbar-item-output
v-for="output in outputs"
:key="output.id"
:output="output"
/>
<!-- Outputs: stream volume -->
<hr class="fd-navbar-divider">
<hr class="fd-navbar-divider" />
<div class="navbar-item">
<div class="level is-mobile">
<div class="level-left fd-expanded">
<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>
<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" /></span
></a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
<p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p>
<range-slider
<p
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"
min="0"
max="100"
@ -90,7 +205,7 @@
:disabled="!playing"
:value="stream_volume"
@change="set_stream_volume">
</range-slider>
</range-slider-->
</div>
</div>
</div>
@ -98,14 +213,14 @@
</div>
<!-- Playback controls -->
<hr class="fd-navbar-divider">
<hr class="fd-navbar-divider" />
<div class="navbar-item">
<div class="level is-mobile fd-expanded">
<div class="level-item">
<div class="buttons has-addons">
<player-button-repeat class="button"></player-button-repeat>
<player-button-shuffle class="button"></player-button-shuffle>
<player-button-consume class="button"></player-button-consume>
<player-button-repeat class="button" />
<player-button-shuffle class="button" />
<player-button-consume class="button" />
</div>
</div>
</div>
@ -115,41 +230,59 @@
</div>
<!-- Player menu (only visible on mobile and tablet) -->
<div class="navbar-menu is-hidden-desktop" :class="{ 'is-active': show_player_menu }">
<div class="navbar-start">
</div>
<div
class="navbar-menu is-hidden-desktop"
:class="{ 'is-active': show_player_menu }"
>
<div class="navbar-start" />
<div class="navbar-end">
<!-- Repeat/shuffle/consume -->
<div class="navbar-item">
<div class="buttons is-centered">
<player-button-repeat class="button" icon_style="mdi-18px"></player-button-repeat>
<player-button-shuffle class="button" icon_style="mdi-18px"></player-button-shuffle>
<player-button-consume class="button" icon_style="mdi-18px"></player-button-consume>
<player-button-repeat class="button" icon_style="mdi-18px" />
<player-button-shuffle class="button" icon_style="mdi-18px" />
<player-button-consume class="button" icon_style="mdi-18px" />
</div>
</div>
<hr class="fd-navbar-divider">
<hr class="fd-navbar-divider" />
<!-- Outputs: master volume -->
<div class="navbar-item">
<div class="level is-mobile">
<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">
<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>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
<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"
min="0"
max="100"
step="1"
:value="player.volume"
@change="set_volume">
</range-slider>
</range-slider-->
</div>
</div>
</div>
@ -157,25 +290,55 @@
</div>
<!-- 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 -->
<hr class="fd-navbar-divider">
<hr class="fd-navbar-divider" />
<div class="navbar-item fd-has-margin-bottom">
<div class="level is-mobile">
<div class="level-left fd-expanded">
<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>
<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" />
</span>
</a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
<p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p>
<range-slider
<p
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"
min="0"
max="100"
@ -183,7 +346,7 @@
:disabled="!playing"
:value="stream_volume"
@change="set_stream_volume">
</range-slider>
</range-slider-->
</div>
</div>
</div>
@ -197,17 +360,18 @@
<script>
import webapi from '@/webapi'
import _audio from '@/audio'
import NavbarItemLink from './NavbarItemLink'
import NavbarItemOutput from './NavbarItemOutput'
import PlayerButtonPlayPause from '@/components/PlayerButtonPlayPause'
import PlayerButtonNext from '@/components/PlayerButtonNext'
import PlayerButtonPrevious from '@/components/PlayerButtonPrevious'
import PlayerButtonShuffle from '@/components/PlayerButtonShuffle'
import PlayerButtonConsume from '@/components/PlayerButtonConsume'
import PlayerButtonRepeat from '@/components/PlayerButtonRepeat'
import PlayerButtonSeekBack from '@/components/PlayerButtonSeekBack'
import PlayerButtonSeekForward from '@/components/PlayerButtonSeekForward'
import RangeSlider from 'vue-range-slider'
import NavbarItemLink from './NavbarItemLink.vue'
import NavbarItemOutput from './NavbarItemOutput.vue'
import PlayerButtonPlayPause from '@/components/PlayerButtonPlayPause.vue'
import PlayerButtonNext from '@/components/PlayerButtonNext.vue'
import PlayerButtonPrevious from '@/components/PlayerButtonPrevious.vue'
import PlayerButtonShuffle from '@/components/PlayerButtonShuffle.vue'
import PlayerButtonConsume from '@/components/PlayerButtonConsume.vue'
import PlayerButtonRepeat from '@/components/PlayerButtonRepeat.vue'
import PlayerButtonSeekBack from '@/components/PlayerButtonSeekBack.vue'
import PlayerButtonSeekForward from '@/components/PlayerButtonSeekForward.vue'
//import RangeSlider from 'vue-range-slider'
import Slider from '@vueform/slider'
import * as types from '@/store/mutation_types'
export default {
@ -215,7 +379,8 @@ export default {
components: {
NavbarItemLink,
NavbarItemOutput,
RangeSlider,
//RangeSlider,
Slider,
PlayerButtonPlayPause,
PlayerButtonNext,
PlayerButtonPrevious,
@ -226,7 +391,7 @@ export default {
PlayerButtonSeekBack
},
data () {
data() {
return {
old_volume: 0,
@ -241,49 +406,67 @@ export default {
computed: {
show_player_menu: {
get () {
get() {
return this.$store.state.show_player_menu
},
set (value) {
set(value) {
this.$store.commit(types.SHOW_PLAYER_MENU, value)
}
},
show_burger_menu () {
show_burger_menu() {
return this.$store.state.show_burger_menu
},
zindex () {
zindex() {
if (this.show_burger_menu) {
return 'z-index: 20'
}
return ''
},
state () {
state() {
return this.$store.state.player
},
now_playing () {
now_playing() {
return this.$store.getters.now_playing
},
is_now_playing_page () {
is_now_playing_page() {
return this.$route.path === '/now-playing'
},
outputs () {
outputs() {
return this.$store.state.outputs
},
player () {
player() {
return this.$store.state.player
},
config () {
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: {
on_click_outside_outputs () {
on_click_outside_outputs() {
this.show_outputs_menu = false
},
@ -302,21 +485,24 @@ export default {
setupAudio: function () {
const a = _audio.setupAudio()
a.addEventListener('waiting', e => {
a.addEventListener('waiting', (e) => {
this.playing = false
this.loading = true
})
a.addEventListener('playing', e => {
a.addEventListener('playing', (e) => {
this.playing = true
this.loading = false
})
a.addEventListener('ended', e => {
a.addEventListener('ended', (e) => {
this.playing = false
this.loading = false
})
a.addEventListener('error', e => {
a.addEventListener('error', (e) => {
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.loading = false
})
@ -353,27 +539,8 @@ export default {
this.stream_volume = newVolume
_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>
<style>
</style>
<style></style>

View File

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

View File

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

View File

@ -1,167 +1,235 @@
<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">
<navbar-item-link to="/playlists" v-if="is_visible_playlists">
<span class="icon"><i class="mdi mdi-library-music"></i></span>
<navbar-item-link v-if="is_visible_playlists" to="/playlists">
<span class="icon"><i class="mdi mdi-library-music" /></span>
</navbar-item-link>
<navbar-item-link to="/music" v-if="is_visible_music">
<span class="icon"><i class="mdi mdi-music"></i></span>
<navbar-item-link v-if="is_visible_music" to="/music">
<span class="icon"><i class="mdi mdi-music" /></span>
</navbar-item-link>
<navbar-item-link to="/podcasts" v-if="is_visible_podcasts">
<span class="icon"><i class="mdi mdi-microphone"></i></span>
<navbar-item-link v-if="is_visible_podcasts" to="/podcasts">
<span class="icon"><i class="mdi mdi-microphone" /></span>
</navbar-item-link>
<navbar-item-link to="/audiobooks" v-if="is_visible_audiobooks">
<span class="icon"><i class="mdi mdi-book-open-variant"></i></span>
<navbar-item-link v-if="is_visible_audiobooks" to="/audiobooks">
<span class="icon"><i class="mdi mdi-book-open-variant" /></span>
</navbar-item-link>
<navbar-item-link to="/radio" v-if="is_visible_radio">
<span class="icon"><i class="mdi mdi-radio"></i></span>
<navbar-item-link v-if="is_visible_radio" to="/radio">
<span class="icon"><i class="mdi mdi-radio" /></span>
</navbar-item-link>
<navbar-item-link to="/files" v-if="is_visible_files">
<span class="icon"><i class="mdi mdi-folder-open"></i></span>
<navbar-item-link v-if="is_visible_files" to="/files">
<span class="icon"><i class="mdi mdi-folder-open" /></span>
</navbar-item-link>
<navbar-item-link to="/search" v-if="is_visible_search">
<span class="icon"><i class="mdi mdi-magnify"></i></span>
<navbar-item-link v-if="is_visible_search" to="/search">
<span class="icon"><i class="mdi mdi-magnify" /></span>
</navbar-item-link>
<div class="navbar-burger" @click="show_burger_menu = !show_burger_menu" :class="{ 'is-active': show_burger_menu }">
<span></span>
<span></span>
<span></span>
<div
class="navbar-burger"
:class="{ 'is-active': show_burger_menu }"
@click="show_burger_menu = !show_burger_menu"
>
<span />
<span />
<span />
</div>
</div>
<div class="navbar-menu" :class="{ 'is-active': show_burger_menu }">
<div class="navbar-start">
</div>
<div class="navbar-start" />
<div class="navbar-end">
<!-- Burger menu entries -->
<div class="navbar-item has-dropdown is-hoverable"
:class="{ 'is-active': show_settings_menu }"
@click="on_click_outside_settings">
<div
class="navbar-item has-dropdown is-hoverable"
:class="{ 'is-active': show_settings_menu }"
@click="on_click_outside_settings"
>
<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>
</a>
<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="/music" exact><span class="icon"><i class="mdi mdi-music"></i></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 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">
<navbar-item-link to="/settings/webinterface">
Settings
</navbar-item-link>
<a class="navbar-item" @click.stop.prevent="open_update_dialog()">
Update Library
</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 class="is-overlay" v-show="show_settings_menu"
style="z-index:10; width: 100vw; height:100vh;"
@click="show_settings_menu = false"></div>
<div
v-show="show_settings_menu"
class="is-overlay"
style="z-index: 10; width: 100vw; height: 100vh"
@click="show_settings_menu = false"
/>
</nav>
</template>
<script>
import NavbarItemLink from './NavbarItemLink'
import NavbarItemLink from './NavbarItemLink.vue'
import * as types from '@/store/mutation_types'
export default {
name: 'NavbarTop',
components: { NavbarItemLink },
data () {
data() {
return {
show_settings_menu: false
}
},
computed: {
is_visible_playlists () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_playlists').value
is_visible_playlists() {
return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_playlists'
).value
},
is_visible_music () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_music').value
is_visible_music() {
return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_music'
).value
},
is_visible_podcasts () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_podcasts').value
is_visible_podcasts() {
return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_podcasts'
).value
},
is_visible_audiobooks () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_audiobooks').value
is_visible_audiobooks() {
return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_audiobooks'
).value
},
is_visible_radio () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_radio').value
is_visible_radio() {
return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_radio'
).value
},
is_visible_files () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_files').value
is_visible_files() {
return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_files'
).value
},
is_visible_search () {
return this.$store.getters.settings_option('webinterface', 'show_menu_item_search').value
is_visible_search() {
return this.$store.getters.settings_option(
'webinterface',
'show_menu_item_search'
).value
},
player () {
player() {
return this.$store.state.player
},
config () {
config() {
return this.$store.state.config
},
library () {
library() {
return this.$store.state.library
},
audiobooks () {
audiobooks() {
return this.$store.state.audiobooks_count
},
podcasts () {
podcasts() {
return this.$store.state.podcasts_count
},
spotify_enabled () {
spotify_enabled() {
return this.$store.state.spotify.webapi_token_valid
},
show_burger_menu: {
get () {
get() {
return this.$store.state.show_burger_menu
},
set (value) {
set(value) {
this.$store.commit(types.SHOW_BURGER_MENU, value)
}
},
show_player_menu () {
show_player_menu() {
return this.$store.state.show_player_menu
},
show_update_dialog: {
get () {
get() {
return this.$store.state.show_update_dialog
},
set (value) {
set(value) {
this.$store.commit(types.SHOW_UPDATE_DIALOG, value)
}
},
zindex () {
zindex() {
if (this.show_player_menu) {
return 'z-index: 20'
}
@ -169,19 +237,24 @@ export default {
}
},
methods: {
on_click_outside_settings () {
this.show_settings_menu = !this.show_settings_menu
watch: {
$route(to, from) {
this.show_settings_menu = false
}
},
watch: {
$route (to, from) {
methods: {
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_burger_menu = false
}
}
}
</script>
<style>
</style>
<style></style>

View File

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

View File

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

View File

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

View File

@ -1,6 +1,17 @@
<template>
<a @click="toggle_play_pause" :disabled="disabled">
<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>
<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
}
]"
/></span>
</a>
</template>
@ -16,16 +27,18 @@ export default {
},
computed: {
is_playing () {
is_playing() {
return this.$store.state.player.state === 'play'
},
is_pause_allowed () {
return (this.$store.getters.now_playing &&
this.$store.getters.now_playing.data_kind !== 'pipe')
is_pause_allowed() {
return (
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
}
},
@ -34,7 +47,12 @@ export default {
toggle_play_pause: function () {
if (this.disabled) {
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
}
@ -51,5 +69,4 @@ export default {
}
</script>
<style>
</style>
<style></style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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