Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
959d0e5c08 |
@ -22,8 +22,9 @@ module.exports = {
|
|||||||
'vue/component-tags-order': ['error', {
|
'vue/component-tags-order': ['error', {
|
||||||
order: ['template', 'style', 'script']
|
order: ['template', 'style', 'script']
|
||||||
}],
|
}],
|
||||||
'no-console': 'off',
|
'no-console': 'warn',
|
||||||
'no-debugger': 'warn',
|
'no-debugger': 'warn',
|
||||||
|
'no-useless-constructor': 'off', // Crashes eslint
|
||||||
'no-empty-pattern': 'off',
|
'no-empty-pattern': 'off',
|
||||||
'comma-dangle': 'off',
|
'comma-dangle': 'off',
|
||||||
'space-before-function-paren': ['error', 'never'],
|
'space-before-function-paren': ['error', 'never'],
|
||||||
|
50
.github/workflows/ci.yml
vendored
@ -1,10 +1,10 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on:
|
|
||||||
- push
|
on: [push, pull_request]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: ${{ github.repository }}
|
IMAGE: ${{ github.repository }}
|
||||||
VERSION: ${{ github.sha }}
|
TAG: ${{ github.sha }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@ -21,7 +21,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
export VUE_APP_BUILD=$VERSION
|
export VUE_APP_BUILD=$TAG
|
||||||
export VUE_APP_BUILD_DATE=$(date --iso-8601)
|
export VUE_APP_BUILD_DATE=$(date --iso-8601)
|
||||||
yarn build
|
yarn build
|
||||||
|
|
||||||
@ -31,26 +31,18 @@ jobs:
|
|||||||
name: dist
|
name: dist
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
build_docker_image:
|
- name: Set up Docker Buildx
|
||||||
runs-on: ubuntu-latest
|
uses: docker/setup-buildx-action@v1
|
||||||
needs: build
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Download artifact
|
- name: Log in to docker hub
|
||||||
uses: actions/download-artifact@v2
|
run: docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist
|
|
||||||
|
|
||||||
- name: Build docker image
|
- name: Build docker image
|
||||||
run: docker build -t $IMAGE:$VERSION -f docker/Dockerfile .
|
run: |
|
||||||
|
docker buildx build \
|
||||||
- name: Push docker image
|
--platform linux/arm64 \
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
--tag $IMAGE:$TAG \
|
||||||
run: |
|
--file docker/Dockerfile .
|
||||||
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
|
||||||
docker push $IMAGE:$VERSION
|
|
||||||
|
|
||||||
preview:
|
preview:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -61,7 +53,6 @@ jobs:
|
|||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v2
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist
|
|
||||||
|
|
||||||
- name: Deploy preview
|
- name: Deploy preview
|
||||||
uses: netlify/actions/cli@master
|
uses: netlify/actions/cli@master
|
||||||
@ -69,7 +60,7 @@ jobs:
|
|||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
with:
|
with:
|
||||||
args: deploy --dir=dist
|
args: deploy --dir=.
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -80,7 +71,6 @@ jobs:
|
|||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v2
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist
|
|
||||||
|
|
||||||
- name: Deploy site
|
- name: Deploy site
|
||||||
uses: netlify/actions/cli@master
|
uses: netlify/actions/cli@master
|
||||||
@ -88,16 +78,16 @@ jobs:
|
|||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
with:
|
with:
|
||||||
args: deploy --dir=dist --prod
|
args: deploy --dir=. --prod
|
||||||
|
|
||||||
publish_latest_docker_image:
|
publish_docker_image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build_docker_image
|
needs: build
|
||||||
if: github.ref == 'refs/heads/master'
|
if: github.ref == 'refs/heads/master'
|
||||||
steps:
|
steps:
|
||||||
- name: Push latest
|
- name: Push latest
|
||||||
run: |
|
run: |
|
||||||
docker pull $IMAGE:$VERSION
|
docker pull $IMAGE:$TAG
|
||||||
docker tag $IMAGE:$VERSION $IMAGE:latest
|
docker tag $IMAGE:$TAG $IMAGE:latest
|
||||||
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
docker push $IMAGE:latest
|
docker push $IMAGE:latest
|
||||||
|
29
.github/workflows/pr.yml
vendored
@ -1,29 +0,0 @@
|
|||||||
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 .
|
|
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"CurrentProjectSetting": null
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"ExpandedNodes": [
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"PreviewInSolutionExplorer": false
|
|
||||||
}
|
|
BIN
.vs/slnx.sqlite
61
README.md
@ -1,44 +1,40 @@
|
|||||||
# Airsonic (refix) UI
|
# Airsonic Web Client
|
||||||
|
|
||||||
[](https://github.com/tamland/airsonic-refix/actions)
|
[](https://github.com/tamland/airsonic-frontend/actions)
|
||||||
[](https://hub.docker.com/r/tamland/airsonic-refix)
|
|
||||||
|
|
||||||
Modern responsive web frontend for [Airsonic](https://github.com/airsonic-advanced/airsonic-advanced) and other [Subsonic](https://github.com/topics/subsonic) based music servers.
|
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.
|
||||||
|
|
||||||
## Features
|
|
||||||
- Responsive UI for desktop and mobile
|
|
||||||
- Browse library for albums, artist, generes
|
|
||||||
- Playback with persistent queue, repeat & shuffle
|
|
||||||
- MediaSession integration
|
|
||||||
- View, create, and edit playlists with drag and drop
|
|
||||||
- Built-in 'random' playlist
|
|
||||||
- Search
|
|
||||||
- Favourites
|
|
||||||
- Internet radio
|
|
||||||
- Podcasts
|
|
||||||
|
|
||||||
## [Live demo](https://airsonic.netlify.com)
|
|
||||||
|
|
||||||
Enter the following details:
|
|
||||||
Server: `/api`
|
|
||||||
Username: `guest4`, `guest5`, `guest6` etc.
|
|
||||||
Password:`guest`
|
|
||||||
|
|
||||||
You can try the demo with your own local server as well. Simply enter the full URL of your Airsonic server in the Server field (such as http://localhost:8080) with your credentials. **Note**: if your server is using http only you must allow mixed content in your browser otherwise login will not work.
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||

|

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

|

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