Compare commits

..

128 Commits

Author SHA1 Message Date
3f6a74a45d css changes to make scrollbar sticky 2021-07-20 02:54:44 +02:00
Thomas Amland
73b2b493a4 replace bootstrap-vue progress bar with vue-slider-component
better hit area, drag support
2021-07-17 19:53:10 +02:00
tamland
e1dc32060b
Merge pull request #44 from tamland/dependabot/npm_and_yarn/color-string-1.5.5
Bump color-string from 1.5.3 to 1.5.5
2021-07-04 12:41:45 +02:00
Thomas Amland
487753a5a1 update dependencies 2021-07-04 12:35:08 +02:00
Thomas Amland
c9874b67bd ci: fix missing IMAGE variable 2021-07-04 12:21:44 +02:00
dependabot[bot]
c5957da93b
Bump color-string from 1.5.3 to 1.5.5
Bumps [color-string](https://github.com/Qix-/color-string) from 1.5.3 to 1.5.5.
- [Release notes](https://github.com/Qix-/color-string/releases)
- [Changelog](https://github.com/Qix-/color-string/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Qix-/color-string/commits/1.5.5)

---
updated-dependencies:
- dependency-name: color-string
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-27 09:41:38 +00:00
tamland
9eb30f9182
Merge pull request #42 from tamland/dependabot/npm_and_yarn/postcss-7.0.36
Bump postcss from 7.0.26 to 7.0.36
2021-06-27 11:41:02 +02:00
dependabot[bot]
6383a30b31
Bump postcss from 7.0.26 to 7.0.36
Bumps [postcss](https://github.com/postcss/postcss) from 7.0.26 to 7.0.36.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/7.0.26...7.0.36)

---
updated-dependencies:
- dependency-name: postcss
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-17 08:45:06 +00:00
tamland
d73e69a045
Merge pull request #41 from tamland/dependabot/npm_and_yarn/acorn-6.4.2
Bump acorn from 6.4.0 to 6.4.2
2021-06-13 13:24:44 +02:00
Thomas Amland
9a99d99d30 try to fix pr build 2021-06-13 13:17:34 +02:00
dependabot[bot]
fd122588ad
Bump acorn from 6.4.0 to 6.4.2
Bumps [acorn](https://github.com/acornjs/acorn) from 6.4.0 to 6.4.2.
- [Release notes](https://github.com/acornjs/acorn/releases)
- [Commits](https://github.com/acornjs/acorn/compare/6.4.0...6.4.2)

---
updated-dependencies:
- dependency-name: acorn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-13 10:20:40 +00:00
Thomas Amland
5847ff0b94 update dependencies 2021-06-13 12:14:05 +02:00
tamland
89e2b7d5dc
Merge pull request #40 from tamland/dependabot/npm_and_yarn/ws-6.2.2
Bump ws from 6.2.1 to 6.2.2
2021-06-13 12:07:09 +02:00
dependabot[bot]
81e4593d57
Bump ws from 6.2.1 to 6.2.2
Bumps [ws](https://github.com/websockets/ws) from 6.2.1 to 6.2.2.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/commits)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-06 10:30:40 +00:00
tamland
0543b4342b
Merge pull request #39 from tamland/dependabot/npm_and_yarn/lodash-4.17.21
Bump lodash from 4.17.15 to 4.17.21
2021-05-29 11:07:59 +02:00
dependabot[bot]
94692ff809
Bump lodash from 4.17.15 to 4.17.21
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.21.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.21)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-29 09:01:07 +00:00
Thomas Amland
aaaa4e5500 update dependencies 2021-05-29 10:59:04 +02:00
tamland
209ed37007
Merge pull request #38 from tamland/dependabot/npm_and_yarn/dns-packet-1.3.4
Bump dns-packet from 1.3.1 to 1.3.4
2021-05-29 10:55:16 +02:00
tamland
2ccf815b70
Merge pull request #37 from tamland/dependabot/npm_and_yarn/browserslist-4.16.6
Bump browserslist from 4.8.7 to 4.16.6
2021-05-29 10:55:04 +02:00
dependabot[bot]
eecfc93828
Bump dns-packet from 1.3.1 to 1.3.4
Bumps [dns-packet](https://github.com/mafintosh/dns-packet) from 1.3.1 to 1.3.4.
- [Release notes](https://github.com/mafintosh/dns-packet/releases)
- [Changelog](https://github.com/mafintosh/dns-packet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mafintosh/dns-packet/compare/v1.3.1...v1.3.4)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-28 23:16:45 +00:00
dependabot[bot]
3180b9d8c2
Bump browserslist from 4.8.7 to 4.16.6
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.8.7 to 4.16.6.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.8.7...4.16.6)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-26 17:10:47 +00:00
tamland
babaebeca9
Merge pull request #36 from danejur/master
small fix to prevent JS error from displaying when no track is loaded
2021-05-20 18:58:05 +02:00
danejur
04554b338e small fix to prevent JS error from displaying when no track is loaded 2021-05-20 12:26:42 -04:00
tamland
8a2248f3a8
Merge pull request #35 from tamland/dependabot/npm_and_yarn/hosted-git-info-2.8.9
Bump hosted-git-info from 2.8.5 to 2.8.9
2021-05-13 13:17:19 +02:00
tamland
890672643c
Merge pull request #34 from tamland/dependabot/npm_and_yarn/url-parse-1.5.1
Bump url-parse from 1.4.7 to 1.5.1
2021-05-13 13:17:08 +02:00
Thomas Amland
40b0d77c47 fix overflow menu scrolling 2021-05-13 13:08:23 +02:00
Thomas Amland
6ca8874fc5 improve render performance of overflow menu 2021-05-13 13:03:24 +02:00
Thomas Amland
4312a4899b add favourite button to player 2021-05-13 13:00:56 +02:00
Thomas Amland
3636635b23 update dependencies 2021-05-13 10:18:17 +02:00
dependabot[bot]
2ed470c418
Bump hosted-git-info from 2.8.5 to 2.8.9
Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.5 to 2.8.9.
- [Release notes](https://github.com/npm/hosted-git-info/releases)
- [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md)
- [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.5...v2.8.9)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-11 21:58:31 +00:00
dependabot[bot]
f58414a842
Bump url-parse from 1.4.7 to 1.5.1
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.4.7 to 1.5.1.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.4.7...1.5.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-10 05:18:22 +00:00
Thomas Amland
1745495d2a refactor 2021-05-09 13:01:53 +02:00
Thomas Amland
cdb4540d82 rename starred to favourites 2021-05-09 13:00:31 +02:00
Thomas Amland
b1ee4b18dc split starred into tabs 2021-05-09 10:56:58 +02:00
Thomas Amland
3d89d3f26d fix document title 2021-05-01 19:14:19 +02:00
Thomas Amland
da88dc18e7 fix app name 2021-05-01 19:13:11 +02:00
Thomas Amland
8818a20afc remove icon background 2021-05-01 19:13:04 +02:00
Thomas Amland
a98e5ab486 fix missing error messages 2021-04-25 18:19:45 +02:00
Thomas Amland
28dfdb82c1 allow inline style from vue-slider-component 2021-04-25 13:49:09 +02:00
Thomas Amland
23507436f6 improve volume control style 2021-04-25 13:12:26 +02:00
Thomas Amland
cc6a82116b update dependencies 2021-04-25 11:37:40 +02:00
Thomas Amland
60a1b7f70b lint: disable no-console 2021-04-25 11:32:14 +02:00
Thomas Amland
8229276683 ignore AbortError from audio.play() 2021-04-25 11:26:23 +02:00
Thomas Amland
92bead8b6b reduce padding top 2021-04-25 11:03:46 +02:00
Thomas Amland
56a30c484c refactor event handlers 2021-04-25 11:03:46 +02:00
Thomas Amland
86e66425d2 reduce album image size in player 2021-04-25 11:03:46 +02:00
Thomas Amland
40574314f7 remove end fade
audio.duration too unreliable
2021-04-25 11:03:46 +02:00
Thomas Amland
fd71ce5d15 add new audio controller 2021-04-25 11:03:46 +02:00
tamland
1d49e741b0
Merge pull request #33 from tamland/dependabot/npm_and_yarn/ssri-6.0.2
Bump ssri from 6.0.1 to 6.0.2
2021-04-20 17:04:26 +02:00
dependabot[bot]
2620ece704
Bump ssri from 6.0.1 to 6.0.2
Bumps [ssri](https://github.com/npm/ssri) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/npm/ssri/releases)
- [Changelog](https://github.com/npm/ssri/blob/v6.0.2/CHANGELOG.md)
- [Commits](https://github.com/npm/ssri/compare/v6.0.1...v6.0.2)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-20 01:35:30 +00:00
tamland
67ea0eaae8
Merge pull request #32 from tamland/dependabot/npm_and_yarn/y18n-4.0.1
Bump y18n from 4.0.0 to 4.0.1
2021-04-03 15:45:36 +02:00
dependabot[bot]
e8186c6407
Bump y18n from 4.0.0 to 4.0.1
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-01 06:05:13 +00:00
Thomas Amland
ac00a8eff7 open settings page in tab to avoid ending playback 2021-03-28 13:10:57 +02:00
Thomas Amland
38a2fbf791 fix modal close button style 2021-03-28 13:09:43 +02:00
Thomas Amland
4e8cd1f2e8 fix project url 2021-03-28 13:09:28 +02:00
Thomas Amland
2c56585a9c use text instead of svg for better integration 2021-03-28 12:56:37 +02:00
Thomas Amland
ae8c2611f2 highlight nested routes in nav 2021-03-27 18:47:03 +01:00
Thomas Amland
11dbb60100 sidebar: highlight background on active and hover 2021-03-27 18:47:03 +01:00
Thomas Amland
1d8a739766 update logo 2021-03-27 18:47:03 +01:00
Thomas Amland
96752100e3 update readme 2021-03-26 08:53:07 +01:00
tamland
44fdf99d70
Merge pull request #31 from sentriz/master
Fallback to tag duration to when calculating progress
2021-03-25 19:07:32 +01:00
sentriz
2d60223581
Fallback to tag duration to when calculating progress 2021-03-24 13:30:13 +00:00
Thomas Amland
2fc640c34b change login page icon 2021-03-21 17:39:24 +01:00
Thomas Amland
41eb1e9ca3 update readme 2021-03-20 14:33:07 +01:00
Thomas Amland
727be5b16b highlight playing track in queue based on queue index instead of track id
fixes #16
2021-03-20 13:45:15 +01:00
Thomas Amland
9ab8c444ef refactor track list components 2021-03-20 13:31:53 +01:00
Thomas Amland
cddb6fe85e update dependencies 2021-03-20 11:26:17 +01:00
Thomas Amland
f609f7132b link to genre on album page 2021-03-14 14:04:14 +01:00
Thomas Amland
325f642d72 add download button. fixes #23 2021-03-14 13:47:42 +01:00
Thomas Amland
4e71857e2a remove size param from stream url 2021-03-14 13:45:18 +01:00
Thomas Amland
be1e322461 add tooltip to repeat/shuffle buttons 2021-03-14 13:18:29 +01:00
Thomas Amland
14bef85046 fix queue persistence 2021-03-14 13:11:52 +01:00
Thomas Amland
43592bce8a add clear queue button 2021-03-14 13:03:43 +01:00
Thomas Amland
92179f4914 add home link to logo 2021-03-14 12:44:21 +01:00
Thomas Amland
f89f6b36c8 add Play next & Add to queue to album page 2021-03-14 12:44:21 +01:00
Thomas Amland
4100572b54 update dependencies 2021-03-14 12:44:21 +01:00
Thomas Amland
c3ec82dc74 skip docker push for PRs 2021-03-14 12:44:21 +01:00
Thomas Amland
ffa001a42e use /ping instead of /ping.view to check login 2021-03-14 12:44:21 +01:00
tamland
797015caf9
Merge pull request #29 from tamland/dependabot/npm_and_yarn/elliptic-6.5.4
Bump elliptic from 6.5.3 to 6.5.4
2021-03-13 17:58:47 +01:00
tamland
292cefb22b
Merge pull request #20 from tamland/dependabot/npm_and_yarn/highlight.js-9.18.5
Bump highlight.js from 9.18.1 to 9.18.5
2021-03-13 17:54:00 +01:00
dependabot[bot]
f1afda87c5
Bump elliptic from 6.5.3 to 6.5.4
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.3 to 6.5.4.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.3...v6.5.4)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-11 03:33:37 +00:00
tamland
e8711d2744
Merge pull request #25 from JoelMesser/Default-Title
Default title
2021-02-17 16:51:52 +01:00
tamland
8f551a7e05
Merge pull request #24 from JoelMesser/master
Fixing the description on the podcast episodes
2021-02-17 16:51:25 +01:00
Joel Messer
71ee7e2ff3
Setting a default page title
Setting a default title for the page, so it doesn't display (for example) "<ip>:<port>/podcast/7"
2021-02-16 13:07:34 -05:00
Joel Messer
d66c59f19b
Fixing the description on the podcast episodes 2021-02-16 12:56:26 -05:00
Thomas Amland
c072bccc58 Merge branch 'a0js-add-volume-control' 2021-01-31 18:26:33 +01:00
Thomas Amland
84d62f4eee fix overflow after adding volume control 2021-01-31 18:24:23 +01:00
Thomas Amland
8b4c482efa add mute toggle 2021-01-30 21:24:34 +01:00
Thomas Amland
fef13f18a9 set slider width. fixes browser inconsistencies 2021-01-30 21:11:44 +01:00
Thomas Amland
80dc608144 add volume tooltip 2021-01-30 13:20:44 +01:00
Thomas Amland
da2a5333fe fix volume icon size 2021-01-30 13:18:04 +01:00
Thomas Amland
c7c89a306a increase volume slider size in dropdown 2021-01-30 12:57:07 +01:00
Thomas Amland
bf03d8907b default volume should be 1 2021-01-30 12:57:03 +01:00
Ammon Sarver
a0de1f0c5a Adds volume control 2021-01-29 17:21:24 -07:00
Thomas Amland
8022929dc1 initial podcast support 2021-01-24 19:14:16 +01:00
Thomas Amland
353c57d819 update dev dependencies 2021-01-24 12:44:35 +01:00
Thomas Amland
822c13e9ba update bootstrap 2021-01-24 12:44:35 +01:00
Thomas Amland
a3fd828834 fix click event bubbling from context menu 2021-01-24 12:44:35 +01:00
Thomas Amland
588f975eba use isPlaying getter 2021-01-24 12:44:34 +01:00
Thomas Amland
8e9a6ca26d allow editing playlist name and comment 2021-01-24 12:44:34 +01:00
Thomas Amland
383334cfe5 fix radio table 2021-01-24 12:44:31 +01:00
Thomas Amland
c49eb98efb refactor table style 2021-01-24 12:26:49 +01:00
Thomas Amland
1e7d87671c add spinner to starred page 2020-12-27 18:18:23 +01:00
Thomas Amland
bcb1edebef update dependencies 2020-12-27 17:04:49 +01:00
Thomas Amland
d002a5c09a add missing return in methods 2020-12-27 16:55:21 +01:00
Thomas Amland
ca94462d93 remove dead code 2020-12-19 12:52:16 +01:00
Thomas Amland
6bf5d6cdd3 track list: make entire row clickable 2020-12-19 12:41:33 +01:00
Thomas Amland
af044fb544 update dependencies 2020-12-13 13:32:28 +01:00
Thomas Amland
d2d85b20aa update readme 2020-12-13 12:36:26 +01:00
Thomas Amland
ec74e13f94 update screenshots 2020-12-13 12:29:30 +01:00
Thomas Amland
181cd70bc6 remove limit in genre track list 2020-12-13 12:08:38 +01:00
Thomas Amland
b65eb4580c fix genre page only showing 10 albums 2020-12-13 11:56:01 +01:00
Thomas Amland
e077fabdca sort genres by most albums 2020-12-13 11:28:07 +01:00
Thomas Amland
f11add00d9 move login to a separate page 2020-12-13 11:06:19 +01:00
Thomas Amland
1e5c3e521e reduce bundle size 2020-12-05 11:41:31 +01:00
Thomas Amland
2692db3611 add spinner to artist library page 2020-12-05 11:06:07 +01:00
Thomas Amland
7e8b7c4478 update dependencies 2020-12-05 10:57:50 +01:00
Thomas Amland
f9cc0faa12 update build/install instructions 2020-12-05 10:54:03 +01:00
Thomas Amland
ec40140f8c update feature list 2020-12-05 10:50:41 +01:00
Thomas Amland
63addd5744 update demo login details 2020-12-05 10:43:04 +01:00
Thomas Amland
a2c37eb4b2 skip to beginning on previous 2020-11-29 12:30:42 +01:00
Thomas Amland
3e8f758ee0 update dependencies 2020-11-29 12:07:01 +01:00
tamland
9a68b816f7
Merge pull request #19 from hoyon/set-page-title
Set page title to current track
2020-11-29 12:06:42 +01:00
dependabot[bot]
e6ca1e63a8
Bump highlight.js from 9.18.1 to 9.18.5
Bumps [highlight.js](https://github.com/highlightjs/highlight.js) from 9.18.1 to 9.18.5.
- [Release notes](https://github.com/highlightjs/highlight.js/releases)
- [Changelog](https://github.com/highlightjs/highlight.js/blob/9.18.5/CHANGES.md)
- [Commits](https://github.com/highlightjs/highlight.js/compare/9.18.1...9.18.5)

Signed-off-by: dependabot[bot] <support@github.com>
2020-11-25 06:40:12 +00:00
Ho-Yon Mak
8bc9473efd Set page title to current track 2020-11-21 13:12:57 +00:00
Thomas Amland
a11af769ee player: add overflow menu 2020-11-15 20:35:26 +01:00
Thomas Amland
bc06cc37d5 update dependencies 2020-11-14 19:43:25 +01:00
Thomas Amland
a900000ce2 update dev dependencies 2020-11-14 19:36:21 +01:00
71 changed files with 17837 additions and 1703 deletions

View File

@ -22,9 +22,8 @@ module.exports = {
'vue/component-tags-order': ['error', {
order: ['template', 'style', 'script']
}],
'no-console': 'warn',
'no-console': 'off',
'no-debugger': 'warn',
'no-useless-constructor': 'off', // Crashes eslint
'no-empty-pattern': 'off',
'comma-dangle': 'off',
'space-before-function-paren': ['error', 'never'],

View File

@ -1,10 +1,10 @@
name: CI
on: [push, pull_request]
on:
- push
env:
IMAGE: ${{ github.repository }}
TAG: ${{ github.sha }}
VERSION: ${{ github.sha }}
jobs:
build:
@ -21,7 +21,7 @@ jobs:
- name: Build
run: |
export VUE_APP_BUILD=$TAG
export VUE_APP_BUILD=$VERSION
export VUE_APP_BUILD_DATE=$(date --iso-8601)
yarn build
@ -31,18 +31,26 @@ jobs:
name: dist
path: dist
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
build_docker_image:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v2
- name: Log in to docker hub
run: docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Download artifact
uses: actions/download-artifact@v2
with:
name: dist
path: dist
- name: Build docker image
run: docker build -t $IMAGE:$VERSION -f docker/Dockerfile .
- name: Push docker image
if: ${{ github.event_name != 'pull_request' }}
run: |
docker buildx build \
--platform linux/arm64 \
--tag $IMAGE:$TAG \
--file docker/Dockerfile .
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
docker push $IMAGE:$VERSION
preview:
runs-on: ubuntu-latest
@ -53,6 +61,7 @@ jobs:
uses: actions/download-artifact@v2
with:
name: dist
path: dist
- name: Deploy preview
uses: netlify/actions/cli@master
@ -60,7 +69,7 @@ jobs:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
with:
args: deploy --dir=.
args: deploy --dir=dist
deploy:
runs-on: ubuntu-latest
@ -71,6 +80,7 @@ jobs:
uses: actions/download-artifact@v2
with:
name: dist
path: dist
- name: Deploy site
uses: netlify/actions/cli@master
@ -78,16 +88,16 @@ jobs:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
with:
args: deploy --dir=. --prod
args: deploy --dir=dist --prod
publish_docker_image:
publish_latest_docker_image:
runs-on: ubuntu-latest
needs: build
needs: build_docker_image
if: github.ref == 'refs/heads/master'
steps:
- name: Push latest
run: |
docker pull $IMAGE:$TAG
docker tag $IMAGE:$TAG $IMAGE:latest
docker pull $IMAGE:$VERSION
docker tag $IMAGE:$VERSION $IMAGE:latest
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
docker push $IMAGE:latest

29
.github/workflows/pr.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: PR
on:
- pull_request
env:
IMAGE: ${{ github.repository }}
VERSION: ${{ github.sha }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '12.x'
- name: Install dependencies
run: yarn install
- name: Build
run: |
export VUE_APP_BUILD=$VERSION
export VUE_APP_BUILD_DATE=$(date --iso-8601)
yarn build
- name: Build docker image
run: docker build -t $IMAGE:$VERSION -f docker/Dockerfile .

3
.vs/ProjectSettings.json Normal file
View File

@ -0,0 +1,3 @@
{
"CurrentProjectSetting": null
}

View File

@ -0,0 +1,6 @@
{
"ExpandedNodes": [
""
],
"PreviewInSolutionExplorer": false
}

BIN
.vs/slnx.sqlite Normal file

Binary file not shown.

View File

@ -1,40 +1,44 @@
# Airsonic Web Client
# Airsonic (refix) UI
[![](https://github.com/tamland/airsonic-frontend/workflows/CI/badge.svg)](https://github.com/tamland/airsonic-frontend/actions)
[![Build](https://img.shields.io/github/workflow/status/tamland/airsonic-refix/CI?style=flat-square)](https://github.com/tamland/airsonic-refix/actions)
[![Docker Pulls](https://img.shields.io/docker/pulls/tamland/airsonic-refix?style=flat-square)](https://hub.docker.com/r/tamland/airsonic-refix)
Modern responsive web frontend for [Airsonic](https://github.com/airsonic/airsonic). It's currently based on the [Subsonic API](http://www.subsonic.org/pages/api.jsp) and should work with other backends implementing this API as well.
Modern responsive web frontend for [Airsonic](https://github.com/airsonic-advanced/airsonic-advanced) and other [Subsonic](https://github.com/topics/subsonic) based music servers.
## Features
- Responsive UI for desktop and mobile
- Browse library for albums, artist, generes
- Playback with persistent queue, repeat & shuffle
- MediaSession integration
- View, create, and edit playlists with drag and drop
- Built-in 'random' playlist
- Search
- Favourites
- Internet radio
- Podcasts
## [Live demo](https://airsonic.netlify.com)
Enter the following details:
Server: `/api`
Username: `guest4`, `guest5`, `guest6` etc.
Password:`guest`
You can try the demo with your own local server as well. Simply enter the full URL of your Airsonic server in the Server field (such as http://localhost:8080) with your credentials. **Note**: if your server is using http only you must allow mixed content in your browser otherwise login will not work.
## Screenshots
![Screenshot](screenshots/album.png)
![Screenshot](screenshots/albumlist.png)
## Supported features
- Responsive UI. Works on mobile and desktop
- Playback with presistent queue
- Browse library for albums, artist, generes and starred songs
- Create, delete and edit playlists
- Search for artists, albums and songs
- Play random songs with the built-in smart playlist
## Demo
https://airsonic.netlify.com
Server: `/api`
Username: `guest1`
Password:`guest`
You can use the URL and credentials for your own server if you prefer. **Note**: if your server is using http only you must allow mixed content in your browser otherwise login will not work.
## Install
### Docker
```
$ docker run -d -p 8080:80 tamland/airsonic-frontend:latest
$ docker run -d -p 8080:80 tamland/airsonic-refix:latest
```
You can now access the application at http://localhost:8080/
@ -45,8 +49,8 @@ Environment variables:
### Pre-built bundle
Pre-built bundles can be found in the [Actions](https://github.com/tamland/airsonic-frontend/actions)
tab. Download/extract artifact and serve with your favourite web server.
Pre-built bundles can be found in the [Actions](https://github.com/tamland/airsonic-refix/actions)
tab. Download/extract artifact and serve with any web server such as nginx or apache.
### Build from source
@ -57,6 +61,11 @@ $ yarn build
Bundle can be found in the `dist` folder.
Build docker image:
```
$ docker build -f docker/Dockerfile .
```
## Develop

14995
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "airsonic",
"name": "airsonic-refix",
"version": "0.0.0",
"private": true,
"scripts": {
@ -8,33 +8,34 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.20.0",
"bootstrap": "^4.5.2",
"bootstrap-vue": "^2.17.3",
"axios": "^0.21.1",
"bootstrap": "^4.6.0",
"bootstrap-vue": "^2.21.2",
"md5-es": "1.8.2",
"vue": "^2.6.12",
"vue-infinite-loading": "2.4.5",
"vue-router": "^3.4.6",
"vuex": "^3.5.1"
"vue-router": "^3.5.2",
"vue-slider-component": "3.2.13",
"vuex": "^3.6.2"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.4.0",
"@typescript-eslint/parser": "^4.4.0",
"@vue/cli-plugin-babel": "^4.5.7",
"@vue/cli-plugin-eslint": "~4.5.7",
"@vue/cli-plugin-typescript": "^4.5.7",
"@vue/cli-service": "^4.5.7",
"@vue/eslint-config-standard": "^5.1.2",
"@typescript-eslint/eslint-plugin": "^4.28.1",
"@typescript-eslint/parser": "^4.28.1",
"@vue/cli-plugin-babel": "^4.5.13",
"@vue/cli-plugin-eslint": "~4.5.13",
"@vue/cli-plugin-typescript": "^4.5.13",
"@vue/cli-service": "^4.5.13",
"@vue/eslint-config-standard": "^6.0.0",
"@vue/eslint-config-typescript": "^7.0.0",
"eslint": "^7.11.0",
"eslint-plugin-import": "^2.22.1",
"eslint": "^7.30.0",
"eslint-plugin-import": "^2.23.3",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^7.0.1",
"sass": "^1.27.0",
"sass-loader": "^10.0.3",
"typescript": "^4.0.3",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^7.12.1",
"sass": "^1.34.0",
"sass-loader": "^10.1.1",
"typescript": "^4.3.5",
"vue-template-compiler": "^2.6.12"
},
"postcss": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" fill="#fff" version="1.1" viewBox="0 0 135.47 135.47"
xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#">
<rect width="100%" height="100%" fill="#09f"/>
<svg width="512" height="512" fill="#09f" version="1.1" viewBox="0 0 135.47 135.47" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(0 -161.53)">
<g transform="matrix(1.0344 0 0 1.0869 -2.0685 -19.991)">
<rect x="9.9294" y="224.55" width="5.9939" height="23.366" rx="2.997" ry="2.997" />

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -4,16 +4,16 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<% if (process.env.NODE_ENV === "production") { %>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src *; img-src *; media-src *; manifest-src 'self'; style-src 'self'; script-src 'self'; base-uri 'self';">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src *; img-src *; media-src *; manifest-src 'self'; style-src 'self' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-624gmqlO23N0g1Ru4tkjuaPEoL/hXP4w7tUqel4WM98=' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-5uOIRR03mYcVoiexgzGGALQ0p1Babe2XxbeIl9t1UpA=' 'sha256-lM8P08IzH0mbT5Tvlm1F5BY3h0gPsb0qNpnZW9YHc7A='; script-src 'self'; base-uri 'self';">
<% } else { %>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src *; img-src *; media-src *; manifest-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; base-uri 'self';">
<% } %>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#1a1a1a" />
<meta name="theme-color" content="#000" />
<link rel="icon" href="<%= BASE_URL %>icon.svg">
<link rel=manifest href="<%= BASE_URL %>manifest.webmanifest">
<script src="<%= BASE_URL %>env.js"></script>
<title></title>
<title>Airsonic (refix)</title>
</head>
<body>
<noscript>

View File

@ -1,25 +1,16 @@
{
"name": "Airsonic",
"short_name": "Airsonic",
"name": "Airsonic (refix)",
"short_name": "Airsonic (refix)",
"start_url": "/",
"display": "standalone",
"theme_color": "#09F",
"background_color": "#09F",
"theme_color": "#000",
"background_color": "#000",
"icons": [
{
"src": "./icon.svg",
"type": "image/svg",
"sizes": "any"
},
{
"src": "./icon-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "./icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
"type": "image/svg+xml",
"sizes": "any",
"purpose": "any"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -34,7 +34,7 @@
computed: {
build: () => process.env.VUE_APP_BUILD,
buildDate: () => process.env.VUE_APP_BUILD_DATE,
url: () => 'https://github.com/tamland/airsonic-frontend'
url: () => 'https://github.com/tamland/airsonic-refix'
},
})
</script>

View File

@ -1,35 +1,26 @@
<template>
<div>
<div class="min-vh-100 d-flex -align-items-stretch -justify-spcace-between">
<Sidebar />
<main class="container-fluid pt-3 pb-3">
<TopNav />
<router-view />
</main>
</div>
<ErrorBar />
<Player />
<component :is="layout">
<router-view />
</component>
</div>
</template>
<style lang="scss">
main {
margin-bottom: 80px;
overflow-x: hidden;
}
</style>
<script lang="ts">
import ErrorBar from './ErrorBar.vue'
import TopNav from './TopNav.vue'
import Sidebar from './Sidebar.vue'
import Player from '@/player/Player.vue'
import Default from '@/app/layout/Default.vue'
import Fullscreen from '@/app/layout/Fullscreen.vue'
export default {
components: {
ErrorBar,
TopNav,
Sidebar,
Player,
Default,
Fullscreen,
},
computed: {
layout(): string {
return (this as any).$route.meta.layout || 'Default'
}
}
}
</script>

View File

@ -1,4 +1,7 @@
<template functional>
<div class="d-flex align-items-end logo-container"
:class="data.staticClass || ''"
v-bind="data.attrs">
<svg xmlns="http://www.w3.org/2000/svg" height="100%" viewBox="5.74 31.24 123.89 72.89">
<g transform="matrix(1.0344 0 0 1.0869 -2.068 -181.521)">
<rect width="5.994" height="23.366" x="9.929" y="224.55" rx="2.997" ry="2.997" />
@ -15,9 +18,15 @@
<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&nbsp;
<span class="text-muted">(refix)</span>
</span>
</div>
</template>
<style scoped>
svg {
fill: var(--primary);
height: 32px;
margin-bottom: 2px;
}
</style>

View File

@ -1,14 +1,13 @@
<template>
<div>
<nav class="nav flex-column">
<div class="nav-link logo d-flex justify-content-between">
<div class="sidebar-brand d-flex justify-content-between align-items-end">
<Logo />
<button class="btn btn-link btn-lg p-0 d-md-none" @click="hideMenu">
<button class="btn btn-link btn-lg p-0 m-0 d-md-none" @click="hideMenu">
<Icon icon="x" />
</button>
</div>
<router-link class="nav-link" :to="{name: 'home'}">
<router-link class="nav-link" :to="{name: 'home'}" exact>
<Icon icon="card-text" class="" /> Discover
</router-link>
@ -16,13 +15,11 @@
<Icon icon="music-note-list" /> Playing
</router-link>
<a class="nav-link disabled">
<small class="text-uppercase text-muted font-weight-bold">
<small class="sidebar-heading text-muted">
Library
</small>
</a>
<router-link class="nav-link" :to="{name: 'albums', params: {sort: 'recently-added'}}">
<router-link class="nav-link" :to="{name: 'albums-default'}">
<Icon icon="collection" /> Albums
</router-link>
@ -34,8 +31,12 @@
<Icon icon="collection" /> Genres
</router-link>
<router-link class="nav-link" :to="{name: 'starred'}">
<Icon icon="star" /> Starred
<router-link class="nav-link" :to="{name: 'favourites'}">
<Icon icon="heart" /> Favourites
</router-link>
<router-link class="nav-link" :to="{name: 'podcasts'}">
<Icon icon="rss" /> Podcasts
</router-link>
<router-link class="nav-link" :to="{name: 'radio'}">
@ -44,7 +45,6 @@
<PlaylistNav />
</nav>
</div>
</template>
<script lang="ts">
import Vue from 'vue'

View File

@ -18,50 +18,73 @@
</b-sidebar>
</div>
</template>
<style>
.sidebar-container nav {
padding-top: 0.5rem;
<style lang="scss">
.sidebar-container {
.nav {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: white transparent;
max-height: 100vh;
padding-bottom: 80px;
flex-wrap: nowrap;
}
.sidebar-container .sidebar-fixed {
.sidebar-fixed {
position: sticky;
top: 0;
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-bottom: 180px;
width: 250px;
}
.sidebar-container .logo {
height: 48px;
.sidebar-brand {
padding: 1rem 1rem 0.75rem;
}
.sidebar-heading {
padding: 0.5rem 1rem;
font-weight: bold;
text-transform: uppercase;
display: block;
}
.sidebar-container .nav-link {
a.nav-link {
flex-shrink: 0;
width: calc(100%);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-container a.nav-link .b-icon {
.b-icon {
margin-right: 0.75rem;
}
.sidebar-container a.nav-link:not(.active) .b-icon {
&: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);
}
}
}
}
</style>
<script lang="ts">
import Vue from 'vue'
import Nav from './Nav.vue'
import { mapState, mapActions } from 'vuex'
import Vue from "vue";
import Nav from "./Nav.vue";
import { mapState, mapActions } from "vuex";
export default Vue.extend({
export default Vue.extend({
components: {
Nav,
},
computed: {
...mapState(['showMenu'])
...mapState(["showMenu"]),
},
methods: {
...mapActions(['hideMenu']),
...mapActions(["hideMenu"]),
},
})
});
</script>

View File

@ -22,7 +22,7 @@
{{ username }}
</b-dropdown-text>
<b-dropdown-divider />
<b-dropdown-item :href="`${server}/settings.view`">
<b-dropdown-item :href="`${server}/settings.view`" target="_blank">
Server settings
</b-dropdown-item>
<b-dropdown-item-button @click="scan">
@ -67,7 +67,7 @@
'showMenu',
]),
scan() {
this.$api.scan()
return this.$api.scan()
},
logout() {
this.$auth.logout()

View File

@ -0,0 +1,31 @@
<template>
<div>
<div class="min-vh-100 d-flex">
<Sidebar />
<main class="container-fluid py-2">
<TopNav />
<slot />
</main>
</div>
<Player />
</div>
</template>
<style lang="scss">
main {
margin-bottom: 80px;
overflow-x: hidden;
}
</style>
<script lang="ts">
import TopNav from '@/app/TopNav.vue'
import Sidebar from '@/app/Sidebar.vue'
import Player from '@/player/Player.vue'
export default {
components: {
TopNav,
Sidebar,
Player,
},
}
</script>

View File

@ -0,0 +1,5 @@
<template>
<main class="container-fluid">
<slot />
</main>
</template>

View File

@ -1,9 +1,14 @@
<template>
<div>
<b-modal size="sm" hide-header hide-footer no-close-on-esc :visible="showModal">
<div class="row align-items-center h-100 mt-5">
<div v-if="!displayForm" class="mx-auto">
<span class="spinner-border " />
</div>
<div v-else class="mx-auto card " style="width: 22rem;">
<b-overlay rounded :show="busy" opacity="0.1">
<div class="card-body">
<form @submit.prevent="login">
<div style="font-size: 4rem; color: #fff;" class="text-center">
<Icon icon="person-circle" />
<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" />
@ -20,17 +25,23 @@
</template>
</b-alert>
<button class="btn btn-primary btn-block" :disabled="busy" @click="login">
<b-spinner v-show="busy" small type="grow" /> Log in
<span v-show="false" class="spinner-border spinner-border-sm" /> Log in
</button>
</form>
</b-modal>
</div>
</b-overlay>
</div>
</div>
</template>>
<script lang="ts">
import Vue from 'vue'
import { config } from '@/shared/config'
import Logo from '@/app/Logo.vue'
export default Vue.extend({
components: {
Logo,
},
props: {
returnTo: { type: String, required: true },
},
@ -42,7 +53,7 @@
rememberLogin: true,
busy: false,
error: null,
showModal: false,
displayForm: false,
}
},
computed: {
@ -52,8 +63,8 @@
config: () => config
},
async created() {
this.server = await this.$auth.server
this.username = await this.$auth.username
this.server = this.$auth.server
this.username = this.$auth.username
const success = await this.$auth.autoLogin()
if (success) {
this.$store.commit('setLoginSuccess', {
@ -62,7 +73,7 @@
})
this.$router.replace(this.returnTo)
} else {
this.showModal = true
this.displayForm = true
}
},
methods: {

View File

@ -47,7 +47,7 @@ export class AuthService {
hash: string,
remember: boolean
) {
const url = `${server}/rest/ping.view?u=${username}&s=${salt}&t=${hash}&v=1.15.0&c=app&f=json`
const url = `${server}/rest/ping?u=${username}&s=${salt}&t=${hash}&v=1.15.0&c=app&f=json`
return axios.get(url)
.then((response) => {
const subsonicResponse = response.data['subsonic-response']

1
src/global.d.ts vendored
View File

@ -4,6 +4,7 @@ declare module '*.vue' {
}
declare module 'md5-es';
declare module 'vue-slider-component';
type MediaSessionPlaybackState = 'none' | 'paused' | 'playing';

View File

@ -1,47 +0,0 @@
<template>
<b-dropdown variant="link" boundary="window" no-caret toggle-class="p-0">
<template #button-content>
<Icon icon="three-dots-vertical" />
</template>
<b-dropdown-item-button @click="setNextInQueue()">
Play next
</b-dropdown-item-button>
<b-dropdown-item-button @click="addToQueue()">
Add to queue
</b-dropdown-item-button>
<b-dropdown-item-button @click="toggleStarred()">
{{ starred ? 'Unstar' : 'Star' }}
</b-dropdown-item-button>
<slot :item="track" />
</b-dropdown>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
props: {
track: { type: Object, required: true },
},
data() {
return {
starred: this.track.starred,
}
},
methods: {
toggleStarred() {
if (this.starred) {
this.$api.unstar('track', this.track.id)
} else {
this.$api.star('track', this.track.id)
}
this.starred = !this.starred
},
setNextInQueue() {
return this.$store.dispatch('player/setNextInQueue', this.track)
},
addToQueue() {
return this.$store.dispatch('player/addToQueue', this.track)
},
}
})
</script>

View File

@ -1,133 +0,0 @@
<template>
<div>
<table class="table table-hover table-borderless">
<thead>
<tr>
<th class="pl-0 pr-0 text-center">
#
</th>
<th class="text-left">
Title
</th>
<th v-if="!noArtist" class="text-left d-none d-lg-table-cell">
Artist
</th>
<th v-if="!noAlbum" class="text-left d-none d-md-table-cell">
Album
</th>
<th v-if="!noDuration" class="text-right d-none d-md-table-cell">
Duration
</th>
<th class="text-right">
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in tracks"
:key="index"
:class="{'active': item.id === playingTrackId}"
:draggable="true"
@dragstart="dragstart(item.id, $event)"
>
<td class="pl-0 pr-0 text-center text-muted"
style="min-width: 20px; max-width: 20px;"
@click="play(index)">
<template v-if="item.id === playingTrackId">
<Icon :icon="isPlaying ? 'pause-fill' : 'play-fill'" />
</template>
<template v-else>
<span class="track-number">{{ item.track || 1 }}</span>
<Icon class="track-number-hover" icon="play-fill" />
</template>
</td>
<td @click="play(index)">
{{ item.title }}
<div class="d-lg-none text-muted">
<small>{{ item.artist }}</small>
</div>
</td>
<td v-if="!noArtist" class="d-none d-lg-table-cell">
<template v-if="item.artistId">
<router-link :to="{name: 'artist', params: {id: item.artistId}}">
{{ item.artist }}
</router-link>
</template>
<template v-else>
{{ item.artist }}
</template>
</td>
<td v-if="!noAlbum" class="d-none d-md-table-cell">
<router-link :to="{name: 'album', params: {id: item.albumId}}">
{{ item.album }}
</router-link>
</td>
<td v-if="!noDuration" class="text-right d-none d-md-table-cell">
{{ $formatDuration(item.duration) }}
</td>
<td class="text-right">
<TrackContextMenu :track="item">
<slot name="context-menu" :index="index" :item="item" />
</TrackContextMenu>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style lang="scss" scoped>
.track-number-hover {
display: none;
}
tr:hover {
.track-number-hover {
display: inline;
}
.track-number {
display: none;
}
}
</style>
<script lang="ts">
import Vue from 'vue'
import { mapActions, mapGetters, mapState } from 'vuex'
import TrackContextMenu from '@/library/TrackContextMenu.vue'
export default Vue.extend({
components: {
TrackContextMenu,
},
props: {
tracks: { type: Array, required: true },
noAlbum: { type: Boolean, default: false },
noArtist: { type: Boolean, default: false },
noDuration: { type: Boolean, default: false },
},
computed: {
...mapState('player', {
isPlaying: 'isPlaying',
}),
...mapGetters({
playingTrackId: 'player/trackId',
}),
},
methods: {
...mapActions({
playPause: 'player/playPause',
}),
play(index: number) {
if ((this.tracks as any)[index].id === this.playingTrackId) {
return this.$store.dispatch('player/playPause')
}
return this.$store.dispatch('player/playTrackList', {
index,
tracks: this.tracks,
})
},
dragstart(id: string, event: any) {
event.dataTransfer.setData('id', id)
},
}
})
</script>

View File

@ -13,15 +13,30 @@
{{ album.artist }}
</router-link>
<span v-if="album.year"> {{ album.year }}</span>
<span v-if="album.genre"> {{ album.genre }}</span>
<span v-if="album.genreId">
<router-link :to="{name: 'genre', params: { id: album.genreId }}">
{{ album.genreId }}
</router-link>
</span>
</p>
<div class="text-nowrap">
<b-btn variant="secondary" class="mr-2" @click="play">
<b-button variant="secondary" class="mr-2" @click="play">
<Icon icon="play-fill" /> Play
</b-btn>
<b-btn variant="secondary" class="mr-2" @click="toggleStar">
<Icon :icon="album.starred ? 'star-fill' : 'star'" />
</b-btn>
</b-button>
<b-button variant="secondary" class="mr-2" @click="toggleFavourite">
<Icon :icon="album.favourite ? 'heart-fill' : 'heart'" />
</b-button>
<b-dropdown variant="secondary" no-caret toggle-class="px-1">
<template #button-content>
<Icon icon="three-dots-vertical" />
</template>
<b-dropdown-item-btn @click="setNextInQueue">
Play next
</b-dropdown-item-btn>
<b-dropdown-item-btn @click="addToQueue">
Add to queue
</b-dropdown-item-btn>
</b-dropdown>
</div>
</div>
</div>
@ -39,7 +54,7 @@
</style>
<script lang="ts">
import Vue from 'vue'
import TrackList from '@/library/TrackList.vue'
import TrackList from '@/library/track/TrackList.vue'
import { Album } from '@/shared/api'
export default Vue.extend({
@ -54,27 +69,36 @@
album: null as null | Album,
}
},
async mounted() {
async created() {
this.album = await this.$api.getAlbumDetails(this.id)
},
methods: {
play() {
if (this.album?.tracks) {
if (this.album) {
return this.$store.dispatch('player/playTrackList', {
index: 0,
tracks: this.album.tracks,
})
}
},
toggleStar() {
setNextInQueue() {
if (this.album) {
const value = !this.album.starred
this.album.starred = value
return value
? this.$api.starAlbum(this.album.id)
: this.$api.unstarAlbum(this.album.id)
return this.$store.dispatch('player/setNextInQueue', this.album.tracks)
}
},
addToQueue() {
if (this.album) {
return this.$store.dispatch('player/addToQueue', this.album.tracks)
}
},
toggleFavourite() {
if (this.album) {
this.album.favourite = !this.album.favourite
return this.album.favourite
? this.$api.addFavourite(this.album.id, 'album')
: this.$api.removeFavourite(this.album.id, 'album')
}
},
}
})
</script>

View File

@ -54,7 +54,7 @@
methods: {
loadMore() {
this.loading = true
this.$api.getAlbums(this.sort as AlbumSort, 50, this.offset).then(albums => {
return this.$api.getAlbums(this.sort as AlbumSort, 50, this.offset).then(albums => {
this.albums.push(...albums)
this.offset += albums.length
this.hasMore = albums.length > 0

View File

@ -1,5 +1,7 @@
<template>
<ContentLoader v-slot :loading="items == null">
<ArtistList :items="items" />
</ContentLoader>
</template>
<script lang="ts">
import Vue from 'vue'
@ -12,13 +14,11 @@
},
data() {
return {
items: [] as Artist[]
items: null as null | Artist[]
}
},
created() {
this.$api.getArtists().then(items => {
this.items = items
})
async created() {
this.items = await this.$api.getArtists()
}
})
</script>

View File

@ -0,0 +1,52 @@
<template>
<div>
<h1>Favourites</h1>
<ul class="nav-underlined">
<li>
<router-link :to="{... $route, params: { }}">
Albums
</router-link>
</li>
<li>
<router-link :to="{... $route, params: { section: 'artists' }}">
Artists
</router-link>
</li>
<li>
<router-link :to="{... $route, params: { section: 'tracks' }}">
Tracks
</router-link>
</li>
</ul>
<ContentLoader v-slot :loading="result == null">
<ArtistList v-if="section === 'artists'" :items="result.artists" />
<TrackList v-else-if="section === 'tracks'" :tracks="result.tracks" />
<AlbumList v-else :items="result.albums" />
</ContentLoader>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import AlbumList from '@/library/album/AlbumList.vue'
import ArtistList from '@/library/artist/ArtistList.vue'
import TrackList from '@/library/track/TrackList.vue'
export default Vue.extend({
components: {
AlbumList,
ArtistList,
TrackList,
},
props: {
section: { type: String, default: '' },
},
data() {
return {
result: null as any,
}
},
async created() {
this.result = await this.$api.getFavourites()
}
})
</script>

View File

@ -14,44 +14,40 @@
</li>
</ul>
<template v-if="section === 'tracks'">
<ContentLoader v-slot :loading="tracks == null">
<TrackList :tracks="tracks" />
</ContentLoader>
<InfiniteList v-slot="{ items }" key="tracks" :load="loadTracks">
<TrackList :tracks="items" />
</InfiniteList>
</template>
<template v-else>
<ContentLoader v-slot :loading="albums == null">
<AlbumList :items="albums" />
</ContentLoader>
<InfiniteList v-slot="{ items }" key="albums" :load="loadAlbums">
<AlbumList :items="items" />
</InfiniteList>
</template>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import AlbumList from '@/library/album/AlbumList.vue'
import TrackList from '@/library/TrackList.vue'
import TrackList from '@/library/track/TrackList.vue'
import InfiniteList from '@/shared/components/InfiniteList.vue'
export default Vue.extend({
components: {
AlbumList,
TrackList,
InfiniteList,
},
props: {
id: { type: String, required: true },
section: { type: String, default: '' },
},
data() {
return {
albums: null as null | any[],
tracks: null as null | any[],
}
methods: {
loadAlbums(offset: number) {
return this.$api.getAlbumsByGenre(this.id, 50, offset)
},
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>

View File

@ -6,7 +6,7 @@
:title="item.name">
<template #text>
<strong>{{ item.albumCount }}</strong> albums
<strong>{{ item.songCount }}</strong> songs
<strong>{{ item.trackCount }}</strong> tracks
</template>
</Tile>
</Tiles>
@ -20,10 +20,8 @@
items: [],
}
},
created() {
this.$api.getGenres().then((items) => {
this.items = items
})
async created() {
this.items = await this.$api.getGenres()
},
})
</script>

View File

@ -0,0 +1,74 @@
<template>
<ContentLoader v-slot :loading="podcast ==null">
<h1>{{ podcast.name }}</h1>
<p>{{ podcast.description }}</p>
<BaseTable>
<BaseTableHead>
<th class="text-right d-none d-md-table-cell">
Duration
</th>
</BaseTableHead>
<tbody>
<tr v-for="(item, index) in podcast.tracks" :key="index"
:class="{'active': item.id === playingTrackId, 'disabled': !item.playable}"
@click="play(item)">
<CellTrackNumber :active="item.id === playingTrackId && isPlaying" :track="item" />
<CellTitle :track="item" />
<CellDuration :track="item" />
<CellActions :track="item" />
</tr>
</tbody>
</BaseTable>
</ContentLoader>
</template>
<script lang="ts">
import Vue from 'vue'
import { mapGetters } from 'vuex'
import CellTrackNumber from '@/library/track/CellTrackNumber.vue'
import CellActions from '@/library/track/CellActions.vue'
import CellDuration from '@/library/track/CellDuration.vue'
import CellTitle from '@/library/track/CellTitle.vue'
import BaseTable from '@/library/track/BaseTable.vue'
import BaseTableHead from '@/library/track/BaseTableHead.vue'
export default Vue.extend({
components: {
BaseTableHead,
BaseTable,
CellTitle,
CellDuration,
CellActions,
CellTrackNumber
},
props: {
id: { type: String, required: true },
},
data() {
return {
podcast: null as null | any,
}
},
computed: {
...mapGetters({
playingTrackId: 'player/trackId',
isPlaying: 'player/isPlaying',
}),
},
async created() {
this.podcast = await this.$api.getPodcast(this.id)
},
methods: {
async play(track: any) {
if (!track.playable) {
return
}
const tracks = this.podcast.tracks.filter((x: any) => x.playable)
const index = tracks.findIndex((x: any) => x.id === track.id)
return this.$store.dispatch('player/playTrackList', {
index,
tracks,
})
},
}
})
</script>

View File

@ -0,0 +1,42 @@
<template>
<ContentLoader v-slot :loading="items == null">
<div class="d-flex justify-content-between">
<h1>Podcasts</h1>
<OverflowMenu>
<b-dropdown-item-btn @click="refresh()">
Refresh
</b-dropdown-item-btn>
</OverflowMenu>
</div>
<Tiles square>
<Tile v-for="item in items" :key="item.id"
:image="item.image"
:to="{name: 'podcast', params: { id: item.id } }"
:title="item.name">
<template #text>
<strong>{{ item.trackCount }}</strong> episodes
</template>
</Tile>
</Tiles>
</ContentLoader>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
data() {
return {
items: null as null | any[],
}
},
async created() {
this.items = await this.$api.getPodcasts()
},
methods: {
async refresh() {
await this.$api.refreshPodcasts()
this.items = await this.$api.getPodcasts()
}
}
})
</script>

View File

@ -1,45 +1,37 @@
<template>
<div v-if="items">
<h1>Radio</h1>
<table class="table table-hover table-borderless">
<thead>
<tr>
<th class="text-left">
Title
</th>
<th class="text-right">
Actions
</th>
</tr>
</thead>
<BaseTable>
<BaseTableHead />
<tbody>
<tr v-for="(item, index) in items" :key="index"
:class="{'active': item.id === playingTrackId}">
<td @click="play(index)">
{{ item.title }}
<div>
<small class="text-muted">
{{ item.description }}
</small>
</div>
</td>
<td class="text-right">
<TrackContextMenu :track="item" />
</td>
:class="{'active': item.id === playingTrackId}"
@click="play(index)">
<CellTrackNumber :active="item.id === playingTrackId && isPlaying" :track="item" />
<CellTitle :track="item" />
<CellActions :track="item" />
</tr>
</tbody>
</table>
</BaseTable>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import TrackContextMenu from '@/library/TrackContextMenu.vue'
import { RadioStation } from '@/shared/api'
import { mapGetters } from 'vuex'
import CellTrackNumber from '@/library/track/CellTrackNumber.vue'
import CellActions from '@/library/track/CellActions.vue'
import CellTitle from '@/library/track/CellTitle.vue'
import BaseTable from '@/library/track/BaseTable.vue'
import BaseTableHead from '@/library/track/BaseTableHead.vue'
export default Vue.extend({
components: {
TrackContextMenu,
BaseTableHead,
BaseTable,
CellTitle,
CellActions,
CellTrackNumber,
},
data() {
return {
@ -49,6 +41,7 @@
computed: {
...mapGetters({
playingTrackId: 'player/trackId',
isPlaying: 'player/isPlaying',
}),
},
async created() {

View File

@ -1,40 +0,0 @@
<template>
<div v-if="result">
<div v-if="result.albums.length > 0" class="mb-4">
<h1>Albums</h1>
<AlbumList :items="result.albums" />
</div>
<div v-if="result.artists.length > 0" class="mb-4">
<h1>Artists</h1>
<ArtistList :items="result.artists" />
</div>
<div v-if="result.tracks.length > 0" class="mb-4">
<h1>Tracks</h1>
<TrackList :tracks="result.tracks" />
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import AlbumList from '@/library/album/AlbumList.vue'
import ArtistList from '@/library/artist/ArtistList.vue'
import TrackList from '@/library/TrackList.vue'
export default Vue.extend({
components: {
AlbumList,
ArtistList,
TrackList,
},
data() {
return {
result: null as any,
}
},
created() {
this.$api.getStarred().then(result => {
this.result = result
})
}
})
</script>

View File

@ -0,0 +1,5 @@
<template functional>
<table class="table table-hover table-borderless table-numbered">
<slot />
</table>
</template>

View File

@ -0,0 +1,14 @@
<template functional>
<thead>
<tr>
<th>#</th>
<th class="text-left">
Title
</th>
<slot />
<th class="text-right">
Actions
</th>
</tr>
</thead>
</template>

View File

@ -0,0 +1,50 @@
<template>
<td class="text-right" @click.stop="">
<OverflowMenu :disabled="track.playable === false">
<b-dropdown-item-button @click="setNextInQueue()">
Play next
</b-dropdown-item-button>
<b-dropdown-item-button @click="addToQueue()">
Add to queue
</b-dropdown-item-button>
<b-dropdown-item-button @click="toggleFavourite()">
{{ favourite ? 'Remove from favourites' : 'Add to favourites' }}
</b-dropdown-item-button>
<b-dropdown-item-button @click="download()">
Download
</b-dropdown-item-button>
<slot :item="track" />
</OverflowMenu>
</td>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
props: {
track: { type: Object, required: true },
},
data() {
return {
favourite: this.track.favourite,
}
},
methods: {
toggleFavourite() {
this.favourite = !this.favourite
return this.favourite
? this.$store.dispatch('addFavourite', this.track.id)
: this.$store.dispatch('removeFavourite', this.track.id)
},
download() {
window.location.href = this.$api.getDownloadUrl(this.track.id)
},
setNextInQueue() {
return this.$store.dispatch('player/setNextInQueue', [this.track])
},
addToQueue() {
return this.$store.dispatch('player/addToQueue', [this.track])
},
}
})
</script>

View File

@ -0,0 +1,12 @@
<template functional>
<td class="d-none d-md-table-cell">
<template v-if="props.track.albumId">
<router-link :to="{name: 'album', params: {id: props.track.albumId}}" @click.native.stop>
{{ props.track.album }}
</router-link>
</template>
<template v-else>
{{ props.track.album }}
</template>
</td>
</template>

View File

@ -0,0 +1,12 @@
<template functional>
<td class="d-none d-lg-table-cell">
<template v-if="props.track.artistId">
<router-link :to="{name: 'artist', params: {id: props.track.artistId}}" @click.native.stop>
{{ props.track.artist }}
</router-link>
</template>
<template v-else>
{{ props.track.artist }}
</template>
</td>
</template>

View File

@ -0,0 +1,7 @@
<template functional>
<td class="text-right d-none d-md-table-cell">
<template v-if="props.track.duration">
{{ parent.$formatDuration(props.track.duration) }}
</template>
</td>
</template>

View File

@ -0,0 +1,11 @@
<template functional>
<td>
{{ props.track.title }}
<div v-if="props.track.description" class="text-muted">
<small>{{ props.track.description }}</small>
</div>
<div v-else-if="props.track.artist" class="d-lg-none text-muted">
<small>{{ props.track.artist }}</small>
</div>
</td>
</template>

View File

@ -0,0 +1,8 @@
<template functional>
<td>
<button>
<Icon class="icon" :icon="props.active ? 'pause-fill' :'play-fill'" />
<span class="number">{{ props.track.track || 1 }}</span>
</button>
</td>
</template>

View File

@ -0,0 +1,84 @@
<template>
<BaseTable>
<BaseTableHead>
<th v-if="!noArtist" class="text-left d-none d-lg-table-cell">
Artist
</th>
<th v-if="!noAlbum" class="text-left d-none d-md-table-cell">
Album
</th>
<th v-if="!noDuration" class="text-right d-none d-md-table-cell">
Duration
</th>
</BaseTableHead>
<tbody>
<tr v-for="(item, index) in tracks" :key="index"
:class="{'active': item.id === playingTrackId}"
:draggable="true" @dragstart="dragstart(item.id, $event)"
@click="play(index)">
<CellTrackNumber :active="item.id === playingTrackId && isPlaying" :track="item" />
<CellTitle :track="item" />
<CellArtist v-if="!noArtist" :track="item" />
<CellAlbum v-if="!noAlbum" :track="item" />
<CellDuration v-if="!noDuration" :track="item" />
<CellActions :track="item">
<slot name="context-menu" :index="index" :item="item" />
</CellActions>
</tr>
</tbody>
</BaseTable>
</template>
<script lang="ts">
import Vue from 'vue'
import { mapActions, mapGetters } from 'vuex'
import CellDuration from '@/library/track/CellDuration.vue'
import CellArtist from '@/library/track/CellArtist.vue'
import CellAlbum from '@/library/track/CellAlbum.vue'
import CellTrackNumber from '@/library/track/CellTrackNumber.vue'
import CellActions from '@/library/track/CellActions.vue'
import CellTitle from '@/library/track/CellTitle.vue'
import BaseTable from '@/library/track/BaseTable.vue'
import BaseTableHead from '@/library/track/BaseTableHead.vue'
export default Vue.extend({
components: {
BaseTableHead,
BaseTable,
CellTitle,
CellActions,
CellTrackNumber,
CellAlbum,
CellArtist,
CellDuration,
},
props: {
tracks: { type: Array, required: true },
noAlbum: { type: Boolean, default: false },
noArtist: { type: Boolean, default: false },
noDuration: { type: Boolean, default: false },
},
computed: {
...mapGetters({
playingTrackId: 'player/trackId',
isPlaying: 'player/isPlaying',
}),
},
methods: {
...mapActions({
playPause: 'player/playPause',
}),
play(index: number) {
if ((this.tracks as any)[index].id === this.playingTrackId) {
return this.$store.dispatch('player/playPause')
}
return this.$store.dispatch('player/playTrackList', {
index,
tracks: this.tracks,
})
},
dragstart(id: string, event: any) {
event.dataTransfer.setData('id', id)
},
}
})
</script>

View File

@ -1,7 +1,6 @@
import Vue from 'vue'
import Router from 'vue-router'
import Vuex from 'vuex'
import { BootstrapVue } from 'bootstrap-vue'
import '@/style/main.scss'
import '@/shared/components'
import App from '@/app/App.vue'
@ -21,7 +20,6 @@ declare module 'vue/types/vue' {
Vue.config.productionTip = false
Vue.use(Router)
Vue.use(Vuex)
Vue.use(BootstrapVue)
const authService = new AuthService()
const api = new API(authService)

View File

@ -2,30 +2,24 @@
<div :class="{'visible': visible}" class="player elevated d-flex">
<div class="flex-fill">
<!-- Progress --->
<div class="progress2" @click="seek">
<b-progress :value="progress" :max="100" height="4px" />
</div>
<div class="row align-items-center m-0">
<ProgressBar
style="margin-bottom: -5px; margin-top: -9px"
:value="progress" @input="seek"
/>
<div class="row align-items-center m-0" style="padding-top: -10px">
<!-- Track info --->
<div class="col p-0 d-flex flex-nowrap align-items-center justify-content-start" style="width: 0; min-width: 0">
<template v-if="track">
<router-link :to="{ name: 'queue' }">
<template v-if="track.image">
<img class="d-sm-none" width="64px" height="64px" :src="track.image">
<img class="d-none d-sm-inline-block" width="74px" height="74px" :src="track.image">
</template>
<template v-else>
<img class="d-sm-none" width="64px" height="64px" src="@/shared/assets/fallback.svg">
<img class="d-none d-sm-inline-block" width="74px" height="74px" src="@/shared/assets/fallback.svg">
</template>
<router-link :to="{ name: 'queue' }" style="padding: 12px">
<img v-if="track.image" width="52px" height="52px" :src="track.image">
<img v-else width="52px" height="52px" src="@/shared/assets/fallback.svg">
</router-link>
<div class="pl-3" style="min-width: 0">
<div style="min-width: 0">
<div class="text-truncate">
{{ track.title }}
</div>
<div class="text-truncate text-muted">
{{ track.artist }}
{{ track.artist || track.album || track.description }}
</div>
</div>
</template>
@ -45,30 +39,64 @@
</div>
<!-- Controls right --->
<div class="col p-0 d-none d-sm-block " style="min-width: 0; width: 0;">
<div class="d-flex justify-content-end pr-3">
<b-button variant="link"
class="m-0 d-none d-sm-inline-block"
:class="{ 'text-primary': shuffleActive }"
<div class="col-auto col-sm p-0">
<div class="d-flex flex-nowrap justify-content-end pr-3">
<div class="m-0 d-none d-md-inline-flex align-items-center">
<b-button title="Favourite"
variant="link" class="m-0"
@click="toggleFavourite">
<Icon :icon="track && track.favourite ? 'heart-fill' : 'heart'" />
</b-button>
<b-button id="player-volume-btn" variant="link" title="Volume">
<Icon :icon="muteActive ? 'volume-mute-fill' : 'volume-up-fill'" />
</b-button>
<b-popover target="player-volume-btn" placement="top" triggers="click blur" no-fade>
<Slider class="pt-2" style="height: 120px;" direction="btt"
:min="0" :max="1" :step="0.01" percent
:value="muteActive ? 0.0 : volume" @input="setVolume"
/>
</b-popover>
<b-button title="Shuffle"
variant="link" class="m-0" :class="{ 'text-primary': shuffleActive }"
@click="toggleShuffle">
<Icon icon="shuffle" />
</b-button>
<b-button variant="link"
class="m-0 d-none d-sm-inline-block "
:class="{ 'text-primary': repeatActive }"
<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>
</template>
<style scoped>
.progress2 {
cursor: pointer;
}
.player {
position: fixed;
bottom: 0;
@ -82,25 +110,44 @@
height: auto;
max-height: 100px;
}
.b-icon {
display: flex;
align-items: center;
}
</style>
<script lang="ts">
import Vue from 'vue'
import { mapState, mapGetters, mapActions } from 'vuex'
import ProgressBar from '@/player/ProgressBar.vue'
export default Vue.extend({
components: {
ProgressBar,
},
computed: {
...mapState('player', {
isPlaying: (state: any) => state.isPlaying,
currentTime: (state: any) => state.currentTime,
repeatActive: (state: any) => state.repeat,
shuffleActive: (state: any) => state.shuffle,
muteActive: (state: any) => state.mute,
visible: (state: any) => state.queue.length > 0,
volume: (state: any) => state.volume,
}),
...mapGetters('player', [
'track',
'progress',
]),
},
watch: {
track: {
immediate: true,
handler(track: any) {
document.title = [track?.title, track?.artist || track?.album, 'Airsonic (refix)']
.filter(x => !!x).join(' • ')
}
}
},
methods: {
...mapActions('player', [
'playPause',
@ -108,14 +155,17 @@
'previous',
'toggleRepeat',
'toggleShuffle',
'toggleMute',
'seek',
]),
seek(event: any) {
if (event.target) {
const width = event.currentTarget.clientWidth
const value = event.offsetX / width
return this.$store.dispatch('player/seek', value)
}
setVolume(volume: any) {
return this.$store.dispatch('player/setVolume', parseFloat(volume))
},
toggleFavourite() {
return this.track.favourite
? this.$store.dispatch('removeFavourite', this.track.id)
: this.$store.dispatch('addFavourite', this.track.id)
}
}
})
</script>

View File

@ -0,0 +1,62 @@
<template>
<VueSlider
v-bind="$attrs"
:value="value"
:min="0"
:max="100"
:interval="0.001"
:lazy="true"
:contained="true"
:dot-options="{tooltip: 'none'}"
@change="onInput"
/>
</template>
<style lang="scss" scoped>
@import '/src/style/variables';
@import '~vue-slider-component/theme/material.css';
.vue-slider {
height: 4px !important;
padding: 5px 0 !important;
cursor: pointer;
}
::v-deep .vue-slider-rail {
background-color: $secondary;
border-radius: 0;
}
::v-deep .vue-slider-process {
background-color: $primary;
border-radius: 0;
}
::v-deep .vue-slider-dot-handle {
background-color: $primary;
}
::v-deep .vue-slider-dot-handle::after {
background-color: rgba($primary, 0.32);
transform: translate(-50%, -50%) scale(1);
}
.vue-slider:not(:hover) ::v-deep .vue-slider-dot-handle {
display: none;
}
.vue-slider:hover ::v-deep .vue-slider-dot-handle {
display: block;
}
</style>
<script lang="ts">
import Vue from 'vue'
import VueSlider from 'vue-slider-component'
export default Vue.extend({
components: {
VueSlider,
},
props: {
value: { type: Number, required: true },
},
methods: {
onInput(value: number) {
this.$emit('input', value)
},
}
})
</script>

View File

@ -1,32 +1,96 @@
<template>
<div>
<TrackList :tracks="tracks">
<template #context-menu="{index}">
<div class="d-flex justify-content-between align-items-center mb-2">
<h1 class="mb-0">
Playing
</h1>
<b-button variant="secondary" @click="clear()">
Clear
</b-button>
</div>
<BaseTable>
<BaseTableHead>
<th class="text-left d-none d-lg-table-cell">
Artist
</th>
<th class="text-left d-none d-md-table-cell">
Album
</th>
<th class="text-right d-none d-md-table-cell">
Duration
</th>
</BaseTableHead>
<tbody>
<tr v-for="(item, index) in tracks" :key="index"
:class="{'active': index === queueIndex}"
:draggable="true" @dragstart="dragstart(item.id, $event)"
@click="play(index)">
<CellTrackNumber :active="index === queueIndex && isPlaying" :track="item" />
<CellTitle :track="item" />
<CellArtist :track="item" />
<CellAlbum :track="item" />
<CellDuration :track="item" />
<CellActions :track="item">
<b-dropdown-item-button @click="remove(index)">
Remove
</b-dropdown-item-button>
</template>
</TrackList>
</CellActions>
</tr>
</tbody>
</BaseTable>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import { mapState, mapMutations } from 'vuex'
import TrackList from '@/library/TrackList.vue'
import { mapState, mapMutations, mapGetters } from 'vuex'
import TrackList from '@/library/track/TrackList.vue'
import BaseTable from '@/library/track/BaseTable.vue'
import BaseTableHead from '@/library/track/BaseTableHead.vue'
import CellTrackNumber from '@/library/track/CellTrackNumber.vue'
import CellDuration from '@/library/track/CellDuration.vue'
import CellAlbum from '@/library/track/CellAlbum.vue'
import CellArtist from '@/library/track/CellArtist.vue'
import CellTitle from '@/library/track/CellTitle.vue'
import CellActions from '@/library/track/CellActions.vue'
export default Vue.extend({
components: {
CellActions,
CellTitle,
CellArtist,
CellAlbum,
CellDuration,
CellTrackNumber,
BaseTableHead,
BaseTable,
TrackList,
},
computed: {
...mapState('player', {
tracks: (state: any) => state.queue,
})
tracks: 'queue',
queueIndex: 'queueIndex',
}),
...mapGetters('player', {
isPlaying: 'isPlaying',
}),
},
methods: {
...mapMutations('player', {
remove: 'removeFromQueue',
clear: 'clearQueue',
}),
play(index: number) {
if (index === this.queueIndex) {
return this.$store.dispatch('player/playPause')
}
return this.$store.dispatch('player/playTrackList', {
index,
tracks: this.tracks,
})
},
dragstart(id: string, event: any) {
event.dataTransfer.setData('id', id)
},
}
})
</script>

159
src/player/audio.ts Normal file
View File

@ -0,0 +1,159 @@
export class AudioController {
private audio = new Audio()
private handle = -1
private volume = 1.0
private fadeDuration = 200
private buffer = new Audio()
ontimeupdate: (value: number) => void = () => { /* do nothing */ }
ondurationchange: (value: number) => void = () => { /* do nothing */ }
onended: () => void = () => { /* do nothing */ }
onerror: (err: MediaError | null) => void = () => { /* do nothing */ }
currentTime() {
return this.audio.currentTime
}
duration() {
return this.audio.duration
}
setBuffer(url: string) {
this.buffer.src = url
}
setVolume(value: number) {
this.cancelFade()
this.volume = value
this.audio.volume = value
}
async pause() {
await this.fadeOut()
this.audio.pause()
}
async resume() {
this.audio.volume = 0.0
await this.audio.play()
this.fadeIn()
}
async seek(value: number) {
await this.fadeOut(this.fadeDuration / 2.0)
this.audio.volume = 0.0
this.audio.currentTime = value
await this.fadeIn(this.fadeDuration / 2.0)
}
async changeTrack(url: string, options: { paused?: boolean } = {}) {
if (this.audio) {
this.cancelFade()
endPlayback(this.audio, this.fadeDuration)
}
this.audio = new Audio(url)
this.audio.onerror = () => {
this.onerror(this.audio.error)
}
this.audio.onended = () => {
this.onended()
}
this.audio.ontimeupdate = () => {
this.ontimeupdate(this.audio.currentTime)
}
this.audio.ondurationchange = () => {
this.ondurationchange(this.audio.duration)
}
this.ondurationchange(this.audio.duration)
this.ontimeupdate(this.audio.currentTime)
this.audio.volume = 0.0
if (options.paused !== true) {
try {
await this.audio.play()
} catch (error) {
if (error.name === 'AbortError') {
console.warn(error)
return
}
throw error
}
this.fadeIn()
}
}
private cancelFade() {
clearTimeout(this.handle)
}
private fadeIn(duration: number = this.fadeDuration) {
this.fadeFromTo(0.0, this.volume, duration).then()
}
private fadeOut(duration: number = this.fadeDuration) {
return this.fadeFromTo(this.volume, 0.0, duration)
}
private fadeFromTo(from: number, to: number, duration: number) {
console.info(`AudioController: start fade (${from}, ${to}, ${duration})`)
const startTime = Date.now()
const step = (to - from) / duration
if (duration <= 0.0) {
this.audio.volume = to
}
clearTimeout(this.handle)
return new Promise<void>((resolve) => {
const run = () => {
if (this.audio.volume === to) {
console.info(
'AudioController: fade result. ' +
`duration: ${duration}ms, actual: ${Date.now() - startTime}ms, ` +
`volume: ${this.audio.volume}`)
resolve()
return
}
const elapsed = Date.now() - startTime
this.audio.volume = clamp(0.0, this.volume, from + (elapsed * step))
this.handle = setTimeout(run, 10)
}
run()
})
}
}
function endPlayback(audio: HTMLAudioElement, duration: number) {
async function fade(audio: HTMLAudioElement, from: number, to: number, duration: number) {
if (duration <= 0.0) {
audio.volume = to
return audio
}
const startTime = Date.now()
const step = (to - from) / duration
while (audio.volume !== to) {
const elapsed = Date.now() - startTime
audio.volume = clamp(0.0, 1.0, from + (elapsed * step))
await sleep(10)
}
return audio
}
console.info(`AudioController: ending payback for ${audio}`)
audio.ontimeupdate = null
audio.ondurationchange = null
audio.onerror = null
audio.onended = null
const startTime = Date.now()
fade(audio, audio.volume, 0.0, duration)
.catch((err) => console.warn('Error during fade out: ' + err.stack))
.finally(() => {
audio.pause()
console.info(`AudioController: ending payback done. actual ${Date.now() - startTime}ms`)
})
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function clamp(min: number, max: number, value: number) {
return Math.max(min, Math.min(value, max))
}

View File

@ -1,14 +1,14 @@
import { Store, Module } from 'vuex'
import { shuffle, trackListEquals } from '@/shared/utils'
import { API } from '@/shared/api'
import { AudioController } from '@/player/audio'
const audio = new Audio()
const storedQueue = JSON.parse(localStorage.getItem('queue') || '[]')
const storedQueueIndex = parseInt(localStorage.getItem('queueIndex') || '-1')
if (storedQueueIndex > -1 && storedQueueIndex < storedQueue.length) {
audio.src = storedQueue[storedQueueIndex].url
}
const storedVolume = parseFloat(localStorage.getItem('player.volume') || '1.0')
const storedMuteState = localStorage.getItem('player.mute') === 'true'
const mediaSession: MediaSession | undefined = navigator.mediaSession
const audio = new AudioController()
interface State {
queue: any[];
@ -19,6 +19,13 @@ interface State {
currentTime: number; // position of current track in seconds
repeat: boolean;
shuffle: boolean;
mute: boolean;
volume: number; // integer between 0 and 1 representing the volume of the player
}
function persistQueue(state: State) {
localStorage.setItem('queue', JSON.stringify(state.queue))
localStorage.setItem('queueIndex', state.queueIndex.toString())
}
export const playerModule: Module<State, any> = {
@ -32,6 +39,8 @@ export const playerModule: Module<State, any> = {
currentTime: 0,
repeat: localStorage.getItem('player.repeat') !== 'false',
shuffle: localStorage.getItem('player.shuffle') === 'true',
mute: storedMuteState,
volume: storedVolume,
},
mutations: {
@ -55,10 +64,14 @@ export const playerModule: Module<State, any> = {
state.shuffle = enable
localStorage.setItem('player.shuffle', enable)
},
setMute(state, enable) {
state.mute = enable
localStorage.setItem('player.mute', enable)
},
setQueue(state, queue) {
state.queue = queue
state.queueIndex = -1
localStorage.setItem('queue', JSON.stringify(queue))
persistQueue(state)
},
setQueueIndex(state, index) {
if (state.queue.length === 0) {
@ -67,10 +80,12 @@ export const playerModule: Module<State, any> = {
index = Math.max(0, index)
index = index < state.queue.length ? index : 0
state.queueIndex = index
localStorage.setItem('queueIndex', index)
persistQueue(state)
state.scrobbled = false
const track = state.queue[index]
audio.src = track.url
state.duration = track.duration
const next = (index + 1) % state.queue.length
audio.setBuffer(state.queue[next].url)
if (mediaSession) {
mediaSession.metadata = new MediaMetadata({
title: track.title,
@ -80,35 +95,59 @@ export const playerModule: Module<State, any> = {
})
}
},
addToQueue(state, track) {
state.queue.push(track)
addToQueue(state, tracks) {
state.queue.push(...tracks)
persistQueue(state)
},
removeFromQueue(state, index) {
state.queue.splice(index, 1)
if (index < state.queueIndex) {
state.queueIndex--
}
persistQueue(state)
},
setNextInQueue(state, track) {
state.queue.splice(state.queueIndex + 1, 0, track)
clearQueue(state) {
if (state.queueIndex >= 0) {
state.queue = [state.queue[state.queueIndex]]
state.queueIndex = 0
persistQueue(state)
}
},
setNextInQueue(state, tracks) {
state.queue.splice(state.queueIndex + 1, 0, ...tracks)
persistQueue(state)
},
setCurrentTime(state, value: any) {
state.currentTime = value
},
setDuration(state, value: any) {
if (isFinite(value)) {
state.duration = value
}
},
setScrobbled(state) {
state.scrobbled = true
},
setVolume(state, value: number) {
state.volume = value
state.mute = value <= 0.0
localStorage.setItem('player.volume', String(value))
},
updateTrack(state, track) {
const idx = state.queue.findIndex(x => x.id === track.id)
if (idx > -1) {
state.queue[idx] = Object.assign(state.queue[idx], track)
persistQueue(state)
}
},
},
actions: {
async playTrackList({ commit, state }, { tracks, index }) {
async playTrackList({ commit, state, getters }, { tracks, index }) {
if (trackListEquals(state.queue, tracks)) {
commit('setQueueIndex', index)
commit('setPlaying')
await audio.play()
await audio.changeTrack(getters.track.url)
return
}
tracks = [...tracks]
@ -123,44 +162,38 @@ export const playerModule: Module<State, any> = {
commit('setQueueIndex', index)
}
commit('setPlaying')
await audio.play()
await audio.changeTrack(getters.track.url)
},
async resume({ commit }) {
commit('setPlaying')
await audio.play()
await audio.resume()
},
async pause({ commit }) {
audio.pause()
commit('setPaused')
},
async playPause({ state, dispatch }) {
if (state.isPlaying) {
return dispatch('pause')
}
return dispatch('resume')
return state.isPlaying ? dispatch('pause') : dispatch('resume')
},
async next({ commit, state, getters, dispatch }) {
if (!state.repeat && !getters.hasNext) {
return dispatch('resetQueue')
}
async next({ commit, state, getters }) {
commit('setQueueIndex', state.queueIndex + 1)
commit('setPlaying')
await audio.play()
await audio.changeTrack(getters.track.url)
},
async previous({ commit, state }) {
commit('setQueueIndex', state.queueIndex - 1)
async previous({ commit, state, getters }) {
commit('setQueueIndex', audio.currentTime() > 3 ? state.queueIndex : state.queueIndex - 1)
commit('setPlaying')
await audio.play()
await audio.changeTrack(getters.track.url)
},
seek({ state }, value) {
if (isFinite(state.duration)) {
audio.currentTime = state.duration * value
audio.seek(state.duration * (value / 100.0))
}
},
resetQueue({ commit }) {
audio.pause()
async resetQueue({ commit, getters }) {
commit('setQueueIndex', 0)
commit('setPaused')
await audio.changeTrack(getters.track.url, { paused: true })
},
toggleRepeat({ commit, state }) {
commit('setRepeat', !state.repeat)
@ -168,11 +201,19 @@ export const playerModule: Module<State, any> = {
toggleShuffle({ commit, state }) {
commit('setShuffle', !state.shuffle)
},
addToQueue({ commit }, track) {
commit('addToQueue', track)
toggleMute({ commit, state }) {
commit('setMute', !state.mute)
audio.setVolume(state.mute ? 0.0 : state.volume)
},
setNextInQueue({ commit }, track) {
commit('setNextInQueue', track)
addToQueue({ state, commit }, tracks) {
commit('addToQueue', state.shuffle ? shuffle([...tracks]) : tracks)
},
setNextInQueue({ state, commit }, tracks) {
commit('setNextInQueue', state.shuffle ? shuffle([...tracks]) : tracks)
},
setVolume({ commit }, value) {
audio.setVolume(value)
commit('setVolume', value)
},
},
@ -186,6 +227,9 @@ export const playerModule: Module<State, any> = {
trackId(state, getters): number {
return getters.track ? getters.track.id : -1
},
isPlaying(state): boolean {
return state.isPlaying
},
progress(state) {
if (state.currentTime > -1 && state.duration > 0) {
return (state.currentTime / state.duration) * 100
@ -202,27 +246,38 @@ export const playerModule: Module<State, any> = {
}
export function setupAudio(store: Store<any>, api: API) {
audio.ontimeupdate = () => {
store.commit('player/setCurrentTime', audio.currentTime)
audio.ontimeupdate = (value: number) => {
store.commit('player/setCurrentTime', value)
// Scrobble
if (store.state.player.scrobbled === false &&
audio.duration > 30 &&
audio.currentTime / audio.duration > 0.7) {
if (
store.state.player.scrobbled === false &&
store.state.player.duration > 30 &&
audio.currentTime() / store.state.player.duration > 0.7
) {
const id = store.getters['player/trackId']
store.commit('player/setScrobbled')
api.scrobble(id)
}
}
audio.ondurationchange = () => {
store.commit('player/setDuration', audio.duration)
}
audio.onerror = () => {
store.commit('player/setPaused')
store.commit('setError', audio.error)
audio.ondurationchange = (value: number) => {
store.commit('player/setDuration', value)
}
audio.onended = () => {
store.dispatch('player/next')
if (store.getters['player/hasNext'] || store.state.player.repeat) {
return store.dispatch('player/next')
} else {
return store.dispatch('player/resetQueue')
}
}
audio.onerror = (error: any) => {
store.commit('player/setPaused')
store.commit('setError', error)
}
audio.setVolume(storedMuteState ? 0.0 : storedVolume)
const url = store.getters['player/track']?.url
if (url) {
audio.changeTrack(url, { paused: true })
}
if (mediaSession) {
@ -243,16 +298,16 @@ export function setupAudio(store: Store<any>, api: API) {
})
mediaSession.setActionHandler('seekto', (details) => {
if (details.seekTime) {
audio.currentTime = details.seekTime
audio.seek(details.seekTime)
}
})
mediaSession.setActionHandler('seekforward', (details) => {
const offset = details.seekOffset || 10
audio.currentTime = Math.min(audio.currentTime + offset, audio.duration)
audio.seek(Math.min(audio.currentTime() + offset, audio.duration()))
})
mediaSession.setActionHandler('seekbackward', (details) => {
const offset = details.seekOffset || 10
audio.currentTime = Math.max(audio.currentTime - offset, 0)
audio.seek(Math.max(audio.currentTime() - offset, 0))
})
// FIXME
// function updatePositionState() {

View File

@ -3,27 +3,48 @@
<div class="d-flex justify-content-between">
<h1>{{ playlist.name }}</h1>
<OverflowMenu>
<b-dropdown-item-btn @click="showEditModal = true">
Edit playlist
</b-dropdown-item-btn>
<b-dropdown-item-btn variant="danger" @click="deletePlaylist()">
Delete playlist
</b-dropdown-item-btn>
</OverflowMenu>
</div>
<TrackList :tracks="playlist.tracks" @remove="remove(index)">
<p v-if="playlist.comment" class="text-muted">
{{ playlist.comment }}
</p>
<TrackList :tracks="playlist.tracks">
<template #context-menu="{index}">
<b-dropdown-item-button @click="remove(index)">
Remove
</b-dropdown-item-button>
</template>
</TrackList>
<EditModal :visible.sync="showEditModal" :item="playlist" @confirm="updatePlaylist">
<template #title>
Edit playlist
</template>
<template #default="{ item }">
<b-form-group label="Name">
<b-form-input v-model="item.name" type="text" />
</b-form-group>
<b-form-group label="Comment">
<b-form-textarea v-model="item.comment" />
</b-form-group>
</template>
</EditModal>
</ContentLoader>
</template>
<script lang="ts">
import Vue from 'vue'
import TrackList from '@/library/TrackList.vue'
import TrackList from '@/library/track/TrackList.vue'
import EditModal from '@/shared/components/EditModal.vue'
export default Vue.extend({
components: {
TrackList,
EditModal,
},
props: {
id: { type: String, required: true }
@ -31,6 +52,7 @@
data() {
return {
playlist: null as any,
showEditModal: false,
}
},
watch: {
@ -49,6 +71,10 @@
this.playlist.tracks.splice(index, 1)
return this.$api.removeFromPlaylist(this.id, index.toString())
},
updatePlaylist(value: any) {
this.playlist = value
return this.$store.dispatch('updatePlaylist', this.playlist)
},
deletePlaylist() {
return this.$store.dispatch('deletePlaylist', this.id).then(() => {
this.$router.replace({ name: 'playlists' })

View File

@ -1,13 +1,11 @@
<template>
<div style="max-width: 100%">
<span class="nav-link">
<small class="text-uppercase text-muted font-weight-bold">
<small class="sidebar-heading text-muted">
Playlists
<button class="btn btn-link btn-sm p-0 float-right" @click="showModal = true">
<Icon icon="plus" />
</button>
</small>
</span>
<router-link class="nav-link" :to="{name: 'playlist', params: { id: 'random' }}">
<Icon icon="music-note-list" class="mr-2" /> Random
@ -26,6 +24,9 @@
</router-link>
<b-modal v-model="showModal" title="New playlist">
<template #modal-header-close>
<Icon icon="x" />
</template>
<b-form-group label="Name">
<b-form-input v-model="playlistName" type="text" />
</b-form-group>

View File

@ -1,41 +0,0 @@
<template>
<div v-if="items">
<TrackList :tracks="items" />
<table class="table">
<thead />
<tbody>
<tr v-for="item in items" :key="item.id">
<td>
<Icon icon="play-fill" @click="() => {}" />
<Icon icon="plus" @click="() => {}" />
</td>
<td>{{ item.artist }}</td>
<td>{{ item.album }}</td>
<td>{{ $formatDuration(item.duration) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import TrackList from '@/library/TrackList.vue'
export default Vue.extend({
components: {
TrackList,
},
data() {
return {
loading: true,
items: [] as any[],
}
},
created() {
this.$api.getRandomSongs().then(items => {
this.loading = false
this.items = items// .sort((a: any, b:any) => a.created.localeCompare(b.created));
})
}
})
</script>

View File

@ -18,7 +18,7 @@
import Vue from 'vue'
import AlbumList from '@/library/album/AlbumList.vue'
import ArtistList from '@/library/artist/ArtistList.vue'
import TrackList from '@/library/TrackList.vue'
import TrackList from '@/library/track/TrackList.vue'
export default Vue.extend({
components: {

View File

@ -12,7 +12,7 @@ export interface Track {
id: string
title: string
duration: number
starred: boolean
favourite: boolean
image?: string
url?: string
track?: number
@ -28,8 +28,8 @@ export interface Album {
artist: string
artistId: string
year: number
starred: boolean
genre?: string
favourite: boolean
genreId?: string
image?: string
tracks?: Track[]
}
@ -39,7 +39,7 @@ export interface Artist {
name: string
albumCount: number
description?: string
starred: boolean
favourite: boolean
lastFmUrl?: string
musicBrainzUrl?: string
similarArtist?: Artist[]
@ -109,27 +109,28 @@ export class API {
.map((item: any) => ({
id: item.value,
name: item.value,
...item,
albumCount: item.albumCount,
trackCount: item.songCount,
}))
.sort((a: any, b:any) => a.name.localeCompare(b.name))
.sort((a: any, b:any) => b.albumCount - a.albumCount)
}
async getAlbumsByGenre(id: string) {
async getAlbumsByGenre(id: string, size: number, offset = 0) {
const params = {
type: 'byGenre',
genre: id,
count: 500,
offset: 0,
size,
offset,
}
const response = await this.get('rest/getAlbumList2', params)
return (response.albumList2?.album || []).map(this.normalizeAlbum, this)
}
async getTracksByGenre(id: string) {
async getTracksByGenre(id: string, size: number, offset = 0) {
const params = {
genre: id,
count: 500,
offset: 0,
count: size,
offset,
}
const response = await this.get('rest/getSongsByGenre', params)
return (response.songsByGenre?.song || []).map(this.normalizeTrack, this)
@ -202,6 +203,15 @@ export class API {
return this.getPlaylists()
}
async editPlaylist(playlistId: string, name: string, comment: string) {
const params = {
playlistId,
name,
comment,
}
await this.get('rest/updatePlaylist', params)
}
async deletePlaylist(id: string) {
await this.get('rest/deletePlaylist', { id })
}
@ -230,7 +240,7 @@ export class API {
return (response.randomSongs?.song || []).map(this.normalizeTrack, this)
}
async getStarred() {
async getFavourites() {
const response = await this.get('rest/getStarred2')
return {
albums: (response.starred2?.album || []).map(this.normalizeAlbum, this),
@ -239,15 +249,7 @@ export class API {
}
}
starAlbum(id: string) {
return this.star('album', id)
}
unstarAlbum(id: string) {
return this.unstar('album', id)
}
async star(type: 'track' | 'album' | 'artist', id: string) {
async addFavourite(id: string, type: 'track' | 'album' | 'artist') {
const params = {
id: type === 'track' ? id : undefined,
albumId: type === 'album' ? id : undefined,
@ -256,7 +258,7 @@ export class API {
await this.get('rest/star', params)
}
async unstar(type: 'track' | 'album' | 'artist', id: string) {
async removeFavourite(id: string, type: 'track' | 'album' | 'artist') {
const params = {
id: type === 'track' ? id : undefined,
albumId: type === 'album' ? id : undefined,
@ -280,6 +282,7 @@ export class API {
async getRadioStations(): Promise<RadioStation[]> {
const response = await this.get('rest/getInternetRadioStations')
return (response?.internetRadioStations?.internetRadioStation || [])
.map((item: any, idx: number) => ({ ...item, track: idx + 1 }))
.map(this.normalizeRadioStation, this)
}
@ -308,6 +311,20 @@ export class API {
return this.get('rest/deleteInternetRadioStation', { id })
}
async getPodcasts(): Promise<any[]> {
const response = await this.get('rest/getPodcasts')
return (response?.podcasts?.channel || []).map(this.normalizePodcast, this)
}
async getPodcast(id: string): Promise<any> {
const response = await this.get('rest/getPodcasts', { id })
return this.normalizePodcast(response?.podcasts?.channel[0])
}
async refreshPodcasts(): Promise<void> {
return this.get('rest/refreshPodcasts')
}
async scan(): Promise<void> {
return this.get('rest/startScan')
}
@ -321,9 +338,10 @@ export class API {
id: `radio-${item.id}`,
title: item.name,
description: item.homePageUrl,
track: item.track,
url: item.streamUrl,
duration: 0,
starred: false,
favourite: false,
}
}
@ -332,7 +350,7 @@ export class API {
id: item.id,
title: item.title,
duration: item.duration,
starred: !!item.starred,
favourite: !!item.starred,
track: item.track,
album: item.album,
albumId: item.albumId,
@ -351,8 +369,8 @@ export class API {
artistId: item.artistId,
image: this.getCoverArtUrl(item),
year: item.year || 0,
starred: !!item.starred,
genre: item.genre,
favourite: !!item.starred,
genreId: item.genre,
tracks: (item.song || []).map(this.normalizeTrack, this)
}
}
@ -366,7 +384,7 @@ export class API {
id: item.id,
name: item.name,
description: (item.biography || '').replace(/<a[^>]*>.*?<\/a>/gm, ''),
starred: !!item.starred,
favourite: !!item.starred,
albumCount: item.albumCount,
lastFmUrl: item.lastFmUrl,
musicBrainzUrl: item.musicBrainzId
@ -377,6 +395,44 @@ export class API {
}
}
private normalizePodcast(podcast: any): any {
const image = podcast.originalImageUrl
return {
id: podcast.id,
name: podcast.title,
description: podcast.description,
image: image,
url: podcast.url,
trackCount: podcast.episode.length,
tracks: podcast.episode.map((episode: any, index: number) => ({
id: episode.id,
title: episode.title,
duration: episode.duration,
favourite: false,
track: podcast.episode.length - index,
album: podcast.title,
albumId: null,
artist: '',
artistId: null,
image,
url: episode.streamId ? this.getStreamUrl(episode.streamId) : null,
description: episode.description,
playable: episode.status === 'completed',
})),
}
}
getDownloadUrl(id: any) {
const { server, username, salt, hash } = this.auth
return `${server}/rest/download` +
`?id=${id}` +
'&v=1.15.0' +
`&u=${username}` +
`&s=${salt}` +
`&t=${hash}` +
`&c=${this.clientName}`
}
private getCoverArtUrl(item: any) {
if (!item.coverArt) {
return undefined
@ -401,7 +457,6 @@ export class API {
`&u=${username}` +
`&s=${salt}` +
`&t=${hash}` +
`&c=${this.clientName}` +
'&size=300'
`&c=${this.clientName}`
}
}

View File

@ -0,0 +1,47 @@
<template>
<b-modal ok-title="Save" :visible="visible" @ok="confirm" @change="change">
<template #modal-header-close>
<Icon icon="x" />
</template>
<template #modal-title>
<slot name="title" :item="copy">
{{ title }}
</slot>
</template>
<template v-if="visible">
<slot :item="copy" />
</template>
</b-modal>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
props: {
item: { type: Object, default: null },
visible: { type: Boolean, required: true },
title: { type: String, default: '' },
},
data() {
return {
copy: null,
}
},
watch: {
item: {
immediate: true,
handler(value: any) {
this.copy = { ...value }
}
}
},
methods: {
confirm() {
this.$emit('confirm', this.copy)
},
change() {
this.$emit('update:visible', false)
},
}
})
</script>

View File

@ -11,8 +11,6 @@
BIconCardText,
BIconChevronCompactRight,
BIconMusicNoteList,
BIconStar,
BIconStarFill,
BIconCollection,
BIconCollectionFill,
BIconList,
@ -25,8 +23,12 @@
BIconThreeDotsVertical,
BIconBoxArrowRight,
BIconPersonFill,
BIconPersonCircle,
BIconRss,
BIconX,
BIconVolumeMuteFill,
BIconVolumeUpFill,
BIconHeart,
BIconHeartFill,
} from 'bootstrap-vue'
export default Vue.extend({
@ -38,8 +40,6 @@
BIconCardText,
BIconChevronCompactRight,
BIconMusicNoteList,
BIconStar,
BIconStarFill,
BIconCollection,
BIconCollectionFill,
BIconList,
@ -52,8 +52,12 @@
BIconThreeDotsVertical,
BIconBoxArrowRight,
BIconPersonFill,
BIconPersonCircle,
BIconRss,
BIconX,
BIconVolumeMuteFill,
BIconVolumeUpFill,
BIconHeart,
BIconHeartFill,
},
props: {
icon: { type: String, required: true }

View File

@ -0,0 +1,34 @@
<template>
<div>
<slot :items="items" />
<InfiniteLoader :loading="loading" :has-more="hasMore" @load-more="loadMore" />
</div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
props: {
load: { type: Function, required: true },
},
data() {
return {
items: [] as any[],
loading: false,
offset: 0 as number,
hasMore: true,
}
},
methods: {
loadMore() {
this.loading = true
return this.load(this.offset).then((items: any[]) => {
this.items.push(...items)
this.offset += items.length
this.hasMore = items.length > 0
this.loading = false
})
}
}
})
</script>

View File

@ -1,5 +1,11 @@
<template>
<b-dropdown variant="link" boundary="window" no-caret toggle-class="p-0">
<b-dropdown
variant="link"
no-caret
toggle-class="p-0"
:disabled="disabled"
lazy
>
<template #button-content>
<Icon icon="three-dots-vertical" />
</template>
@ -9,5 +15,9 @@
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({})
export default Vue.extend({
props: {
disabled: { type: Boolean, default: false }
}
})
</script>

View File

@ -0,0 +1,72 @@
<template>
<VueSlider
v-bind="$attrs"
:value="value"
:min="min"
:max="max"
:interval="step"
:tooltip-formatter="formatter"
@change="onInput"
/>
</template>
<style lang="scss" scoped>
@import '/src/style/_variables';
@import '~vue-slider-component/theme/material.css';
.vue-slider {
cursor: pointer;
}
::v-deep .vue-slider-rail {
background-color: $secondary;
border-radius: 0;
}
::v-deep .vue-slider-process {
background-color: $primary;
border-radius: 0;
}
::v-deep .vue-slider-dot-handle {
background-color: $primary;
}
::v-deep .vue-slider-dot-handle::after {
background-color: rgba($primary, 0.32);
transform: translate(-50%, -50%) scale(1);
}
::v-deep .vue-slider-dot-handle:hover .vue-slider-dot-tooltip {
visibility: visible;
}
::v-deep .vue-slider-dot-tooltip-inner {
background-color: $primary;
border-color: $primary;
}
::v-deep .vue-slider-dot-tooltip-text {
width: 40px;
height: 40px;
}
</style>
<script lang="ts">
import Vue from 'vue'
import VueSlider from 'vue-slider-component'
export default Vue.extend({
components: {
VueSlider,
},
props: {
value: { type: Number, required: true },
min: { type: Number, required: true },
max: { type: Number, required: true },
step: { type: Number, required: true },
percent: { type: Boolean, default: false },
},
methods: {
onInput(value: number) {
this.$emit('input', value)
},
formatter(value: number) {
return this.percent
? `${Math.round(((value - this.min) * 100) / (this.max - this.min))}%`
: `${value}`
}
}
})
</script>

View File

@ -4,8 +4,36 @@ import ExternalLink from './ExternalLink.vue'
import Icon from './Icon.vue'
import InfiniteLoader from './InfiniteLoader.vue'
import OverflowMenu from './OverflowMenu.vue'
import Slider from './Slider.vue'
import Tiles from './Tiles.vue'
import Tile from './Tile.vue'
import {
BAlert,
BAvatar,
BButton,
BFormCheckbox,
BFormGroup,
BFormInput,
BFormTextarea,
BModal,
BOverlay,
BPopover,
BSidebar,
DropdownPlugin,
} from 'bootstrap-vue'
Vue.component('BModal', BModal)
Vue.component('BAlert', BAlert)
Vue.component('BAvatar', BAvatar)
Vue.component('BSidebar', BSidebar)
Vue.component('BFormGroup', BFormGroup)
Vue.component('BFormInput', BFormInput)
Vue.component('BFormCheckbox', BFormCheckbox)
Vue.component('BFormTextarea', BFormTextarea)
Vue.component('BButton', BButton)
Vue.component('BPopover', BPopover)
Vue.component('BOverlay', BOverlay)
Vue.use(DropdownPlugin)
const components = {
ContentLoader,
@ -13,6 +41,7 @@ const components = {
Icon,
InfiniteLoader,
OverflowMenu,
Slider,
Tiles,
Tile,
}

View File

@ -6,11 +6,12 @@ import ArtistDetails from '@/library/artist/ArtistDetails.vue'
import ArtistLibrary from '@/library/artist/ArtistLibrary.vue'
import AlbumDetails from '@/library/album/AlbumDetails.vue'
import AlbumLibrary from '@/library/album/AlbumLibrary.vue'
import RandomSongs from '@/playlist/RandomSongs.vue'
import GenreDetails from '@/library/genre/GenreDetails.vue'
import GenreLibrary from '@/library/genre/GenreLibrary.vue'
import Starred from '@/library/starred/Starred.vue'
import Favourites from '@/library/favourite/Favourites.vue'
import RadioStations from '@/library/radio/RadioStations.vue'
import PodcastDetails from '@/library/podcast/PodcastDetails.vue'
import PodcastLibrary from '@/library/podcast/PodcastLibrary.vue'
import Playlist from '@/playlist/Playlist.vue'
import PlaylistList from '@/playlist/PlaylistList.vue'
import SearchResult from '@/search/SearchResult.vue'
@ -33,13 +34,24 @@ export function setupRouter(auth: AuthService) {
component: Login,
props: (route) => ({
returnTo: route.query.returnTo,
})
}),
meta: {
layout: 'fullscreen'
}
},
{
name: 'queue',
path: '/queue',
component: Queue,
},
{
name: 'albums-default',
path: '/albums',
redirect: ({
name: 'albums',
params: { sort: 'recently-added' }
}),
},
{
name: 'albums',
path: '/albums/:sort',
@ -48,7 +60,7 @@ export function setupRouter(auth: AuthService) {
},
{
name: 'album',
path: '/album/:id',
path: '/albums/id/:id',
component: AlbumDetails,
props: true,
},
@ -59,7 +71,7 @@ export function setupRouter(auth: AuthService) {
},
{
name: 'artist',
path: '/artist/:id',
path: '/artists/:id',
component: ArtistDetails,
props: true,
},
@ -70,20 +82,32 @@ export function setupRouter(auth: AuthService) {
},
{
name: 'genre',
path: '/genre/:id/:section?',
path: '/genres/:id/:section?',
component: GenreDetails,
props: true,
},
{
name: 'starred',
path: '/starred',
component: Starred,
name: 'favourites',
path: '/favourites/:section?',
component: Favourites,
props: true,
},
{
name: 'radio',
path: '/radio',
component: RadioStations,
},
{
name: 'podcasts',
path: '/podcasts',
component: PodcastLibrary,
},
{
name: 'podcast',
path: '/podcasts/:id',
component: PodcastDetails,
props: true,
},
{
name: 'playlists',
path: '/playlists',
@ -95,11 +119,6 @@ export function setupRouter(auth: AuthService) {
component: Playlist,
props: true,
},
{
name: 'playlist-random',
path: '/random',
component: RandomSongs,
},
{
name: 'search',
path: '/search',

View File

@ -41,6 +41,10 @@ const setupRootModule = (authService: AuthService, api: API): Module<State, any>
state.playlists = playlists
.sort((a: any, b: any) => b.changed.localeCompare(a.changed))
},
setPlaylist(state, playlist: any) {
const idx = state.playlists.findIndex(x => x.id === playlist.id)
state.playlists.splice(idx, 1, playlist)
},
removePlaylist(state, id: string) {
state.playlists = state.playlists.filter(p => p.id !== id)
},
@ -62,6 +66,16 @@ const setupRootModule = (authService: AuthService, api: API): Module<State, any>
commit('setPlaylists', result)
})
},
updatePlaylist({ commit, state }, { id, name, comment }) {
api.editPlaylist(id, name, comment).then(() => {
const playlist = {
...state.playlists.find(x => x.id === id),
name,
comment,
}
commit('setPlaylist', playlist)
})
},
addTrackToPlaylist({ }, { playlistId, trackId }) {
api.addToPlaylist(playlistId, trackId)
},
@ -69,6 +83,14 @@ const setupRootModule = (authService: AuthService, api: API): Module<State, any>
api.deletePlaylist(id).then(() => {
commit('removePlaylist', id)
})
},
addFavourite({ commit }, id) {
commit('player/updateTrack', { id, favourite: true })
return api.addFavourite(id, 'track')
},
removeFavourite({ commit }, id) {
commit('player/updateTrack', { id, favourite: false })
return api.removeFavourite(id, 'track')
}
},
})

15
src/style/_variables.scss Normal file
View File

@ -0,0 +1,15 @@
$theme-elevation-0: hsl(0, 0%, 0%);
$theme-elevation-1: hsl(0, 0%, 10%);
$theme-elevation-2: hsl(0, 0%, 20%);
$theme-text: #ccc;
$theme-text-muted: #999;
$primary: #09f;
$danger: #ff4141;
$secondary: $theme-elevation-2;
$body-bg: $theme-elevation-0;
$body-color: $theme-text;
$link-color: $theme-text;
$text-muted: $theme-text-muted;
$border-color: $theme-elevation-2;

View File

@ -1,18 +1,4 @@
$theme-elevation-0: hsl(0, 0%, 0%);
$theme-elevation-1: hsl(0, 0%, 10%);
$theme-elevation-2: hsl(0, 0%, 20%);
$theme-text: #ccc;
$theme-text-muted: #999;
$primary: #09f;
$danger: #ff4141;
$secondary: $theme-elevation-2;
$body-bg: $theme-elevation-0;
$body-color: $theme-text;
$link-color: $theme-text;
$text-muted: $theme-text-muted;
$border-color: $theme-elevation-2;
@import "./variables";
// Card
$card-bg: $theme-elevation-1;
@ -30,15 +16,20 @@ $dropdown-link-hover-color: $theme-text-muted;
$dropdown-border-color: $theme-elevation-2;
$dropdown-divider-bg: $theme-elevation-2;
// Popover
$popover-bg: $theme-elevation-1;
$popover-border-color: $theme-elevation-2;
// Form
$input-bg: $theme-elevation-2;
$input-border-color: $theme-elevation-2;
$input-color: $theme-text;
// Other
$progress-bg: rgb(35, 35, 35);
$custom-range-track-height: 0.1rem;
$custom-range-thumb-bg: $theme-text;
$custom-range-track-bg: $theme-text-muted;
:root {
--text-body: #{$theme-text};
--text-muted: #{$theme-text-muted};
}
@ -71,3 +62,8 @@ $enable-responsive-font-sizes: true;
@import '~bootstrap';
@import '~bootstrap-vue';
.modal-header .close {
color: $theme-text;
opacity: 1;
}

View File

@ -6,8 +6,54 @@ table thead tr {
color: $theme-text-muted;
}
table tr.active {
table.table {
tr {
cursor: pointer;
}
tr.active {
td, td a, td svg {
color: var(--primary);
}
}
tr.disabled {
cursor: default;
td, td a, td svg {
color: var(--text-muted);
}
button {
cursor: default;
}
}
}
table.table-numbered {
th:first-child, td:first-child {
padding-left: 0;
padding-right: 0;
width: 26px;
max-width: 26px;
text-align: center;
color: var(--text-muted);
}
tr td:first-child button {
border: none;
background: none;
color: inherit;
font: inherit;
outline: inherit;
.number { display: inline; }
.icon { display: none; }
}
tr.active td:first-child button {
.number { display: none;}
.icon { display: inline;}
}
tr:hover td:first-child button {
.number { display: none; }
.icon { display: inline; }
}
tr.disabled:hover td:first-child button {
.number { display: inline;}
.icon { display: none;}
}
}

2017
yarn.lock

File diff suppressed because it is too large Load Diff