Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
959d0e5c08 |
@ -22,8 +22,9 @@ module.exports = {
|
||||
'vue/component-tags-order': ['error', {
|
||||
order: ['template', 'style', 'script']
|
||||
}],
|
||||
'no-console': 'off',
|
||||
'no-console': 'warn',
|
||||
'no-debugger': 'warn',
|
||||
'no-useless-constructor': 'off', // Crashes eslint
|
||||
'no-empty-pattern': 'off',
|
||||
'comma-dangle': 'off',
|
||||
'space-before-function-paren': ['error', 'never'],
|
||||
|
46
.github/workflows/ci.yml
vendored
@ -1,10 +1,10 @@
|
||||
name: CI
|
||||
on:
|
||||
- push
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
env:
|
||||
IMAGE: ${{ github.repository }}
|
||||
VERSION: ${{ github.sha }}
|
||||
TAG: ${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
export VUE_APP_BUILD=$VERSION
|
||||
export VUE_APP_BUILD=$TAG
|
||||
export VUE_APP_BUILD_DATE=$(date --iso-8601)
|
||||
yarn build
|
||||
|
||||
@ -31,26 +31,18 @@ jobs:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
build_docker_image:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
- name: Log in to docker hub
|
||||
run: docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Build docker image
|
||||
run: docker build -t $IMAGE:$VERSION -f docker/Dockerfile .
|
||||
|
||||
- name: Push docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
run: |
|
||||
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
docker push $IMAGE:$VERSION
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--tag $IMAGE:$TAG \
|
||||
--file docker/Dockerfile .
|
||||
|
||||
preview:
|
||||
runs-on: ubuntu-latest
|
||||
@ -61,7 +53,6 @@ jobs:
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- name: Deploy preview
|
||||
uses: netlify/actions/cli@master
|
||||
@ -69,7 +60,7 @@ jobs:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
with:
|
||||
args: deploy --dir=dist
|
||||
args: deploy --dir=.
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@ -80,7 +71,6 @@ jobs:
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- name: Deploy site
|
||||
uses: netlify/actions/cli@master
|
||||
@ -88,16 +78,16 @@ jobs:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
with:
|
||||
args: deploy --dir=dist --prod
|
||||
args: deploy --dir=. --prod
|
||||
|
||||
publish_latest_docker_image:
|
||||
publish_docker_image:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_docker_image
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/master'
|
||||
steps:
|
||||
- name: Push latest
|
||||
run: |
|
||||
docker pull $IMAGE:$VERSION
|
||||
docker tag $IMAGE:$VERSION $IMAGE:latest
|
||||
docker pull $IMAGE:$TAG
|
||||
docker tag $IMAGE:$TAG $IMAGE:latest
|
||||
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
docker push $IMAGE:latest
|
||||
|
29
.github/workflows/pr.yml
vendored
@ -1,29 +0,0 @@
|
||||
name: PR
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
env:
|
||||
IMAGE: ${{ github.repository }}
|
||||
VERSION: ${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '12.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
export VUE_APP_BUILD=$VERSION
|
||||
export VUE_APP_BUILD_DATE=$(date --iso-8601)
|
||||
yarn build
|
||||
|
||||
- name: Build docker image
|
||||
run: docker build -t $IMAGE:$VERSION -f docker/Dockerfile .
|
@ -1,3 +0,0 @@
|
||||
{
|
||||
"CurrentProjectSetting": null
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
{
|
||||
"ExpandedNodes": [
|
||||
""
|
||||
],
|
||||
"PreviewInSolutionExplorer": false
|
||||
}
|
BIN
.vs/slnx.sqlite
61
README.md
@ -1,44 +1,40 @@
|
||||
# Airsonic (refix) UI
|
||||
# Airsonic Web Client
|
||||
|
||||
[](https://github.com/tamland/airsonic-refix/actions)
|
||||
[](https://hub.docker.com/r/tamland/airsonic-refix)
|
||||
[](https://github.com/tamland/airsonic-frontend/actions)
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||

|
||||
|
||||

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