Compare commits
128 Commits
Author | SHA1 | Date | |
---|---|---|---|
3f6a74a45d | |||
|
73b2b493a4 | ||
|
e1dc32060b | ||
|
487753a5a1 | ||
|
c9874b67bd | ||
|
c5957da93b | ||
|
9eb30f9182 | ||
|
6383a30b31 | ||
|
d73e69a045 | ||
|
9a99d99d30 | ||
|
fd122588ad | ||
|
5847ff0b94 | ||
|
89e2b7d5dc | ||
|
81e4593d57 | ||
|
0543b4342b | ||
|
94692ff809 | ||
|
aaaa4e5500 | ||
|
209ed37007 | ||
|
2ccf815b70 | ||
|
eecfc93828 | ||
|
3180b9d8c2 | ||
|
babaebeca9 | ||
|
04554b338e | ||
|
8a2248f3a8 | ||
|
890672643c | ||
|
40b0d77c47 | ||
|
6ca8874fc5 | ||
|
4312a4899b | ||
|
3636635b23 | ||
|
2ed470c418 | ||
|
f58414a842 | ||
|
1745495d2a | ||
|
cdb4540d82 | ||
|
b1ee4b18dc | ||
|
3d89d3f26d | ||
|
da88dc18e7 | ||
|
8818a20afc | ||
|
a98e5ab486 | ||
|
28dfdb82c1 | ||
|
23507436f6 | ||
|
cc6a82116b | ||
|
60a1b7f70b | ||
|
8229276683 | ||
|
92bead8b6b | ||
|
56a30c484c | ||
|
86e66425d2 | ||
|
40574314f7 | ||
|
fd71ce5d15 | ||
|
1d49e741b0 | ||
|
2620ece704 | ||
|
67ea0eaae8 | ||
|
e8186c6407 | ||
|
ac00a8eff7 | ||
|
38a2fbf791 | ||
|
4e8cd1f2e8 | ||
|
2c56585a9c | ||
|
ae8c2611f2 | ||
|
11dbb60100 | ||
|
1d8a739766 | ||
|
96752100e3 | ||
|
44fdf99d70 | ||
|
2d60223581 | ||
|
2fc640c34b | ||
|
41eb1e9ca3 | ||
|
727be5b16b | ||
|
9ab8c444ef | ||
|
cddb6fe85e | ||
|
f609f7132b | ||
|
325f642d72 | ||
|
4e71857e2a | ||
|
be1e322461 | ||
|
14bef85046 | ||
|
43592bce8a | ||
|
92179f4914 | ||
|
f89f6b36c8 | ||
|
4100572b54 | ||
|
c3ec82dc74 | ||
|
ffa001a42e | ||
|
797015caf9 | ||
|
292cefb22b | ||
|
f1afda87c5 | ||
|
e8711d2744 | ||
|
8f551a7e05 | ||
|
71ee7e2ff3 | ||
|
d66c59f19b | ||
|
c072bccc58 | ||
|
84d62f4eee | ||
|
8b4c482efa | ||
|
fef13f18a9 | ||
|
80dc608144 | ||
|
da2a5333fe | ||
|
c7c89a306a | ||
|
bf03d8907b | ||
|
a0de1f0c5a | ||
|
8022929dc1 | ||
|
353c57d819 | ||
|
822c13e9ba | ||
|
a3fd828834 | ||
|
588f975eba | ||
|
8e9a6ca26d | ||
|
383334cfe5 | ||
|
c49eb98efb | ||
|
1e7d87671c | ||
|
bcb1edebef | ||
|
d002a5c09a | ||
|
ca94462d93 | ||
|
6bf5d6cdd3 | ||
|
af044fb544 | ||
|
d2d85b20aa | ||
|
ec74e13f94 | ||
|
181cd70bc6 | ||
|
b65eb4580c | ||
|
e077fabdca | ||
|
f11add00d9 | ||
|
1e5c3e521e | ||
|
2692db3611 | ||
|
7e8b7c4478 | ||
|
f9cc0faa12 | ||
|
ec40140f8c | ||
|
63addd5744 | ||
|
a2c37eb4b2 | ||
|
3e8f758ee0 | ||
|
9a68b816f7 | ||
|
e6ca1e63a8 | ||
|
8bc9473efd | ||
|
a11af769ee | ||
|
bc06cc37d5 | ||
|
a900000ce2 |
@ -22,9 +22,8 @@ module.exports = {
|
|||||||
'vue/component-tags-order': ['error', {
|
'vue/component-tags-order': ['error', {
|
||||||
order: ['template', 'style', 'script']
|
order: ['template', 'style', 'script']
|
||||||
}],
|
}],
|
||||||
'no-console': 'warn',
|
'no-console': 'off',
|
||||||
'no-debugger': 'warn',
|
'no-debugger': 'warn',
|
||||||
'no-useless-constructor': 'off', // Crashes eslint
|
|
||||||
'no-empty-pattern': 'off',
|
'no-empty-pattern': 'off',
|
||||||
'comma-dangle': 'off',
|
'comma-dangle': 'off',
|
||||||
'space-before-function-paren': ['error', 'never'],
|
'space-before-function-paren': ['error', 'never'],
|
||||||
|
46
.github/workflows/ci.yml
vendored
@ -1,10 +1,10 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
on:
|
||||||
on: [push, pull_request]
|
- push
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: ${{ github.repository }}
|
IMAGE: ${{ github.repository }}
|
||||||
TAG: ${{ github.sha }}
|
VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@ -21,7 +21,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
export VUE_APP_BUILD=$TAG
|
export VUE_APP_BUILD=$VERSION
|
||||||
export VUE_APP_BUILD_DATE=$(date --iso-8601)
|
export VUE_APP_BUILD_DATE=$(date --iso-8601)
|
||||||
yarn build
|
yarn build
|
||||||
|
|
||||||
@ -31,18 +31,26 @@ jobs:
|
|||||||
name: dist
|
name: dist
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
build_docker_image:
|
||||||
uses: docker/setup-buildx-action@v1
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Log in to docker hub
|
- name: Download artifact
|
||||||
run: docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist
|
||||||
|
|
||||||
- name: Build docker image
|
- name: Build docker image
|
||||||
|
run: docker build -t $IMAGE:$VERSION -f docker/Dockerfile .
|
||||||
|
|
||||||
|
- name: Push docker image
|
||||||
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx build \
|
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
--platform linux/arm64 \
|
docker push $IMAGE:$VERSION
|
||||||
--tag $IMAGE:$TAG \
|
|
||||||
--file docker/Dockerfile .
|
|
||||||
|
|
||||||
preview:
|
preview:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -53,6 +61,7 @@ jobs:
|
|||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v2
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
|
path: dist
|
||||||
|
|
||||||
- name: Deploy preview
|
- name: Deploy preview
|
||||||
uses: netlify/actions/cli@master
|
uses: netlify/actions/cli@master
|
||||||
@ -60,7 +69,7 @@ jobs:
|
|||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
with:
|
with:
|
||||||
args: deploy --dir=.
|
args: deploy --dir=dist
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -71,6 +80,7 @@ jobs:
|
|||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v2
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
|
path: dist
|
||||||
|
|
||||||
- name: Deploy site
|
- name: Deploy site
|
||||||
uses: netlify/actions/cli@master
|
uses: netlify/actions/cli@master
|
||||||
@ -78,16 +88,16 @@ jobs:
|
|||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
with:
|
with:
|
||||||
args: deploy --dir=. --prod
|
args: deploy --dir=dist --prod
|
||||||
|
|
||||||
publish_docker_image:
|
publish_latest_docker_image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build_docker_image
|
||||||
if: github.ref == 'refs/heads/master'
|
if: github.ref == 'refs/heads/master'
|
||||||
steps:
|
steps:
|
||||||
- name: Push latest
|
- name: Push latest
|
||||||
run: |
|
run: |
|
||||||
docker pull $IMAGE:$TAG
|
docker pull $IMAGE:$VERSION
|
||||||
docker tag $IMAGE:$TAG $IMAGE:latest
|
docker tag $IMAGE:$VERSION $IMAGE:latest
|
||||||
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
docker push $IMAGE:latest
|
docker push $IMAGE:latest
|
||||||
|
29
.github/workflows/pr.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
name: PR
|
||||||
|
on:
|
||||||
|
- pull_request
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ github.repository }}
|
||||||
|
VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: '12.x'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
export VUE_APP_BUILD=$VERSION
|
||||||
|
export VUE_APP_BUILD_DATE=$(date --iso-8601)
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
- name: Build docker image
|
||||||
|
run: docker build -t $IMAGE:$VERSION -f docker/Dockerfile .
|
3
.vs/ProjectSettings.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"CurrentProjectSetting": null
|
||||||
|
}
|
6
.vs/VSWorkspaceState.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"ExpandedNodes": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"PreviewInSolutionExplorer": false
|
||||||
|
}
|
BIN
.vs/slnx.sqlite
Normal file
61
README.md
@ -1,40 +1,44 @@
|
|||||||
# Airsonic Web Client
|
# Airsonic (refix) UI
|
||||||
|
|
||||||
[](https://github.com/tamland/airsonic-frontend/actions)
|
[](https://github.com/tamland/airsonic-refix/actions)
|
||||||
|
[](https://hub.docker.com/r/tamland/airsonic-refix)
|
||||||
|
|
||||||
Modern responsive web frontend for [Airsonic](https://github.com/airsonic/airsonic). It's currently based on the [Subsonic API](http://www.subsonic.org/pages/api.jsp) and should work with other backends implementing this API as well.
|
Modern responsive web frontend for [Airsonic](https://github.com/airsonic-advanced/airsonic-advanced) and other [Subsonic](https://github.com/topics/subsonic) based music servers.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Responsive UI for desktop and mobile
|
||||||
|
- Browse library for albums, artist, generes
|
||||||
|
- Playback with persistent queue, repeat & shuffle
|
||||||
|
- MediaSession integration
|
||||||
|
- View, create, and edit playlists with drag and drop
|
||||||
|
- Built-in 'random' playlist
|
||||||
|
- Search
|
||||||
|
- Favourites
|
||||||
|
- Internet radio
|
||||||
|
- Podcasts
|
||||||
|
|
||||||
|
## [Live demo](https://airsonic.netlify.com)
|
||||||
|
|
||||||
|
Enter the following details:
|
||||||
|
Server: `/api`
|
||||||
|
Username: `guest4`, `guest5`, `guest6` etc.
|
||||||
|
Password:`guest`
|
||||||
|
|
||||||
|
You can try the demo with your own local server as well. Simply enter the full URL of your Airsonic server in the Server field (such as http://localhost:8080) with your credentials. **Note**: if your server is using http only you must allow mixed content in your browser otherwise login will not work.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
## Supported features
|
|
||||||
- Responsive UI. Works on mobile and desktop
|
|
||||||
- Playback with presistent queue
|
|
||||||
- Browse library for albums, artist, generes and starred songs
|
|
||||||
- Create, delete and edit playlists
|
|
||||||
- Search for artists, albums and songs
|
|
||||||
- Play random songs with the built-in smart playlist
|
|
||||||
|
|
||||||
|
|
||||||
## Demo
|
|
||||||
|
|
||||||
https://airsonic.netlify.com
|
|
||||||
|
|
||||||
Server: `/api`
|
|
||||||
Username: `guest1`
|
|
||||||
Password:`guest`
|
|
||||||
|
|
||||||
You can use the URL and credentials for your own server if you prefer. **Note**: if your server is using http only you must allow mixed content in your browser otherwise login will not work.
|
|
||||||
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```
|
```
|
||||||
$ docker run -d -p 8080:80 tamland/airsonic-frontend:latest
|
$ docker run -d -p 8080:80 tamland/airsonic-refix:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
You can now access the application at http://localhost:8080/
|
You can now access the application at http://localhost:8080/
|
||||||
@ -45,8 +49,8 @@ Environment variables:
|
|||||||
|
|
||||||
### Pre-built bundle
|
### Pre-built bundle
|
||||||
|
|
||||||
Pre-built bundles can be found in the [Actions](https://github.com/tamland/airsonic-frontend/actions)
|
Pre-built bundles can be found in the [Actions](https://github.com/tamland/airsonic-refix/actions)
|
||||||
tab. Download/extract artifact and serve with your favourite web server.
|
tab. Download/extract artifact and serve with any web server such as nginx or apache.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
@ -57,6 +61,11 @@ $ yarn build
|
|||||||
|
|
||||||
Bundle can be found in the `dist` folder.
|
Bundle can be found in the `dist` folder.
|
||||||
|
|
||||||
|
Build docker image:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ docker build -f docker/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
|
14995
package-lock.json
generated
Normal file
43
package.json
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "airsonic",
|
"name": "airsonic-refix",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -8,33 +8,34 @@
|
|||||||
"lint": "vue-cli-service lint"
|
"lint": "vue-cli-service lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.20.0",
|
"axios": "^0.21.1",
|
||||||
"bootstrap": "^4.5.2",
|
"bootstrap": "^4.6.0",
|
||||||
"bootstrap-vue": "^2.17.3",
|
"bootstrap-vue": "^2.21.2",
|
||||||
"md5-es": "1.8.2",
|
"md5-es": "1.8.2",
|
||||||
"vue": "^2.6.12",
|
"vue": "^2.6.12",
|
||||||
"vue-infinite-loading": "2.4.5",
|
"vue-infinite-loading": "2.4.5",
|
||||||
"vue-router": "^3.4.6",
|
"vue-router": "^3.5.2",
|
||||||
"vuex": "^3.5.1"
|
"vue-slider-component": "3.2.13",
|
||||||
|
"vuex": "^3.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^4.4.0",
|
"@typescript-eslint/eslint-plugin": "^4.28.1",
|
||||||
"@typescript-eslint/parser": "^4.4.0",
|
"@typescript-eslint/parser": "^4.28.1",
|
||||||
"@vue/cli-plugin-babel": "^4.5.7",
|
"@vue/cli-plugin-babel": "^4.5.13",
|
||||||
"@vue/cli-plugin-eslint": "~4.5.7",
|
"@vue/cli-plugin-eslint": "~4.5.13",
|
||||||
"@vue/cli-plugin-typescript": "^4.5.7",
|
"@vue/cli-plugin-typescript": "^4.5.13",
|
||||||
"@vue/cli-service": "^4.5.7",
|
"@vue/cli-service": "^4.5.13",
|
||||||
"@vue/eslint-config-standard": "^5.1.2",
|
"@vue/eslint-config-standard": "^6.0.0",
|
||||||
"@vue/eslint-config-typescript": "^7.0.0",
|
"@vue/eslint-config-typescript": "^7.0.0",
|
||||||
"eslint": "^7.11.0",
|
"eslint": "^7.30.0",
|
||||||
"eslint-plugin-import": "^2.22.1",
|
"eslint-plugin-import": "^2.23.3",
|
||||||
"eslint-plugin-node": "^11.1.0",
|
"eslint-plugin-node": "^11.1.0",
|
||||||
"eslint-plugin-promise": "^4.2.1",
|
"eslint-plugin-promise": "^5.1.0",
|
||||||
"eslint-plugin-standard": "^4.0.0",
|
"eslint-plugin-standard": "^5.0.0",
|
||||||
"eslint-plugin-vue": "^7.0.1",
|
"eslint-plugin-vue": "^7.12.1",
|
||||||
"sass": "^1.27.0",
|
"sass": "^1.34.0",
|
||||||
"sass-loader": "^10.0.3",
|
"sass-loader": "^10.1.1",
|
||||||
"typescript": "^4.0.3",
|
"typescript": "^4.3.5",
|
||||||
"vue-template-compiler": "^2.6.12"
|
"vue-template-compiler": "^2.6.12"
|
||||||
},
|
},
|
||||||
"postcss": {
|
"postcss": {
|
||||||
|
Before Width: | Height: | Size: 9.0 KiB |
Before Width: | Height: | Size: 4.6 KiB |
@ -1,7 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="512" height="512" fill="#fff" version="1.1" viewBox="0 0 135.47 135.47"
|
<svg width="512" height="512" fill="#09f" version="1.1" viewBox="0 0 135.47 135.47" xmlns="http://www.w3.org/2000/svg">
|
||||||
xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#">
|
|
||||||
<rect width="100%" height="100%" fill="#09f"/>
|
|
||||||
<g transform="translate(0 -161.53)">
|
<g transform="translate(0 -161.53)">
|
||||||
<g transform="matrix(1.0344 0 0 1.0869 -2.0685 -19.991)">
|
<g transform="matrix(1.0344 0 0 1.0869 -2.0685 -19.991)">
|
||||||
<rect x="9.9294" y="224.55" width="5.9939" height="23.366" rx="2.997" ry="2.997" />
|
<rect x="9.9294" y="224.55" width="5.9939" height="23.366" rx="2.997" ry="2.997" />
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
@ -4,16 +4,16 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<% if (process.env.NODE_ENV === "production") { %>
|
<% if (process.env.NODE_ENV === "production") { %>
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src *; img-src *; media-src *; manifest-src 'self'; style-src 'self'; script-src 'self'; base-uri 'self';">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src *; img-src *; media-src *; manifest-src 'self'; style-src 'self' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-624gmqlO23N0g1Ru4tkjuaPEoL/hXP4w7tUqel4WM98=' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-5uOIRR03mYcVoiexgzGGALQ0p1Babe2XxbeIl9t1UpA=' 'sha256-lM8P08IzH0mbT5Tvlm1F5BY3h0gPsb0qNpnZW9YHc7A='; script-src 'self'; base-uri 'self';">
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src *; img-src *; media-src *; manifest-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; base-uri 'self';">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src *; img-src *; media-src *; manifest-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; base-uri 'self';">
|
||||||
<% } %>
|
<% } %>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<meta name="theme-color" content="#1a1a1a" />
|
<meta name="theme-color" content="#000" />
|
||||||
<link rel="icon" href="<%= BASE_URL %>icon.svg">
|
<link rel="icon" href="<%= BASE_URL %>icon.svg">
|
||||||
<link rel=manifest href="<%= BASE_URL %>manifest.webmanifest">
|
<link rel=manifest href="<%= BASE_URL %>manifest.webmanifest">
|
||||||
<script src="<%= BASE_URL %>env.js"></script>
|
<script src="<%= BASE_URL %>env.js"></script>
|
||||||
<title></title>
|
<title>Airsonic (refix)</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript>
|
||||||
|
@ -1,25 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "Airsonic",
|
"name": "Airsonic (refix)",
|
||||||
"short_name": "Airsonic",
|
"short_name": "Airsonic (refix)",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#09F",
|
"theme_color": "#000",
|
||||||
"background_color": "#09F",
|
"background_color": "#000",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "./icon.svg",
|
"src": "./icon.svg",
|
||||||
"type": "image/svg",
|
"type": "image/svg+xml",
|
||||||
"sizes": "any"
|
"sizes": "any",
|
||||||
},
|
"purpose": "any"
|
||||||
{
|
|
||||||
"src": "./icon-192x192.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "192x192"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "./icon-512x512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 330 KiB |
Before Width: | Height: | Size: 926 KiB After Width: | Height: | Size: 1.1 MiB |
@ -34,7 +34,7 @@
|
|||||||
computed: {
|
computed: {
|
||||||
build: () => process.env.VUE_APP_BUILD,
|
build: () => process.env.VUE_APP_BUILD,
|
||||||
buildDate: () => process.env.VUE_APP_BUILD_DATE,
|
buildDate: () => process.env.VUE_APP_BUILD_DATE,
|
||||||
url: () => 'https://github.com/tamland/airsonic-frontend'
|
url: () => 'https://github.com/tamland/airsonic-refix'
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,35 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="min-vh-100 d-flex -align-items-stretch -justify-spcace-between">
|
|
||||||
<Sidebar />
|
|
||||||
<main class="container-fluid pt-3 pb-3">
|
|
||||||
<TopNav />
|
|
||||||
<router-view />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<ErrorBar />
|
<ErrorBar />
|
||||||
<Player />
|
<component :is="layout">
|
||||||
|
<router-view />
|
||||||
|
</component>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style lang="scss">
|
|
||||||
main {
|
|
||||||
margin-bottom: 80px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ErrorBar from './ErrorBar.vue'
|
import ErrorBar from './ErrorBar.vue'
|
||||||
import TopNav from './TopNav.vue'
|
import Default from '@/app/layout/Default.vue'
|
||||||
import Sidebar from './Sidebar.vue'
|
import Fullscreen from '@/app/layout/Fullscreen.vue'
|
||||||
import Player from '@/player/Player.vue'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ErrorBar,
|
ErrorBar,
|
||||||
TopNav,
|
Default,
|
||||||
Sidebar,
|
Fullscreen,
|
||||||
Player,
|
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
layout(): string {
|
||||||
|
return (this as any).$route.meta.layout || 'Default'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
<template functional>
|
<template functional>
|
||||||
|
<div class="d-flex align-items-end logo-container"
|
||||||
|
:class="data.staticClass || ''"
|
||||||
|
v-bind="data.attrs">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="100%" viewBox="5.74 31.24 123.89 72.89">
|
<svg xmlns="http://www.w3.org/2000/svg" height="100%" viewBox="5.74 31.24 123.89 72.89">
|
||||||
<g transform="matrix(1.0344 0 0 1.0869 -2.068 -181.521)">
|
<g transform="matrix(1.0344 0 0 1.0869 -2.068 -181.521)">
|
||||||
<rect width="5.994" height="23.366" x="9.929" y="224.55" rx="2.997" ry="2.997" />
|
<rect width="5.994" height="23.366" x="9.929" y="224.55" rx="2.997" ry="2.997" />
|
||||||
@ -15,9 +18,15 @@
|
|||||||
<rect width="5.994" height="22.372" x="119.04" y="230.78" rx="2.997" ry="2.997" />
|
<rect width="5.994" height="22.372" x="119.04" y="230.78" rx="2.997" ry="2.997" />
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-body ml-2 text-nowrap">airsonic
|
||||||
|
<span class="text-muted">(refix)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
svg {
|
svg {
|
||||||
fill: var(--primary);
|
fill: var(--primary);
|
||||||
|
height: 32px;
|
||||||
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
|
||||||
<nav class="nav flex-column">
|
<nav class="nav flex-column">
|
||||||
<div class="nav-link logo d-flex justify-content-between">
|
<div class="sidebar-brand d-flex justify-content-between align-items-end">
|
||||||
<Logo />
|
<Logo />
|
||||||
<button class="btn btn-link btn-lg p-0 d-md-none" @click="hideMenu">
|
<button class="btn btn-link btn-lg p-0 m-0 d-md-none" @click="hideMenu">
|
||||||
<Icon icon="x" />
|
<Icon icon="x" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<router-link class="nav-link" :to="{name: 'home'}">
|
<router-link class="nav-link" :to="{name: 'home'}" exact>
|
||||||
<Icon icon="card-text" class="" /> Discover
|
<Icon icon="card-text" class="" /> Discover
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
@ -16,13 +15,11 @@
|
|||||||
<Icon icon="music-note-list" /> Playing
|
<Icon icon="music-note-list" /> Playing
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<a class="nav-link disabled">
|
<small class="sidebar-heading text-muted">
|
||||||
<small class="text-uppercase text-muted font-weight-bold">
|
|
||||||
Library
|
Library
|
||||||
</small>
|
</small>
|
||||||
</a>
|
|
||||||
|
|
||||||
<router-link class="nav-link" :to="{name: 'albums', params: {sort: 'recently-added'}}">
|
<router-link class="nav-link" :to="{name: 'albums-default'}">
|
||||||
<Icon icon="collection" /> Albums
|
<Icon icon="collection" /> Albums
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
@ -34,8 +31,12 @@
|
|||||||
<Icon icon="collection" /> Genres
|
<Icon icon="collection" /> Genres
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link class="nav-link" :to="{name: 'starred'}">
|
<router-link class="nav-link" :to="{name: 'favourites'}">
|
||||||
<Icon icon="star" /> Starred
|
<Icon icon="heart" /> Favourites
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link class="nav-link" :to="{name: 'podcasts'}">
|
||||||
|
<Icon icon="rss" /> Podcasts
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link class="nav-link" :to="{name: 'radio'}">
|
<router-link class="nav-link" :to="{name: 'radio'}">
|
||||||
@ -44,7 +45,6 @@
|
|||||||
|
|
||||||
<PlaylistNav />
|
<PlaylistNav />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
@ -18,50 +18,73 @@
|
|||||||
</b-sidebar>
|
</b-sidebar>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style>
|
<style lang="scss">
|
||||||
.sidebar-container nav {
|
.sidebar-container {
|
||||||
padding-top: 0.5rem;
|
.nav {
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: white transparent;
|
||||||
|
max-height: 100vh;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
.sidebar-container .sidebar-fixed {
|
.sidebar-fixed {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
padding-bottom: 180px;
|
|
||||||
width: 250px;
|
width: 250px;
|
||||||
}
|
}
|
||||||
|
.sidebar-brand {
|
||||||
.sidebar-container .logo {
|
padding: 1rem 1rem 0.75rem;
|
||||||
height: 48px;
|
}
|
||||||
|
.sidebar-heading {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-container .nav-link {
|
a.nav-link {
|
||||||
|
flex-shrink: 0;
|
||||||
width: calc(100%);
|
width: calc(100%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
.b-icon {
|
||||||
|
|
||||||
.sidebar-container a.nav-link .b-icon {
|
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
}
|
}
|
||||||
|
&:not(.router-link-active) .b-icon {
|
||||||
.sidebar-container a.nav-link:not(.active) .b-icon {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
&:hover {
|
||||||
|
color: inherit;
|
||||||
|
background-color: rgba(255, 255, 255, 0.045);
|
||||||
|
}
|
||||||
|
&.router-link-active {
|
||||||
|
color: var(--primary);
|
||||||
|
background-color: rgba(255, 255, 255, 0.045);
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from "vue";
|
||||||
import Nav from './Nav.vue'
|
import Nav from "./Nav.vue";
|
||||||
import { mapState, mapActions } from 'vuex'
|
import { mapState, mapActions } from "vuex";
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
components: {
|
components: {
|
||||||
Nav,
|
Nav,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['showMenu'])
|
...mapState(["showMenu"]),
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['hideMenu']),
|
...mapActions(["hideMenu"]),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
{{ username }}
|
{{ username }}
|
||||||
</b-dropdown-text>
|
</b-dropdown-text>
|
||||||
<b-dropdown-divider />
|
<b-dropdown-divider />
|
||||||
<b-dropdown-item :href="`${server}/settings.view`">
|
<b-dropdown-item :href="`${server}/settings.view`" target="_blank">
|
||||||
Server settings
|
Server settings
|
||||||
</b-dropdown-item>
|
</b-dropdown-item>
|
||||||
<b-dropdown-item-button @click="scan">
|
<b-dropdown-item-button @click="scan">
|
||||||
@ -67,7 +67,7 @@
|
|||||||
'showMenu',
|
'showMenu',
|
||||||
]),
|
]),
|
||||||
scan() {
|
scan() {
|
||||||
this.$api.scan()
|
return this.$api.scan()
|
||||||
},
|
},
|
||||||
logout() {
|
logout() {
|
||||||
this.$auth.logout()
|
this.$auth.logout()
|
||||||
|
31
src/app/layout/Default.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="min-vh-100 d-flex">
|
||||||
|
<Sidebar />
|
||||||
|
<main class="container-fluid py-2">
|
||||||
|
<TopNav />
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<Player />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style lang="scss">
|
||||||
|
main {
|
||||||
|
margin-bottom: 80px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script lang="ts">
|
||||||
|
import TopNav from '@/app/TopNav.vue'
|
||||||
|
import Sidebar from '@/app/Sidebar.vue'
|
||||||
|
import Player from '@/player/Player.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
TopNav,
|
||||||
|
Sidebar,
|
||||||
|
Player,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
5
src/app/layout/Fullscreen.vue
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container-fluid">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</template>
|
@ -1,9 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="row align-items-center h-100 mt-5">
|
||||||
<b-modal size="sm" hide-header hide-footer no-close-on-esc :visible="showModal">
|
<div v-if="!displayForm" class="mx-auto">
|
||||||
|
<span class="spinner-border " />
|
||||||
|
</div>
|
||||||
|
<div v-else class="mx-auto card " style="width: 22rem;">
|
||||||
|
<b-overlay rounded :show="busy" opacity="0.1">
|
||||||
|
<div class="card-body">
|
||||||
<form @submit.prevent="login">
|
<form @submit.prevent="login">
|
||||||
<div style="font-size: 4rem; color: #fff;" class="text-center">
|
<div class="d-flex mb-2">
|
||||||
<Icon icon="person-circle" />
|
<Logo class="mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<b-form-group v-if="!config.serverUrl" label="Server">
|
<b-form-group v-if="!config.serverUrl" label="Server">
|
||||||
<b-form-input v-model="server" name="server" type="text" :state="valid" />
|
<b-form-input v-model="server" name="server" type="text" :state="valid" />
|
||||||
@ -20,17 +25,23 @@
|
|||||||
</template>
|
</template>
|
||||||
</b-alert>
|
</b-alert>
|
||||||
<button class="btn btn-primary btn-block" :disabled="busy" @click="login">
|
<button class="btn btn-primary btn-block" :disabled="busy" @click="login">
|
||||||
<b-spinner v-show="busy" small type="grow" /> Log in
|
<span v-show="false" class="spinner-border spinner-border-sm" /> Log in
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</b-modal>
|
</div>
|
||||||
|
</b-overlay>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>>
|
</template>>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import { config } from '@/shared/config'
|
import { config } from '@/shared/config'
|
||||||
|
import Logo from '@/app/Logo.vue'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
|
components: {
|
||||||
|
Logo,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
returnTo: { type: String, required: true },
|
returnTo: { type: String, required: true },
|
||||||
},
|
},
|
||||||
@ -42,7 +53,7 @@
|
|||||||
rememberLogin: true,
|
rememberLogin: true,
|
||||||
busy: false,
|
busy: false,
|
||||||
error: null,
|
error: null,
|
||||||
showModal: false,
|
displayForm: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -52,8 +63,8 @@
|
|||||||
config: () => config
|
config: () => config
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
this.server = await this.$auth.server
|
this.server = this.$auth.server
|
||||||
this.username = await this.$auth.username
|
this.username = this.$auth.username
|
||||||
const success = await this.$auth.autoLogin()
|
const success = await this.$auth.autoLogin()
|
||||||
if (success) {
|
if (success) {
|
||||||
this.$store.commit('setLoginSuccess', {
|
this.$store.commit('setLoginSuccess', {
|
||||||
@ -62,7 +73,7 @@
|
|||||||
})
|
})
|
||||||
this.$router.replace(this.returnTo)
|
this.$router.replace(this.returnTo)
|
||||||
} else {
|
} else {
|
||||||
this.showModal = true
|
this.displayForm = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -47,7 +47,7 @@ export class AuthService {
|
|||||||
hash: string,
|
hash: string,
|
||||||
remember: boolean
|
remember: boolean
|
||||||
) {
|
) {
|
||||||
const url = `${server}/rest/ping.view?u=${username}&s=${salt}&t=${hash}&v=1.15.0&c=app&f=json`
|
const url = `${server}/rest/ping?u=${username}&s=${salt}&t=${hash}&v=1.15.0&c=app&f=json`
|
||||||
return axios.get(url)
|
return axios.get(url)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const subsonicResponse = response.data['subsonic-response']
|
const subsonicResponse = response.data['subsonic-response']
|
||||||
|
1
src/global.d.ts
vendored
@ -4,6 +4,7 @@ declare module '*.vue' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare module 'md5-es';
|
declare module 'md5-es';
|
||||||
|
declare module 'vue-slider-component';
|
||||||
|
|
||||||
type MediaSessionPlaybackState = 'none' | 'paused' | 'playing';
|
type MediaSessionPlaybackState = 'none' | 'paused' | 'playing';
|
||||||
|
|
||||||
|
@ -1,47 +0,0 @@
|
|||||||
<template>
|
|
||||||
<b-dropdown variant="link" boundary="window" no-caret toggle-class="p-0">
|
|
||||||
<template #button-content>
|
|
||||||
<Icon icon="three-dots-vertical" />
|
|
||||||
</template>
|
|
||||||
<b-dropdown-item-button @click="setNextInQueue()">
|
|
||||||
Play next
|
|
||||||
</b-dropdown-item-button>
|
|
||||||
<b-dropdown-item-button @click="addToQueue()">
|
|
||||||
Add to queue
|
|
||||||
</b-dropdown-item-button>
|
|
||||||
<b-dropdown-item-button @click="toggleStarred()">
|
|
||||||
{{ starred ? 'Unstar' : 'Star' }}
|
|
||||||
</b-dropdown-item-button>
|
|
||||||
<slot :item="track" />
|
|
||||||
</b-dropdown>
|
|
||||||
</template>
|
|
||||||
<script lang="ts">
|
|
||||||
import Vue from 'vue'
|
|
||||||
|
|
||||||
export default Vue.extend({
|
|
||||||
props: {
|
|
||||||
track: { type: Object, required: true },
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
starred: this.track.starred,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
toggleStarred() {
|
|
||||||
if (this.starred) {
|
|
||||||
this.$api.unstar('track', this.track.id)
|
|
||||||
} else {
|
|
||||||
this.$api.star('track', this.track.id)
|
|
||||||
}
|
|
||||||
this.starred = !this.starred
|
|
||||||
},
|
|
||||||
setNextInQueue() {
|
|
||||||
return this.$store.dispatch('player/setNextInQueue', this.track)
|
|
||||||
},
|
|
||||||
addToQueue() {
|
|
||||||
return this.$store.dispatch('player/addToQueue', this.track)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
@ -1,133 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<table class="table table-hover table-borderless">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="pl-0 pr-0 text-center">
|
|
||||||
#
|
|
||||||
</th>
|
|
||||||
<th class="text-left">
|
|
||||||
Title
|
|
||||||
</th>
|
|
||||||
<th v-if="!noArtist" class="text-left d-none d-lg-table-cell">
|
|
||||||
Artist
|
|
||||||
</th>
|
|
||||||
<th v-if="!noAlbum" class="text-left d-none d-md-table-cell">
|
|
||||||
Album
|
|
||||||
</th>
|
|
||||||
<th v-if="!noDuration" class="text-right d-none d-md-table-cell">
|
|
||||||
Duration
|
|
||||||
</th>
|
|
||||||
<th class="text-right">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="(item, index) in tracks"
|
|
||||||
:key="index"
|
|
||||||
:class="{'active': item.id === playingTrackId}"
|
|
||||||
:draggable="true"
|
|
||||||
@dragstart="dragstart(item.id, $event)"
|
|
||||||
>
|
|
||||||
<td class="pl-0 pr-0 text-center text-muted"
|
|
||||||
style="min-width: 20px; max-width: 20px;"
|
|
||||||
@click="play(index)">
|
|
||||||
<template v-if="item.id === playingTrackId">
|
|
||||||
<Icon :icon="isPlaying ? 'pause-fill' : 'play-fill'" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="track-number">{{ item.track || 1 }}</span>
|
|
||||||
<Icon class="track-number-hover" icon="play-fill" />
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
<td @click="play(index)">
|
|
||||||
{{ item.title }}
|
|
||||||
<div class="d-lg-none text-muted">
|
|
||||||
<small>{{ item.artist }}</small>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td v-if="!noArtist" class="d-none d-lg-table-cell">
|
|
||||||
<template v-if="item.artistId">
|
|
||||||
<router-link :to="{name: 'artist', params: {id: item.artistId}}">
|
|
||||||
{{ item.artist }}
|
|
||||||
</router-link>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ item.artist }}
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
<td v-if="!noAlbum" class="d-none d-md-table-cell">
|
|
||||||
<router-link :to="{name: 'album', params: {id: item.albumId}}">
|
|
||||||
{{ item.album }}
|
|
||||||
</router-link>
|
|
||||||
</td>
|
|
||||||
<td v-if="!noDuration" class="text-right d-none d-md-table-cell">
|
|
||||||
{{ $formatDuration(item.duration) }}
|
|
||||||
</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<TrackContextMenu :track="item">
|
|
||||||
<slot name="context-menu" :index="index" :item="item" />
|
|
||||||
</TrackContextMenu>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.track-number-hover {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
tr:hover {
|
|
||||||
.track-number-hover {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
.track-number {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script lang="ts">
|
|
||||||
import Vue from 'vue'
|
|
||||||
import { mapActions, mapGetters, mapState } from 'vuex'
|
|
||||||
import TrackContextMenu from '@/library/TrackContextMenu.vue'
|
|
||||||
|
|
||||||
export default Vue.extend({
|
|
||||||
components: {
|
|
||||||
TrackContextMenu,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
tracks: { type: Array, required: true },
|
|
||||||
noAlbum: { type: Boolean, default: false },
|
|
||||||
noArtist: { type: Boolean, default: false },
|
|
||||||
noDuration: { type: Boolean, default: false },
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapState('player', {
|
|
||||||
isPlaying: 'isPlaying',
|
|
||||||
}),
|
|
||||||
...mapGetters({
|
|
||||||
playingTrackId: 'player/trackId',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
...mapActions({
|
|
||||||
playPause: 'player/playPause',
|
|
||||||
}),
|
|
||||||
play(index: number) {
|
|
||||||
if ((this.tracks as any)[index].id === this.playingTrackId) {
|
|
||||||
return this.$store.dispatch('player/playPause')
|
|
||||||
}
|
|
||||||
return this.$store.dispatch('player/playTrackList', {
|
|
||||||
index,
|
|
||||||
tracks: this.tracks,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
dragstart(id: string, event: any) {
|
|
||||||
event.dataTransfer.setData('id', id)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
@ -13,15 +13,30 @@
|
|||||||
{{ album.artist }}
|
{{ album.artist }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-if="album.year"> • {{ album.year }}</span>
|
<span v-if="album.year"> • {{ album.year }}</span>
|
||||||
<span v-if="album.genre"> • {{ album.genre }}</span>
|
<span v-if="album.genreId"> •
|
||||||
|
<router-link :to="{name: 'genre', params: { id: album.genreId }}">
|
||||||
|
{{ album.genreId }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="text-nowrap">
|
<div class="text-nowrap">
|
||||||
<b-btn variant="secondary" class="mr-2" @click="play">
|
<b-button variant="secondary" class="mr-2" @click="play">
|
||||||
<Icon icon="play-fill" /> Play
|
<Icon icon="play-fill" /> Play
|
||||||
</b-btn>
|
</b-button>
|
||||||
<b-btn variant="secondary" class="mr-2" @click="toggleStar">
|
<b-button variant="secondary" class="mr-2" @click="toggleFavourite">
|
||||||
<Icon :icon="album.starred ? 'star-fill' : 'star'" />
|
<Icon :icon="album.favourite ? 'heart-fill' : 'heart'" />
|
||||||
</b-btn>
|
</b-button>
|
||||||
|
<b-dropdown variant="secondary" no-caret toggle-class="px-1">
|
||||||
|
<template #button-content>
|
||||||
|
<Icon icon="three-dots-vertical" />
|
||||||
|
</template>
|
||||||
|
<b-dropdown-item-btn @click="setNextInQueue">
|
||||||
|
Play next
|
||||||
|
</b-dropdown-item-btn>
|
||||||
|
<b-dropdown-item-btn @click="addToQueue">
|
||||||
|
Add to queue
|
||||||
|
</b-dropdown-item-btn>
|
||||||
|
</b-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -39,7 +54,7 @@
|
|||||||
</style>
|
</style>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import TrackList from '@/library/TrackList.vue'
|
import TrackList from '@/library/track/TrackList.vue'
|
||||||
import { Album } from '@/shared/api'
|
import { Album } from '@/shared/api'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
@ -54,27 +69,36 @@
|
|||||||
album: null as null | Album,
|
album: null as null | Album,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async created() {
|
||||||
this.album = await this.$api.getAlbumDetails(this.id)
|
this.album = await this.$api.getAlbumDetails(this.id)
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
play() {
|
play() {
|
||||||
if (this.album?.tracks) {
|
if (this.album) {
|
||||||
return this.$store.dispatch('player/playTrackList', {
|
return this.$store.dispatch('player/playTrackList', {
|
||||||
index: 0,
|
index: 0,
|
||||||
tracks: this.album.tracks,
|
tracks: this.album.tracks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleStar() {
|
setNextInQueue() {
|
||||||
if (this.album) {
|
if (this.album) {
|
||||||
const value = !this.album.starred
|
return this.$store.dispatch('player/setNextInQueue', this.album.tracks)
|
||||||
this.album.starred = value
|
|
||||||
return value
|
|
||||||
? this.$api.starAlbum(this.album.id)
|
|
||||||
: this.$api.unstarAlbum(this.album.id)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
addToQueue() {
|
||||||
|
if (this.album) {
|
||||||
|
return this.$store.dispatch('player/addToQueue', this.album.tracks)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
toggleFavourite() {
|
||||||
|
if (this.album) {
|
||||||
|
this.album.favourite = !this.album.favourite
|
||||||
|
return this.album.favourite
|
||||||
|
? this.$api.addFavourite(this.album.id, 'album')
|
||||||
|
: this.$api.removeFavourite(this.album.id, 'album')
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
@ -54,7 +54,7 @@
|
|||||||
methods: {
|
methods: {
|
||||||
loadMore() {
|
loadMore() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.$api.getAlbums(this.sort as AlbumSort, 50, this.offset).then(albums => {
|
return this.$api.getAlbums(this.sort as AlbumSort, 50, this.offset).then(albums => {
|
||||||
this.albums.push(...albums)
|
this.albums.push(...albums)
|
||||||
this.offset += albums.length
|
this.offset += albums.length
|
||||||
this.hasMore = albums.length > 0
|
this.hasMore = albums.length > 0
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<ContentLoader v-slot :loading="items == null">
|
||||||
<ArtistList :items="items" />
|
<ArtistList :items="items" />
|
||||||
|
</ContentLoader>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
@ -12,13 +14,11 @@
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
items: [] as Artist[]
|
items: null as null | Artist[]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
async created() {
|
||||||
this.$api.getArtists().then(items => {
|
this.items = await this.$api.getArtists()
|
||||||
this.items = items
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
52
src/library/favourite/Favourites.vue
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>Favourites</h1>
|
||||||
|
<ul class="nav-underlined">
|
||||||
|
<li>
|
||||||
|
<router-link :to="{... $route, params: { }}">
|
||||||
|
Albums
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link :to="{... $route, params: { section: 'artists' }}">
|
||||||
|
Artists
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link :to="{... $route, params: { section: 'tracks' }}">
|
||||||
|
Tracks
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ContentLoader v-slot :loading="result == null">
|
||||||
|
<ArtistList v-if="section === 'artists'" :items="result.artists" />
|
||||||
|
<TrackList v-else-if="section === 'tracks'" :tracks="result.tracks" />
|
||||||
|
<AlbumList v-else :items="result.albums" />
|
||||||
|
</ContentLoader>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
import AlbumList from '@/library/album/AlbumList.vue'
|
||||||
|
import ArtistList from '@/library/artist/ArtistList.vue'
|
||||||
|
import TrackList from '@/library/track/TrackList.vue'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
components: {
|
||||||
|
AlbumList,
|
||||||
|
ArtistList,
|
||||||
|
TrackList,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
section: { type: String, default: '' },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
result: null as any,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
this.result = await this.$api.getFavourites()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
@ -14,44 +14,40 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<template v-if="section === 'tracks'">
|
<template v-if="section === 'tracks'">
|
||||||
<ContentLoader v-slot :loading="tracks == null">
|
<InfiniteList v-slot="{ items }" key="tracks" :load="loadTracks">
|
||||||
<TrackList :tracks="tracks" />
|
<TrackList :tracks="items" />
|
||||||
</ContentLoader>
|
</InfiniteList>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ContentLoader v-slot :loading="albums == null">
|
<InfiniteList v-slot="{ items }" key="albums" :load="loadAlbums">
|
||||||
<AlbumList :items="albums" />
|
<AlbumList :items="items" />
|
||||||
</ContentLoader>
|
</InfiniteList>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import AlbumList from '@/library/album/AlbumList.vue'
|
import AlbumList from '@/library/album/AlbumList.vue'
|
||||||
import TrackList from '@/library/TrackList.vue'
|
import TrackList from '@/library/track/TrackList.vue'
|
||||||
|
import InfiniteList from '@/shared/components/InfiniteList.vue'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
components: {
|
components: {
|
||||||
AlbumList,
|
AlbumList,
|
||||||
TrackList,
|
TrackList,
|
||||||
|
InfiniteList,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
id: { type: String, required: true },
|
id: { type: String, required: true },
|
||||||
section: { type: String, default: '' },
|
section: { type: String, default: '' },
|
||||||
},
|
},
|
||||||
data() {
|
methods: {
|
||||||
return {
|
loadAlbums(offset: number) {
|
||||||
albums: null as null | any[],
|
return this.$api.getAlbumsByGenre(this.id, 50, offset)
|
||||||
tracks: null as null | any[],
|
},
|
||||||
}
|
loadTracks(offset: number) {
|
||||||
|
return this.$api.getTracksByGenre(this.id, 50, offset)
|
||||||
},
|
},
|
||||||
created() {
|
|
||||||
this.$api.getAlbumsByGenre(this.id).then(result => {
|
|
||||||
this.albums = result
|
|
||||||
})
|
|
||||||
this.$api.getTracksByGenre(this.id).then(result => {
|
|
||||||
this.tracks = result
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
:title="item.name">
|
:title="item.name">
|
||||||
<template #text>
|
<template #text>
|
||||||
<strong>{{ item.albumCount }}</strong> albums •
|
<strong>{{ item.albumCount }}</strong> albums •
|
||||||
<strong>{{ item.songCount }}</strong> songs
|
<strong>{{ item.trackCount }}</strong> tracks
|
||||||
</template>
|
</template>
|
||||||
</Tile>
|
</Tile>
|
||||||
</Tiles>
|
</Tiles>
|
||||||
@ -20,10 +20,8 @@
|
|||||||
items: [],
|
items: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
async created() {
|
||||||
this.$api.getGenres().then((items) => {
|
this.items = await this.$api.getGenres()
|
||||||
this.items = items
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
74
src/library/podcast/PodcastDetails.vue
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<ContentLoader v-slot :loading="podcast ==null">
|
||||||
|
<h1>{{ podcast.name }}</h1>
|
||||||
|
<p>{{ podcast.description }}</p>
|
||||||
|
<BaseTable>
|
||||||
|
<BaseTableHead>
|
||||||
|
<th class="text-right d-none d-md-table-cell">
|
||||||
|
Duration
|
||||||
|
</th>
|
||||||
|
</BaseTableHead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, index) in podcast.tracks" :key="index"
|
||||||
|
:class="{'active': item.id === playingTrackId, 'disabled': !item.playable}"
|
||||||
|
@click="play(item)">
|
||||||
|
<CellTrackNumber :active="item.id === playingTrackId && isPlaying" :track="item" />
|
||||||
|
<CellTitle :track="item" />
|
||||||
|
<CellDuration :track="item" />
|
||||||
|
<CellActions :track="item" />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</BaseTable>
|
||||||
|
</ContentLoader>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
|
import CellTrackNumber from '@/library/track/CellTrackNumber.vue'
|
||||||
|
import CellActions from '@/library/track/CellActions.vue'
|
||||||
|
import CellDuration from '@/library/track/CellDuration.vue'
|
||||||
|
import CellTitle from '@/library/track/CellTitle.vue'
|
||||||
|
import BaseTable from '@/library/track/BaseTable.vue'
|
||||||
|
import BaseTableHead from '@/library/track/BaseTableHead.vue'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
components: {
|
||||||
|
BaseTableHead,
|
||||||
|
BaseTable,
|
||||||
|
CellTitle,
|
||||||
|
CellDuration,
|
||||||
|
CellActions,
|
||||||
|
CellTrackNumber
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
id: { type: String, required: true },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
podcast: null as null | any,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
playingTrackId: 'player/trackId',
|
||||||
|
isPlaying: 'player/isPlaying',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
this.podcast = await this.$api.getPodcast(this.id)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async play(track: any) {
|
||||||
|
if (!track.playable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tracks = this.podcast.tracks.filter((x: any) => x.playable)
|
||||||
|
const index = tracks.findIndex((x: any) => x.id === track.id)
|
||||||
|
return this.$store.dispatch('player/playTrackList', {
|
||||||
|
index,
|
||||||
|
tracks,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
42
src/library/podcast/PodcastLibrary.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<ContentLoader v-slot :loading="items == null">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<h1>Podcasts</h1>
|
||||||
|
<OverflowMenu>
|
||||||
|
<b-dropdown-item-btn @click="refresh()">
|
||||||
|
Refresh
|
||||||
|
</b-dropdown-item-btn>
|
||||||
|
</OverflowMenu>
|
||||||
|
</div>
|
||||||
|
<Tiles square>
|
||||||
|
<Tile v-for="item in items" :key="item.id"
|
||||||
|
:image="item.image"
|
||||||
|
:to="{name: 'podcast', params: { id: item.id } }"
|
||||||
|
:title="item.name">
|
||||||
|
<template #text>
|
||||||
|
<strong>{{ item.trackCount }}</strong> episodes
|
||||||
|
</template>
|
||||||
|
</Tile>
|
||||||
|
</Tiles>
|
||||||
|
</ContentLoader>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
items: null as null | any[],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
this.items = await this.$api.getPodcasts()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async refresh() {
|
||||||
|
await this.$api.refreshPodcasts()
|
||||||
|
this.items = await this.$api.getPodcasts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
@ -1,45 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="items">
|
<div v-if="items">
|
||||||
<h1>Radio</h1>
|
<h1>Radio</h1>
|
||||||
<table class="table table-hover table-borderless">
|
<BaseTable>
|
||||||
<thead>
|
<BaseTableHead />
|
||||||
<tr>
|
|
||||||
<th class="text-left">
|
|
||||||
Title
|
|
||||||
</th>
|
|
||||||
<th class="text-right">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(item, index) in items" :key="index"
|
<tr v-for="(item, index) in items" :key="index"
|
||||||
:class="{'active': item.id === playingTrackId}">
|
:class="{'active': item.id === playingTrackId}"
|
||||||
<td @click="play(index)">
|
@click="play(index)">
|
||||||
{{ item.title }}
|
<CellTrackNumber :active="item.id === playingTrackId && isPlaying" :track="item" />
|
||||||
<div>
|
<CellTitle :track="item" />
|
||||||
<small class="text-muted">
|
<CellActions :track="item" />
|
||||||
{{ item.description }}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<TrackContextMenu :track="item" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</BaseTable>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import TrackContextMenu from '@/library/TrackContextMenu.vue'
|
|
||||||
import { RadioStation } from '@/shared/api'
|
import { RadioStation } from '@/shared/api'
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
|
import CellTrackNumber from '@/library/track/CellTrackNumber.vue'
|
||||||
|
import CellActions from '@/library/track/CellActions.vue'
|
||||||
|
import CellTitle from '@/library/track/CellTitle.vue'
|
||||||
|
import BaseTable from '@/library/track/BaseTable.vue'
|
||||||
|
import BaseTableHead from '@/library/track/BaseTableHead.vue'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
components: {
|
components: {
|
||||||
TrackContextMenu,
|
BaseTableHead,
|
||||||
|
BaseTable,
|
||||||
|
CellTitle,
|
||||||
|
CellActions,
|
||||||
|
CellTrackNumber,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -49,6 +41,7 @@
|
|||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
playingTrackId: 'player/trackId',
|
playingTrackId: 'player/trackId',
|
||||||
|
isPlaying: 'player/isPlaying',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="result">
|
|
||||||
<div v-if="result.albums.length > 0" class="mb-4">
|
|
||||||
<h1>Albums</h1>
|
|
||||||
<AlbumList :items="result.albums" />
|
|
||||||
</div>
|
|
||||||
<div v-if="result.artists.length > 0" class="mb-4">
|
|
||||||
<h1>Artists</h1>
|
|
||||||
<ArtistList :items="result.artists" />
|
|
||||||
</div>
|
|
||||||
<div v-if="result.tracks.length > 0" class="mb-4">
|
|
||||||
<h1>Tracks</h1>
|
|
||||||
<TrackList :tracks="result.tracks" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script lang="ts">
|
|
||||||
import Vue from 'vue'
|
|
||||||
import AlbumList from '@/library/album/AlbumList.vue'
|
|
||||||
import ArtistList from '@/library/artist/ArtistList.vue'
|
|
||||||
import TrackList from '@/library/TrackList.vue'
|
|
||||||
|
|
||||||
export default Vue.extend({
|
|
||||||
components: {
|
|
||||||
AlbumList,
|
|
||||||
ArtistList,
|
|
||||||
TrackList,
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
result: null as any,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.$api.getStarred().then(result => {
|
|
||||||
this.result = result
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
5
src/library/track/BaseTable.vue
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template functional>
|
||||||
|
<table class="table table-hover table-borderless table-numbered">
|
||||||
|
<slot />
|
||||||
|
</table>
|
||||||
|
</template>
|
14
src/library/track/BaseTableHead.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<template functional>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th class="text-left">
|
||||||
|
Title
|
||||||
|
</th>
|
||||||
|
<slot />
|
||||||
|
<th class="text-right">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</template>
|
50
src/library/track/CellActions.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<td class="text-right" @click.stop="">
|
||||||
|
<OverflowMenu :disabled="track.playable === false">
|
||||||
|
<b-dropdown-item-button @click="setNextInQueue()">
|
||||||
|
Play next
|
||||||
|
</b-dropdown-item-button>
|
||||||
|
<b-dropdown-item-button @click="addToQueue()">
|
||||||
|
Add to queue
|
||||||
|
</b-dropdown-item-button>
|
||||||
|
<b-dropdown-item-button @click="toggleFavourite()">
|
||||||
|
{{ favourite ? 'Remove from favourites' : 'Add to favourites' }}
|
||||||
|
</b-dropdown-item-button>
|
||||||
|
<b-dropdown-item-button @click="download()">
|
||||||
|
Download
|
||||||
|
</b-dropdown-item-button>
|
||||||
|
<slot :item="track" />
|
||||||
|
</OverflowMenu>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
props: {
|
||||||
|
track: { type: Object, required: true },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
favourite: this.track.favourite,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleFavourite() {
|
||||||
|
this.favourite = !this.favourite
|
||||||
|
return this.favourite
|
||||||
|
? this.$store.dispatch('addFavourite', this.track.id)
|
||||||
|
: this.$store.dispatch('removeFavourite', this.track.id)
|
||||||
|
},
|
||||||
|
download() {
|
||||||
|
window.location.href = this.$api.getDownloadUrl(this.track.id)
|
||||||
|
},
|
||||||
|
setNextInQueue() {
|
||||||
|
return this.$store.dispatch('player/setNextInQueue', [this.track])
|
||||||
|
},
|
||||||
|
addToQueue() {
|
||||||
|
return this.$store.dispatch('player/addToQueue', [this.track])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
12
src/library/track/CellAlbum.vue
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<template functional>
|
||||||
|
<td class="d-none d-md-table-cell">
|
||||||
|
<template v-if="props.track.albumId">
|
||||||
|
<router-link :to="{name: 'album', params: {id: props.track.albumId}}" @click.native.stop>
|
||||||
|
{{ props.track.album }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ props.track.album }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</template>
|
12
src/library/track/CellArtist.vue
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<template functional>
|
||||||
|
<td class="d-none d-lg-table-cell">
|
||||||
|
<template v-if="props.track.artistId">
|
||||||
|
<router-link :to="{name: 'artist', params: {id: props.track.artistId}}" @click.native.stop>
|
||||||
|
{{ props.track.artist }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ props.track.artist }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</template>
|
7
src/library/track/CellDuration.vue
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<template functional>
|
||||||
|
<td class="text-right d-none d-md-table-cell">
|
||||||
|
<template v-if="props.track.duration">
|
||||||
|
{{ parent.$formatDuration(props.track.duration) }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</template>
|
11
src/library/track/CellTitle.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<template functional>
|
||||||
|
<td>
|
||||||
|
{{ props.track.title }}
|
||||||
|
<div v-if="props.track.description" class="text-muted">
|
||||||
|
<small>{{ props.track.description }}</small>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="props.track.artist" class="d-lg-none text-muted">
|
||||||
|
<small>{{ props.track.artist }}</small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
8
src/library/track/CellTrackNumber.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template functional>
|
||||||
|
<td>
|
||||||
|
<button>
|
||||||
|
<Icon class="icon" :icon="props.active ? 'pause-fill' :'play-fill'" />
|
||||||
|
<span class="number">{{ props.track.track || 1 }}</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</template>
|
84
src/library/track/TrackList.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<BaseTable>
|
||||||
|
<BaseTableHead>
|
||||||
|
<th v-if="!noArtist" class="text-left d-none d-lg-table-cell">
|
||||||
|
Artist
|
||||||
|
</th>
|
||||||
|
<th v-if="!noAlbum" class="text-left d-none d-md-table-cell">
|
||||||
|
Album
|
||||||
|
</th>
|
||||||
|
<th v-if="!noDuration" class="text-right d-none d-md-table-cell">
|
||||||
|
Duration
|
||||||
|
</th>
|
||||||
|
</BaseTableHead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, index) in tracks" :key="index"
|
||||||
|
:class="{'active': item.id === playingTrackId}"
|
||||||
|
:draggable="true" @dragstart="dragstart(item.id, $event)"
|
||||||
|
@click="play(index)">
|
||||||
|
<CellTrackNumber :active="item.id === playingTrackId && isPlaying" :track="item" />
|
||||||
|
<CellTitle :track="item" />
|
||||||
|
<CellArtist v-if="!noArtist" :track="item" />
|
||||||
|
<CellAlbum v-if="!noAlbum" :track="item" />
|
||||||
|
<CellDuration v-if="!noDuration" :track="item" />
|
||||||
|
<CellActions :track="item">
|
||||||
|
<slot name="context-menu" :index="index" :item="item" />
|
||||||
|
</CellActions>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</BaseTable>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
import { mapActions, mapGetters } from 'vuex'
|
||||||
|
import CellDuration from '@/library/track/CellDuration.vue'
|
||||||
|
import CellArtist from '@/library/track/CellArtist.vue'
|
||||||
|
import CellAlbum from '@/library/track/CellAlbum.vue'
|
||||||
|
import CellTrackNumber from '@/library/track/CellTrackNumber.vue'
|
||||||
|
import CellActions from '@/library/track/CellActions.vue'
|
||||||
|
import CellTitle from '@/library/track/CellTitle.vue'
|
||||||
|
import BaseTable from '@/library/track/BaseTable.vue'
|
||||||
|
import BaseTableHead from '@/library/track/BaseTableHead.vue'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
components: {
|
||||||
|
BaseTableHead,
|
||||||
|
BaseTable,
|
||||||
|
CellTitle,
|
||||||
|
CellActions,
|
||||||
|
CellTrackNumber,
|
||||||
|
CellAlbum,
|
||||||
|
CellArtist,
|
||||||
|
CellDuration,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
tracks: { type: Array, required: true },
|
||||||
|
noAlbum: { type: Boolean, default: false },
|
||||||
|
noArtist: { type: Boolean, default: false },
|
||||||
|
noDuration: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
playingTrackId: 'player/trackId',
|
||||||
|
isPlaying: 'player/isPlaying',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions({
|
||||||
|
playPause: 'player/playPause',
|
||||||
|
}),
|
||||||
|
play(index: number) {
|
||||||
|
if ((this.tracks as any)[index].id === this.playingTrackId) {
|
||||||
|
return this.$store.dispatch('player/playPause')
|
||||||
|
}
|
||||||
|
return this.$store.dispatch('player/playTrackList', {
|
||||||
|
index,
|
||||||
|
tracks: this.tracks,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
dragstart(id: string, event: any) {
|
||||||
|
event.dataTransfer.setData('id', id)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
@ -1,7 +1,6 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import Router from 'vue-router'
|
import Router from 'vue-router'
|
||||||
import Vuex from 'vuex'
|
import Vuex from 'vuex'
|
||||||
import { BootstrapVue } from 'bootstrap-vue'
|
|
||||||
import '@/style/main.scss'
|
import '@/style/main.scss'
|
||||||
import '@/shared/components'
|
import '@/shared/components'
|
||||||
import App from '@/app/App.vue'
|
import App from '@/app/App.vue'
|
||||||
@ -21,7 +20,6 @@ declare module 'vue/types/vue' {
|
|||||||
Vue.config.productionTip = false
|
Vue.config.productionTip = false
|
||||||
Vue.use(Router)
|
Vue.use(Router)
|
||||||
Vue.use(Vuex)
|
Vue.use(Vuex)
|
||||||
Vue.use(BootstrapVue)
|
|
||||||
|
|
||||||
const authService = new AuthService()
|
const authService = new AuthService()
|
||||||
const api = new API(authService)
|
const api = new API(authService)
|
||||||
|
@ -2,30 +2,24 @@
|
|||||||
<div :class="{'visible': visible}" class="player elevated d-flex">
|
<div :class="{'visible': visible}" class="player elevated d-flex">
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<!-- Progress --->
|
<!-- Progress --->
|
||||||
<div class="progress2" @click="seek">
|
<ProgressBar
|
||||||
<b-progress :value="progress" :max="100" height="4px" />
|
style="margin-bottom: -5px; margin-top: -9px"
|
||||||
</div>
|
:value="progress" @input="seek"
|
||||||
<div class="row align-items-center m-0">
|
/>
|
||||||
|
<div class="row align-items-center m-0" style="padding-top: -10px">
|
||||||
<!-- Track info --->
|
<!-- Track info --->
|
||||||
<div class="col p-0 d-flex flex-nowrap align-items-center justify-content-start" style="width: 0; min-width: 0">
|
<div class="col p-0 d-flex flex-nowrap align-items-center justify-content-start" style="width: 0; min-width: 0">
|
||||||
<template v-if="track">
|
<template v-if="track">
|
||||||
<router-link :to="{ name: 'queue' }">
|
<router-link :to="{ name: 'queue' }" style="padding: 12px">
|
||||||
<template v-if="track.image">
|
<img v-if="track.image" width="52px" height="52px" :src="track.image">
|
||||||
<img class="d-sm-none" width="64px" height="64px" :src="track.image">
|
<img v-else width="52px" height="52px" src="@/shared/assets/fallback.svg">
|
||||||
<img class="d-none d-sm-inline-block" width="74px" height="74px" :src="track.image">
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<img class="d-sm-none" width="64px" height="64px" src="@/shared/assets/fallback.svg">
|
|
||||||
<img class="d-none d-sm-inline-block" width="74px" height="74px" src="@/shared/assets/fallback.svg">
|
|
||||||
</template>
|
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<div style="min-width: 0">
|
||||||
<div class="pl-3" style="min-width: 0">
|
|
||||||
<div class="text-truncate">
|
<div class="text-truncate">
|
||||||
{{ track.title }}
|
{{ track.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-truncate text-muted">
|
<div class="text-truncate text-muted">
|
||||||
{{ track.artist }}
|
{{ track.artist || track.album || track.description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -45,30 +39,64 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls right --->
|
<!-- Controls right --->
|
||||||
<div class="col p-0 d-none d-sm-block " style="min-width: 0; width: 0;">
|
<div class="col-auto col-sm p-0">
|
||||||
<div class="d-flex justify-content-end pr-3">
|
<div class="d-flex flex-nowrap justify-content-end pr-3">
|
||||||
<b-button variant="link"
|
<div class="m-0 d-none d-md-inline-flex align-items-center">
|
||||||
class="m-0 d-none d-sm-inline-block"
|
<b-button title="Favourite"
|
||||||
:class="{ 'text-primary': shuffleActive }"
|
variant="link" class="m-0"
|
||||||
|
@click="toggleFavourite">
|
||||||
|
<Icon :icon="track && track.favourite ? 'heart-fill' : 'heart'" />
|
||||||
|
</b-button>
|
||||||
|
<b-button id="player-volume-btn" variant="link" title="Volume">
|
||||||
|
<Icon :icon="muteActive ? 'volume-mute-fill' : 'volume-up-fill'" />
|
||||||
|
</b-button>
|
||||||
|
<b-popover target="player-volume-btn" placement="top" triggers="click blur" no-fade>
|
||||||
|
<Slider class="pt-2" style="height: 120px;" direction="btt"
|
||||||
|
:min="0" :max="1" :step="0.01" percent
|
||||||
|
:value="muteActive ? 0.0 : volume" @input="setVolume"
|
||||||
|
/>
|
||||||
|
</b-popover>
|
||||||
|
<b-button title="Shuffle"
|
||||||
|
variant="link" class="m-0" :class="{ 'text-primary': shuffleActive }"
|
||||||
@click="toggleShuffle">
|
@click="toggleShuffle">
|
||||||
<Icon icon="shuffle" />
|
<Icon icon="shuffle" />
|
||||||
</b-button>
|
</b-button>
|
||||||
<b-button variant="link"
|
<b-button title="Repeat"
|
||||||
class="m-0 d-none d-sm-inline-block "
|
variant="link" class="m-0" :class="{ 'text-primary': repeatActive }"
|
||||||
:class="{ 'text-primary': repeatActive }"
|
|
||||||
@click="toggleRepeat">
|
@click="toggleRepeat">
|
||||||
<Icon icon="arrow-repeat" />
|
<Icon icon="arrow-repeat" />
|
||||||
</b-button>
|
</b-button>
|
||||||
</div>
|
</div>
|
||||||
|
<OverflowMenu class="d-md-none">
|
||||||
|
<b-dropdown-text>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Volume</strong>
|
||||||
|
<Slider class="px-3" style="width: 120px;"
|
||||||
|
:min="0" :max="1" :step="0.01" percent
|
||||||
|
:value="muteActive ? 0.0 : volume" @input="setVolume"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</b-dropdown-text>
|
||||||
|
<b-dropdown-text>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<strong>Repeat</strong>
|
||||||
|
<b-form-checkbox switch :checked="repeatActive" @change="toggleRepeat" />
|
||||||
|
</div>
|
||||||
|
</b-dropdown-text>
|
||||||
|
<b-dropdown-text>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<strong>Shuffle</strong>
|
||||||
|
<b-form-checkbox switch :checked="shuffleActive" @change="toggleShuffle" />
|
||||||
|
</div>
|
||||||
|
</b-dropdown-text>
|
||||||
|
</OverflowMenu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.progress2 {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.player {
|
.player {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@ -82,25 +110,44 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
max-height: 100px;
|
max-height: 100px;
|
||||||
}
|
}
|
||||||
|
.b-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import { mapState, mapGetters, mapActions } from 'vuex'
|
import { mapState, mapGetters, mapActions } from 'vuex'
|
||||||
|
import ProgressBar from '@/player/ProgressBar.vue'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
|
components: {
|
||||||
|
ProgressBar,
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState('player', {
|
...mapState('player', {
|
||||||
isPlaying: (state: any) => state.isPlaying,
|
isPlaying: (state: any) => state.isPlaying,
|
||||||
currentTime: (state: any) => state.currentTime,
|
currentTime: (state: any) => state.currentTime,
|
||||||
repeatActive: (state: any) => state.repeat,
|
repeatActive: (state: any) => state.repeat,
|
||||||
shuffleActive: (state: any) => state.shuffle,
|
shuffleActive: (state: any) => state.shuffle,
|
||||||
|
muteActive: (state: any) => state.mute,
|
||||||
visible: (state: any) => state.queue.length > 0,
|
visible: (state: any) => state.queue.length > 0,
|
||||||
|
volume: (state: any) => state.volume,
|
||||||
}),
|
}),
|
||||||
...mapGetters('player', [
|
...mapGetters('player', [
|
||||||
'track',
|
'track',
|
||||||
'progress',
|
'progress',
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
track: {
|
||||||
|
immediate: true,
|
||||||
|
handler(track: any) {
|
||||||
|
document.title = [track?.title, track?.artist || track?.album, 'Airsonic (refix)']
|
||||||
|
.filter(x => !!x).join(' • ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions('player', [
|
...mapActions('player', [
|
||||||
'playPause',
|
'playPause',
|
||||||
@ -108,14 +155,17 @@
|
|||||||
'previous',
|
'previous',
|
||||||
'toggleRepeat',
|
'toggleRepeat',
|
||||||
'toggleShuffle',
|
'toggleShuffle',
|
||||||
|
'toggleMute',
|
||||||
|
'seek',
|
||||||
]),
|
]),
|
||||||
seek(event: any) {
|
setVolume(volume: any) {
|
||||||
if (event.target) {
|
return this.$store.dispatch('player/setVolume', parseFloat(volume))
|
||||||
const width = event.currentTarget.clientWidth
|
|
||||||
const value = event.offsetX / width
|
|
||||||
return this.$store.dispatch('player/seek', value)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
toggleFavourite() {
|
||||||
|
return this.track.favourite
|
||||||
|
? this.$store.dispatch('removeFavourite', this.track.id)
|
||||||
|
: this.$store.dispatch('addFavourite', this.track.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
62
src/player/ProgressBar.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<VueSlider
|
||||||
|
v-bind="$attrs"
|
||||||
|
:value="value"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:interval="0.001"
|
||||||
|
:lazy="true"
|
||||||
|
:contained="true"
|
||||||
|
:dot-options="{tooltip: 'none'}"
|
||||||
|
@change="onInput"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '/src/style/variables';
|
||||||
|
@import '~vue-slider-component/theme/material.css';
|
||||||
|
|
||||||
|
.vue-slider {
|
||||||
|
height: 4px !important;
|
||||||
|
padding: 5px 0 !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
::v-deep .vue-slider-rail {
|
||||||
|
background-color: $secondary;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
::v-deep .vue-slider-process {
|
||||||
|
background-color: $primary;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
::v-deep .vue-slider-dot-handle {
|
||||||
|
background-color: $primary;
|
||||||
|
}
|
||||||
|
::v-deep .vue-slider-dot-handle::after {
|
||||||
|
background-color: rgba($primary, 0.32);
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
.vue-slider:not(:hover) ::v-deep .vue-slider-dot-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.vue-slider:hover ::v-deep .vue-slider-dot-handle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
import VueSlider from 'vue-slider-component'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
components: {
|
||||||
|
VueSlider,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: { type: Number, required: true },
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onInput(value: number) {
|
||||||
|
this.$emit('input', value)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
@ -1,32 +1,96 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<TrackList :tracks="tracks">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<template #context-menu="{index}">
|
<h1 class="mb-0">
|
||||||
|
Playing
|
||||||
|
</h1>
|
||||||
|
<b-button variant="secondary" @click="clear()">
|
||||||
|
Clear
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
<BaseTable>
|
||||||
|
<BaseTableHead>
|
||||||
|
<th class="text-left d-none d-lg-table-cell">
|
||||||
|
Artist
|
||||||
|
</th>
|
||||||
|
<th class="text-left d-none d-md-table-cell">
|
||||||
|
Album
|
||||||
|
</th>
|
||||||
|
<th class="text-right d-none d-md-table-cell">
|
||||||
|
Duration
|
||||||
|
</th>
|
||||||
|
</BaseTableHead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, index) in tracks" :key="index"
|
||||||
|
:class="{'active': index === queueIndex}"
|
||||||
|
:draggable="true" @dragstart="dragstart(item.id, $event)"
|
||||||
|
@click="play(index)">
|
||||||
|
<CellTrackNumber :active="index === queueIndex && isPlaying" :track="item" />
|
||||||
|
<CellTitle :track="item" />
|
||||||
|
<CellArtist :track="item" />
|
||||||
|
<CellAlbum :track="item" />
|
||||||
|
<CellDuration :track="item" />
|
||||||
|
<CellActions :track="item">
|
||||||
<b-dropdown-item-button @click="remove(index)">
|
<b-dropdown-item-button @click="remove(index)">
|
||||||
Remove
|
Remove
|
||||||
</b-dropdown-item-button>
|
</b-dropdown-item-button>
|
||||||
</template>
|
</CellActions>
|
||||||
</TrackList>
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</BaseTable>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import { mapState, mapMutations } from 'vuex'
|
import { mapState, mapMutations, mapGetters } from 'vuex'
|
||||||
import TrackList from '@/library/TrackList.vue'
|
import TrackList from '@/library/track/TrackList.vue'
|
||||||
|
import BaseTable from '@/library/track/BaseTable.vue'
|
||||||
|
import BaseTableHead from '@/library/track/BaseTableHead.vue'
|
||||||
|
import CellTrackNumber from '@/library/track/CellTrackNumber.vue'
|
||||||
|
import CellDuration from '@/library/track/CellDuration.vue'
|
||||||
|
import CellAlbum from '@/library/track/CellAlbum.vue'
|
||||||
|
import CellArtist from '@/library/track/CellArtist.vue'
|
||||||
|
import CellTitle from '@/library/track/CellTitle.vue'
|
||||||
|
import CellActions from '@/library/track/CellActions.vue'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
components: {
|
components: {
|
||||||
|
CellActions,
|
||||||
|
CellTitle,
|
||||||
|
CellArtist,
|
||||||
|
CellAlbum,
|
||||||
|
CellDuration,
|
||||||
|
CellTrackNumber,
|
||||||
|
BaseTableHead,
|
||||||
|
BaseTable,
|
||||||
TrackList,
|
TrackList,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState('player', {
|
...mapState('player', {
|
||||||
tracks: (state: any) => state.queue,
|
tracks: 'queue',
|
||||||
})
|
queueIndex: 'queueIndex',
|
||||||
|
}),
|
||||||
|
...mapGetters('player', {
|
||||||
|
isPlaying: 'isPlaying',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapMutations('player', {
|
...mapMutations('player', {
|
||||||
remove: 'removeFromQueue',
|
remove: 'removeFromQueue',
|
||||||
|
clear: 'clearQueue',
|
||||||
}),
|
}),
|
||||||
|
play(index: number) {
|
||||||
|
if (index === this.queueIndex) {
|
||||||
|
return this.$store.dispatch('player/playPause')
|
||||||
|
}
|
||||||
|
return this.$store.dispatch('player/playTrackList', {
|
||||||
|
index,
|
||||||
|
tracks: this.tracks,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
dragstart(id: string, event: any) {
|
||||||
|
event.dataTransfer.setData('id', id)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
159
src/player/audio.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
export class AudioController {
|
||||||
|
private audio = new Audio()
|
||||||
|
private handle = -1
|
||||||
|
private volume = 1.0
|
||||||
|
private fadeDuration = 200
|
||||||
|
private buffer = new Audio()
|
||||||
|
|
||||||
|
ontimeupdate: (value: number) => void = () => { /* do nothing */ }
|
||||||
|
ondurationchange: (value: number) => void = () => { /* do nothing */ }
|
||||||
|
onended: () => void = () => { /* do nothing */ }
|
||||||
|
onerror: (err: MediaError | null) => void = () => { /* do nothing */ }
|
||||||
|
|
||||||
|
currentTime() {
|
||||||
|
return this.audio.currentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
duration() {
|
||||||
|
return this.audio.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
setBuffer(url: string) {
|
||||||
|
this.buffer.src = url
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(value: number) {
|
||||||
|
this.cancelFade()
|
||||||
|
this.volume = value
|
||||||
|
this.audio.volume = value
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause() {
|
||||||
|
await this.fadeOut()
|
||||||
|
this.audio.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume() {
|
||||||
|
this.audio.volume = 0.0
|
||||||
|
await this.audio.play()
|
||||||
|
this.fadeIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
async seek(value: number) {
|
||||||
|
await this.fadeOut(this.fadeDuration / 2.0)
|
||||||
|
this.audio.volume = 0.0
|
||||||
|
this.audio.currentTime = value
|
||||||
|
await this.fadeIn(this.fadeDuration / 2.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeTrack(url: string, options: { paused?: boolean } = {}) {
|
||||||
|
if (this.audio) {
|
||||||
|
this.cancelFade()
|
||||||
|
endPlayback(this.audio, this.fadeDuration)
|
||||||
|
}
|
||||||
|
this.audio = new Audio(url)
|
||||||
|
this.audio.onerror = () => {
|
||||||
|
this.onerror(this.audio.error)
|
||||||
|
}
|
||||||
|
this.audio.onended = () => {
|
||||||
|
this.onended()
|
||||||
|
}
|
||||||
|
this.audio.ontimeupdate = () => {
|
||||||
|
this.ontimeupdate(this.audio.currentTime)
|
||||||
|
}
|
||||||
|
this.audio.ondurationchange = () => {
|
||||||
|
this.ondurationchange(this.audio.duration)
|
||||||
|
}
|
||||||
|
this.ondurationchange(this.audio.duration)
|
||||||
|
this.ontimeupdate(this.audio.currentTime)
|
||||||
|
this.audio.volume = 0.0
|
||||||
|
|
||||||
|
if (options.paused !== true) {
|
||||||
|
try {
|
||||||
|
await this.audio.play()
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
console.warn(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
this.fadeIn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelFade() {
|
||||||
|
clearTimeout(this.handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fadeIn(duration: number = this.fadeDuration) {
|
||||||
|
this.fadeFromTo(0.0, this.volume, duration).then()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fadeOut(duration: number = this.fadeDuration) {
|
||||||
|
return this.fadeFromTo(this.volume, 0.0, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fadeFromTo(from: number, to: number, duration: number) {
|
||||||
|
console.info(`AudioController: start fade (${from}, ${to}, ${duration})`)
|
||||||
|
const startTime = Date.now()
|
||||||
|
const step = (to - from) / duration
|
||||||
|
if (duration <= 0.0) {
|
||||||
|
this.audio.volume = to
|
||||||
|
}
|
||||||
|
clearTimeout(this.handle)
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const run = () => {
|
||||||
|
if (this.audio.volume === to) {
|
||||||
|
console.info(
|
||||||
|
'AudioController: fade result. ' +
|
||||||
|
`duration: ${duration}ms, actual: ${Date.now() - startTime}ms, ` +
|
||||||
|
`volume: ${this.audio.volume}`)
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
this.audio.volume = clamp(0.0, this.volume, from + (elapsed * step))
|
||||||
|
this.handle = setTimeout(run, 10)
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endPlayback(audio: HTMLAudioElement, duration: number) {
|
||||||
|
async function fade(audio: HTMLAudioElement, from: number, to: number, duration: number) {
|
||||||
|
if (duration <= 0.0) {
|
||||||
|
audio.volume = to
|
||||||
|
return audio
|
||||||
|
}
|
||||||
|
const startTime = Date.now()
|
||||||
|
const step = (to - from) / duration
|
||||||
|
while (audio.volume !== to) {
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
audio.volume = clamp(0.0, 1.0, from + (elapsed * step))
|
||||||
|
await sleep(10)
|
||||||
|
}
|
||||||
|
return audio
|
||||||
|
}
|
||||||
|
console.info(`AudioController: ending payback for ${audio}`)
|
||||||
|
audio.ontimeupdate = null
|
||||||
|
audio.ondurationchange = null
|
||||||
|
audio.onerror = null
|
||||||
|
audio.onended = null
|
||||||
|
const startTime = Date.now()
|
||||||
|
fade(audio, audio.volume, 0.0, duration)
|
||||||
|
.catch((err) => console.warn('Error during fade out: ' + err.stack))
|
||||||
|
.finally(() => {
|
||||||
|
audio.pause()
|
||||||
|
console.info(`AudioController: ending payback done. actual ${Date.now() - startTime}ms`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(min: number, max: number, value: number) {
|
||||||
|
return Math.max(min, Math.min(value, max))
|
||||||
|
}
|
@ -1,14 +1,14 @@
|
|||||||
import { Store, Module } from 'vuex'
|
import { Store, Module } from 'vuex'
|
||||||
import { shuffle, trackListEquals } from '@/shared/utils'
|
import { shuffle, trackListEquals } from '@/shared/utils'
|
||||||
import { API } from '@/shared/api'
|
import { API } from '@/shared/api'
|
||||||
|
import { AudioController } from '@/player/audio'
|
||||||
|
|
||||||
const audio = new Audio()
|
|
||||||
const storedQueue = JSON.parse(localStorage.getItem('queue') || '[]')
|
const storedQueue = JSON.parse(localStorage.getItem('queue') || '[]')
|
||||||
const storedQueueIndex = parseInt(localStorage.getItem('queueIndex') || '-1')
|
const storedQueueIndex = parseInt(localStorage.getItem('queueIndex') || '-1')
|
||||||
if (storedQueueIndex > -1 && storedQueueIndex < storedQueue.length) {
|
const storedVolume = parseFloat(localStorage.getItem('player.volume') || '1.0')
|
||||||
audio.src = storedQueue[storedQueueIndex].url
|
const storedMuteState = localStorage.getItem('player.mute') === 'true'
|
||||||
}
|
|
||||||
const mediaSession: MediaSession | undefined = navigator.mediaSession
|
const mediaSession: MediaSession | undefined = navigator.mediaSession
|
||||||
|
const audio = new AudioController()
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
queue: any[];
|
queue: any[];
|
||||||
@ -19,6 +19,13 @@ interface State {
|
|||||||
currentTime: number; // position of current track in seconds
|
currentTime: number; // position of current track in seconds
|
||||||
repeat: boolean;
|
repeat: boolean;
|
||||||
shuffle: boolean;
|
shuffle: boolean;
|
||||||
|
mute: boolean;
|
||||||
|
volume: number; // integer between 0 and 1 representing the volume of the player
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistQueue(state: State) {
|
||||||
|
localStorage.setItem('queue', JSON.stringify(state.queue))
|
||||||
|
localStorage.setItem('queueIndex', state.queueIndex.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
export const playerModule: Module<State, any> = {
|
export const playerModule: Module<State, any> = {
|
||||||
@ -32,6 +39,8 @@ export const playerModule: Module<State, any> = {
|
|||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
repeat: localStorage.getItem('player.repeat') !== 'false',
|
repeat: localStorage.getItem('player.repeat') !== 'false',
|
||||||
shuffle: localStorage.getItem('player.shuffle') === 'true',
|
shuffle: localStorage.getItem('player.shuffle') === 'true',
|
||||||
|
mute: storedMuteState,
|
||||||
|
volume: storedVolume,
|
||||||
},
|
},
|
||||||
|
|
||||||
mutations: {
|
mutations: {
|
||||||
@ -55,10 +64,14 @@ export const playerModule: Module<State, any> = {
|
|||||||
state.shuffle = enable
|
state.shuffle = enable
|
||||||
localStorage.setItem('player.shuffle', enable)
|
localStorage.setItem('player.shuffle', enable)
|
||||||
},
|
},
|
||||||
|
setMute(state, enable) {
|
||||||
|
state.mute = enable
|
||||||
|
localStorage.setItem('player.mute', enable)
|
||||||
|
},
|
||||||
setQueue(state, queue) {
|
setQueue(state, queue) {
|
||||||
state.queue = queue
|
state.queue = queue
|
||||||
state.queueIndex = -1
|
state.queueIndex = -1
|
||||||
localStorage.setItem('queue', JSON.stringify(queue))
|
persistQueue(state)
|
||||||
},
|
},
|
||||||
setQueueIndex(state, index) {
|
setQueueIndex(state, index) {
|
||||||
if (state.queue.length === 0) {
|
if (state.queue.length === 0) {
|
||||||
@ -67,10 +80,12 @@ export const playerModule: Module<State, any> = {
|
|||||||
index = Math.max(0, index)
|
index = Math.max(0, index)
|
||||||
index = index < state.queue.length ? index : 0
|
index = index < state.queue.length ? index : 0
|
||||||
state.queueIndex = index
|
state.queueIndex = index
|
||||||
localStorage.setItem('queueIndex', index)
|
persistQueue(state)
|
||||||
state.scrobbled = false
|
state.scrobbled = false
|
||||||
const track = state.queue[index]
|
const track = state.queue[index]
|
||||||
audio.src = track.url
|
state.duration = track.duration
|
||||||
|
const next = (index + 1) % state.queue.length
|
||||||
|
audio.setBuffer(state.queue[next].url)
|
||||||
if (mediaSession) {
|
if (mediaSession) {
|
||||||
mediaSession.metadata = new MediaMetadata({
|
mediaSession.metadata = new MediaMetadata({
|
||||||
title: track.title,
|
title: track.title,
|
||||||
@ -80,35 +95,59 @@ export const playerModule: Module<State, any> = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addToQueue(state, track) {
|
addToQueue(state, tracks) {
|
||||||
state.queue.push(track)
|
state.queue.push(...tracks)
|
||||||
|
persistQueue(state)
|
||||||
},
|
},
|
||||||
removeFromQueue(state, index) {
|
removeFromQueue(state, index) {
|
||||||
state.queue.splice(index, 1)
|
state.queue.splice(index, 1)
|
||||||
if (index < state.queueIndex) {
|
if (index < state.queueIndex) {
|
||||||
state.queueIndex--
|
state.queueIndex--
|
||||||
}
|
}
|
||||||
|
persistQueue(state)
|
||||||
},
|
},
|
||||||
setNextInQueue(state, track) {
|
clearQueue(state) {
|
||||||
state.queue.splice(state.queueIndex + 1, 0, track)
|
if (state.queueIndex >= 0) {
|
||||||
|
state.queue = [state.queue[state.queueIndex]]
|
||||||
|
state.queueIndex = 0
|
||||||
|
persistQueue(state)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setNextInQueue(state, tracks) {
|
||||||
|
state.queue.splice(state.queueIndex + 1, 0, ...tracks)
|
||||||
|
persistQueue(state)
|
||||||
},
|
},
|
||||||
setCurrentTime(state, value: any) {
|
setCurrentTime(state, value: any) {
|
||||||
state.currentTime = value
|
state.currentTime = value
|
||||||
},
|
},
|
||||||
setDuration(state, value: any) {
|
setDuration(state, value: any) {
|
||||||
|
if (isFinite(value)) {
|
||||||
state.duration = value
|
state.duration = value
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setScrobbled(state) {
|
setScrobbled(state) {
|
||||||
state.scrobbled = true
|
state.scrobbled = true
|
||||||
},
|
},
|
||||||
|
setVolume(state, value: number) {
|
||||||
|
state.volume = value
|
||||||
|
state.mute = value <= 0.0
|
||||||
|
localStorage.setItem('player.volume', String(value))
|
||||||
|
},
|
||||||
|
updateTrack(state, track) {
|
||||||
|
const idx = state.queue.findIndex(x => x.id === track.id)
|
||||||
|
if (idx > -1) {
|
||||||
|
state.queue[idx] = Object.assign(state.queue[idx], track)
|
||||||
|
persistQueue(state)
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
async playTrackList({ commit, state }, { tracks, index }) {
|
async playTrackList({ commit, state, getters }, { tracks, index }) {
|
||||||
if (trackListEquals(state.queue, tracks)) {
|
if (trackListEquals(state.queue, tracks)) {
|
||||||
commit('setQueueIndex', index)
|
commit('setQueueIndex', index)
|
||||||
commit('setPlaying')
|
commit('setPlaying')
|
||||||
await audio.play()
|
await audio.changeTrack(getters.track.url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tracks = [...tracks]
|
tracks = [...tracks]
|
||||||
@ -123,44 +162,38 @@ export const playerModule: Module<State, any> = {
|
|||||||
commit('setQueueIndex', index)
|
commit('setQueueIndex', index)
|
||||||
}
|
}
|
||||||
commit('setPlaying')
|
commit('setPlaying')
|
||||||
await audio.play()
|
await audio.changeTrack(getters.track.url)
|
||||||
},
|
},
|
||||||
async resume({ commit }) {
|
async resume({ commit }) {
|
||||||
commit('setPlaying')
|
commit('setPlaying')
|
||||||
await audio.play()
|
await audio.resume()
|
||||||
},
|
},
|
||||||
async pause({ commit }) {
|
async pause({ commit }) {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
commit('setPaused')
|
commit('setPaused')
|
||||||
},
|
},
|
||||||
async playPause({ state, dispatch }) {
|
async playPause({ state, dispatch }) {
|
||||||
if (state.isPlaying) {
|
return state.isPlaying ? dispatch('pause') : dispatch('resume')
|
||||||
return dispatch('pause')
|
|
||||||
}
|
|
||||||
return dispatch('resume')
|
|
||||||
},
|
},
|
||||||
async next({ commit, state, getters, dispatch }) {
|
async next({ commit, state, getters }) {
|
||||||
if (!state.repeat && !getters.hasNext) {
|
|
||||||
return dispatch('resetQueue')
|
|
||||||
}
|
|
||||||
commit('setQueueIndex', state.queueIndex + 1)
|
commit('setQueueIndex', state.queueIndex + 1)
|
||||||
commit('setPlaying')
|
commit('setPlaying')
|
||||||
await audio.play()
|
await audio.changeTrack(getters.track.url)
|
||||||
},
|
},
|
||||||
async previous({ commit, state }) {
|
async previous({ commit, state, getters }) {
|
||||||
commit('setQueueIndex', state.queueIndex - 1)
|
commit('setQueueIndex', audio.currentTime() > 3 ? state.queueIndex : state.queueIndex - 1)
|
||||||
commit('setPlaying')
|
commit('setPlaying')
|
||||||
await audio.play()
|
await audio.changeTrack(getters.track.url)
|
||||||
},
|
},
|
||||||
seek({ state }, value) {
|
seek({ state }, value) {
|
||||||
if (isFinite(state.duration)) {
|
if (isFinite(state.duration)) {
|
||||||
audio.currentTime = state.duration * value
|
audio.seek(state.duration * (value / 100.0))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
resetQueue({ commit }) {
|
async resetQueue({ commit, getters }) {
|
||||||
audio.pause()
|
|
||||||
commit('setQueueIndex', 0)
|
commit('setQueueIndex', 0)
|
||||||
commit('setPaused')
|
commit('setPaused')
|
||||||
|
await audio.changeTrack(getters.track.url, { paused: true })
|
||||||
},
|
},
|
||||||
toggleRepeat({ commit, state }) {
|
toggleRepeat({ commit, state }) {
|
||||||
commit('setRepeat', !state.repeat)
|
commit('setRepeat', !state.repeat)
|
||||||
@ -168,11 +201,19 @@ export const playerModule: Module<State, any> = {
|
|||||||
toggleShuffle({ commit, state }) {
|
toggleShuffle({ commit, state }) {
|
||||||
commit('setShuffle', !state.shuffle)
|
commit('setShuffle', !state.shuffle)
|
||||||
},
|
},
|
||||||
addToQueue({ commit }, track) {
|
toggleMute({ commit, state }) {
|
||||||
commit('addToQueue', track)
|
commit('setMute', !state.mute)
|
||||||
|
audio.setVolume(state.mute ? 0.0 : state.volume)
|
||||||
},
|
},
|
||||||
setNextInQueue({ commit }, track) {
|
addToQueue({ state, commit }, tracks) {
|
||||||
commit('setNextInQueue', track)
|
commit('addToQueue', state.shuffle ? shuffle([...tracks]) : tracks)
|
||||||
|
},
|
||||||
|
setNextInQueue({ state, commit }, tracks) {
|
||||||
|
commit('setNextInQueue', state.shuffle ? shuffle([...tracks]) : tracks)
|
||||||
|
},
|
||||||
|
setVolume({ commit }, value) {
|
||||||
|
audio.setVolume(value)
|
||||||
|
commit('setVolume', value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -186,6 +227,9 @@ export const playerModule: Module<State, any> = {
|
|||||||
trackId(state, getters): number {
|
trackId(state, getters): number {
|
||||||
return getters.track ? getters.track.id : -1
|
return getters.track ? getters.track.id : -1
|
||||||
},
|
},
|
||||||
|
isPlaying(state): boolean {
|
||||||
|
return state.isPlaying
|
||||||
|
},
|
||||||
progress(state) {
|
progress(state) {
|
||||||
if (state.currentTime > -1 && state.duration > 0) {
|
if (state.currentTime > -1 && state.duration > 0) {
|
||||||
return (state.currentTime / state.duration) * 100
|
return (state.currentTime / state.duration) * 100
|
||||||
@ -202,27 +246,38 @@ export const playerModule: Module<State, any> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setupAudio(store: Store<any>, api: API) {
|
export function setupAudio(store: Store<any>, api: API) {
|
||||||
audio.ontimeupdate = () => {
|
audio.ontimeupdate = (value: number) => {
|
||||||
store.commit('player/setCurrentTime', audio.currentTime)
|
store.commit('player/setCurrentTime', value)
|
||||||
|
|
||||||
// Scrobble
|
// Scrobble
|
||||||
if (store.state.player.scrobbled === false &&
|
if (
|
||||||
audio.duration > 30 &&
|
store.state.player.scrobbled === false &&
|
||||||
audio.currentTime / audio.duration > 0.7) {
|
store.state.player.duration > 30 &&
|
||||||
|
audio.currentTime() / store.state.player.duration > 0.7
|
||||||
|
) {
|
||||||
const id = store.getters['player/trackId']
|
const id = store.getters['player/trackId']
|
||||||
store.commit('player/setScrobbled')
|
store.commit('player/setScrobbled')
|
||||||
api.scrobble(id)
|
api.scrobble(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
audio.ondurationchange = () => {
|
audio.ondurationchange = (value: number) => {
|
||||||
store.commit('player/setDuration', audio.duration)
|
store.commit('player/setDuration', value)
|
||||||
}
|
|
||||||
audio.onerror = () => {
|
|
||||||
store.commit('player/setPaused')
|
|
||||||
store.commit('setError', audio.error)
|
|
||||||
}
|
}
|
||||||
audio.onended = () => {
|
audio.onended = () => {
|
||||||
store.dispatch('player/next')
|
if (store.getters['player/hasNext'] || store.state.player.repeat) {
|
||||||
|
return store.dispatch('player/next')
|
||||||
|
} else {
|
||||||
|
return store.dispatch('player/resetQueue')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audio.onerror = (error: any) => {
|
||||||
|
store.commit('player/setPaused')
|
||||||
|
store.commit('setError', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.setVolume(storedMuteState ? 0.0 : storedVolume)
|
||||||
|
const url = store.getters['player/track']?.url
|
||||||
|
if (url) {
|
||||||
|
audio.changeTrack(url, { paused: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaSession) {
|
if (mediaSession) {
|
||||||
@ -243,16 +298,16 @@ export function setupAudio(store: Store<any>, api: API) {
|
|||||||
})
|
})
|
||||||
mediaSession.setActionHandler('seekto', (details) => {
|
mediaSession.setActionHandler('seekto', (details) => {
|
||||||
if (details.seekTime) {
|
if (details.seekTime) {
|
||||||
audio.currentTime = details.seekTime
|
audio.seek(details.seekTime)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
mediaSession.setActionHandler('seekforward', (details) => {
|
mediaSession.setActionHandler('seekforward', (details) => {
|
||||||
const offset = details.seekOffset || 10
|
const offset = details.seekOffset || 10
|
||||||
audio.currentTime = Math.min(audio.currentTime + offset, audio.duration)
|
audio.seek(Math.min(audio.currentTime() + offset, audio.duration()))
|
||||||
})
|
})
|
||||||
mediaSession.setActionHandler('seekbackward', (details) => {
|
mediaSession.setActionHandler('seekbackward', (details) => {
|
||||||
const offset = details.seekOffset || 10
|
const offset = details.seekOffset || 10
|
||||||
audio.currentTime = Math.max(audio.currentTime - offset, 0)
|
audio.seek(Math.max(audio.currentTime() - offset, 0))
|
||||||
})
|
})
|
||||||
// FIXME
|
// FIXME
|
||||||
// function updatePositionState() {
|
// function updatePositionState() {
|
||||||
|
@ -3,27 +3,48 @@
|
|||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<h1>{{ playlist.name }}</h1>
|
<h1>{{ playlist.name }}</h1>
|
||||||
<OverflowMenu>
|
<OverflowMenu>
|
||||||
|
<b-dropdown-item-btn @click="showEditModal = true">
|
||||||
|
Edit playlist
|
||||||
|
</b-dropdown-item-btn>
|
||||||
<b-dropdown-item-btn variant="danger" @click="deletePlaylist()">
|
<b-dropdown-item-btn variant="danger" @click="deletePlaylist()">
|
||||||
Delete playlist
|
Delete playlist
|
||||||
</b-dropdown-item-btn>
|
</b-dropdown-item-btn>
|
||||||
</OverflowMenu>
|
</OverflowMenu>
|
||||||
</div>
|
</div>
|
||||||
<TrackList :tracks="playlist.tracks" @remove="remove(index)">
|
<p v-if="playlist.comment" class="text-muted">
|
||||||
|
{{ playlist.comment }}
|
||||||
|
</p>
|
||||||
|
<TrackList :tracks="playlist.tracks">
|
||||||
<template #context-menu="{index}">
|
<template #context-menu="{index}">
|
||||||
<b-dropdown-item-button @click="remove(index)">
|
<b-dropdown-item-button @click="remove(index)">
|
||||||
Remove
|
Remove
|
||||||
</b-dropdown-item-button>
|
</b-dropdown-item-button>
|
||||||
</template>
|
</template>
|
||||||
</TrackList>
|
</TrackList>
|
||||||
|
<EditModal :visible.sync="showEditModal" :item="playlist" @confirm="updatePlaylist">
|
||||||
|
<template #title>
|
||||||
|
Edit playlist
|
||||||
|
</template>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<b-form-group label="Name">
|
||||||
|
<b-form-input v-model="item.name" type="text" />
|
||||||
|
</b-form-group>
|
||||||
|
<b-form-group label="Comment">
|
||||||
|
<b-form-textarea v-model="item.comment" />
|
||||||
|
</b-form-group>
|
||||||
|
</template>
|
||||||
|
</EditModal>
|
||||||
</ContentLoader>
|
</ContentLoader>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import TrackList from '@/library/TrackList.vue'
|
import TrackList from '@/library/track/TrackList.vue'
|
||||||
|
import EditModal from '@/shared/components/EditModal.vue'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
components: {
|
components: {
|
||||||
TrackList,
|
TrackList,
|
||||||
|
EditModal,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
id: { type: String, required: true }
|
id: { type: String, required: true }
|
||||||
@ -31,6 +52,7 @@
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
playlist: null as any,
|
playlist: null as any,
|
||||||
|
showEditModal: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -49,6 +71,10 @@
|
|||||||
this.playlist.tracks.splice(index, 1)
|
this.playlist.tracks.splice(index, 1)
|
||||||
return this.$api.removeFromPlaylist(this.id, index.toString())
|
return this.$api.removeFromPlaylist(this.id, index.toString())
|
||||||
},
|
},
|
||||||
|
updatePlaylist(value: any) {
|
||||||
|
this.playlist = value
|
||||||
|
return this.$store.dispatch('updatePlaylist', this.playlist)
|
||||||
|
},
|
||||||
deletePlaylist() {
|
deletePlaylist() {
|
||||||
return this.$store.dispatch('deletePlaylist', this.id).then(() => {
|
return this.$store.dispatch('deletePlaylist', this.id).then(() => {
|
||||||
this.$router.replace({ name: 'playlists' })
|
this.$router.replace({ name: 'playlists' })
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="max-width: 100%">
|
<div style="max-width: 100%">
|
||||||
<span class="nav-link">
|
<small class="sidebar-heading text-muted">
|
||||||
<small class="text-uppercase text-muted font-weight-bold">
|
|
||||||
Playlists
|
Playlists
|
||||||
<button class="btn btn-link btn-sm p-0 float-right" @click="showModal = true">
|
<button class="btn btn-link btn-sm p-0 float-right" @click="showModal = true">
|
||||||
<Icon icon="plus" />
|
<Icon icon="plus" />
|
||||||
</button>
|
</button>
|
||||||
</small>
|
</small>
|
||||||
</span>
|
|
||||||
|
|
||||||
<router-link class="nav-link" :to="{name: 'playlist', params: { id: 'random' }}">
|
<router-link class="nav-link" :to="{name: 'playlist', params: { id: 'random' }}">
|
||||||
<Icon icon="music-note-list" class="mr-2" /> Random
|
<Icon icon="music-note-list" class="mr-2" /> Random
|
||||||
@ -26,6 +24,9 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<b-modal v-model="showModal" title="New playlist">
|
<b-modal v-model="showModal" title="New playlist">
|
||||||
|
<template #modal-header-close>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</template>
|
||||||
<b-form-group label="Name">
|
<b-form-group label="Name">
|
||||||
<b-form-input v-model="playlistName" type="text" />
|
<b-form-input v-model="playlistName" type="text" />
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
|
@ -1,41 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="items">
|
|
||||||
<TrackList :tracks="items" />
|
|
||||||
<table class="table">
|
|
||||||
<thead />
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="item in items" :key="item.id">
|
|
||||||
<td>
|
|
||||||
<Icon icon="play-fill" @click="() => {}" />
|
|
||||||
<Icon icon="plus" @click="() => {}" />
|
|
||||||
</td>
|
|
||||||
<td>{{ item.artist }}</td>
|
|
||||||
<td>{{ item.album }}</td>
|
|
||||||
<td>{{ $formatDuration(item.duration) }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script lang="ts">
|
|
||||||
import Vue from 'vue'
|
|
||||||
import TrackList from '@/library/TrackList.vue'
|
|
||||||
|
|
||||||
export default Vue.extend({
|
|
||||||
components: {
|
|
||||||
TrackList,
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
loading: true,
|
|
||||||
items: [] as any[],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.$api.getRandomSongs().then(items => {
|
|
||||||
this.loading = false
|
|
||||||
this.items = items// .sort((a: any, b:any) => a.created.localeCompare(b.created));
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
@ -18,7 +18,7 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import AlbumList from '@/library/album/AlbumList.vue'
|
import AlbumList from '@/library/album/AlbumList.vue'
|
||||||
import ArtistList from '@/library/artist/ArtistList.vue'
|
import ArtistList from '@/library/artist/ArtistList.vue'
|
||||||
import TrackList from '@/library/TrackList.vue'
|
import TrackList from '@/library/track/TrackList.vue'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
components: {
|
components: {
|
||||||
|
@ -12,7 +12,7 @@ export interface Track {
|
|||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
duration: number
|
duration: number
|
||||||
starred: boolean
|
favourite: boolean
|
||||||
image?: string
|
image?: string
|
||||||
url?: string
|
url?: string
|
||||||
track?: number
|
track?: number
|
||||||
@ -28,8 +28,8 @@ export interface Album {
|
|||||||
artist: string
|
artist: string
|
||||||
artistId: string
|
artistId: string
|
||||||
year: number
|
year: number
|
||||||
starred: boolean
|
favourite: boolean
|
||||||
genre?: string
|
genreId?: string
|
||||||
image?: string
|
image?: string
|
||||||
tracks?: Track[]
|
tracks?: Track[]
|
||||||
}
|
}
|
||||||
@ -39,7 +39,7 @@ export interface Artist {
|
|||||||
name: string
|
name: string
|
||||||
albumCount: number
|
albumCount: number
|
||||||
description?: string
|
description?: string
|
||||||
starred: boolean
|
favourite: boolean
|
||||||
lastFmUrl?: string
|
lastFmUrl?: string
|
||||||
musicBrainzUrl?: string
|
musicBrainzUrl?: string
|
||||||
similarArtist?: Artist[]
|
similarArtist?: Artist[]
|
||||||
@ -109,27 +109,28 @@ export class API {
|
|||||||
.map((item: any) => ({
|
.map((item: any) => ({
|
||||||
id: item.value,
|
id: item.value,
|
||||||
name: item.value,
|
name: item.value,
|
||||||
...item,
|
albumCount: item.albumCount,
|
||||||
|
trackCount: item.songCount,
|
||||||
}))
|
}))
|
||||||
.sort((a: any, b:any) => a.name.localeCompare(b.name))
|
.sort((a: any, b:any) => b.albumCount - a.albumCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAlbumsByGenre(id: string) {
|
async getAlbumsByGenre(id: string, size: number, offset = 0) {
|
||||||
const params = {
|
const params = {
|
||||||
type: 'byGenre',
|
type: 'byGenre',
|
||||||
genre: id,
|
genre: id,
|
||||||
count: 500,
|
size,
|
||||||
offset: 0,
|
offset,
|
||||||
}
|
}
|
||||||
const response = await this.get('rest/getAlbumList2', params)
|
const response = await this.get('rest/getAlbumList2', params)
|
||||||
return (response.albumList2?.album || []).map(this.normalizeAlbum, this)
|
return (response.albumList2?.album || []).map(this.normalizeAlbum, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTracksByGenre(id: string) {
|
async getTracksByGenre(id: string, size: number, offset = 0) {
|
||||||
const params = {
|
const params = {
|
||||||
genre: id,
|
genre: id,
|
||||||
count: 500,
|
count: size,
|
||||||
offset: 0,
|
offset,
|
||||||
}
|
}
|
||||||
const response = await this.get('rest/getSongsByGenre', params)
|
const response = await this.get('rest/getSongsByGenre', params)
|
||||||
return (response.songsByGenre?.song || []).map(this.normalizeTrack, this)
|
return (response.songsByGenre?.song || []).map(this.normalizeTrack, this)
|
||||||
@ -202,6 +203,15 @@ export class API {
|
|||||||
return this.getPlaylists()
|
return this.getPlaylists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async editPlaylist(playlistId: string, name: string, comment: string) {
|
||||||
|
const params = {
|
||||||
|
playlistId,
|
||||||
|
name,
|
||||||
|
comment,
|
||||||
|
}
|
||||||
|
await this.get('rest/updatePlaylist', params)
|
||||||
|
}
|
||||||
|
|
||||||
async deletePlaylist(id: string) {
|
async deletePlaylist(id: string) {
|
||||||
await this.get('rest/deletePlaylist', { id })
|
await this.get('rest/deletePlaylist', { id })
|
||||||
}
|
}
|
||||||
@ -230,7 +240,7 @@ export class API {
|
|||||||
return (response.randomSongs?.song || []).map(this.normalizeTrack, this)
|
return (response.randomSongs?.song || []).map(this.normalizeTrack, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStarred() {
|
async getFavourites() {
|
||||||
const response = await this.get('rest/getStarred2')
|
const response = await this.get('rest/getStarred2')
|
||||||
return {
|
return {
|
||||||
albums: (response.starred2?.album || []).map(this.normalizeAlbum, this),
|
albums: (response.starred2?.album || []).map(this.normalizeAlbum, this),
|
||||||
@ -239,15 +249,7 @@ export class API {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
starAlbum(id: string) {
|
async addFavourite(id: string, type: 'track' | 'album' | 'artist') {
|
||||||
return this.star('album', id)
|
|
||||||
}
|
|
||||||
|
|
||||||
unstarAlbum(id: string) {
|
|
||||||
return this.unstar('album', id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async star(type: 'track' | 'album' | 'artist', id: string) {
|
|
||||||
const params = {
|
const params = {
|
||||||
id: type === 'track' ? id : undefined,
|
id: type === 'track' ? id : undefined,
|
||||||
albumId: type === 'album' ? id : undefined,
|
albumId: type === 'album' ? id : undefined,
|
||||||
@ -256,7 +258,7 @@ export class API {
|
|||||||
await this.get('rest/star', params)
|
await this.get('rest/star', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
async unstar(type: 'track' | 'album' | 'artist', id: string) {
|
async removeFavourite(id: string, type: 'track' | 'album' | 'artist') {
|
||||||
const params = {
|
const params = {
|
||||||
id: type === 'track' ? id : undefined,
|
id: type === 'track' ? id : undefined,
|
||||||
albumId: type === 'album' ? id : undefined,
|
albumId: type === 'album' ? id : undefined,
|
||||||
@ -280,6 +282,7 @@ export class API {
|
|||||||
async getRadioStations(): Promise<RadioStation[]> {
|
async getRadioStations(): Promise<RadioStation[]> {
|
||||||
const response = await this.get('rest/getInternetRadioStations')
|
const response = await this.get('rest/getInternetRadioStations')
|
||||||
return (response?.internetRadioStations?.internetRadioStation || [])
|
return (response?.internetRadioStations?.internetRadioStation || [])
|
||||||
|
.map((item: any, idx: number) => ({ ...item, track: idx + 1 }))
|
||||||
.map(this.normalizeRadioStation, this)
|
.map(this.normalizeRadioStation, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -308,6 +311,20 @@ export class API {
|
|||||||
return this.get('rest/deleteInternetRadioStation', { id })
|
return this.get('rest/deleteInternetRadioStation', { id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPodcasts(): Promise<any[]> {
|
||||||
|
const response = await this.get('rest/getPodcasts')
|
||||||
|
return (response?.podcasts?.channel || []).map(this.normalizePodcast, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPodcast(id: string): Promise<any> {
|
||||||
|
const response = await this.get('rest/getPodcasts', { id })
|
||||||
|
return this.normalizePodcast(response?.podcasts?.channel[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshPodcasts(): Promise<void> {
|
||||||
|
return this.get('rest/refreshPodcasts')
|
||||||
|
}
|
||||||
|
|
||||||
async scan(): Promise<void> {
|
async scan(): Promise<void> {
|
||||||
return this.get('rest/startScan')
|
return this.get('rest/startScan')
|
||||||
}
|
}
|
||||||
@ -321,9 +338,10 @@ export class API {
|
|||||||
id: `radio-${item.id}`,
|
id: `radio-${item.id}`,
|
||||||
title: item.name,
|
title: item.name,
|
||||||
description: item.homePageUrl,
|
description: item.homePageUrl,
|
||||||
|
track: item.track,
|
||||||
url: item.streamUrl,
|
url: item.streamUrl,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
starred: false,
|
favourite: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -332,7 +350,7 @@ export class API {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
duration: item.duration,
|
duration: item.duration,
|
||||||
starred: !!item.starred,
|
favourite: !!item.starred,
|
||||||
track: item.track,
|
track: item.track,
|
||||||
album: item.album,
|
album: item.album,
|
||||||
albumId: item.albumId,
|
albumId: item.albumId,
|
||||||
@ -351,8 +369,8 @@ export class API {
|
|||||||
artistId: item.artistId,
|
artistId: item.artistId,
|
||||||
image: this.getCoverArtUrl(item),
|
image: this.getCoverArtUrl(item),
|
||||||
year: item.year || 0,
|
year: item.year || 0,
|
||||||
starred: !!item.starred,
|
favourite: !!item.starred,
|
||||||
genre: item.genre,
|
genreId: item.genre,
|
||||||
tracks: (item.song || []).map(this.normalizeTrack, this)
|
tracks: (item.song || []).map(this.normalizeTrack, this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -366,7 +384,7 @@ export class API {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
description: (item.biography || '').replace(/<a[^>]*>.*?<\/a>/gm, ''),
|
description: (item.biography || '').replace(/<a[^>]*>.*?<\/a>/gm, ''),
|
||||||
starred: !!item.starred,
|
favourite: !!item.starred,
|
||||||
albumCount: item.albumCount,
|
albumCount: item.albumCount,
|
||||||
lastFmUrl: item.lastFmUrl,
|
lastFmUrl: item.lastFmUrl,
|
||||||
musicBrainzUrl: item.musicBrainzId
|
musicBrainzUrl: item.musicBrainzId
|
||||||
@ -377,6 +395,44 @@ export class API {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizePodcast(podcast: any): any {
|
||||||
|
const image = podcast.originalImageUrl
|
||||||
|
return {
|
||||||
|
id: podcast.id,
|
||||||
|
name: podcast.title,
|
||||||
|
description: podcast.description,
|
||||||
|
image: image,
|
||||||
|
url: podcast.url,
|
||||||
|
trackCount: podcast.episode.length,
|
||||||
|
tracks: podcast.episode.map((episode: any, index: number) => ({
|
||||||
|
id: episode.id,
|
||||||
|
title: episode.title,
|
||||||
|
duration: episode.duration,
|
||||||
|
favourite: false,
|
||||||
|
track: podcast.episode.length - index,
|
||||||
|
album: podcast.title,
|
||||||
|
albumId: null,
|
||||||
|
artist: '',
|
||||||
|
artistId: null,
|
||||||
|
image,
|
||||||
|
url: episode.streamId ? this.getStreamUrl(episode.streamId) : null,
|
||||||
|
description: episode.description,
|
||||||
|
playable: episode.status === 'completed',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDownloadUrl(id: any) {
|
||||||
|
const { server, username, salt, hash } = this.auth
|
||||||
|
return `${server}/rest/download` +
|
||||||
|
`?id=${id}` +
|
||||||
|
'&v=1.15.0' +
|
||||||
|
`&u=${username}` +
|
||||||
|
`&s=${salt}` +
|
||||||
|
`&t=${hash}` +
|
||||||
|
`&c=${this.clientName}`
|
||||||
|
}
|
||||||
|
|
||||||
private getCoverArtUrl(item: any) {
|
private getCoverArtUrl(item: any) {
|
||||||
if (!item.coverArt) {
|
if (!item.coverArt) {
|
||||||
return undefined
|
return undefined
|
||||||
@ -401,7 +457,6 @@ export class API {
|
|||||||
`&u=${username}` +
|
`&u=${username}` +
|
||||||
`&s=${salt}` +
|
`&s=${salt}` +
|
||||||
`&t=${hash}` +
|
`&t=${hash}` +
|
||||||
`&c=${this.clientName}` +
|
`&c=${this.clientName}`
|
||||||
'&size=300'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
47
src/shared/components/EditModal.vue
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<b-modal ok-title="Save" :visible="visible" @ok="confirm" @change="change">
|
||||||
|
<template #modal-header-close>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</template>
|
||||||
|
<template #modal-title>
|
||||||
|
<slot name="title" :item="copy">
|
||||||
|
{{ title }}
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
<template v-if="visible">
|
||||||
|
<slot :item="copy" />
|
||||||
|
</template>
|
||||||
|
</b-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
props: {
|
||||||
|
item: { type: Object, default: null },
|
||||||
|
visible: { type: Boolean, required: true },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
copy: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
item: {
|
||||||
|
immediate: true,
|
||||||
|
handler(value: any) {
|
||||||
|
this.copy = { ...value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
confirm() {
|
||||||
|
this.$emit('confirm', this.copy)
|
||||||
|
},
|
||||||
|
change() {
|
||||||
|
this.$emit('update:visible', false)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
@ -11,8 +11,6 @@
|
|||||||
BIconCardText,
|
BIconCardText,
|
||||||
BIconChevronCompactRight,
|
BIconChevronCompactRight,
|
||||||
BIconMusicNoteList,
|
BIconMusicNoteList,
|
||||||
BIconStar,
|
|
||||||
BIconStarFill,
|
|
||||||
BIconCollection,
|
BIconCollection,
|
||||||
BIconCollectionFill,
|
BIconCollectionFill,
|
||||||
BIconList,
|
BIconList,
|
||||||
@ -25,8 +23,12 @@
|
|||||||
BIconThreeDotsVertical,
|
BIconThreeDotsVertical,
|
||||||
BIconBoxArrowRight,
|
BIconBoxArrowRight,
|
||||||
BIconPersonFill,
|
BIconPersonFill,
|
||||||
BIconPersonCircle,
|
BIconRss,
|
||||||
BIconX,
|
BIconX,
|
||||||
|
BIconVolumeMuteFill,
|
||||||
|
BIconVolumeUpFill,
|
||||||
|
BIconHeart,
|
||||||
|
BIconHeartFill,
|
||||||
} from 'bootstrap-vue'
|
} from 'bootstrap-vue'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
@ -38,8 +40,6 @@
|
|||||||
BIconCardText,
|
BIconCardText,
|
||||||
BIconChevronCompactRight,
|
BIconChevronCompactRight,
|
||||||
BIconMusicNoteList,
|
BIconMusicNoteList,
|
||||||
BIconStar,
|
|
||||||
BIconStarFill,
|
|
||||||
BIconCollection,
|
BIconCollection,
|
||||||
BIconCollectionFill,
|
BIconCollectionFill,
|
||||||
BIconList,
|
BIconList,
|
||||||
@ -52,8 +52,12 @@
|
|||||||
BIconThreeDotsVertical,
|
BIconThreeDotsVertical,
|
||||||
BIconBoxArrowRight,
|
BIconBoxArrowRight,
|
||||||
BIconPersonFill,
|
BIconPersonFill,
|
||||||
BIconPersonCircle,
|
BIconRss,
|
||||||
BIconX,
|
BIconX,
|
||||||
|
BIconVolumeMuteFill,
|
||||||
|
BIconVolumeUpFill,
|
||||||
|
BIconHeart,
|
||||||
|
BIconHeartFill,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
icon: { type: String, required: true }
|
icon: { type: String, required: true }
|
||||||
|
34
src/shared/components/InfiniteList.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<slot :items="items" />
|
||||||
|
<InfiniteLoader :loading="loading" :has-more="hasMore" @load-more="loadMore" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
props: {
|
||||||
|
load: { type: Function, required: true },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
items: [] as any[],
|
||||||
|
loading: false,
|
||||||
|
offset: 0 as number,
|
||||||
|
hasMore: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadMore() {
|
||||||
|
this.loading = true
|
||||||
|
return this.load(this.offset).then((items: any[]) => {
|
||||||
|
this.items.push(...items)
|
||||||
|
this.offset += items.length
|
||||||
|
this.hasMore = items.length > 0
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
@ -1,5 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<b-dropdown variant="link" boundary="window" no-caret toggle-class="p-0">
|
<b-dropdown
|
||||||
|
variant="link"
|
||||||
|
no-caret
|
||||||
|
toggle-class="p-0"
|
||||||
|
:disabled="disabled"
|
||||||
|
lazy
|
||||||
|
>
|
||||||
<template #button-content>
|
<template #button-content>
|
||||||
<Icon icon="three-dots-vertical" />
|
<Icon icon="three-dots-vertical" />
|
||||||
</template>
|
</template>
|
||||||
@ -9,5 +15,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
|
||||||
export default Vue.extend({})
|
export default Vue.extend({
|
||||||
|
props: {
|
||||||
|
disabled: { type: Boolean, default: false }
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
72
src/shared/components/Slider.vue
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<VueSlider
|
||||||
|
v-bind="$attrs"
|
||||||
|
:value="value"
|
||||||
|
:min="min"
|
||||||
|
:max="max"
|
||||||
|
:interval="step"
|
||||||
|
:tooltip-formatter="formatter"
|
||||||
|
@change="onInput"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '/src/style/_variables';
|
||||||
|
@import '~vue-slider-component/theme/material.css';
|
||||||
|
|
||||||
|
.vue-slider {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
::v-deep .vue-slider-rail {
|
||||||
|
background-color: $secondary;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
::v-deep .vue-slider-process {
|
||||||
|
background-color: $primary;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
::v-deep .vue-slider-dot-handle {
|
||||||
|
background-color: $primary;
|
||||||
|
}
|
||||||
|
::v-deep .vue-slider-dot-handle::after {
|
||||||
|
background-color: rgba($primary, 0.32);
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
::v-deep .vue-slider-dot-handle:hover .vue-slider-dot-tooltip {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
::v-deep .vue-slider-dot-tooltip-inner {
|
||||||
|
background-color: $primary;
|
||||||
|
border-color: $primary;
|
||||||
|
}
|
||||||
|
::v-deep .vue-slider-dot-tooltip-text {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
import VueSlider from 'vue-slider-component'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
components: {
|
||||||
|
VueSlider,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: { type: Number, required: true },
|
||||||
|
min: { type: Number, required: true },
|
||||||
|
max: { type: Number, required: true },
|
||||||
|
step: { type: Number, required: true },
|
||||||
|
percent: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onInput(value: number) {
|
||||||
|
this.$emit('input', value)
|
||||||
|
},
|
||||||
|
formatter(value: number) {
|
||||||
|
return this.percent
|
||||||
|
? `${Math.round(((value - this.min) * 100) / (this.max - this.min))}%`
|
||||||
|
: `${value}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
@ -4,8 +4,36 @@ import ExternalLink from './ExternalLink.vue'
|
|||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import InfiniteLoader from './InfiniteLoader.vue'
|
import InfiniteLoader from './InfiniteLoader.vue'
|
||||||
import OverflowMenu from './OverflowMenu.vue'
|
import OverflowMenu from './OverflowMenu.vue'
|
||||||
|
import Slider from './Slider.vue'
|
||||||
import Tiles from './Tiles.vue'
|
import Tiles from './Tiles.vue'
|
||||||
import Tile from './Tile.vue'
|
import Tile from './Tile.vue'
|
||||||
|
import {
|
||||||
|
BAlert,
|
||||||
|
BAvatar,
|
||||||
|
BButton,
|
||||||
|
BFormCheckbox,
|
||||||
|
BFormGroup,
|
||||||
|
BFormInput,
|
||||||
|
BFormTextarea,
|
||||||
|
BModal,
|
||||||
|
BOverlay,
|
||||||
|
BPopover,
|
||||||
|
BSidebar,
|
||||||
|
DropdownPlugin,
|
||||||
|
} from 'bootstrap-vue'
|
||||||
|
|
||||||
|
Vue.component('BModal', BModal)
|
||||||
|
Vue.component('BAlert', BAlert)
|
||||||
|
Vue.component('BAvatar', BAvatar)
|
||||||
|
Vue.component('BSidebar', BSidebar)
|
||||||
|
Vue.component('BFormGroup', BFormGroup)
|
||||||
|
Vue.component('BFormInput', BFormInput)
|
||||||
|
Vue.component('BFormCheckbox', BFormCheckbox)
|
||||||
|
Vue.component('BFormTextarea', BFormTextarea)
|
||||||
|
Vue.component('BButton', BButton)
|
||||||
|
Vue.component('BPopover', BPopover)
|
||||||
|
Vue.component('BOverlay', BOverlay)
|
||||||
|
Vue.use(DropdownPlugin)
|
||||||
|
|
||||||
const components = {
|
const components = {
|
||||||
ContentLoader,
|
ContentLoader,
|
||||||
@ -13,6 +41,7 @@ const components = {
|
|||||||
Icon,
|
Icon,
|
||||||
InfiniteLoader,
|
InfiniteLoader,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
|
Slider,
|
||||||
Tiles,
|
Tiles,
|
||||||
Tile,
|
Tile,
|
||||||
}
|
}
|
||||||
|
@ -6,11 +6,12 @@ import ArtistDetails from '@/library/artist/ArtistDetails.vue'
|
|||||||
import ArtistLibrary from '@/library/artist/ArtistLibrary.vue'
|
import ArtistLibrary from '@/library/artist/ArtistLibrary.vue'
|
||||||
import AlbumDetails from '@/library/album/AlbumDetails.vue'
|
import AlbumDetails from '@/library/album/AlbumDetails.vue'
|
||||||
import AlbumLibrary from '@/library/album/AlbumLibrary.vue'
|
import AlbumLibrary from '@/library/album/AlbumLibrary.vue'
|
||||||
import RandomSongs from '@/playlist/RandomSongs.vue'
|
|
||||||
import GenreDetails from '@/library/genre/GenreDetails.vue'
|
import GenreDetails from '@/library/genre/GenreDetails.vue'
|
||||||
import GenreLibrary from '@/library/genre/GenreLibrary.vue'
|
import GenreLibrary from '@/library/genre/GenreLibrary.vue'
|
||||||
import Starred from '@/library/starred/Starred.vue'
|
import Favourites from '@/library/favourite/Favourites.vue'
|
||||||
import RadioStations from '@/library/radio/RadioStations.vue'
|
import RadioStations from '@/library/radio/RadioStations.vue'
|
||||||
|
import PodcastDetails from '@/library/podcast/PodcastDetails.vue'
|
||||||
|
import PodcastLibrary from '@/library/podcast/PodcastLibrary.vue'
|
||||||
import Playlist from '@/playlist/Playlist.vue'
|
import Playlist from '@/playlist/Playlist.vue'
|
||||||
import PlaylistList from '@/playlist/PlaylistList.vue'
|
import PlaylistList from '@/playlist/PlaylistList.vue'
|
||||||
import SearchResult from '@/search/SearchResult.vue'
|
import SearchResult from '@/search/SearchResult.vue'
|
||||||
@ -33,13 +34,24 @@ export function setupRouter(auth: AuthService) {
|
|||||||
component: Login,
|
component: Login,
|
||||||
props: (route) => ({
|
props: (route) => ({
|
||||||
returnTo: route.query.returnTo,
|
returnTo: route.query.returnTo,
|
||||||
})
|
}),
|
||||||
|
meta: {
|
||||||
|
layout: 'fullscreen'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'queue',
|
name: 'queue',
|
||||||
path: '/queue',
|
path: '/queue',
|
||||||
component: Queue,
|
component: Queue,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'albums-default',
|
||||||
|
path: '/albums',
|
||||||
|
redirect: ({
|
||||||
|
name: 'albums',
|
||||||
|
params: { sort: 'recently-added' }
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'albums',
|
name: 'albums',
|
||||||
path: '/albums/:sort',
|
path: '/albums/:sort',
|
||||||
@ -48,7 +60,7 @@ export function setupRouter(auth: AuthService) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'album',
|
name: 'album',
|
||||||
path: '/album/:id',
|
path: '/albums/id/:id',
|
||||||
component: AlbumDetails,
|
component: AlbumDetails,
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
@ -59,7 +71,7 @@ export function setupRouter(auth: AuthService) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'artist',
|
name: 'artist',
|
||||||
path: '/artist/:id',
|
path: '/artists/:id',
|
||||||
component: ArtistDetails,
|
component: ArtistDetails,
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
@ -70,20 +82,32 @@ export function setupRouter(auth: AuthService) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'genre',
|
name: 'genre',
|
||||||
path: '/genre/:id/:section?',
|
path: '/genres/:id/:section?',
|
||||||
component: GenreDetails,
|
component: GenreDetails,
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'starred',
|
name: 'favourites',
|
||||||
path: '/starred',
|
path: '/favourites/:section?',
|
||||||
component: Starred,
|
component: Favourites,
|
||||||
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'radio',
|
name: 'radio',
|
||||||
path: '/radio',
|
path: '/radio',
|
||||||
component: RadioStations,
|
component: RadioStations,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'podcasts',
|
||||||
|
path: '/podcasts',
|
||||||
|
component: PodcastLibrary,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'podcast',
|
||||||
|
path: '/podcasts/:id',
|
||||||
|
component: PodcastDetails,
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'playlists',
|
name: 'playlists',
|
||||||
path: '/playlists',
|
path: '/playlists',
|
||||||
@ -95,11 +119,6 @@ export function setupRouter(auth: AuthService) {
|
|||||||
component: Playlist,
|
component: Playlist,
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'playlist-random',
|
|
||||||
path: '/random',
|
|
||||||
component: RandomSongs,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'search',
|
name: 'search',
|
||||||
path: '/search',
|
path: '/search',
|
||||||
|
@ -41,6 +41,10 @@ const setupRootModule = (authService: AuthService, api: API): Module<State, any>
|
|||||||
state.playlists = playlists
|
state.playlists = playlists
|
||||||
.sort((a: any, b: any) => b.changed.localeCompare(a.changed))
|
.sort((a: any, b: any) => b.changed.localeCompare(a.changed))
|
||||||
},
|
},
|
||||||
|
setPlaylist(state, playlist: any) {
|
||||||
|
const idx = state.playlists.findIndex(x => x.id === playlist.id)
|
||||||
|
state.playlists.splice(idx, 1, playlist)
|
||||||
|
},
|
||||||
removePlaylist(state, id: string) {
|
removePlaylist(state, id: string) {
|
||||||
state.playlists = state.playlists.filter(p => p.id !== id)
|
state.playlists = state.playlists.filter(p => p.id !== id)
|
||||||
},
|
},
|
||||||
@ -62,6 +66,16 @@ const setupRootModule = (authService: AuthService, api: API): Module<State, any>
|
|||||||
commit('setPlaylists', result)
|
commit('setPlaylists', result)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
updatePlaylist({ commit, state }, { id, name, comment }) {
|
||||||
|
api.editPlaylist(id, name, comment).then(() => {
|
||||||
|
const playlist = {
|
||||||
|
...state.playlists.find(x => x.id === id),
|
||||||
|
name,
|
||||||
|
comment,
|
||||||
|
}
|
||||||
|
commit('setPlaylist', playlist)
|
||||||
|
})
|
||||||
|
},
|
||||||
addTrackToPlaylist({ }, { playlistId, trackId }) {
|
addTrackToPlaylist({ }, { playlistId, trackId }) {
|
||||||
api.addToPlaylist(playlistId, trackId)
|
api.addToPlaylist(playlistId, trackId)
|
||||||
},
|
},
|
||||||
@ -69,6 +83,14 @@ const setupRootModule = (authService: AuthService, api: API): Module<State, any>
|
|||||||
api.deletePlaylist(id).then(() => {
|
api.deletePlaylist(id).then(() => {
|
||||||
commit('removePlaylist', id)
|
commit('removePlaylist', id)
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
addFavourite({ commit }, id) {
|
||||||
|
commit('player/updateTrack', { id, favourite: true })
|
||||||
|
return api.addFavourite(id, 'track')
|
||||||
|
},
|
||||||
|
removeFavourite({ commit }, id) {
|
||||||
|
commit('player/updateTrack', { id, favourite: false })
|
||||||
|
return api.removeFavourite(id, 'track')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
15
src/style/_variables.scss
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
$theme-elevation-0: hsl(0, 0%, 0%);
|
||||||
|
$theme-elevation-1: hsl(0, 0%, 10%);
|
||||||
|
$theme-elevation-2: hsl(0, 0%, 20%);
|
||||||
|
$theme-text: #ccc;
|
||||||
|
$theme-text-muted: #999;
|
||||||
|
|
||||||
|
$primary: #09f;
|
||||||
|
$danger: #ff4141;
|
||||||
|
$secondary: $theme-elevation-2;
|
||||||
|
|
||||||
|
$body-bg: $theme-elevation-0;
|
||||||
|
$body-color: $theme-text;
|
||||||
|
$link-color: $theme-text;
|
||||||
|
$text-muted: $theme-text-muted;
|
||||||
|
$border-color: $theme-elevation-2;
|
@ -1,18 +1,4 @@
|
|||||||
$theme-elevation-0: hsl(0, 0%, 0%);
|
@import "./variables";
|
||||||
$theme-elevation-1: hsl(0, 0%, 10%);
|
|
||||||
$theme-elevation-2: hsl(0, 0%, 20%);
|
|
||||||
$theme-text: #ccc;
|
|
||||||
$theme-text-muted: #999;
|
|
||||||
|
|
||||||
$primary: #09f;
|
|
||||||
$danger: #ff4141;
|
|
||||||
$secondary: $theme-elevation-2;
|
|
||||||
|
|
||||||
$body-bg: $theme-elevation-0;
|
|
||||||
$body-color: $theme-text;
|
|
||||||
$link-color: $theme-text;
|
|
||||||
$text-muted: $theme-text-muted;
|
|
||||||
$border-color: $theme-elevation-2;
|
|
||||||
|
|
||||||
// Card
|
// Card
|
||||||
$card-bg: $theme-elevation-1;
|
$card-bg: $theme-elevation-1;
|
||||||
@ -30,15 +16,20 @@ $dropdown-link-hover-color: $theme-text-muted;
|
|||||||
$dropdown-border-color: $theme-elevation-2;
|
$dropdown-border-color: $theme-elevation-2;
|
||||||
$dropdown-divider-bg: $theme-elevation-2;
|
$dropdown-divider-bg: $theme-elevation-2;
|
||||||
|
|
||||||
|
// Popover
|
||||||
|
$popover-bg: $theme-elevation-1;
|
||||||
|
$popover-border-color: $theme-elevation-2;
|
||||||
|
|
||||||
// Form
|
// Form
|
||||||
$input-bg: $theme-elevation-2;
|
$input-bg: $theme-elevation-2;
|
||||||
$input-border-color: $theme-elevation-2;
|
$input-border-color: $theme-elevation-2;
|
||||||
$input-color: $theme-text;
|
$input-color: $theme-text;
|
||||||
|
$custom-range-track-height: 0.1rem;
|
||||||
// Other
|
$custom-range-thumb-bg: $theme-text;
|
||||||
$progress-bg: rgb(35, 35, 35);
|
$custom-range-track-bg: $theme-text-muted;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--text-body: #{$theme-text};
|
||||||
--text-muted: #{$theme-text-muted};
|
--text-muted: #{$theme-text-muted};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,3 +62,8 @@ $enable-responsive-font-sizes: true;
|
|||||||
|
|
||||||
@import '~bootstrap';
|
@import '~bootstrap';
|
||||||
@import '~bootstrap-vue';
|
@import '~bootstrap-vue';
|
||||||
|
|
||||||
|
.modal-header .close {
|
||||||
|
color: $theme-text;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@ -6,8 +6,54 @@ table thead tr {
|
|||||||
color: $theme-text-muted;
|
color: $theme-text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
table tr.active {
|
table.table {
|
||||||
|
tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
tr.active {
|
||||||
td, td a, td svg {
|
td, td a, td svg {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
tr.disabled {
|
||||||
|
cursor: default;
|
||||||
|
td, td a, td svg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table-numbered {
|
||||||
|
th:first-child, td:first-child {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
width: 26px;
|
||||||
|
max-width: 26px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
tr td:first-child button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
outline: inherit;
|
||||||
|
.number { display: inline; }
|
||||||
|
.icon { display: none; }
|
||||||
|
}
|
||||||
|
tr.active td:first-child button {
|
||||||
|
.number { display: none;}
|
||||||
|
.icon { display: inline;}
|
||||||
|
}
|
||||||
|
tr:hover td:first-child button {
|
||||||
|
.number { display: none; }
|
||||||
|
.icon { display: inline; }
|
||||||
|
}
|
||||||
|
tr.disabled:hover td:first-child button {
|
||||||
|
.number { display: inline;}
|
||||||
|
.icon { display: none;}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|