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', {
|
||||
order: ['template', 'style', 'script']
|
||||
}],
|
||||
'no-console': 'warn',
|
||||
'no-console': 'off',
|
||||
'no-debugger': 'warn',
|
||||
'no-useless-constructor': 'off', // Crashes eslint
|
||||
'no-empty-pattern': 'off',
|
||||
'comma-dangle': 'off',
|
||||
'space-before-function-paren': ['error', 'never'],
|
||||
|
50
.github/workflows/ci.yml
vendored
@ -1,10 +1,10 @@
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
- push
|
||||
|
||||
env:
|
||||
IMAGE: ${{ github.repository }}
|
||||
TAG: ${{ github.sha }}
|
||||
VERSION: ${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
export VUE_APP_BUILD=$TAG
|
||||
export VUE_APP_BUILD=$VERSION
|
||||
export VUE_APP_BUILD_DATE=$(date --iso-8601)
|
||||
yarn build
|
||||
|
||||
@ -31,18 +31,26 @@ jobs:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
build_docker_image:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Log in to docker hub
|
||||
run: docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- name: Build docker image
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--tag $IMAGE:$TAG \
|
||||
--file docker/Dockerfile .
|
||||
- name: Build docker image
|
||||
run: docker build -t $IMAGE:$VERSION -f docker/Dockerfile .
|
||||
|
||||
- name: Push docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
run: |
|
||||
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
docker push $IMAGE:$VERSION
|
||||
|
||||
preview:
|
||||
runs-on: ubuntu-latest
|
||||
@ -53,6 +61,7 @@ jobs:
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- name: Deploy preview
|
||||
uses: netlify/actions/cli@master
|
||||
@ -60,7 +69,7 @@ jobs:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
with:
|
||||
args: deploy --dir=.
|
||||
args: deploy --dir=dist
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@ -71,6 +80,7 @@ jobs:
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- name: Deploy site
|
||||
uses: netlify/actions/cli@master
|
||||
@ -78,16 +88,16 @@ jobs:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
with:
|
||||
args: deploy --dir=. --prod
|
||||
args: deploy --dir=dist --prod
|
||||
|
||||
publish_docker_image:
|
||||
publish_latest_docker_image:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: build_docker_image
|
||||
if: github.ref == 'refs/heads/master'
|
||||
steps:
|
||||
- name: Push latest
|
||||
run: |
|
||||
docker pull $IMAGE:$TAG
|
||||
docker tag $IMAGE:$TAG $IMAGE:latest
|
||||
docker pull $IMAGE:$VERSION
|
||||
docker tag $IMAGE:$VERSION $IMAGE:latest
|
||||
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
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
|
||||
|
||||
### 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/
|
||||
@ -45,8 +49,8 @@ Environment variables:
|
||||
|
||||
### Pre-built bundle
|
||||
|
||||
Pre-built bundles can be found in the [Actions](https://github.com/tamland/airsonic-frontend/actions)
|
||||
tab. Download/extract artifact and serve with your favourite web server.
|
||||
Pre-built bundles can be found in the [Actions](https://github.com/tamland/airsonic-refix/actions)
|
||||
tab. Download/extract artifact and serve with any web server such as nginx or apache.
|
||||
|
||||
### Build from source
|
||||
|
||||
@ -57,6 +61,11 @@ $ yarn build
|
||||
|
||||
Bundle can be found in the `dist` folder.
|
||||
|
||||
Build docker image:
|
||||
|
||||
```
|
||||
$ docker build -f docker/Dockerfile .
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
|
14995
package-lock.json
generated
Normal file
43
package.json
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "airsonic",
|
||||
"name": "airsonic-refix",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@ -8,33 +8,34 @@
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.20.0",
|
||||
"bootstrap": "^4.5.2",
|
||||
"bootstrap-vue": "^2.17.3",
|
||||
"axios": "^0.21.1",
|
||||
"bootstrap": "^4.6.0",
|
||||
"bootstrap-vue": "^2.21.2",
|
||||
"md5-es": "1.8.2",
|
||||
"vue": "^2.6.12",
|
||||
"vue-infinite-loading": "2.4.5",
|
||||
"vue-router": "^3.4.6",
|
||||
"vuex": "^3.5.1"
|
||||
"vue-router": "^3.5.2",
|
||||
"vue-slider-component": "3.2.13",
|
||||
"vuex": "^3.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^4.4.0",
|
||||
"@typescript-eslint/parser": "^4.4.0",
|
||||
"@vue/cli-plugin-babel": "^4.5.7",
|
||||
"@vue/cli-plugin-eslint": "~4.5.7",
|
||||
"@vue/cli-plugin-typescript": "^4.5.7",
|
||||
"@vue/cli-service": "^4.5.7",
|
||||
"@vue/eslint-config-standard": "^5.1.2",
|
||||
"@typescript-eslint/eslint-plugin": "^4.28.1",
|
||||
"@typescript-eslint/parser": "^4.28.1",
|
||||
"@vue/cli-plugin-babel": "^4.5.13",
|
||||
"@vue/cli-plugin-eslint": "~4.5.13",
|
||||
"@vue/cli-plugin-typescript": "^4.5.13",
|
||||
"@vue/cli-service": "^4.5.13",
|
||||
"@vue/eslint-config-standard": "^6.0.0",
|
||||
"@vue/eslint-config-typescript": "^7.0.0",
|
||||
"eslint": "^7.11.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint": "^7.30.0",
|
||||
"eslint-plugin-import": "^2.23.3",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-standard": "^4.0.0",
|
||||
"eslint-plugin-vue": "^7.0.1",
|
||||
"sass": "^1.27.0",
|
||||
"sass-loader": "^10.0.3",
|
||||
"typescript": "^4.0.3",
|
||||
"eslint-plugin-promise": "^5.1.0",
|
||||
"eslint-plugin-standard": "^5.0.0",
|
||||
"eslint-plugin-vue": "^7.12.1",
|
||||
"sass": "^1.34.0",
|
||||
"sass-loader": "^10.1.1",
|
||||
"typescript": "^4.3.5",
|
||||
"vue-template-compiler": "^2.6.12"
|
||||
},
|
||||
"postcss": {
|
||||
|
Before Width: | Height: | Size: 9.0 KiB |
Before Width: | Height: | Size: 4.6 KiB |
@ -1,21 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" fill="#fff" version="1.1" viewBox="0 0 135.47 135.47"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#">
|
||||
<rect width="100%" height="100%" fill="#09f"/>
|
||||
<svg width="512" height="512" fill="#09f" version="1.1" viewBox="0 0 135.47 135.47" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(0 -161.53)">
|
||||
<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="19.849" y="215.2" width="5.9939" height="41.989" rx="2.997" ry="2.819" />
|
||||
<rect x="29.768" y="211.49" width="5.9939" height="49.213" rx="2.997" ry="2.997" />
|
||||
<rect x="39.688" y="202.01" width="5.9939" height="58.69" rx="2.997" ry="3.0381" />
|
||||
<rect x="49.607" y="198.3" width="5.9939" height="62.402" rx="2.997" ry="2.997" />
|
||||
<rect x="59.526" y="197.97" width="5.9939" height="62.733" rx="2.997" ry="2.997" />
|
||||
<rect x="69.446" y="201.81" width="5.9939" height="58.889" rx="2.997" ry="2.997" />
|
||||
<rect x="79.365" y="211.29" width="5.9939" height="49.412" rx="2.997" ry="2.997" />
|
||||
<rect x="89.285" y="211.75" width="5.9939" height="48.948" rx="2.997" ry="2.997" />
|
||||
<rect x="99.204" y="216.53" width="5.9939" height="44.176" rx="2.997" ry="2.997" />
|
||||
<rect x="109.12" y="223.55" width="5.9939" height="36.886" rx="2.997" ry="2.997" />
|
||||
<rect x="119.04" y="230.78" width="5.9939" height="22.372" 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" />
|
||||
<rect x="19.849" y="215.2" width="5.9939" height="41.989" rx="2.997" ry="2.819" />
|
||||
<rect x="29.768" y="211.49" width="5.9939" height="49.213" rx="2.997" ry="2.997" />
|
||||
<rect x="39.688" y="202.01" width="5.9939" height="58.69" rx="2.997" ry="3.0381" />
|
||||
<rect x="49.607" y="198.3" width="5.9939" height="62.402" rx="2.997" ry="2.997" />
|
||||
<rect x="59.526" y="197.97" width="5.9939" height="62.733" rx="2.997" ry="2.997" />
|
||||
<rect x="69.446" y="201.81" width="5.9939" height="58.889" rx="2.997" ry="2.997" />
|
||||
<rect x="79.365" y="211.29" width="5.9939" height="49.412" rx="2.997" ry="2.997" />
|
||||
<rect x="89.285" y="211.75" width="5.9939" height="48.948" rx="2.997" ry="2.997" />
|
||||
<rect x="99.204" y="216.53" width="5.9939" height="44.176" rx="2.997" ry="2.997" />
|
||||
<rect x="109.12" y="223.55" width="5.9939" height="36.886" rx="2.997" ry="2.997" />
|
||||
<rect x="119.04" y="230.78" width="5.9939" height="22.372" rx="2.997" ry="2.997" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
@ -4,16 +4,16 @@
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<% 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 { %>
|
||||
<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="theme-color" content="#1a1a1a" />
|
||||
<meta name="theme-color" content="#000" />
|
||||
<link rel="icon" href="<%= BASE_URL %>icon.svg">
|
||||
<link rel=manifest href="<%= BASE_URL %>manifest.webmanifest">
|
||||
<script src="<%= BASE_URL %>env.js"></script>
|
||||
<title></title>
|
||||
<title>Airsonic (refix)</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
@ -1,25 +1,16 @@
|
||||
{
|
||||
"name": "Airsonic",
|
||||
"short_name": "Airsonic",
|
||||
"name": "Airsonic (refix)",
|
||||
"short_name": "Airsonic (refix)",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#09F",
|
||||
"background_color": "#09F",
|
||||
"theme_color": "#000",
|
||||
"background_color": "#000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icon.svg",
|
||||
"type": "image/svg",
|
||||
"sizes": "any"
|
||||
},
|
||||
{
|
||||
"src": "./icon-192x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "./icon-512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "any",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
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: {
|
||||
build: () => process.env.VUE_APP_BUILD,
|
||||
buildDate: () => process.env.VUE_APP_BUILD_DATE,
|
||||
url: () => 'https://github.com/tamland/airsonic-frontend'
|
||||
url: () => 'https://github.com/tamland/airsonic-refix'
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
@ -1,35 +1,26 @@
|
||||
<template>
|
||||
<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 />
|
||||
<Player />
|
||||
<component :is="layout">
|
||||
<router-view />
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
main {
|
||||
margin-bottom: 80px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import ErrorBar from './ErrorBar.vue'
|
||||
import TopNav from './TopNav.vue'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import Player from '@/player/Player.vue'
|
||||
import Default from '@/app/layout/Default.vue'
|
||||
import Fullscreen from '@/app/layout/Fullscreen.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ErrorBar,
|
||||
TopNav,
|
||||
Sidebar,
|
||||
Player,
|
||||
Default,
|
||||
Fullscreen,
|
||||
},
|
||||
computed: {
|
||||
layout(): string {
|
||||
return (this as any).$route.meta.layout || 'Default'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,23 +1,32 @@
|
||||
<template functional>
|
||||
<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)">
|
||||
<rect width="5.994" height="23.366" x="9.929" y="224.55" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="41.989" x="19.849" y="215.2" rx="2.997" ry="2.819" />
|
||||
<rect width="5.994" height="49.213" x="29.768" y="211.49" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="58.69" x="39.688" y="202.01" rx="2.997" ry="3.038" />
|
||||
<rect width="5.994" height="62.402" x="49.607" y="198.3" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="62.733" x="59.526" y="197.97" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="58.889" x="69.446" y="201.81" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="49.412" x="79.365" y="211.29" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="48.948" x="89.285" y="211.75" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="44.176" x="99.204" y="216.53" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="36.886" x="109.12" y="223.55" 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>
|
||||
</svg>
|
||||
<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">
|
||||
<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="41.989" x="19.849" y="215.2" rx="2.997" ry="2.819" />
|
||||
<rect width="5.994" height="49.213" x="29.768" y="211.49" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="58.69" x="39.688" y="202.01" rx="2.997" ry="3.038" />
|
||||
<rect width="5.994" height="62.402" x="49.607" y="198.3" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="62.733" x="59.526" y="197.97" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="58.889" x="69.446" y="201.81" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="49.412" x="79.365" y="211.29" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="48.948" x="89.285" y="211.75" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="44.176" x="99.204" y="216.53" rx="2.997" ry="2.997" />
|
||||
<rect width="5.994" height="36.886" x="109.12" y="223.55" 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>
|
||||
</svg>
|
||||
<span class="text-body ml-2 text-nowrap">airsonic
|
||||
<span class="text-muted">(refix)</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
svg {
|
||||
fill: var(--primary);
|
||||
height: 32px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,50 +1,50 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="nav flex-column">
|
||||
<div class="nav-link logo d-flex justify-content-between">
|
||||
<Logo />
|
||||
<button class="btn btn-link btn-lg p-0 d-md-none" @click="hideMenu">
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="nav flex-column">
|
||||
<div class="sidebar-brand d-flex justify-content-between align-items-end">
|
||||
<Logo />
|
||||
<button class="btn btn-link btn-lg p-0 m-0 d-md-none" @click="hideMenu">
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<router-link class="nav-link" :to="{name: 'home'}">
|
||||
<Icon icon="card-text" class="" /> Discover
|
||||
</router-link>
|
||||
<router-link class="nav-link" :to="{name: 'home'}" exact>
|
||||
<Icon icon="card-text" class="" /> Discover
|
||||
</router-link>
|
||||
|
||||
<router-link class="nav-link" :to="{name: 'queue'}">
|
||||
<Icon icon="music-note-list" /> Playing
|
||||
</router-link>
|
||||
<router-link class="nav-link" :to="{name: 'queue'}">
|
||||
<Icon icon="music-note-list" /> Playing
|
||||
</router-link>
|
||||
|
||||
<a class="nav-link disabled">
|
||||
<small class="text-uppercase text-muted font-weight-bold">
|
||||
Library
|
||||
</small>
|
||||
</a>
|
||||
<small class="sidebar-heading text-muted">
|
||||
Library
|
||||
</small>
|
||||
|
||||
<router-link class="nav-link" :to="{name: 'albums', params: {sort: 'recently-added'}}">
|
||||
<Icon icon="collection" /> Albums
|
||||
</router-link>
|
||||
<router-link class="nav-link" :to="{name: 'albums-default'}">
|
||||
<Icon icon="collection" /> Albums
|
||||
</router-link>
|
||||
|
||||
<router-link class="nav-link" :to="{name: 'artists'}">
|
||||
<Icon icon="collection" /> Artists
|
||||
</router-link>
|
||||
<router-link class="nav-link" :to="{name: 'artists'}">
|
||||
<Icon icon="collection" /> Artists
|
||||
</router-link>
|
||||
|
||||
<router-link class="nav-item nav-link" :to="{name: 'genres'}">
|
||||
<Icon icon="collection" /> Genres
|
||||
</router-link>
|
||||
<router-link class="nav-item nav-link" :to="{name: 'genres'}">
|
||||
<Icon icon="collection" /> Genres
|
||||
</router-link>
|
||||
|
||||
<router-link class="nav-link" :to="{name: 'starred'}">
|
||||
<Icon icon="star" /> Starred
|
||||
</router-link>
|
||||
<router-link class="nav-link" :to="{name: 'favourites'}">
|
||||
<Icon icon="heart" /> Favourites
|
||||
</router-link>
|
||||
|
||||
<router-link class="nav-link" :to="{name: 'radio'}">
|
||||
<Icon icon="broadcast" /> Radio
|
||||
</router-link>
|
||||
<router-link class="nav-link" :to="{name: 'podcasts'}">
|
||||
<Icon icon="rss" /> Podcasts
|
||||
</router-link>
|
||||
|
||||
<PlaylistNav />
|
||||
</nav>
|
||||
</div>
|
||||
<router-link class="nav-link" :to="{name: 'radio'}">
|
||||
<Icon icon="broadcast" /> Radio
|
||||
</router-link>
|
||||
|
||||
<PlaylistNav />
|
||||
</nav>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
|
@ -18,50 +18,73 @@
|
||||
</b-sidebar>
|
||||
</div>
|
||||
</template>
|
||||
<style>
|
||||
.sidebar-container nav {
|
||||
padding-top: 0.5rem;
|
||||
<style lang="scss">
|
||||
.sidebar-container {
|
||||
.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-right: 0.5rem;
|
||||
padding-bottom: 180px;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.sidebar-container .logo {
|
||||
height: 48px;
|
||||
.sidebar-brand {
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
}
|
||||
.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%);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.b-icon {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
&:not(.router-link-active) .b-icon {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-container a.nav-link .b-icon {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-container a.nav-link:not(.active) .b-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import Nav from './Nav.vue'
|
||||
import { mapState, mapActions } from 'vuex'
|
||||
import Vue from "vue";
|
||||
import Nav from "./Nav.vue";
|
||||
import { mapState, mapActions } from "vuex";
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Nav,
|
||||
},
|
||||
computed: {
|
||||
...mapState(['showMenu'])
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['hideMenu']),
|
||||
},
|
||||
})
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Nav,
|
||||
},
|
||||
computed: {
|
||||
...mapState(["showMenu"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["hideMenu"]),
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -22,7 +22,7 @@
|
||||
{{ username }}
|
||||
</b-dropdown-text>
|
||||
<b-dropdown-divider />
|
||||
<b-dropdown-item :href="`${server}/settings.view`">
|
||||
<b-dropdown-item :href="`${server}/settings.view`" target="_blank">
|
||||
Server settings
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item-button @click="scan">
|
||||
@ -67,7 +67,7 @@
|
||||
'showMenu',
|
||||
]),
|
||||
scan() {
|
||||
this.$api.scan()
|
||||
return this.$api.scan()
|
||||
},
|
||||
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,36 +1,47 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-modal size="sm" hide-header hide-footer no-close-on-esc :visible="showModal">
|
||||
<form @submit.prevent="login">
|
||||
<div style="font-size: 4rem; color: #fff;" class="text-center">
|
||||
<Icon icon="person-circle" />
|
||||
<div class="row align-items-center h-100 mt-5">
|
||||
<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">
|
||||
<div class="d-flex mb-2">
|
||||
<Logo class="mx-auto" />
|
||||
</div>
|
||||
<b-form-group v-if="!config.serverUrl" label="Server">
|
||||
<b-form-input v-model="server" name="server" type="text" :state="valid" />
|
||||
</b-form-group>
|
||||
<b-form-group label="Username">
|
||||
<b-form-input v-model="username" name="username" type="text" :state="valid" />
|
||||
</b-form-group>
|
||||
<b-form-group label="Password">
|
||||
<b-form-input v-model="password" name="password" type="password" :state="valid" />
|
||||
</b-form-group>
|
||||
<b-alert :show="error != null" variant="danger">
|
||||
<template v-if="error != null">
|
||||
Could not log in. ({{ error.message }})
|
||||
</template>
|
||||
</b-alert>
|
||||
<button class="btn btn-primary btn-block" :disabled="busy" @click="login">
|
||||
<span v-show="false" class="spinner-border spinner-border-sm" /> Log in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<b-form-group v-if="!config.serverUrl" label="Server">
|
||||
<b-form-input v-model="server" name="server" type="text" :state="valid" />
|
||||
</b-form-group>
|
||||
<b-form-group label="Username">
|
||||
<b-form-input v-model="username" name="username" type="text" :state="valid" />
|
||||
</b-form-group>
|
||||
<b-form-group label="Password">
|
||||
<b-form-input v-model="password" name="password" type="password" :state="valid" />
|
||||
</b-form-group>
|
||||
<b-alert :show="error != null" variant="danger">
|
||||
<template v-if="error != null">
|
||||
Could not log in. ({{ error.message }})
|
||||
</template>
|
||||
</b-alert>
|
||||
<button class="btn btn-primary btn-block" :disabled="busy" @click="login">
|
||||
<b-spinner v-show="busy" small type="grow" /> Log in
|
||||
</button>
|
||||
</form>
|
||||
</b-modal>
|
||||
</b-overlay>
|
||||
</div>
|
||||
</div>
|
||||
</template>>
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { config } from '@/shared/config'
|
||||
import Logo from '@/app/Logo.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Logo,
|
||||
},
|
||||
props: {
|
||||
returnTo: { type: String, required: true },
|
||||
},
|
||||
@ -42,7 +53,7 @@
|
||||
rememberLogin: true,
|
||||
busy: false,
|
||||
error: null,
|
||||
showModal: false,
|
||||
displayForm: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -52,8 +63,8 @@
|
||||
config: () => config
|
||||
},
|
||||
async created() {
|
||||
this.server = await this.$auth.server
|
||||
this.username = await this.$auth.username
|
||||
this.server = this.$auth.server
|
||||
this.username = this.$auth.username
|
||||
const success = await this.$auth.autoLogin()
|
||||
if (success) {
|
||||
this.$store.commit('setLoginSuccess', {
|
||||
@ -62,7 +73,7 @@
|
||||
})
|
||||
this.$router.replace(this.returnTo)
|
||||
} else {
|
||||
this.showModal = true
|
||||
this.displayForm = true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -47,7 +47,7 @@ export class AuthService {
|
||||
hash: string,
|
||||
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)
|
||||
.then((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 'vue-slider-component';
|
||||
|
||||
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 }}
|
||||
</router-link>
|
||||
<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>
|
||||
<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
|
||||
</b-btn>
|
||||
<b-btn variant="secondary" class="mr-2" @click="toggleStar">
|
||||
<Icon :icon="album.starred ? 'star-fill' : 'star'" />
|
||||
</b-btn>
|
||||
</b-button>
|
||||
<b-button variant="secondary" class="mr-2" @click="toggleFavourite">
|
||||
<Icon :icon="album.favourite ? 'heart-fill' : 'heart'" />
|
||||
</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>
|
||||
@ -39,7 +54,7 @@
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import TrackList from '@/library/TrackList.vue'
|
||||
import TrackList from '@/library/track/TrackList.vue'
|
||||
import { Album } from '@/shared/api'
|
||||
|
||||
export default Vue.extend({
|
||||
@ -54,27 +69,36 @@
|
||||
album: null as null | Album,
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
async created() {
|
||||
this.album = await this.$api.getAlbumDetails(this.id)
|
||||
},
|
||||
methods: {
|
||||
play() {
|
||||
if (this.album?.tracks) {
|
||||
if (this.album) {
|
||||
return this.$store.dispatch('player/playTrackList', {
|
||||
index: 0,
|
||||
tracks: this.album.tracks,
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleStar() {
|
||||
setNextInQueue() {
|
||||
if (this.album) {
|
||||
const value = !this.album.starred
|
||||
this.album.starred = value
|
||||
return value
|
||||
? this.$api.starAlbum(this.album.id)
|
||||
: this.$api.unstarAlbum(this.album.id)
|
||||
return this.$store.dispatch('player/setNextInQueue', this.album.tracks)
|
||||
}
|
||||
}
|
||||
},
|
||||
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>
|
||||
|
@ -54,7 +54,7 @@
|
||||
methods: {
|
||||
loadMore() {
|
||||
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.offset += albums.length
|
||||
this.hasMore = albums.length > 0
|
||||
|
@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<ArtistList :items="items" />
|
||||
<ContentLoader v-slot :loading="items == null">
|
||||
<ArtistList :items="items" />
|
||||
</ContentLoader>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
@ -12,13 +14,11 @@
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
items: [] as Artist[]
|
||||
items: null as null | Artist[]
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.$api.getArtists().then(items => {
|
||||
this.items = items
|
||||
})
|
||||
async created() {
|
||||
this.items = await this.$api.getArtists()
|
||||
}
|
||||
})
|
||||
</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>
|
||||
</ul>
|
||||
<template v-if="section === 'tracks'">
|
||||
<ContentLoader v-slot :loading="tracks == null">
|
||||
<TrackList :tracks="tracks" />
|
||||
</ContentLoader>
|
||||
<InfiniteList v-slot="{ items }" key="tracks" :load="loadTracks">
|
||||
<TrackList :tracks="items" />
|
||||
</InfiniteList>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ContentLoader v-slot :loading="albums == null">
|
||||
<AlbumList :items="albums" />
|
||||
</ContentLoader>
|
||||
<InfiniteList v-slot="{ items }" key="albums" :load="loadAlbums">
|
||||
<AlbumList :items="items" />
|
||||
</InfiniteList>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import Vue from '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({
|
||||
components: {
|
||||
AlbumList,
|
||||
TrackList,
|
||||
InfiniteList,
|
||||
},
|
||||
props: {
|
||||
id: { type: String, required: true },
|
||||
section: { type: String, default: '' },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
albums: null as null | any[],
|
||||
tracks: null as null | any[],
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.$api.getAlbumsByGenre(this.id).then(result => {
|
||||
this.albums = result
|
||||
})
|
||||
this.$api.getTracksByGenre(this.id).then(result => {
|
||||
this.tracks = result
|
||||
})
|
||||
methods: {
|
||||
loadAlbums(offset: number) {
|
||||
return this.$api.getAlbumsByGenre(this.id, 50, offset)
|
||||
},
|
||||
loadTracks(offset: number) {
|
||||
return this.$api.getTracksByGenre(this.id, 50, offset)
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
@ -6,7 +6,7 @@
|
||||
:title="item.name">
|
||||
<template #text>
|
||||
<strong>{{ item.albumCount }}</strong> albums •
|
||||
<strong>{{ item.songCount }}</strong> songs
|
||||
<strong>{{ item.trackCount }}</strong> tracks
|
||||
</template>
|
||||
</Tile>
|
||||
</Tiles>
|
||||
@ -20,10 +20,8 @@
|
||||
items: [],
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.$api.getGenres().then((items) => {
|
||||
this.items = items
|
||||
})
|
||||
async created() {
|
||||
this.items = await this.$api.getGenres()
|
||||
},
|
||||
})
|
||||
</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>
|
||||
<div v-if="items">
|
||||
<h1>Radio</h1>
|
||||
<table class="table table-hover table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">
|
||||
Title
|
||||
</th>
|
||||
<th class="text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<BaseTable>
|
||||
<BaseTableHead />
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in items" :key="index"
|
||||
:class="{'active': item.id === playingTrackId}">
|
||||
<td @click="play(index)">
|
||||
{{ item.title }}
|
||||
<div>
|
||||
<small class="text-muted">
|
||||
{{ item.description }}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<TrackContextMenu :track="item" />
|
||||
</td>
|
||||
:class="{'active': item.id === playingTrackId}"
|
||||
@click="play(index)">
|
||||
<CellTrackNumber :active="item.id === playingTrackId && isPlaying" :track="item" />
|
||||
<CellTitle :track="item" />
|
||||
<CellActions :track="item" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import TrackContextMenu from '@/library/TrackContextMenu.vue'
|
||||
import { RadioStation } from '@/shared/api'
|
||||
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({
|
||||
components: {
|
||||
TrackContextMenu,
|
||||
BaseTableHead,
|
||||
BaseTable,
|
||||
CellTitle,
|
||||
CellActions,
|
||||
CellTrackNumber,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -49,6 +41,7 @@
|
||||
computed: {
|
||||
...mapGetters({
|
||||
playingTrackId: 'player/trackId',
|
||||
isPlaying: 'player/isPlaying',
|
||||
}),
|
||||
},
|
||||
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 Router from 'vue-router'
|
||||
import Vuex from 'vuex'
|
||||
import { BootstrapVue } from 'bootstrap-vue'
|
||||
import '@/style/main.scss'
|
||||
import '@/shared/components'
|
||||
import App from '@/app/App.vue'
|
||||
@ -21,7 +20,6 @@ declare module 'vue/types/vue' {
|
||||
Vue.config.productionTip = false
|
||||
Vue.use(Router)
|
||||
Vue.use(Vuex)
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
const authService = new AuthService()
|
||||
const api = new API(authService)
|
||||
|
@ -2,30 +2,24 @@
|
||||
<div :class="{'visible': visible}" class="player elevated d-flex">
|
||||
<div class="flex-fill">
|
||||
<!-- Progress --->
|
||||
<div class="progress2" @click="seek">
|
||||
<b-progress :value="progress" :max="100" height="4px" />
|
||||
</div>
|
||||
<div class="row align-items-center m-0">
|
||||
<ProgressBar
|
||||
style="margin-bottom: -5px; margin-top: -9px"
|
||||
:value="progress" @input="seek"
|
||||
/>
|
||||
<div class="row align-items-center m-0" style="padding-top: -10px">
|
||||
<!-- Track info --->
|
||||
<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">
|
||||
<router-link :to="{ name: 'queue' }">
|
||||
<template v-if="track.image">
|
||||
<img class="d-sm-none" width="64px" height="64px" :src="track.image">
|
||||
<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 :to="{ name: 'queue' }" style="padding: 12px">
|
||||
<img v-if="track.image" width="52px" height="52px" :src="track.image">
|
||||
<img v-else width="52px" height="52px" src="@/shared/assets/fallback.svg">
|
||||
</router-link>
|
||||
|
||||
<div class="pl-3" style="min-width: 0">
|
||||
<div style="min-width: 0">
|
||||
<div class="text-truncate">
|
||||
{{ track.title }}
|
||||
</div>
|
||||
<div class="text-truncate text-muted">
|
||||
{{ track.artist }}
|
||||
{{ track.artist || track.album || track.description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -45,20 +39,57 @@
|
||||
</div>
|
||||
|
||||
<!-- Controls right --->
|
||||
<div class="col p-0 d-none d-sm-block " style="min-width: 0; width: 0;">
|
||||
<div class="d-flex justify-content-end pr-3">
|
||||
<b-button variant="link"
|
||||
class="m-0 d-none d-sm-inline-block"
|
||||
:class="{ 'text-primary': shuffleActive }"
|
||||
@click="toggleShuffle">
|
||||
<Icon icon="shuffle" />
|
||||
</b-button>
|
||||
<b-button variant="link"
|
||||
class="m-0 d-none d-sm-inline-block "
|
||||
:class="{ 'text-primary': repeatActive }"
|
||||
@click="toggleRepeat">
|
||||
<Icon icon="arrow-repeat" />
|
||||
</b-button>
|
||||
<div class="col-auto col-sm p-0">
|
||||
<div class="d-flex flex-nowrap justify-content-end pr-3">
|
||||
<div class="m-0 d-none d-md-inline-flex align-items-center">
|
||||
<b-button title="Favourite"
|
||||
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">
|
||||
<Icon icon="shuffle" />
|
||||
</b-button>
|
||||
<b-button title="Repeat"
|
||||
variant="link" class="m-0" :class="{ 'text-primary': repeatActive }"
|
||||
@click="toggleRepeat">
|
||||
<Icon icon="arrow-repeat" />
|
||||
</b-button>
|
||||
</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>
|
||||
@ -66,9 +97,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.progress2 {
|
||||
cursor: pointer;
|
||||
}
|
||||
.player {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
@ -82,25 +110,44 @@
|
||||
height: auto;
|
||||
max-height: 100px;
|
||||
}
|
||||
.b-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { mapState, mapGetters, mapActions } from 'vuex'
|
||||
import ProgressBar from '@/player/ProgressBar.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ProgressBar,
|
||||
},
|
||||
computed: {
|
||||
...mapState('player', {
|
||||
isPlaying: (state: any) => state.isPlaying,
|
||||
currentTime: (state: any) => state.currentTime,
|
||||
repeatActive: (state: any) => state.repeat,
|
||||
shuffleActive: (state: any) => state.shuffle,
|
||||
muteActive: (state: any) => state.mute,
|
||||
visible: (state: any) => state.queue.length > 0,
|
||||
volume: (state: any) => state.volume,
|
||||
}),
|
||||
...mapGetters('player', [
|
||||
'track',
|
||||
'progress',
|
||||
]),
|
||||
},
|
||||
watch: {
|
||||
track: {
|
||||
immediate: true,
|
||||
handler(track: any) {
|
||||
document.title = [track?.title, track?.artist || track?.album, 'Airsonic (refix)']
|
||||
.filter(x => !!x).join(' • ')
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions('player', [
|
||||
'playPause',
|
||||
@ -108,14 +155,17 @@
|
||||
'previous',
|
||||
'toggleRepeat',
|
||||
'toggleShuffle',
|
||||
'toggleMute',
|
||||
'seek',
|
||||
]),
|
||||
seek(event: any) {
|
||||
if (event.target) {
|
||||
const width = event.currentTarget.clientWidth
|
||||
const value = event.offsetX / width
|
||||
return this.$store.dispatch('player/seek', value)
|
||||
}
|
||||
setVolume(volume: any) {
|
||||
return this.$store.dispatch('player/setVolume', parseFloat(volume))
|
||||
},
|
||||
toggleFavourite() {
|
||||
return this.track.favourite
|
||||
? this.$store.dispatch('removeFavourite', this.track.id)
|
||||
: this.$store.dispatch('addFavourite', this.track.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
</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>
|
||||
<div>
|
||||
<TrackList :tracks="tracks">
|
||||
<template #context-menu="{index}">
|
||||
<b-dropdown-item-button @click="remove(index)">
|
||||
Remove
|
||||
</b-dropdown-item-button>
|
||||
</template>
|
||||
</TrackList>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<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)">
|
||||
Remove
|
||||
</b-dropdown-item-button>
|
||||
</CellActions>
|
||||
</tr>
|
||||
</tbody>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { mapState, mapMutations } from 'vuex'
|
||||
import TrackList from '@/library/TrackList.vue'
|
||||
import { mapState, mapMutations, mapGetters } from 'vuex'
|
||||
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({
|
||||
components: {
|
||||
CellActions,
|
||||
CellTitle,
|
||||
CellArtist,
|
||||
CellAlbum,
|
||||
CellDuration,
|
||||
CellTrackNumber,
|
||||
BaseTableHead,
|
||||
BaseTable,
|
||||
TrackList,
|
||||
},
|
||||
computed: {
|
||||
...mapState('player', {
|
||||
tracks: (state: any) => state.queue,
|
||||
})
|
||||
tracks: 'queue',
|
||||
queueIndex: 'queueIndex',
|
||||
}),
|
||||
...mapGetters('player', {
|
||||
isPlaying: 'isPlaying',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations('player', {
|
||||
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>
|
||||
|
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 { shuffle, trackListEquals } from '@/shared/utils'
|
||||
import { API } from '@/shared/api'
|
||||
import { AudioController } from '@/player/audio'
|
||||
|
||||
const audio = new Audio()
|
||||
const storedQueue = JSON.parse(localStorage.getItem('queue') || '[]')
|
||||
const storedQueueIndex = parseInt(localStorage.getItem('queueIndex') || '-1')
|
||||
if (storedQueueIndex > -1 && storedQueueIndex < storedQueue.length) {
|
||||
audio.src = storedQueue[storedQueueIndex].url
|
||||
}
|
||||
const storedVolume = parseFloat(localStorage.getItem('player.volume') || '1.0')
|
||||
const storedMuteState = localStorage.getItem('player.mute') === 'true'
|
||||
const mediaSession: MediaSession | undefined = navigator.mediaSession
|
||||
const audio = new AudioController()
|
||||
|
||||
interface State {
|
||||
queue: any[];
|
||||
@ -19,6 +19,13 @@ interface State {
|
||||
currentTime: number; // position of current track in seconds
|
||||
repeat: 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> = {
|
||||
@ -32,6 +39,8 @@ export const playerModule: Module<State, any> = {
|
||||
currentTime: 0,
|
||||
repeat: localStorage.getItem('player.repeat') !== 'false',
|
||||
shuffle: localStorage.getItem('player.shuffle') === 'true',
|
||||
mute: storedMuteState,
|
||||
volume: storedVolume,
|
||||
},
|
||||
|
||||
mutations: {
|
||||
@ -55,10 +64,14 @@ export const playerModule: Module<State, any> = {
|
||||
state.shuffle = enable
|
||||
localStorage.setItem('player.shuffle', enable)
|
||||
},
|
||||
setMute(state, enable) {
|
||||
state.mute = enable
|
||||
localStorage.setItem('player.mute', enable)
|
||||
},
|
||||
setQueue(state, queue) {
|
||||
state.queue = queue
|
||||
state.queueIndex = -1
|
||||
localStorage.setItem('queue', JSON.stringify(queue))
|
||||
persistQueue(state)
|
||||
},
|
||||
setQueueIndex(state, index) {
|
||||
if (state.queue.length === 0) {
|
||||
@ -67,10 +80,12 @@ export const playerModule: Module<State, any> = {
|
||||
index = Math.max(0, index)
|
||||
index = index < state.queue.length ? index : 0
|
||||
state.queueIndex = index
|
||||
localStorage.setItem('queueIndex', index)
|
||||
persistQueue(state)
|
||||
state.scrobbled = false
|
||||
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) {
|
||||
mediaSession.metadata = new MediaMetadata({
|
||||
title: track.title,
|
||||
@ -80,35 +95,59 @@ export const playerModule: Module<State, any> = {
|
||||
})
|
||||
}
|
||||
},
|
||||
addToQueue(state, track) {
|
||||
state.queue.push(track)
|
||||
addToQueue(state, tracks) {
|
||||
state.queue.push(...tracks)
|
||||
persistQueue(state)
|
||||
},
|
||||
removeFromQueue(state, index) {
|
||||
state.queue.splice(index, 1)
|
||||
if (index < state.queueIndex) {
|
||||
state.queueIndex--
|
||||
}
|
||||
persistQueue(state)
|
||||
},
|
||||
setNextInQueue(state, track) {
|
||||
state.queue.splice(state.queueIndex + 1, 0, track)
|
||||
clearQueue(state) {
|
||||
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) {
|
||||
state.currentTime = value
|
||||
},
|
||||
setDuration(state, value: any) {
|
||||
state.duration = value
|
||||
if (isFinite(value)) {
|
||||
state.duration = value
|
||||
}
|
||||
},
|
||||
setScrobbled(state) {
|
||||
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: {
|
||||
async playTrackList({ commit, state }, { tracks, index }) {
|
||||
async playTrackList({ commit, state, getters }, { tracks, index }) {
|
||||
if (trackListEquals(state.queue, tracks)) {
|
||||
commit('setQueueIndex', index)
|
||||
commit('setPlaying')
|
||||
await audio.play()
|
||||
await audio.changeTrack(getters.track.url)
|
||||
return
|
||||
}
|
||||
tracks = [...tracks]
|
||||
@ -123,44 +162,38 @@ export const playerModule: Module<State, any> = {
|
||||
commit('setQueueIndex', index)
|
||||
}
|
||||
commit('setPlaying')
|
||||
await audio.play()
|
||||
await audio.changeTrack(getters.track.url)
|
||||
},
|
||||
async resume({ commit }) {
|
||||
commit('setPlaying')
|
||||
await audio.play()
|
||||
await audio.resume()
|
||||
},
|
||||
async pause({ commit }) {
|
||||
audio.pause()
|
||||
commit('setPaused')
|
||||
},
|
||||
async playPause({ state, dispatch }) {
|
||||
if (state.isPlaying) {
|
||||
return dispatch('pause')
|
||||
}
|
||||
return dispatch('resume')
|
||||
return state.isPlaying ? dispatch('pause') : dispatch('resume')
|
||||
},
|
||||
async next({ commit, state, getters, dispatch }) {
|
||||
if (!state.repeat && !getters.hasNext) {
|
||||
return dispatch('resetQueue')
|
||||
}
|
||||
async next({ commit, state, getters }) {
|
||||
commit('setQueueIndex', state.queueIndex + 1)
|
||||
commit('setPlaying')
|
||||
await audio.play()
|
||||
await audio.changeTrack(getters.track.url)
|
||||
},
|
||||
async previous({ commit, state }) {
|
||||
commit('setQueueIndex', state.queueIndex - 1)
|
||||
async previous({ commit, state, getters }) {
|
||||
commit('setQueueIndex', audio.currentTime() > 3 ? state.queueIndex : state.queueIndex - 1)
|
||||
commit('setPlaying')
|
||||
await audio.play()
|
||||
await audio.changeTrack(getters.track.url)
|
||||
},
|
||||
seek({ state }, value) {
|
||||
if (isFinite(state.duration)) {
|
||||
audio.currentTime = state.duration * value
|
||||
audio.seek(state.duration * (value / 100.0))
|
||||
}
|
||||
},
|
||||
resetQueue({ commit }) {
|
||||
audio.pause()
|
||||
async resetQueue({ commit, getters }) {
|
||||
commit('setQueueIndex', 0)
|
||||
commit('setPaused')
|
||||
await audio.changeTrack(getters.track.url, { paused: true })
|
||||
},
|
||||
toggleRepeat({ commit, state }) {
|
||||
commit('setRepeat', !state.repeat)
|
||||
@ -168,11 +201,19 @@ export const playerModule: Module<State, any> = {
|
||||
toggleShuffle({ commit, state }) {
|
||||
commit('setShuffle', !state.shuffle)
|
||||
},
|
||||
addToQueue({ commit }, track) {
|
||||
commit('addToQueue', track)
|
||||
toggleMute({ commit, state }) {
|
||||
commit('setMute', !state.mute)
|
||||
audio.setVolume(state.mute ? 0.0 : state.volume)
|
||||
},
|
||||
setNextInQueue({ commit }, track) {
|
||||
commit('setNextInQueue', track)
|
||||
addToQueue({ state, commit }, tracks) {
|
||||
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 {
|
||||
return getters.track ? getters.track.id : -1
|
||||
},
|
||||
isPlaying(state): boolean {
|
||||
return state.isPlaying
|
||||
},
|
||||
progress(state) {
|
||||
if (state.currentTime > -1 && state.duration > 0) {
|
||||
return (state.currentTime / state.duration) * 100
|
||||
@ -202,27 +246,38 @@ export const playerModule: Module<State, any> = {
|
||||
}
|
||||
|
||||
export function setupAudio(store: Store<any>, api: API) {
|
||||
audio.ontimeupdate = () => {
|
||||
store.commit('player/setCurrentTime', audio.currentTime)
|
||||
|
||||
audio.ontimeupdate = (value: number) => {
|
||||
store.commit('player/setCurrentTime', value)
|
||||
// Scrobble
|
||||
if (store.state.player.scrobbled === false &&
|
||||
audio.duration > 30 &&
|
||||
audio.currentTime / audio.duration > 0.7) {
|
||||
if (
|
||||
store.state.player.scrobbled === false &&
|
||||
store.state.player.duration > 30 &&
|
||||
audio.currentTime() / store.state.player.duration > 0.7
|
||||
) {
|
||||
const id = store.getters['player/trackId']
|
||||
store.commit('player/setScrobbled')
|
||||
api.scrobble(id)
|
||||
}
|
||||
}
|
||||
audio.ondurationchange = () => {
|
||||
store.commit('player/setDuration', audio.duration)
|
||||
}
|
||||
audio.onerror = () => {
|
||||
store.commit('player/setPaused')
|
||||
store.commit('setError', audio.error)
|
||||
audio.ondurationchange = (value: number) => {
|
||||
store.commit('player/setDuration', value)
|
||||
}
|
||||
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) {
|
||||
@ -243,16 +298,16 @@ export function setupAudio(store: Store<any>, api: API) {
|
||||
})
|
||||
mediaSession.setActionHandler('seekto', (details) => {
|
||||
if (details.seekTime) {
|
||||
audio.currentTime = details.seekTime
|
||||
audio.seek(details.seekTime)
|
||||
}
|
||||
})
|
||||
mediaSession.setActionHandler('seekforward', (details) => {
|
||||
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) => {
|
||||
const offset = details.seekOffset || 10
|
||||
audio.currentTime = Math.max(audio.currentTime - offset, 0)
|
||||
audio.seek(Math.max(audio.currentTime() - offset, 0))
|
||||
})
|
||||
// FIXME
|
||||
// function updatePositionState() {
|
||||
|
@ -3,27 +3,48 @@
|
||||
<div class="d-flex justify-content-between">
|
||||
<h1>{{ playlist.name }}</h1>
|
||||
<OverflowMenu>
|
||||
<b-dropdown-item-btn @click="showEditModal = true">
|
||||
Edit playlist
|
||||
</b-dropdown-item-btn>
|
||||
<b-dropdown-item-btn variant="danger" @click="deletePlaylist()">
|
||||
Delete playlist
|
||||
</b-dropdown-item-btn>
|
||||
</OverflowMenu>
|
||||
</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}">
|
||||
<b-dropdown-item-button @click="remove(index)">
|
||||
Remove
|
||||
</b-dropdown-item-button>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
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({
|
||||
components: {
|
||||
TrackList,
|
||||
EditModal,
|
||||
},
|
||||
props: {
|
||||
id: { type: String, required: true }
|
||||
@ -31,6 +52,7 @@
|
||||
data() {
|
||||
return {
|
||||
playlist: null as any,
|
||||
showEditModal: false,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -49,6 +71,10 @@
|
||||
this.playlist.tracks.splice(index, 1)
|
||||
return this.$api.removeFromPlaylist(this.id, index.toString())
|
||||
},
|
||||
updatePlaylist(value: any) {
|
||||
this.playlist = value
|
||||
return this.$store.dispatch('updatePlaylist', this.playlist)
|
||||
},
|
||||
deletePlaylist() {
|
||||
return this.$store.dispatch('deletePlaylist', this.id).then(() => {
|
||||
this.$router.replace({ name: 'playlists' })
|
||||
|
@ -1,13 +1,11 @@
|
||||
<template>
|
||||
<div style="max-width: 100%">
|
||||
<span class="nav-link">
|
||||
<small class="text-uppercase text-muted font-weight-bold">
|
||||
Playlists
|
||||
<button class="btn btn-link btn-sm p-0 float-right" @click="showModal = true">
|
||||
<Icon icon="plus" />
|
||||
</button>
|
||||
</small>
|
||||
</span>
|
||||
<small class="sidebar-heading text-muted">
|
||||
Playlists
|
||||
<button class="btn btn-link btn-sm p-0 float-right" @click="showModal = true">
|
||||
<Icon icon="plus" />
|
||||
</button>
|
||||
</small>
|
||||
|
||||
<router-link class="nav-link" :to="{name: 'playlist', params: { id: 'random' }}">
|
||||
<Icon icon="music-note-list" class="mr-2" /> Random
|
||||
@ -26,6 +24,9 @@
|
||||
</router-link>
|
||||
|
||||
<b-modal v-model="showModal" title="New playlist">
|
||||
<template #modal-header-close>
|
||||
<Icon icon="x" />
|
||||
</template>
|
||||
<b-form-group label="Name">
|
||||
<b-form-input v-model="playlistName" type="text" />
|
||||
</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 AlbumList from '@/library/album/AlbumList.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({
|
||||
components: {
|
||||
|
@ -12,7 +12,7 @@ export interface Track {
|
||||
id: string
|
||||
title: string
|
||||
duration: number
|
||||
starred: boolean
|
||||
favourite: boolean
|
||||
image?: string
|
||||
url?: string
|
||||
track?: number
|
||||
@ -28,8 +28,8 @@ export interface Album {
|
||||
artist: string
|
||||
artistId: string
|
||||
year: number
|
||||
starred: boolean
|
||||
genre?: string
|
||||
favourite: boolean
|
||||
genreId?: string
|
||||
image?: string
|
||||
tracks?: Track[]
|
||||
}
|
||||
@ -39,7 +39,7 @@ export interface Artist {
|
||||
name: string
|
||||
albumCount: number
|
||||
description?: string
|
||||
starred: boolean
|
||||
favourite: boolean
|
||||
lastFmUrl?: string
|
||||
musicBrainzUrl?: string
|
||||
similarArtist?: Artist[]
|
||||
@ -109,27 +109,28 @@ export class API {
|
||||
.map((item: any) => ({
|
||||
id: 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 = {
|
||||
type: 'byGenre',
|
||||
genre: id,
|
||||
count: 500,
|
||||
offset: 0,
|
||||
size,
|
||||
offset,
|
||||
}
|
||||
const response = await this.get('rest/getAlbumList2', params)
|
||||
return (response.albumList2?.album || []).map(this.normalizeAlbum, this)
|
||||
}
|
||||
|
||||
async getTracksByGenre(id: string) {
|
||||
async getTracksByGenre(id: string, size: number, offset = 0) {
|
||||
const params = {
|
||||
genre: id,
|
||||
count: 500,
|
||||
offset: 0,
|
||||
count: size,
|
||||
offset,
|
||||
}
|
||||
const response = await this.get('rest/getSongsByGenre', params)
|
||||
return (response.songsByGenre?.song || []).map(this.normalizeTrack, this)
|
||||
@ -202,6 +203,15 @@ export class API {
|
||||
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) {
|
||||
await this.get('rest/deletePlaylist', { id })
|
||||
}
|
||||
@ -230,7 +240,7 @@ export class API {
|
||||
return (response.randomSongs?.song || []).map(this.normalizeTrack, this)
|
||||
}
|
||||
|
||||
async getStarred() {
|
||||
async getFavourites() {
|
||||
const response = await this.get('rest/getStarred2')
|
||||
return {
|
||||
albums: (response.starred2?.album || []).map(this.normalizeAlbum, this),
|
||||
@ -239,15 +249,7 @@ export class API {
|
||||
}
|
||||
}
|
||||
|
||||
starAlbum(id: string) {
|
||||
return this.star('album', id)
|
||||
}
|
||||
|
||||
unstarAlbum(id: string) {
|
||||
return this.unstar('album', id)
|
||||
}
|
||||
|
||||
async star(type: 'track' | 'album' | 'artist', id: string) {
|
||||
async addFavourite(id: string, type: 'track' | 'album' | 'artist') {
|
||||
const params = {
|
||||
id: type === 'track' ? id : undefined,
|
||||
albumId: type === 'album' ? id : undefined,
|
||||
@ -256,7 +258,7 @@ export class API {
|
||||
await this.get('rest/star', params)
|
||||
}
|
||||
|
||||
async unstar(type: 'track' | 'album' | 'artist', id: string) {
|
||||
async removeFavourite(id: string, type: 'track' | 'album' | 'artist') {
|
||||
const params = {
|
||||
id: type === 'track' ? id : undefined,
|
||||
albumId: type === 'album' ? id : undefined,
|
||||
@ -280,6 +282,7 @@ export class API {
|
||||
async getRadioStations(): Promise<RadioStation[]> {
|
||||
const response = await this.get('rest/getInternetRadioStations')
|
||||
return (response?.internetRadioStations?.internetRadioStation || [])
|
||||
.map((item: any, idx: number) => ({ ...item, track: idx + 1 }))
|
||||
.map(this.normalizeRadioStation, this)
|
||||
}
|
||||
|
||||
@ -308,6 +311,20 @@ export class API {
|
||||
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> {
|
||||
return this.get('rest/startScan')
|
||||
}
|
||||
@ -321,9 +338,10 @@ export class API {
|
||||
id: `radio-${item.id}`,
|
||||
title: item.name,
|
||||
description: item.homePageUrl,
|
||||
track: item.track,
|
||||
url: item.streamUrl,
|
||||
duration: 0,
|
||||
starred: false,
|
||||
favourite: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -332,7 +350,7 @@ export class API {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
duration: item.duration,
|
||||
starred: !!item.starred,
|
||||
favourite: !!item.starred,
|
||||
track: item.track,
|
||||
album: item.album,
|
||||
albumId: item.albumId,
|
||||
@ -351,8 +369,8 @@ export class API {
|
||||
artistId: item.artistId,
|
||||
image: this.getCoverArtUrl(item),
|
||||
year: item.year || 0,
|
||||
starred: !!item.starred,
|
||||
genre: item.genre,
|
||||
favourite: !!item.starred,
|
||||
genreId: item.genre,
|
||||
tracks: (item.song || []).map(this.normalizeTrack, this)
|
||||
}
|
||||
}
|
||||
@ -366,7 +384,7 @@ export class API {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: (item.biography || '').replace(/<a[^>]*>.*?<\/a>/gm, ''),
|
||||
starred: !!item.starred,
|
||||
favourite: !!item.starred,
|
||||
albumCount: item.albumCount,
|
||||
lastFmUrl: item.lastFmUrl,
|
||||
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) {
|
||||
if (!item.coverArt) {
|
||||
return undefined
|
||||
@ -401,7 +457,6 @@ export class API {
|
||||
`&u=${username}` +
|
||||
`&s=${salt}` +
|
||||
`&t=${hash}` +
|
||||
`&c=${this.clientName}` +
|
||||
'&size=300'
|
||||
`&c=${this.clientName}`
|
||||
}
|
||||
}
|
||||
|
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,
|
||||
BIconChevronCompactRight,
|
||||
BIconMusicNoteList,
|
||||
BIconStar,
|
||||
BIconStarFill,
|
||||
BIconCollection,
|
||||
BIconCollectionFill,
|
||||
BIconList,
|
||||
@ -25,8 +23,12 @@
|
||||
BIconThreeDotsVertical,
|
||||
BIconBoxArrowRight,
|
||||
BIconPersonFill,
|
||||
BIconPersonCircle,
|
||||
BIconRss,
|
||||
BIconX,
|
||||
BIconVolumeMuteFill,
|
||||
BIconVolumeUpFill,
|
||||
BIconHeart,
|
||||
BIconHeartFill,
|
||||
} from 'bootstrap-vue'
|
||||
|
||||
export default Vue.extend({
|
||||
@ -38,8 +40,6 @@
|
||||
BIconCardText,
|
||||
BIconChevronCompactRight,
|
||||
BIconMusicNoteList,
|
||||
BIconStar,
|
||||
BIconStarFill,
|
||||
BIconCollection,
|
||||
BIconCollectionFill,
|
||||
BIconList,
|
||||
@ -52,8 +52,12 @@
|
||||
BIconThreeDotsVertical,
|
||||
BIconBoxArrowRight,
|
||||
BIconPersonFill,
|
||||
BIconPersonCircle,
|
||||
BIconRss,
|
||||
BIconX,
|
||||
BIconVolumeMuteFill,
|
||||
BIconVolumeUpFill,
|
||||
BIconHeart,
|
||||
BIconHeartFill,
|
||||
},
|
||||
props: {
|
||||
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>
|
||||
<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>
|
||||
<Icon icon="three-dots-vertical" />
|
||||
</template>
|
||||
@ -9,5 +15,9 @@
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
|
||||
export default Vue.extend({})
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
disabled: { type: Boolean, default: false }
|
||||
}
|
||||
})
|
||||
</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 InfiniteLoader from './InfiniteLoader.vue'
|
||||
import OverflowMenu from './OverflowMenu.vue'
|
||||
import Slider from './Slider.vue'
|
||||
import Tiles from './Tiles.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 = {
|
||||
ContentLoader,
|
||||
@ -13,6 +41,7 @@ const components = {
|
||||
Icon,
|
||||
InfiniteLoader,
|
||||
OverflowMenu,
|
||||
Slider,
|
||||
Tiles,
|
||||
Tile,
|
||||
}
|
||||
|
@ -6,11 +6,12 @@ import ArtistDetails from '@/library/artist/ArtistDetails.vue'
|
||||
import ArtistLibrary from '@/library/artist/ArtistLibrary.vue'
|
||||
import AlbumDetails from '@/library/album/AlbumDetails.vue'
|
||||
import AlbumLibrary from '@/library/album/AlbumLibrary.vue'
|
||||
import RandomSongs from '@/playlist/RandomSongs.vue'
|
||||
import GenreDetails from '@/library/genre/GenreDetails.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 PodcastDetails from '@/library/podcast/PodcastDetails.vue'
|
||||
import PodcastLibrary from '@/library/podcast/PodcastLibrary.vue'
|
||||
import Playlist from '@/playlist/Playlist.vue'
|
||||
import PlaylistList from '@/playlist/PlaylistList.vue'
|
||||
import SearchResult from '@/search/SearchResult.vue'
|
||||
@ -33,13 +34,24 @@ export function setupRouter(auth: AuthService) {
|
||||
component: Login,
|
||||
props: (route) => ({
|
||||
returnTo: route.query.returnTo,
|
||||
})
|
||||
}),
|
||||
meta: {
|
||||
layout: 'fullscreen'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'queue',
|
||||
path: '/queue',
|
||||
component: Queue,
|
||||
},
|
||||
{
|
||||
name: 'albums-default',
|
||||
path: '/albums',
|
||||
redirect: ({
|
||||
name: 'albums',
|
||||
params: { sort: 'recently-added' }
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'albums',
|
||||
path: '/albums/:sort',
|
||||
@ -48,7 +60,7 @@ export function setupRouter(auth: AuthService) {
|
||||
},
|
||||
{
|
||||
name: 'album',
|
||||
path: '/album/:id',
|
||||
path: '/albums/id/:id',
|
||||
component: AlbumDetails,
|
||||
props: true,
|
||||
},
|
||||
@ -59,7 +71,7 @@ export function setupRouter(auth: AuthService) {
|
||||
},
|
||||
{
|
||||
name: 'artist',
|
||||
path: '/artist/:id',
|
||||
path: '/artists/:id',
|
||||
component: ArtistDetails,
|
||||
props: true,
|
||||
},
|
||||
@ -70,20 +82,32 @@ export function setupRouter(auth: AuthService) {
|
||||
},
|
||||
{
|
||||
name: 'genre',
|
||||
path: '/genre/:id/:section?',
|
||||
path: '/genres/:id/:section?',
|
||||
component: GenreDetails,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
name: 'starred',
|
||||
path: '/starred',
|
||||
component: Starred,
|
||||
name: 'favourites',
|
||||
path: '/favourites/:section?',
|
||||
component: Favourites,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
name: 'radio',
|
||||
path: '/radio',
|
||||
component: RadioStations,
|
||||
},
|
||||
{
|
||||
name: 'podcasts',
|
||||
path: '/podcasts',
|
||||
component: PodcastLibrary,
|
||||
},
|
||||
{
|
||||
name: 'podcast',
|
||||
path: '/podcasts/:id',
|
||||
component: PodcastDetails,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
name: 'playlists',
|
||||
path: '/playlists',
|
||||
@ -95,11 +119,6 @@ export function setupRouter(auth: AuthService) {
|
||||
component: Playlist,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
name: 'playlist-random',
|
||||
path: '/random',
|
||||
component: RandomSongs,
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
path: '/search',
|
||||
|
@ -41,6 +41,10 @@ const setupRootModule = (authService: AuthService, api: API): Module<State, any>
|
||||
state.playlists = playlists
|
||||
.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) {
|
||||
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)
|
||||
})
|
||||
},
|
||||
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 }) {
|
||||
api.addToPlaylist(playlistId, trackId)
|
||||
},
|
||||
@ -69,6 +83,14 @@ const setupRootModule = (authService: AuthService, api: API): Module<State, any>
|
||||
api.deletePlaylist(id).then(() => {
|
||||
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%);
|
||||
$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;
|
||||
@import "./variables";
|
||||
|
||||
// Card
|
||||
$card-bg: $theme-elevation-1;
|
||||
@ -30,15 +16,20 @@ $dropdown-link-hover-color: $theme-text-muted;
|
||||
$dropdown-border-color: $theme-elevation-2;
|
||||
$dropdown-divider-bg: $theme-elevation-2;
|
||||
|
||||
// Popover
|
||||
$popover-bg: $theme-elevation-1;
|
||||
$popover-border-color: $theme-elevation-2;
|
||||
|
||||
// Form
|
||||
$input-bg: $theme-elevation-2;
|
||||
$input-border-color: $theme-elevation-2;
|
||||
$input-color: $theme-text;
|
||||
|
||||
// Other
|
||||
$progress-bg: rgb(35, 35, 35);
|
||||
$custom-range-track-height: 0.1rem;
|
||||
$custom-range-thumb-bg: $theme-text;
|
||||
$custom-range-track-bg: $theme-text-muted;
|
||||
|
||||
:root {
|
||||
--text-body: #{$theme-text};
|
||||
--text-muted: #{$theme-text-muted};
|
||||
}
|
||||
|
||||
@ -71,3 +62,8 @@ $enable-responsive-font-sizes: true;
|
||||
|
||||
@import '~bootstrap';
|
||||
@import '~bootstrap-vue';
|
||||
|
||||
.modal-header .close {
|
||||
color: $theme-text;
|
||||
opacity: 1;
|
||||
}
|
||||
|
@ -6,8 +6,54 @@ table thead tr {
|
||||
color: $theme-text-muted;
|
||||
}
|
||||
|
||||
table tr.active {
|
||||
td, td a, td svg {
|
||||
color: var(--primary);
|
||||
table.table {
|
||||
tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
tr.active {
|
||||
td, td a, td svg {
|
||||
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;}
|
||||
}
|
||||
}
|
||||
|