add eslint

This commit is contained in:
Thomas Amland 2020-08-10 11:11:40 +02:00
parent 9f842bcffe
commit 8e0cc715ab
50 changed files with 2493 additions and 1059 deletions

View File

@ -3,3 +3,4 @@ indent_style = space
indent_size = 2 indent_size = 2
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
max_line_length = 100

38
.eslintrc.js Normal file
View File

@ -0,0 +1,38 @@
module.exports = {
root: true,
env: {
node: true
},
extends: [
'plugin:vue/recommended',
'eslint:recommended',
'@vue/standard',
'@vue/typescript',
'@vue/typescript/recommended'
],
parserOptions: {
ecmaVersion: 2020
},
rules: {
'vue/script-indent': ['error', 2, { baseIndent: 1 }],
'vue/no-unused-components': 'off',
'vue/max-attributes-per-line': 'off',
'vue/html-closing-bracket-newline': 'off',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-useless-constructor': 'off', // Crashes eslint
'no-empty-pattern': 'off',
'comma-dangle': 'off',
'space-before-function-paren': ['error', 'never'],
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
overrides: [
{
files: ['*.vue'],
rules: {
indent: 'off'
}
}
]
}

View File

@ -4,7 +4,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build" "build": "vue-cli-service build",
"lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
"axios": "^0.19.0", "axios": "^0.19.0",
@ -19,11 +20,22 @@
}, },
"devDependencies": { "devDependencies": {
"@types/howler": "^2.2.1", "@types/howler": "^2.2.1",
"@vue/cli-plugin-babel": "^4.4.6", "@typescript-eslint/eslint-plugin": "^3.9.0",
"@vue/cli-plugin-typescript": "^4.4.6", "@typescript-eslint/parser": "^3.9.0",
"@vue/cli-service": "^4.4.6", "@vue/cli-plugin-babel": "^4.5.3",
"@vue/cli-plugin-eslint": "~4.5.3",
"@vue/cli-plugin-typescript": "^4.5.3",
"@vue/cli-service": "^4.5.3",
"@vue/eslint-config-standard": "^5.1.2",
"@vue/eslint-config-typescript": "^5.0.2",
"eslint": "^7.6.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^6.2.2",
"sass": "^1.26.10", "sass": "^1.26.10",
"sass-loader": "^9.0.2", "sass-loader": "^9.0.3",
"typescript": "^3.9.7", "typescript": "^3.9.7",
"vue-template-compiler": "^2.6.10" "vue-template-compiler": "^2.6.10"
}, },

View File

@ -1,15 +1,15 @@
<template> <template>
<div> <div>
<div class="min-vh-100 d-flex align-items-stretch justify-spcace-between"> <div class="min-vh-100 d-flex align-items-stretch justify-spcace-between">
<Sidebar/> <Sidebar />
<div class="main flex-fill"> <div class="main flex-fill">
<div class="container-fluid pt-3 pb-3"> <div class="container-fluid pt-3 pb-3">
<TopNav/> <TopNav />
<router-view></router-view> <router-view />
</div> </div>
</div> </div>
</div> </div>
<ErrorBar/> <ErrorBar />
<footer class="footer elevated"> <footer class="footer elevated">
<Player /> <Player />
</footer> </footer>
@ -28,10 +28,10 @@
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import ErrorBar from "./ErrorBar.vue"; import ErrorBar from './ErrorBar.vue'
import TopNav from "./TopNav.vue"; import TopNav from './TopNav.vue'
import Sidebar from "./Sidebar.vue"; import Sidebar from './Sidebar.vue'
import Player from "@/player/Player.vue"; import Player from '@/player/Player.vue'
export default { export default {
components: { components: {
@ -40,5 +40,5 @@
Sidebar, Sidebar,
Player, Player,
}, },
}; }
</script> </script>

View File

@ -12,8 +12,8 @@
</b-alert> </b-alert>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import { mapState, mapMutations } from 'vuex'; import { mapState, mapMutations } from 'vuex'
export default Vue.extend({ export default Vue.extend({
computed: { computed: {
@ -24,5 +24,5 @@
'clearError' 'clearError'
]), ]),
}, },
}); })
</script> </script>

View File

@ -2,19 +2,19 @@
<div class="text-truncate"> <div class="text-truncate">
<nav class="nav flex-column"> <nav class="nav flex-column">
<router-link class="nav-link logo" :to="{name: 'home'}"> <router-link class="nav-link logo" :to="{name: 'home'}">
<Logo/> <Logo />
</router-link> </router-link>
<router-link class="nav-link" :to="{name: 'home'}"> <router-link class="nav-link" :to="{name: 'home'}">
<Icon icon="card-text" class="mr-2"/> Home <Icon icon="card-text" class="mr-2" /> Home
</router-link> </router-link>
<router-link class="nav-link" :to="{name: 'queue'}"> <router-link class="nav-link" :to="{name: 'queue'}">
<Icon icon="music-note-list" class="mr-2"/> Playing <Icon icon="music-note-list" class="mr-2" /> Playing
</router-link> </router-link>
<router-link class="nav-link" :to="{name: 'starred'}"> <router-link class="nav-link" :to="{name: 'starred'}">
<Icon icon="star-fill" class="mr-2"/> Starred <Icon icon="star-fill" class="mr-2" /> Starred
</router-link> </router-link>
<a class="nav-link disabled"> <a class="nav-link disabled">
@ -24,18 +24,18 @@
</a> </a>
<router-link class="nav-link" :to="{name: 'albums'}"> <router-link class="nav-link" :to="{name: 'albums'}">
<Icon icon="collection-fill" class="mr-2"/> Albums <Icon icon="collection-fill" class="mr-2" /> Albums
</router-link> </router-link>
<router-link class="nav-link" :to="{name: 'artists'}"> <router-link class="nav-link" :to="{name: 'artists'}">
<Icon icon="collection-fill" class="mr-2"/> Artists <Icon icon="collection-fill" class="mr-2" /> Artists
</router-link> </router-link>
<router-link class="nav-item nav-link" :to="{name: 'genres'}"> <router-link class="nav-item nav-link" :to="{name: 'genres'}">
<Icon icon="collection-fill" class="mr-2"/> Genres <Icon icon="collection-fill" class="mr-2" /> Genres
</router-link> </router-link>
<PlaylistNav/> <PlaylistNav />
</nav> </nav>
</div> </div>
</template> </template>
@ -51,21 +51,20 @@
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import Logo from "./Logo.vue"; import Logo from './Logo.vue'
import PlaylistNav from "@/playlist/PlaylistNav.vue"; import PlaylistNav from '@/playlist/PlaylistNav.vue'
import { mapState } from 'vuex';
export default Vue.extend({ export default Vue.extend({
components: { components: {
Logo, Logo,
PlaylistNav, PlaylistNav,
}, },
methods: { methods: {
logout() { logout() {
this.$auth.logout(); this.$auth.logout()
this.$router.go(0); this.$router.go(0)
}, },
} }
}); })
</script> </script>

View File

@ -1,11 +1,10 @@
<template> <template>
<div class="sidebar elevated"> <div class="sidebar elevated">
<div class="d-none d-md-block"> <div class="d-none d-md-block">
<Nav/> <Nav />
</div> </div>
<b-sidebar <b-sidebar
:visible="showMenu" :visible="showMenu"
@hidden="toggleMenu"
class="d-md-none" class="d-md-none"
sidebar-class="elevated" sidebar-class="elevated"
bg-variant="" bg-variant=""
@ -13,8 +12,9 @@
no-header no-header
backdrop backdrop
backdrop-variant="" backdrop-variant=""
@hidden="toggleMenu"
> >
<Nav/> <Nav />
</b-sidebar> </b-sidebar>
</div> </div>
</template> </template>
@ -24,9 +24,9 @@
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import Nav from "./Nav.vue"; import Nav from './Nav.vue'
import { mapState, mapMutations } from 'vuex'; import { mapState, mapMutations } from 'vuex'
export default Vue.extend({ export default Vue.extend({
components: { components: {
@ -38,5 +38,5 @@
methods: { methods: {
...mapMutations(['toggleMenu']), ...mapMutations(['toggleMenu']),
}, },
}); })
</script> </script>

View File

@ -1,18 +1,18 @@
<template> <template>
<div class="d-flex align-items-center mb-2"> <div class="d-flex align-items-center mb-2">
<button class="navbar-toggler text-white d-md-none" @click="toggleMenu"> <button class="navbar-toggler text-white d-md-none" @click="toggleMenu">
<Icon icon="list"/> <Icon icon="list" />
</button> </button>
<div class="ml-auto"></div> <div class="ml-auto" />
<SearchForm/> <SearchForm />
<template v-if="username"> <template v-if="username">
<b-dropdown variant="link" right no-caret> <b-dropdown variant="link" right no-caret>
<template #button-content> <template #button-content>
<b-avatar variant="secondary"> <b-avatar variant="secondary">
<Icon icon="person-fill"/> <Icon icon="person-fill" />
</b-avatar> </b-avatar>
</template> </template>
<b-dropdown-text> <b-dropdown-text>
@ -21,7 +21,7 @@
<b-dropdown-text> <b-dropdown-text>
{{ username }} {{ username }}
</b-dropdown-text> </b-dropdown-text>
<b-dropdown-divider/> <b-dropdown-divider />
<b-dropdown-item-button @click="logout"> <b-dropdown-item-button @click="logout">
Log out Log out
</b-dropdown-item-button> </b-dropdown-item-button>
@ -30,9 +30,9 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import { mapMutations, mapState } from 'vuex'; import { mapMutations, mapState } from 'vuex'
import SearchForm from '@/search/SearchForm.vue'; import SearchForm from '@/search/SearchForm.vue'
export default Vue.extend({ export default Vue.extend({
components: { components: {
@ -40,8 +40,8 @@
}, },
computed: { computed: {
...mapState([ ...mapState([
"server", 'server',
"username", 'username',
]), ]),
}, },
methods: { methods: {
@ -49,9 +49,9 @@
'toggleMenu', 'toggleMenu',
]), ]),
logout() { logout() {
this.$auth.logout(); this.$auth.logout()
this.$router.go(0); this.$router.go(0)
} }
} }
}); })
</script> </script>

View File

@ -3,60 +3,45 @@
<b-modal size="sm" hide-header hide-footer no-close-on-esc :visible="showModal"> <b-modal size="sm" hide-header hide-footer no-close-on-esc :visible="showModal">
<form @submit.prevent="login"> <form @submit.prevent="login">
<div style="font-size: 4rem; color: #fff;" class="text-center"> <div style="font-size: 4rem; color: #fff;" class="text-center">
<Icon icon="person-circle"/> <Icon icon="person-circle" />
</div> </div>
<b-form-group label="Server"> <b-form-group label="Server">
<b-form-input name="server" type="text" v-model="server" :state="valid"/> <b-form-input v-model="server" name="server" type="text" :state="valid" />
</b-form-group> </b-form-group>
<b-form-group label="Username"> <b-form-group label="Username">
<b-form-input name="username" type="text" v-model="username" :state="valid"/> <b-form-input v-model="username" name="username" type="text" :state="valid" />
</b-form-group> </b-form-group>
<b-form-group label="Password"> <b-form-group label="Password">
<b-form-input name="password" type="password" v-model="password" :state="valid"/> <b-form-input v-model="password" name="password" type="password" :state="valid" />
</b-form-group> </b-form-group>
<b-alert :show="error != null" variant="danger"> <b-alert :show="error != null" variant="danger">
<template v-if="error != null"> <template v-if="error != null">
Could not log in. ({{ error.message }}) Could not log in. ({{ error.message }})
</template> </template>
</b-alert> </b-alert>
<button class="btn btn-primary btn-block" @click="login" :disabled="busy"> <button class="btn btn-primary btn-block" :disabled="busy" @click="login">
<b-spinner v-show="busy" small type="grow"/> Log in <b-spinner v-show="busy" small type="grow" /> Log in
</button> </button>
</form> </form>
</b-modal> </b-modal>
</div> </div>
</template>> </template>>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import { mapMutations, mapState } from "vuex";
export default Vue.extend({ export default Vue.extend({
props: { props: {
returnTo: { type: String, required: true }, returnTo: { type: String, required: true },
}, },
data() { data() {
return { return {
server: "", server: '',
username: "", username: '',
password: "", password: '',
rememberLogin: true, rememberLogin: true,
busy: false, busy: false,
error: null, error: null,
showModal: false, showModal: false,
};
},
async created() {
this.server = await this.$auth.server;
this.username = await this.$auth.username;
const success = await this.$auth.autoLogin();
if (success) {
this.$store.commit("setLoginSuccess", {
username: this.username,
server: this.server,
});
this.$router.replace(this.returnTo);
} else {
this.showModal = true;
} }
}, },
computed: { computed: {
@ -64,25 +49,39 @@ export default Vue.extend({
return this.error == null ? null : false return this.error == null ? null : false
} }
}, },
methods: { async created() {
login() { this.server = await this.$auth.server
this.error = null; this.username = await this.$auth.username
this.busy = true; const success = await this.$auth.autoLogin()
this.$auth.loginWithPassword(this.server, this.username, this.password, this.rememberLogin) if (success) {
.then(() => { this.$store.commit('setLoginSuccess', {
this.$store.commit("setLoginSuccess", {
username: this.username, username: this.username,
server: this.server, server: this.server,
}); })
this.$router.replace(this.returnTo); this.$router.replace(this.returnTo)
} else {
this.showModal = true
}
},
methods: {
login() {
this.error = null
this.busy = true
this.$auth.loginWithPassword(this.server, this.username, this.password, this.rememberLogin)
.then(() => {
this.$store.commit('setLoginSuccess', {
username: this.username,
server: this.server,
})
this.$router.replace(this.returnTo)
}) })
.catch(err => { .catch(err => {
this.error = err; this.error = err
}) })
.finally(() => { .finally(() => {
this.busy = false; this.busy = false
}); })
} }
} }
}); })
</script> </script>

View File

@ -1,69 +1,74 @@
import axios from 'axios'; import axios from 'axios'
import { randomString, md5 } from '@/shared/utils'; import { randomString, md5 } from '@/shared/utils'
export class AuthService { export class AuthService {
public server: string = ""; public server = '';
public username: string = ""; public username = '';
public salt: string = ""; public salt = '';
public hash: string = ""; public hash = '';
private authenticated: boolean = false; private authenticated = false;
constructor() { constructor() {
this.server = localStorage.getItem("server") || "/api"; this.server = localStorage.getItem('server') || '/api'
this.username = localStorage.getItem("username") || "guest1"; this.username = localStorage.getItem('username') || 'guest1'
this.salt = localStorage.getItem("salt") || ""; this.salt = localStorage.getItem('salt') || ''
this.hash = localStorage.getItem("hash") || ""; this.hash = localStorage.getItem('hash') || ''
} }
private saveSession() { private saveSession() {
localStorage.setItem("server", this.server); localStorage.setItem('server', this.server)
localStorage.setItem("username", this.username); localStorage.setItem('username', this.username)
localStorage.setItem("salt", this.salt); localStorage.setItem('salt', this.salt)
localStorage.setItem("hash", this.hash); localStorage.setItem('hash', this.hash)
} }
async autoLogin(): Promise<boolean> { async autoLogin(): Promise<boolean> {
if (!this.server || !this.username) { if (!this.server || !this.username) {
return false; return false
} }
return this.loginWithHash(this.server, this.username, this.salt, this.hash, false) return this.loginWithHash(this.server, this.username, this.salt, this.hash, false)
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false)
} }
async loginWithPassword(server: string, username: string, password: string, remember: boolean) { async loginWithPassword(server: string, username: string, password: string, remember: boolean) {
const salt = randomString(); const salt = randomString()
const hash = md5(password + salt); const hash = md5(password + salt)
return this.loginWithHash(server, username, salt, hash, remember); return this.loginWithHash(server, username, salt, hash, remember)
} }
private async loginWithHash(server: string, username: string, salt: string, hash: string, remember: boolean) { private async loginWithHash(
return axios.get(`${server}/rest/ping.view?u=${username}&s=${salt}&t=${hash}&v=1.15.0&c=app&f=json`) server: string,
username: string,
salt: string,
hash: string,
remember: boolean
) {
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) => { .then((response) => {
const subsonicResponse = response.data["subsonic-response"]; const subsonicResponse = response.data['subsonic-response']
if (!subsonicResponse || subsonicResponse.status !== "ok") { if (!subsonicResponse || subsonicResponse.status !== 'ok') {
const err = new Error(subsonicResponse.status); const err = new Error(subsonicResponse.status)
return Promise.reject(err); return Promise.reject(err)
} }
this.authenticated = true; this.authenticated = true
this.server = server; this.server = server
this.username = username; this.username = username
this.salt = salt; this.salt = salt
this.hash = hash; this.hash = hash
if (remember) { if (remember) {
this.saveSession(); this.saveSession()
} }
}) })
} }
logout() { logout() {
localStorage.clear(); localStorage.clear()
sessionStorage.clear(); sessionStorage.clear()
} }
isAuthenticated() { isAuthenticated() {
return this.authenticated; return this.authenticated
} }
} }

17
src/global.d.ts vendored
View File

@ -3,7 +3,7 @@ declare module '*.vue' {
export default Vue export default Vue
} }
declare module "md5-es"; declare module 'md5-es';
interface Navigator { interface Navigator {
readonly mediaSession?: MediaSession; readonly mediaSession?: MediaSession;
@ -15,7 +15,16 @@ interface Window {
type MediaSessionPlaybackState = 'none' | 'paused' | 'playing'; type MediaSessionPlaybackState = 'none' | 'paused' | 'playing';
type MediaSessionAction = 'play' | 'pause' | 'seekbackward' | 'seekforward' | 'seekto' | 'previoustrack' | 'nexttrack' | 'skipad' | 'stop'; type MediaSessionAction =
'play' |
'pause' |
'seekbackward' |
'seekforward' |
'seekto' |
'previoustrack' |
'nexttrack' |
'skipad' |
'stop';
interface MediaSessionActionDetails { interface MediaSessionActionDetails {
action: MediaSessionAction; action: MediaSessionAction;
@ -33,7 +42,9 @@ interface MediaPositionState {
interface MediaSession { interface MediaSession {
playbackState: MediaSessionPlaybackState; playbackState: MediaSessionPlaybackState;
metadata: MediaMetadata | null; metadata: MediaMetadata | null;
setActionHandler(action: MediaSessionAction, listener: ((details: MediaSessionActionDetails) => void)): void; setActionHandler(
action: MediaSessionAction,
listener: ((details: MediaSessionActionDetails) => void)): void;
setPositionState?(arg: MediaPositionState): void; setPositionState?(arg: MediaPositionState): void;
} }

View File

@ -1,29 +1,29 @@
<template> <template>
<div> <div>
<Spinner v-if="loading"/> <Spinner v-if="loading" />
<template v-else> <template v-else>
<div v-for="section in sections" :key="section.key" class="mb-4"> <div v-for="section in sections" :key="section.key" class="mb-4">
<h1>{{ section.name }}</h1> <h1>{{ section.name }}</h1>
<AlbumList :items="$data[section.key]"/> <AlbumList :items="$data[section.key]" />
</div> </div>
</template> </template>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue'
import AlbumList from "@/library/album/AlbumList.vue"; import AlbumList from '@/library/album/AlbumList.vue'
export default Vue.extend({ export default Vue.extend({
components: { components: {
AlbumList, AlbumList,
}, },
data() { data() {
return { return {
sections: [ sections: [
{ name: "Recently played", key: "recent" }, { name: 'Recently played', key: 'recent' },
{ name: "Recently added", key: "newest" }, { name: 'Recently added', key: 'newest' },
{ name: "Most played", key: "frequent" }, { name: 'Most played', key: 'frequent' },
{ name: "Random", key: "random" }, { name: 'Random', key: 'random' },
], ],
loading: true as boolean, loading: true as boolean,
recent: [], recent: [],
@ -33,20 +33,20 @@ export default Vue.extend({
} }
}, },
created() { created() {
const size = 10; const size = 10
this.$api.getAlbums("recent", size).then(result => { this.$api.getAlbums('recent', size).then(result => {
this.recent = result; this.recent = result
this.loading = false; this.loading = false
}); })
this.$api.getAlbums("newest", size).then(result => { this.$api.getAlbums('newest', size).then(result => {
this.newest = result this.newest = result
}); })
this.$api.getAlbums("frequent", size).then(result => { this.$api.getAlbums('frequent', size).then(result => {
this.frequent = result this.frequent = result
}); })
this.$api.getAlbums("random", 50).then(result => { this.$api.getAlbums('random', 50).then(result => {
this.random = result this.random = result
}); })
} }
}); })
</script> </script>

View File

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

View File

@ -3,27 +3,37 @@
<b-table-simple borderless hover> <b-table-simple borderless hover>
<thead> <thead>
<tr> <tr>
<th class="pl-0 pr-0 text-center text-muted"></th> <th class="pl-0 pr-0 text-center text-muted" />
<th class="text-left">Title</th> <th class="text-left">
<th class="text-left d-none d-lg-table-cell">Artist</th> Title
<th class="text-left d-none d-md-table-cell" v-if="showAlbum">Album</th> </th>
<th class="text-right d-none d-md-table-cell">Duration</th> <th class="text-left d-none d-lg-table-cell">
<th class="text-right">Actions</th> Artist
</th>
<th v-if="showAlbum" class="text-left d-none d-md-table-cell">
Album
</th>
<th class="text-right d-none d-md-table-cell">
Duration
</th>
<th class="text-right">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(item, index) in tracks" :key="index" <tr v-for="(item, index) in tracks" :key="index"
draggable="true" @dragstart="dragstart(item.id, $event)" draggable="true" :class="{'text-primary': item.id === playingTrackId}"
:class="{'text-primary': item.id === playingTrackId}"> @dragstart="dragstart(item.id, $event)">
<td class="pl-0 pr-0 text-center text-muted" <td class="pl-0 pr-0 text-center text-muted"
style="min-width: 20px; max-width: 20px;" style="min-width: 20px; max-width: 20px;"
@click="play(index)"> @click="play(index)">
<template v-if="item.id === playingTrackId"> <template v-if="item.id === playingTrackId">
<Icon :icon="isPlaying ? 'pause-fill' : 'play-fill'" class="text-primary"/> <Icon :icon="isPlaying ? 'pause-fill' : 'play-fill'" class="text-primary" />
</template> </template>
<template v-else> <template v-else>
<span class="track-number">{{ item.track || 1 }}</span> <span class="track-number">{{ item.track || 1 }}</span>
<Icon class="track-number-hover" icon="play-fill"/> <Icon class="track-number-hover" icon="play-fill" />
</template> </template>
</td> </td>
<td @click="play(index)"> <td @click="play(index)">
@ -42,7 +52,7 @@
{{ item.artist }} {{ item.artist }}
</template> </template>
</td> </td>
<td class="d-none d-md-table-cell" v-if="showAlbum"> <td v-if="showAlbum" class="d-none d-md-table-cell">
<router-link :to="{name: 'album', params: {id: item.albumId}}"> <router-link :to="{name: 'album', params: {id: item.albumId}}">
{{ item.album }} {{ item.album }}
</router-link> </router-link>
@ -52,9 +62,7 @@
</td> </td>
<td class="text-right"> <td class="text-right">
<TrackContextMenu :track="item"> <TrackContextMenu :track="item">
<template v-slot="{ item }"> <slot name="context-menu" :index="index" :item="item" />
<slot name="context-menu" :index="index" :item="item"></slot>
</template>
</TrackContextMenu> </TrackContextMenu>
</td> </td>
</tr> </tr>
@ -76,11 +84,11 @@
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import { mapActions, mapMutations, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex'
import TrackContextMenu from "@/library/TrackContextMenu.vue" import TrackContextMenu from '@/library/TrackContextMenu.vue'
export default Vue.extend({ export default Vue.extend({
components: { components: {
TrackContextMenu, TrackContextMenu,
}, },
@ -89,20 +97,20 @@ export default Vue.extend({
showAlbum: { type: Boolean, default: false }, showAlbum: { type: Boolean, default: false },
}, },
computed: { computed: {
...mapState("player", { ...mapState('player', {
isPlaying: "isPlaying", isPlaying: 'isPlaying',
}), }),
...mapGetters({ ...mapGetters({
playingTrackId: "player/trackId", playingTrackId: 'player/trackId',
}), }),
}, },
methods: { methods: {
...mapActions({ ...mapActions({
playPause: "player/playPause", playPause: 'player/playPause',
}), }),
play(index: number) { play(index: number) {
if ((this.tracks as any)[index].id === this.playingTrackId) { if ((this.tracks as any)[index].id === this.playingTrackId) {
return this.$store.dispatch("player/playPause") return this.$store.dispatch('player/playPause')
} }
return this.$store.dispatch('player/playQueue', { return this.$store.dispatch('player/playQueue', {
index, index,
@ -110,9 +118,9 @@ export default Vue.extend({
}) })
}, },
dragstart(id: string, event: any) { dragstart(id: string, event: any) {
console.log("dragstart: " + id) console.log('dragstart: ' + id)
event.dataTransfer.setData("id", id); event.dataTransfer.setData('id', id)
}, },
} }
}); })
</script> </script>

View File

@ -1,10 +1,11 @@
<template> <template>
<div v-if="album"> <div v-if="album">
<div class="d-flex mb-3"> <div class="d-flex mb-3">
<b-img height="300" width="300" fluid :src="album.image"/> <b-img height="300" width="300" fluid :src="album.image" />
<div class="ml-3 ml-md-4"> <div class="ml-3 ml-md-4">
<h1>{{ album.name }}</h1> <h1>{{ album.name }}</h1>
<p>by <p>
by
<router-link :to="{name: 'artist', params: { id: album.artistId }}"> <router-link :to="{name: 'artist', params: { id: album.artistId }}">
{{ album.artist }} {{ album.artist }}
</router-link> </router-link>
@ -15,7 +16,7 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<TrackList :tracks="album.song"/> <TrackList :tracks="album.song" />
</div> </div>
</div> </div>
</div> </div>
@ -26,15 +27,15 @@
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import TrackList from "@/library/TrackList.vue" import TrackList from '@/library/TrackList.vue'
export default Vue.extend({ export default Vue.extend({
components: { components: {
TrackList, TrackList,
}, },
props: { props: {
id: String id: { type: String, required: true }
}, },
data() { data() {
return { return {
@ -42,7 +43,7 @@ export default Vue.extend({
} }
}, },
async mounted() { async mounted() {
this.album = await this.$api.getAlbumDetails(this.id); this.album = await this.$api.getAlbumDetails(this.id)
} }
}); })
</script> </script>

View File

@ -1,34 +1,31 @@
<template> <template>
<div> <div>
<Spinner :data="albums" v-slot="{albums: data}"> <Spinner v-slot="{ data }" :data="albums">
<AlbumList :items="albums"/> <AlbumList :items="data" />
</Spinner> </Spinner>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import AlbumList from './AlbumList.vue'; import AlbumList from './AlbumList.vue'
import { AlbumSort } from '@/shared/api'; import { AlbumSort } from '@/shared/api'
export default Vue.extend({ export default Vue.extend({
components: { components: {
AlbumList, AlbumList,
}, },
props: {
msg: String
},
data() { data() {
return { return {
sort: "newest", sort: 'newest',
albums: null, albums: null,
}; }
}, },
computed: { computed: {
sortOptions() { sortOptions() {
return [ return [
{ text: "A-Z", value: "alphabeticalByName" }, { text: 'A-Z', value: 'alphabeticalByName' },
{ text: "Date", value: "newest" }, { text: 'Date', value: 'newest' },
{ text: "frequent", value: "frequent" }, { text: 'frequent', value: 'frequent' },
// { text: "random", value: "random" }, // { text: "random", value: "random" },
// { text: "recent", value: "recent" }, // { text: "recent", value: "recent" },
// { text: "starred", value: "starred" }, // { text: "starred", value: "starred" },
@ -39,12 +36,12 @@ export default Vue.extend({
sort: { sort: {
immediate: true, immediate: true,
handler(value: AlbumSort) { handler(value: AlbumSort) {
this.albums = null; this.albums = null
this.$api.getAlbums(value).then(albums => { this.$api.getAlbums(value).then(albums => {
this.albums = albums; this.albums = albums
}); })
} }
} }
}, },
}); })
</script> </script>

View File

@ -7,32 +7,39 @@
<ExternalLink v-if="item.lastFmUrl" :href="item.lastFmUrl" class="btn btn-secondary mr-2"> <ExternalLink v-if="item.lastFmUrl" :href="item.lastFmUrl" class="btn btn-secondary mr-2">
Last.fm Last.fm
</ExternalLink> </ExternalLink>
<ExternalLink v-if="item.musicBrainzUrl" :href="item.musicBrainzUrl" class="btn btn-secondary"> <ExternalLink
v-if="item.musicBrainzUrl"
:href="item.musicBrainzUrl"
class="btn btn-secondary">
MusicBrainz MusicBrainz
</ExternalLink> </ExternalLink>
</div> </div>
</div> </div>
<h3 class="pt-5">Albums</h3> <h3 class="pt-5">
<AlbumList :items="item.albums"/> Albums
</h3>
<AlbumList :items="item.albums" />
<template v-if="item.similarArtist.length > 0"> <template v-if="item.similarArtist.length > 0">
<h3 class="pt-5">Similar artists</h3> <h3 class="pt-5">
<ArtistList :items="item.similarArtist"/> Similar artists
</h3>
<ArtistList :items="item.similarArtist" />
</template> </template>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import AlbumList from "@/library/album/AlbumList.vue"; import AlbumList from '@/library/album/AlbumList.vue'
import ArtistList from "@/library/artist/ArtistList.vue"; import ArtistList from '@/library/artist/ArtistList.vue'
export default Vue.extend({ export default Vue.extend({
components: { components: {
AlbumList, AlbumList,
ArtistList, ArtistList,
}, },
props: { props: {
id: String id: { type: String, required: true }
}, },
data() { data() {
return { return {
@ -43,10 +50,10 @@ export default Vue.extend({
id: { id: {
immediate: true, immediate: true,
async handler(value: string) { async handler(value: string) {
this.item = null, this.item = null
this.item = await this.$api.getArtistDetails(this.id); this.item = await this.$api.getArtistDetails(value)
} }
} }
} }
}); })
</script> </script>

View File

@ -1,9 +1,9 @@
<template> <template>
<ArtistList :items="items"/> <ArtistList :items="items" />
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import ArtistList from './ArtistList.vue'; import ArtistList from './ArtistList.vue'
export default Vue.extend({ export default Vue.extend({
components: { components: {
@ -12,12 +12,12 @@
data() { data() {
return { return {
items: [] items: []
}; }
}, },
created() { created() {
this.$api.getArtists().then(items => { this.$api.getArtists().then(items => {
this.items = items; this.items = items
}); })
} }
}); })
</script> </script>

View File

@ -1,19 +1,19 @@
<template> <template>
<div v-if="item"> <div v-if="item">
<h1>{{ item.name }}</h1> <h1>{{ item.name }}</h1>
<TrackList :tracks="item.tracks" show-album/> <TrackList :tracks="item.tracks" show-album />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import TrackList from "@/library/TrackList.vue" import TrackList from '@/library/TrackList.vue'
export default Vue.extend({ export default Vue.extend({
components: { components: {
TrackList, TrackList,
}, },
props: { props: {
id: String id: { type: String, required: true }
}, },
data() { data() {
return { return {
@ -21,7 +21,7 @@ export default Vue.extend({
} }
}, },
async created() { async created() {
this.item = await this.$api.getGenreDetails(this.id); this.item = await this.$api.getGenreDetails(this.id)
} }
}); })
</script> </script>

View File

@ -12,19 +12,19 @@
</Tiles> </Tiles>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
export default Vue.extend({ export default Vue.extend({
components: {}, components: {},
data() { data() {
return { return {
items: [], items: [],
}; }
}, },
created() { created() {
this.$api.getGenres().then((items) => { this.$api.getGenres().then((items) => {
this.items = items; this.items = items
}); })
}, },
}); })
</script> </script>

View File

@ -1,9 +1,9 @@
<template> <template>
<TrackList v-if="items" :tracks="items"/> <TrackList v-if="items" :tracks="items" />
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import TrackList from "@/library/TrackList.vue" import TrackList from '@/library/TrackList.vue'
export default Vue.extend({ export default Vue.extend({
components: { components: {
@ -16,8 +16,8 @@
}, },
created() { created() {
this.$api.getStarred().then(result => { this.$api.getStarred().then(result => {
this.items = result; this.items = result
}) })
} }
}); })
</script> </script>

View File

@ -1,15 +1,15 @@
import Vue from 'vue' import Vue from 'vue'
import Router from "vue-router" import Router from 'vue-router'
import Vuex from "vuex" import Vuex from 'vuex'
import { BootstrapVue } from 'bootstrap-vue' import { BootstrapVue } from 'bootstrap-vue'
import '@/style/main.scss' import '@/style/main.scss'
import '@/shared/components' import '@/shared/components'
import '@/shared/filters' import '@/shared/filters'
import App from '@/app/App.vue' import App from '@/app/App.vue'
import {setupRouter} from '@/shared/router' import { setupRouter } from '@/shared/router'
import {setupStore} from '@/shared/store' import { setupStore } from '@/shared/store'
import { API } from '@/shared/api'; import { API } from '@/shared/api'
import { AuthService } from '@/auth/service'; import { AuthService } from '@/auth/service'
import { setupAudio } from './player/store' import { setupAudio } from './player/store'
declare module 'vue/types/vue' { declare module 'vue/types/vue' {
@ -21,20 +21,20 @@ declare module 'vue/types/vue' {
Vue.config.productionTip = false Vue.config.productionTip = false
Vue.use(Router) Vue.use(Router)
Vue.use(Vuex); Vue.use(Vuex)
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
const authService = new AuthService(); const authService = new AuthService()
const api = new API(authService); const api = new API(authService)
const router = setupRouter(authService); const router = setupRouter(authService)
const store = setupStore(authService, api); const store = setupStore(authService, api)
setupAudio(store); setupAudio(store)
Vue.prototype.$auth = authService; Vue.prototype.$auth = authService
Vue.prototype.$api = api; Vue.prototype.$api = api
Vue.config.errorHandler = (err, vm, info) => { Vue.config.errorHandler = (err) => {
store.commit("setError", err) store.commit('setError', err)
} }
new Vue({ new Vue({

View File

@ -2,13 +2,13 @@
<div class="d-flex player"> <div class="d-flex player">
<div v-if="track" class="d-none d-sm-block"> <div v-if="track" class="d-none d-sm-block">
<router-link :to="{name: 'album', params: {id: track.albumId}}"> <router-link :to="{name: 'album', params: {id: track.albumId}}">
<b-img :src="track.image" block width="80px" height="80px"></b-img> <b-img :src="track.image" block width="80px" height="80px" />
</router-link> </router-link>
</div> </div>
<div class="flex-fill"> <div class="flex-fill">
<!-- Progress ---> <!-- Progress --->
<div class="progress2" @click="seek"> <div class="progress2" @click="seek">
<b-progress :value="progress" :max="100" height="4px"></b-progress> <b-progress :value="progress" :max="100" height="4px" />
</div> </div>
<div class="row d-flex align-items-center p-2 m-0"> <div class="row d-flex align-items-center p-2 m-0">
<!-- Track info ---> <!-- Track info --->
@ -26,13 +26,13 @@
<!-- Controls---> <!-- Controls--->
<div class="col-auto p-0"> <div class="col-auto p-0">
<b-button variant="link" class="m-2" @click="previous"> <b-button variant="link" class="m-2" @click="previous">
<Icon icon="skip-start-fill"/> <Icon icon="skip-start-fill" />
</b-button> </b-button>
<b-button variant="link" size="lg" class="m-2" @click="playPause"> <b-button variant="link" size="lg" class="m-2" @click="playPause">
<Icon :icon="isPlaying ? 'pause-fill' : 'play-fill'"/> <Icon :icon="isPlaying ? 'pause-fill' : 'play-fill'" />
</b-button> </b-button>
<b-button variant="link" class="m-2" @click="next"> <b-button variant="link" class="m-2" @click="next">
<Icon icon="skip-end-fill"/> <Icon icon="skip-end-fill" />
</b-button> </b-button>
</div> </div>
<div class="col p-0 text-truncate"> <div class="col p-0 text-truncate">
@ -50,39 +50,39 @@
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import { mapMutations, mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex'
export default Vue.extend({ export default Vue.extend({
data() { data() {
return { return {
}; }
}, },
computed: { computed: {
...mapState("player", { ...mapState('player', {
isPlaying: (state: any) => state.isPlaying, isPlaying: (state: any) => state.isPlaying,
currentTime: (state: any) => state.currentTime, currentTime: (state: any) => state.currentTime,
duration: (state: any) => state.duration, duration: (state: any) => state.duration,
}), }),
...mapGetters("player", [ ...mapGetters('player', [
"track", 'track',
"progress", 'progress',
]), ]),
}, },
methods: { methods: {
...mapActions("player", [ ...mapActions('player', [
"playPause", 'playPause',
"next", 'next',
"previous", 'previous',
]), ]),
seek(event: any) { seek(event: any) {
if (event.target) { if (event.target) {
const width = event.currentTarget.clientWidth; const width = event.currentTarget.clientWidth
const value = event.offsetX / width; const value = event.offsetX / width
return this.$store.dispatch("player/seek", value); return this.$store.dispatch('player/seek', value)
} }
} }
} }
}); })
</script> </script>

View File

@ -10,23 +10,23 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import { mapState, mapMutations, mapActions } from 'vuex'; import { mapState, mapMutations } from 'vuex'
import TrackList from "@/library/TrackList.vue"; import TrackList from '@/library/TrackList.vue'
export default Vue.extend({ export default Vue.extend({
components: { components: {
TrackList, TrackList,
}, },
computed: { computed: {
...mapState("player", { ...mapState('player', {
tracks: (state: any) => state.queue, tracks: (state: any) => state.queue,
}) })
}, },
methods: { methods: {
...mapMutations("player", { ...mapMutations('player', {
remove: "removeFromQueue", remove: 'removeFromQueue',
}), }),
} }
}); })
</script> </script>

View File

@ -3,7 +3,7 @@
<div class="col" d-flex fill-height grow> <div class="col" d-flex fill-height grow>
<h1>{{ track.name }}</h1> <h1>{{ track.name }}</h1>
<div>{{ track.artist }}</div> <div>{{ track.artist }}</div>
<v-card color=blue tile height="100%" width="100%"></v-card> <v-card color="blue" tile height="100%" width="100%" />
</div> </div>
</div> </div>
</template> </template>
@ -18,11 +18,11 @@
</style> </style>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
export default Vue.extend({ export default Vue.extend({
props: { props: {
album: Object album: { type: Object, required: true }
} }
}); })
</script> </script>

View File

@ -1,12 +1,12 @@
import { Store, Module } from 'vuex' import { Store, Module } from 'vuex'
const audio = new Audio(); const audio = new Audio()
const storedQueue = JSON.parse(localStorage.getItem("queue") || "[]"); const storedQueue = JSON.parse(localStorage.getItem('queue') || '[]')
const storedQueueIndex = parseInt(localStorage.getItem("queueIndex") || "-1"); const storedQueueIndex = parseInt(localStorage.getItem('queueIndex') || '-1')
if (storedQueueIndex > -1 && storedQueueIndex < storedQueue.length) { if (storedQueueIndex > -1 && storedQueueIndex < storedQueue.length) {
audio.src = storedQueue[storedQueueIndex].url; audio.src = storedQueue[storedQueueIndex].url
} }
const mediaSession: MediaSession | undefined = navigator.mediaSession; const mediaSession: MediaSession | undefined = navigator.mediaSession
interface State { interface State {
queue: any[]; queue: any[];
@ -28,177 +28,176 @@ export const playerModule: Module<State, any> = {
mutations: { mutations: {
setPlaying(state) { setPlaying(state) {
state.isPlaying = true; state.isPlaying = true
if (mediaSession) { if (mediaSession) {
mediaSession.playbackState = "playing"; mediaSession.playbackState = 'playing'
} }
}, },
setPaused(state) { setPaused(state) {
state.isPlaying = false; state.isPlaying = false
if (mediaSession) { if (mediaSession) {
mediaSession.playbackState = "paused"; mediaSession.playbackState = 'paused'
} }
}, },
setPosition(state, time: number) { setPosition(state, time: number) {
audio.currentTime = time; audio.currentTime = time
}, },
setQueue(state, queue) { setQueue(state, queue) {
state.queue = queue; state.queue = queue
state.queueIndex = -1; state.queueIndex = -1
localStorage.setItem("queue", JSON.stringify(queue)); localStorage.setItem('queue', JSON.stringify(queue))
}, },
setQueueIndex(state, index) { setQueueIndex(state, index) {
if (state.queue.length === 0) { if (state.queue.length === 0) {
return; return
} }
index = Math.max(0, index); index = Math.max(0, index)
index = index < state.queue.length ? index : 0; index = index < state.queue.length ? index : 0
state.queueIndex = index; state.queueIndex = index
localStorage.setItem("queueIndex", index); localStorage.setItem('queueIndex', index)
const track = state.queue[index]; const track = state.queue[index]
audio.src = track.url; audio.src = track.url
if (mediaSession) { if (mediaSession) {
mediaSession.metadata = new MediaMetadata({ mediaSession.metadata = new MediaMetadata({
title: track.title, title: track.title,
artist: track.artist, artist: track.artist,
album: track.album, album: track.album,
artwork: track.image ? [{ src: track.image, sizes: "300x300" }] : undefined, artwork: track.image ? [{ src: track.image, sizes: '300x300' }] : undefined,
}); })
} }
}, },
addToQueue(state, track) { addToQueue(state, track) {
state.queue.push(track); state.queue.push(track)
}, },
removeFromQueue(state, index) { removeFromQueue(state, index) {
state.queue.splice(index, 1); state.queue.splice(index, 1)
if (index < state.queueIndex) { if (index < state.queueIndex) {
state.queueIndex--; state.queueIndex--
} }
}, },
setNextInQueue(state, track) { setNextInQueue(state, track) {
state.queue.splice(state.queueIndex + 1, 0, track); state.queue.splice(state.queueIndex + 1, 0, track)
}, },
setProgress(state, value: any) { setProgress(state, value: any) {
state.currentTime = value; state.currentTime = value
}, },
setDuration(state, value: any) { setDuration(state, value: any) {
state.duration = value; state.duration = value
}, },
}, },
actions: { actions: {
async playQueue({ commit }, { queue, index }) { async playQueue({ commit }, { queue, index }) {
commit("setQueue", [...queue]); commit('setQueue', [...queue])
commit("setQueueIndex", index); commit('setQueueIndex', index)
commit("setPlaying"); commit('setPlaying')
await audio.play(); await audio.play()
}, },
async play({ commit }) { async play({ commit }) {
commit("setPlaying"); commit('setPlaying')
await audio.play(); await audio.play()
}, },
pause({ commit }) { pause({ commit }) {
audio.pause(); audio.pause()
commit("setPaused"); commit('setPaused')
}, },
async next({ commit, state }) { async next({ commit, state }) {
commit("setQueueIndex", state.queueIndex + 1); commit('setQueueIndex', state.queueIndex + 1)
commit("setPlaying"); commit('setPlaying')
await audio.play(); await audio.play()
}, },
async previous({ commit, state }) { async previous({ commit, state }) {
commit("setQueueIndex", state.queueIndex - 1); commit('setQueueIndex', state.queueIndex - 1)
commit("setPlaying"); commit('setPlaying')
await audio.play(); await audio.play()
}, },
playPause({ state, dispatch }) { playPause({ state, dispatch }) {
if (state.isPlaying) { if (state.isPlaying) {
return dispatch("pause"); return dispatch('pause')
} }
return dispatch("play"); return dispatch('play')
}, },
seek({ commit, state }, value) { seek({ commit, state }, value) {
commit("setPosition", state.duration * value); commit('setPosition', state.duration * value)
}, },
playNext({ commit }, track) { playNext({ commit }, track) {
commit("setNextInQueue", track); commit('setNextInQueue', track)
}, },
addToQueue({ commit }, track) { addToQueue({ commit }, track) {
commit("addToQueue", track); commit('addToQueue', track)
}, },
}, },
getters: { getters: {
track(state) { track(state) {
if (state.queueIndex != -1) { if (state.queueIndex !== -1) {
return state.queue[state.queueIndex]; return state.queue[state.queueIndex]
} }
return null; return null
}, },
trackId(state, getters): number { trackId(state, getters): number {
return getters.track ? getters.track.id : -1; return getters.track ? getters.track.id : -1
}, },
progress(state) { progress(state) {
if (state.currentTime > -1 && state.duration > 0) { if (state.currentTime > -1 && state.duration > 0) {
return (state.currentTime / state.duration) * 100; return (state.currentTime / state.duration) * 100
} }
return 0; return 0
}, },
hasNext(state) { hasNext(state) {
return state.queueIndex < state.queue.length - 1; return state.queueIndex < state.queue.length - 1
}, },
hasPrevious(state) { hasPrevious(state) {
return state.queueIndex > 0; return state.queueIndex > 0
}, },
}, },
}; }
export function setupAudio(store: Store<any>) { export function setupAudio(store: Store<any>) {
audio.ontimeupdate = (event) => { audio.ontimeupdate = () => {
store.commit("player/setProgress", audio.currentTime) store.commit('player/setProgress', audio.currentTime)
};
audio.ondurationchange = (event) => {
store.commit("player/setDuration", audio.duration);
} }
audio.onended = (event) => { audio.ondurationchange = () => {
store.dispatch("player/next"); store.commit('player/setDuration', audio.duration)
} }
audio.onerror = (event) => { audio.onended = () => {
store.commit("player/setPaused"); store.dispatch('player/next')
store.commit("setError", audio.error); }
audio.onerror = () => {
store.commit('player/setPaused')
store.commit('setError', audio.error)
}
audio.onwaiting = () => {
console.log('audio is waiting for more data.')
} }
audio.onwaiting = (event) => {
console.log('audio is waiting for more data.');
};
if (mediaSession) { if (mediaSession) {
mediaSession.setActionHandler('play', () => { mediaSession.setActionHandler('play', () => {
store.dispatch("player/play"); store.dispatch('player/play')
}); })
mediaSession.setActionHandler('pause', () => { mediaSession.setActionHandler('pause', () => {
store.dispatch("player/pause"); store.dispatch('player/pause')
}); })
mediaSession.setActionHandler('nexttrack', () => { mediaSession.setActionHandler('nexttrack', () => {
store.dispatch("player/next"); store.dispatch('player/next')
}); })
mediaSession.setActionHandler('previoustrack', () => { mediaSession.setActionHandler('previoustrack', () => {
store.dispatch("player/previous"); store.dispatch('player/previous')
}); })
mediaSession.setActionHandler('stop', () => { mediaSession.setActionHandler('stop', () => {
store.dispatch("player/pause"); store.dispatch('player/pause')
}); })
mediaSession.setActionHandler("seekto", (details) => { mediaSession.setActionHandler('seekto', (details) => {
store.commit("player/setPosition", details.seekTime); store.commit('player/setPosition', details.seekTime)
}); })
mediaSession.setActionHandler("seekforward", (details) => { mediaSession.setActionHandler('seekforward', (details) => {
const offset = details.seekOffset || 10; const offset = details.seekOffset || 10
store.commit("player/setPosition", Math.min(audio.currentTime + offset, audio.duration)); store.commit('player/setPosition', Math.min(audio.currentTime + offset, audio.duration))
}); })
mediaSession.setActionHandler("seekbackward", (details) => { mediaSession.setActionHandler('seekbackward', (details) => {
const offset = details.seekOffset || 10; const offset = details.seekOffset || 10
store.commit("player/setPosition", Math.max(audio.currentTime - offset, 0)); store.commit('player/setPosition', Math.max(audio.currentTime - offset, 0))
}); })
// FIXME // FIXME
// function updatePositionState() { // function updatePositionState() {
// if (mediaSession && mediaSession.setPositionState) { // if (mediaSession && mediaSession.setPositionState) {

View File

@ -1,9 +1,9 @@
<template> <template>
<Spinner :data="playlist" v-slot="{ data }"> <Spinner v-slot="{ data }" :data="playlist">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<h1>{{ data.name }}</h1> <h1>{{ data.name }}</h1>
<OverflowMenu> <OverflowMenu>
<b-dropdown-item-btn @click="deletePlaylist()" variant="danger"> <b-dropdown-item-btn variant="danger" @click="deletePlaylist()">
Delete playlist Delete playlist
</b-dropdown-item-btn> </b-dropdown-item-btn>
</OverflowMenu> </OverflowMenu>
@ -18,43 +18,42 @@
</Spinner> </Spinner>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import TrackList from "@/library/TrackList.vue"; import TrackList from '@/library/TrackList.vue'
import { mapActions } from 'vuex';
export default Vue.extend({ export default Vue.extend({
components: { components: {
TrackList, TrackList,
}, },
props: { props: {
id: String, id: { type: String, required: true }
}, },
data() { data() {
return { return {
playlist: null as any, playlist: null as any,
}; }
}, },
watch: { watch: {
'id': { id: {
immediate: true, immediate: true,
handler(value: string) { handler(value: string) {
this.playlist = null; this.playlist = null
this.$api.getPlaylist(value).then(playlist => { this.$api.getPlaylist(value).then(playlist => {
this.playlist = playlist;//.sort((a: any, b:any) => a.created.localeCompare(b.created)); this.playlist = playlist// .sort((a: any, b:any) => a.created.localeCompare(b.created));
}); })
} }
} }
}, },
methods: { methods: {
remove(index: number) { remove(index: number) {
this.playlist.tracks.splice(index, 1); this.playlist.tracks.splice(index, 1)
return this.$api.removeFromPlaylist(this.id, index.toString()); return this.$api.removeFromPlaylist(this.id, index.toString())
}, },
deletePlaylist() { deletePlaylist() {
return this.$store.dispatch("deletePlaylist", this.id).then(() => { return this.$store.dispatch('deletePlaylist', this.id).then(() => {
this.$router.replace({name: "playlists"}) this.$router.replace({ name: 'playlists' })
}) })
}, },
} }
}); })
</script> </script>

View File

@ -11,13 +11,13 @@
</Tiles> </Tiles>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex'
export default Vue.extend({ export default Vue.extend({
computed: { computed: {
...mapState([ ...mapState([
"playlists" 'playlists'
]), ]),
}, },
methods: { methods: {
@ -25,5 +25,5 @@
deletePlaylist: 'deletePlaylist' deletePlaylist: 'deletePlaylist'
}) })
} }
}); })
</script> </script>

View File

@ -4,20 +4,20 @@
<small class="text-uppercase text-muted font-weight-bold"> <small class="text-uppercase text-muted font-weight-bold">
Playlists Playlists
<button class="btn btn-link btn-sm p-0 float-right" @click="showModal = true"> <button class="btn btn-link btn-sm p-0 float-right" @click="showModal = true">
<Icon icon="plus"/> <Icon icon="plus" />
</button> </button>
</small> </small>
</span> </span>
<router-link class="nav-item nav-link" :to="{name: 'playlist', params: { id: 'random' }}"> <router-link class="nav-item nav-link" :to="{name: 'playlist', params: { id: 'random' }}">
<Icon icon="music-note-list" class="mr-2"/> Random <Icon icon="music-note-list" class="mr-2" /> Random
</router-link> </router-link>
<router-link v-for="item in playlists" :key="item.id" <router-link v-for="item in playlists" :key="item.id"
:to="{name: 'playlist', params: { id: item.id }}" :to="{name: 'playlist', params: { id: item.id }}"
class="nav-item nav-link"> class="nav-item nav-link">
<span @dragover="onDragover" @drop="onDrop(item.id, $event)"> <span @dragover="onDragover" @drop="onDrop(item.id, $event)">
<Icon icon="music-note-list" class="mr-2"/> {{ item.name }} <Icon icon="music-note-list" class="mr-2" /> {{ item.name }}
</span> </span>
</router-link> </router-link>
@ -27,47 +27,48 @@
<b-modal v-model="showModal" title="New playlist"> <b-modal v-model="showModal" title="New playlist">
<b-form-group label="Name"> <b-form-group label="Name">
<b-form-input type="text" v-model="playlistName"/> <b-form-input v-model="playlistName" type="text" />
</b-form-group> </b-form-group>
<template #modal-footer> <template #modal-footer>
<b-button variant="primary" @click="createPlaylist">Create</b-button> <b-button variant="primary" @click="createPlaylist">
Create
</b-button>
</template> </template>
</b-modal> </b-modal>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import { mapState } from 'vuex';
export default Vue.extend({ export default Vue.extend({
data() { data() {
return { return {
playlistName: "", playlistName: '',
showModal: false, showModal: false,
} }
}, },
computed: { computed: {
playlists() { playlists() {
return this.$store.state.playlists.slice(0, 10); return this.$store.state.playlists.slice(0, 10)
}, },
}, },
methods: { methods: {
createPlaylist() { createPlaylist() {
const name = this.playlistName; const name = this.playlistName
this.playlistName = ""; this.playlistName = ''
this.showModal = false; this.showModal = false
return this.$store.dispatch("createPlaylist", name); return this.$store.dispatch('createPlaylist', name)
}, },
onDrop(playlistId: string, event: any) { onDrop(playlistId: string, event: any) {
console.log("onDrop") console.log('onDrop')
event.preventDefault(); event.preventDefault()
const trackId = event.dataTransfer.getData("id"); const trackId = event.dataTransfer.getData('id')
return this.$store.dispatch("addTrackToPlaylist", { playlistId, trackId }) return this.$store.dispatch('addTrackToPlaylist', { playlistId, trackId })
}, },
onDragover(event: any) { onDragover(event: any) {
console.log("onDragover") console.log('onDragover')
event.preventDefault(); event.preventDefault()
}, },
} }
}); })
</script> </script>

View File

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

View File

@ -2,25 +2,25 @@
<div> <div>
<form class="form-inline my-2 my-lg-0" @submit.prevent="search"> <form class="form-inline my-2 my-lg-0" @submit.prevent="search">
<input <input
class="form-control mr-sm-2" v-model="query"
type="search" placeholder="Search" class="form-control mr-sm-2" type="search"
v-model="query"> placeholder="Search">
</form> </form>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
export default Vue.extend({ export default Vue.extend({
data() { data() {
return { return {
query: "" query: ''
} }
}, },
methods: { methods: {
search(): void { search(): void {
this.$router.push({ name: 'search', query: { q: this.query } }); this.$router.push({ name: 'search', query: { q: this.query } })
} }
} }
}); })
</script> </script>

View File

@ -2,25 +2,25 @@
<div v-if="result"> <div v-if="result">
<div v-if="result.artists.length > 0" class="mb-5"> <div v-if="result.artists.length > 0" class="mb-5">
<h1>Artists</h1> <h1>Artists</h1>
<ArtistList :items="result.artists"/> <ArtistList :items="result.artists" />
</div> </div>
<div v-if="result.albums.length > 0" class="mb-5"> <div v-if="result.albums.length > 0" class="mb-5">
<h1>Albums</h1> <h1>Albums</h1>
<AlbumList :items="result.albums"/> <AlbumList :items="result.albums" />
</div> </div>
<div v-if="result.tracks.length > 0"> <div v-if="result.tracks.length > 0">
<h1>Tracks</h1> <h1>Tracks</h1>
<TrackList :tracks="result.tracks" showAlbum/> <TrackList :tracks="result.tracks" show-album />
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
import AlbumList from "@/library/album/AlbumList.vue"; import AlbumList from '@/library/album/AlbumList.vue'
import ArtistList from "@/library/artist/ArtistList.vue"; import ArtistList from '@/library/artist/ArtistList.vue'
import TrackList from "@/library/TrackList.vue" import TrackList from '@/library/TrackList.vue'
export default Vue.extend({ export default Vue.extend({
components: { components: {
@ -29,22 +29,22 @@
TrackList, TrackList,
}, },
props: { props: {
query: String, query: { type: String, required: true }
}, },
data() { data() {
return { return {
result: null as any, result: null as any,
}; }
}, },
watch: { watch: {
query: { query: {
immediate: true, immediate: true,
handler(value: string) { handler(value: string) {
this.$api.search(this.query).then(result => { this.$api.search(value).then(result => {
this.result = result; this.result = result
}); })
} }
} }
}, },
}); })
</script> </script>

View File

@ -1,62 +1,61 @@
import axios, { AxiosRequestConfig, AxiosInstance } from "axios" import axios, { AxiosRequestConfig, AxiosInstance } from 'axios'
import { AuthService } from '@/auth/service'; import { AuthService } from '@/auth/service'
export type AlbumSort = "alphabeticalByName" | "newest" | "recent" | "frequent" | "random"
export type AlbumSort = 'alphabeticalByName' | 'newest' | 'recent' | 'frequent' | 'random'
export class API { export class API {
readonly http: AxiosInstance; readonly http: AxiosInstance;
readonly get: (path: string, params?: any) => Promise<any>; readonly get: (path: string, params?: any) => Promise<any>;
readonly post: (path: string, params?: any) => Promise<any>; readonly post: (path: string, params?: any) => Promise<any>;
readonly clientName = window.origin || "web"; readonly clientName = window.origin || 'web';
constructor(private auth: AuthService) { constructor(private auth: AuthService) {
this.http = axios.create({}); this.http = axios.create({})
this.http.interceptors.request.use((config: AxiosRequestConfig) => { this.http.interceptors.request.use((config: AxiosRequestConfig) => {
config.params = config.params || {}; config.params = config.params || {}
config.baseURL = this.auth.server config.baseURL = this.auth.server
config.params.u = this.auth.username; config.params.u = this.auth.username
config.params.s = this.auth.salt; config.params.s = this.auth.salt
config.params.t = this.auth.hash; config.params.t = this.auth.hash
config.params.c = this.clientName; config.params.c = this.clientName
config.params.f = "json"; config.params.f = 'json'
config.params.v = "1.15.0"; config.params.v = '1.15.0'
return config; return config
}); })
this.get = (path: string, params: any = {}) => { this.get = (path: string, params: any = {}) => {
return this.http.get(path, {params}).then(response => { return this.http.get(path, { params }).then(response => {
const subsonicResponse = response.data["subsonic-response"]; const subsonicResponse = response.data['subsonic-response']
if (subsonicResponse.status !== "ok") { if (subsonicResponse.status !== 'ok') {
const message = subsonicResponse.error?.message || subsonicResponse.status; const message = subsonicResponse.error?.message || subsonicResponse.status
const err = new Error(message); const err = new Error(message)
return Promise.reject(err); return Promise.reject(err)
} }
return Promise.resolve(subsonicResponse); return Promise.resolve(subsonicResponse)
}) })
} }
this.post = (path: string, params: any = {}) => { this.post = (path: string, params: any = {}) => {
return this.http.post(path, params).then(response => { return this.http.post(path, params).then(response => {
const subsonicResponse = response.data["subsonic-response"]; const subsonicResponse = response.data['subsonic-response']
if (subsonicResponse.status !== "ok") { if (subsonicResponse.status !== 'ok') {
const err = new Error(subsonicResponse.status); const err = new Error(subsonicResponse.status)
return Promise.reject(err); return Promise.reject(err)
} }
return Promise.resolve(subsonicResponse); return Promise.resolve(subsonicResponse)
}) })
} }
} }
async getGenres() { async getGenres() {
const response = await this.get("rest/getGenres", {}); const response = await this.get('rest/getGenres', {})
return response.genres.genre return response.genres.genre
.map((item: any) => ({ .map((item: any) => ({
id: encodeURIComponent(item.value), id: encodeURIComponent(item.value),
name: item.value, name: item.value,
...item, ...item,
})) }))
.sort((a: any, b:any) => a.name.localeCompare(b.name));; .sort((a: any, b:any) => a.name.localeCompare(b.name))
} }
async getGenreDetails(id: string) { async getGenreDetails(id: string) {
@ -65,7 +64,7 @@ export class API {
count: 500, count: 500,
offset: 0, offset: 0,
} }
const response = await this.get("rest/getSongsByGenre", params); const response = await this.get('rest/getSongsByGenre', params)
return { return {
name: id, name: id,
tracks: this.normalizeTrackList(response.songsByGenre.song), tracks: this.normalizeTrackList(response.songsByGenre.song),
@ -73,37 +72,37 @@ export class API {
} }
async getArtists() { async getArtists() {
const data = await this.get("rest/getArtists"); const data = await this.get('rest/getArtists')
return data.artists.index.flatMap((index: any) => index.artist.map((artist: any) => ({ return data.artists.index.flatMap((index: any) => index.artist.map((artist: any) => ({
id: artist.id, id: artist.id,
name: artist.name, name: artist.name,
...artist ...artist
}))); })))
} }
async getAlbums(sort: AlbumSort, size: number = 500) { async getAlbums(sort: AlbumSort, size = 500) {
const params = { const params = {
type: sort, type: sort,
offset: "0", offset: '0',
size: size, size: size,
}; }
const data = await this.get("rest/getAlbumList2", params); const data = await this.get('rest/getAlbumList2', params)
return data.albumList2.album.map((item: any) => ({ return data.albumList2.album.map((item: any) => ({
...item, ...item,
image: item.coverArt ? this.getCoverArtUrl(item) : undefined, image: item.coverArt ? this.getCoverArtUrl(item) : undefined,
})); }))
} }
async getArtistDetails(id: string) { async getArtistDetails(id: string) {
const params = { id }; const params = { id }
const [info1, info2] = await Promise.all([ const [info1, info2] = await Promise.all([
this.get("rest/getArtist", params).then(r => r.artist), this.get('rest/getArtist', params).then(r => r.artist),
this.get("rest/getArtistInfo2", params).then(r => r.artistInfo2), this.get('rest/getArtistInfo2', params).then(r => r.artistInfo2),
]) ])
return { return {
id: info1.id, id: info1.id,
name: info1.name, name: info1.name,
description: (info2.biography || "").replace(/<a[^>]*>.*?<\/a>/gm, ''), description: (info2.biography || '').replace(/<a[^>]*>.*?<\/a>/gm, ''),
image: info2.largeImageUrl || info2.mediumImageUrl || info2.smallImageUrl, image: info2.largeImageUrl || info2.mediumImageUrl || info2.smallImageUrl,
lastFmUrl: info2.lastFmUrl, lastFmUrl: info2.lastFmUrl,
musicBrainzUrl: info2.musicBrainzId musicBrainzUrl: info2.musicBrainzId
@ -114,14 +113,14 @@ export class API {
name: artist.name, name: artist.name,
...artist ...artist
})) }))
}; }
} }
async getAlbumDetails(id: string) { async getAlbumDetails(id: string) {
const params = {id}; const params = { id }
const data = await this.get("rest/getAlbum", params); const data = await this.get('rest/getAlbum', params)
const item = data.album; const item = data.album
const image = this.getCoverArtUrl(item); const image = this.getCoverArtUrl(item)
const trackList = item.song.map((s: any) => ({ const trackList = item.song.map((s: any) => ({
...s, ...s,
image, image,
@ -131,16 +130,16 @@ export class API {
...item, ...item,
image, image,
song: trackList, song: trackList,
}; }
} }
async getPlaylists() { async getPlaylists() {
const response = await this.get("rest/getPlaylists"); const response = await this.get('rest/getPlaylists')
return response.playlists.playlist.map((playlist: any) => ({ return response.playlists.playlist.map((playlist: any) => ({
...playlist, ...playlist,
name: playlist.name || "(Unnamed)", name: playlist.name || '(Unnamed)',
image: playlist.songCount > 0 ? this.getCoverArtUrl(playlist) : undefined, image: playlist.songCount > 0 ? this.getCoverArtUrl(playlist) : undefined,
})); }))
} }
async getPlaylist(id: string) { async getPlaylist(id: string) {
@ -149,23 +148,23 @@ export class API {
id, id,
name: 'Random', name: 'Random',
tracks: await this.getRandomSongs(), tracks: await this.getRandomSongs(),
};
} }
const response = await this.get("rest/getPlaylist", { id }); }
const response = await this.get('rest/getPlaylist', { id })
return { return {
...response.playlist, ...response.playlist,
name: response.playlist.name || "(Unnamed)", name: response.playlist.name || '(Unnamed)',
tracks: this.normalizeTrackList(response.playlist.entry || []), tracks: this.normalizeTrackList(response.playlist.entry || []),
}; }
} }
async createPlaylist(name: string) { async createPlaylist(name: string) {
await this.get("rest/createPlaylist", { name }); await this.get('rest/createPlaylist', { name })
return this.getPlaylists(); return this.getPlaylists()
} }
async deletePlaylist(id: string) { async deletePlaylist(id: string) {
await this.get("rest/deletePlaylist", { id }); await this.get('rest/deletePlaylist', { id })
} }
async addToPlaylist(playlistId: string, trackId: string) { async addToPlaylist(playlistId: string, trackId: string) {
@ -173,7 +172,7 @@ export class API {
playlistId, playlistId,
songIdToAdd: trackId, songIdToAdd: trackId,
} }
await this.get("rest/updatePlaylist", params); await this.get('rest/updatePlaylist', params)
} }
async removeFromPlaylist(playlistId: string, index: string) { async removeFromPlaylist(playlistId: string, index: string) {
@ -181,20 +180,20 @@ export class API {
playlistId, playlistId,
songIndexToRemove: index, songIndexToRemove: index,
} }
await this.get("rest/updatePlaylist", params); await this.get('rest/updatePlaylist', params)
} }
async getRandomSongs() { async getRandomSongs() {
const params = { const params = {
size: 200, size: 200,
}; }
const data = await this.get("rest/getRandomSongs", params); const data = await this.get('rest/getRandomSongs', params)
return this.normalizeTrackList(data.randomSongs.song); return this.normalizeTrackList(data.randomSongs.song)
} }
async getStarred() { async getStarred() {
return this return this
.get("rest/getStarred2") .get('rest/getStarred2')
.then(r => this.normalizeTrackList(r.starred2.song)) .then(r => this.normalizeTrackList(r.starred2.song))
} }
@ -204,7 +203,7 @@ export class API {
albumId: type === 'album' ? id : undefined, albumId: type === 'album' ? id : undefined,
artistId: type === 'artist' ? id : undefined, artistId: type === 'artist' ? id : undefined,
} }
await this.get("rest/star", params); await this.get('rest/star', params)
} }
async unstar(type: 'track' | 'album' | 'artist', id: string) { async unstar(type: 'track' | 'album' | 'artist', id: string) {
@ -213,27 +212,27 @@ export class API {
albumId: type === 'album' ? id : undefined, albumId: type === 'album' ? id : undefined,
artistId: type === 'artist' ? id : undefined, artistId: type === 'artist' ? id : undefined,
} }
await this.get("rest/unstar", params); await this.get('rest/unstar', params)
} }
async search(query: string) { async search(query: string) {
const params = { const params = {
query, query,
}; }
const data = await this.get("rest/search3", params); const data = await this.get('rest/search3', params)
return { return {
tracks: this.normalizeTrackList(data.searchResult3.song || []), tracks: this.normalizeTrackList(data.searchResult3.song || []),
albums: (data.searchResult3.album || []).map((x: any) => this.normalizeAlbumResponse(x)), albums: (data.searchResult3.album || []).map((x: any) => this.normalizeAlbumResponse(x)),
artists: (data.searchResult3.artist || []).map((x: any) => this.normalizeArtistResponse(x)), artists: (data.searchResult3.artist || []).map((x: any) => this.normalizeArtistResponse(x)),
}; }
} }
private normalizeTrackList(items: any[]) { private normalizeTrackList(items: any[]) {
return items.map((item => ({ return items.map(item => ({
...item, ...item,
url: this.getStreamUrl(item.id), url: this.getStreamUrl(item.id),
image: this.getCoverArtUrl(item), image: this.getCoverArtUrl(item),
}))) }))
} }
private normalizeAlbumResponse(item: any) { private normalizeAlbumResponse(item: any) {
@ -252,14 +251,29 @@ export class API {
private getCoverArtUrl(item: any) { private getCoverArtUrl(item: any) {
if (!item.coverArt) { if (!item.coverArt) {
return undefined; return undefined
} }
const { server, username, salt, hash } = this.auth; const { server, username, salt, hash } = this.auth
return `${server}/rest/getCoverArt?id=${item.coverArt}&v=1.15.0&u=${username}&s=${salt}&t=${hash}&c=${this.clientName}&size=300` return `${server}/rest/getCoverArt` +
`?id=${item.coverArt}` +
'&v=1.15.0' +
`&u=${username}` +
`&s=${salt}` +
`&t=${hash}` +
`&c=${this.clientName}` +
'&size=300'
} }
private getStreamUrl(id: any) { private getStreamUrl(id: any) {
const { server, username, salt, hash } = this.auth; const { server, username, salt, hash } = this.auth
return `${server}/rest/stream?id=${id}&format=raw&v=1.15.0&u=${username}&s=${salt}&t=${hash}&c=${this.clientName}&size=300` return `${server}/rest/stream` +
`?id=${id}` +
'&format=raw' +
'&v=1.15.0' +
`&u=${username}` +
`&s=${salt}` +
`&t=${hash}` +
`&c=${this.clientName}` +
'&size=300'
} }
} }

View File

@ -3,6 +3,6 @@
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
:class="[data.class, data.staticClass]"> :class="[data.class, data.staticClass]">
<slot/> <slot />
</a> </a>
</template> </template>

View File

@ -1,5 +1,5 @@
<template> <template>
<BIcon :icon="icon" v-bind="$attrs"/> <BIcon :icon="icon" v-bind="$attrs" />
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue' import Vue from 'vue'
@ -42,7 +42,7 @@
BIconPersonCircle, BIconPersonCircle,
}, },
props: { props: {
icon: { type: String } icon: { type: String, required: true }
}, },
}) })
</script> </script>

View File

@ -3,7 +3,7 @@
<div class="fixed-img-inner"> <div class="fixed-img-inner">
<img v-if="props.src" :src="props.src"> <img v-if="props.src" :src="props.src">
<div v-else class="text-muted"> <div v-else class="text-muted">
<Icon icon="music-note-beamed"/> <Icon icon="music-note-beamed" />
</div> </div>
</div> </div>
</div> </div>
@ -42,11 +42,11 @@
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
export default Vue.extend({ export default Vue.extend({
props: { props: {
square: { type: Boolean, default: false }, square: { type: Boolean, default: false },
} }
}); })
</script> </script>

View File

@ -1,13 +1,13 @@
<template> <template>
<b-dropdown variant="link" boundary="window" no-caret toggle-class="p-0"> <b-dropdown variant="link" boundary="window" no-caret toggle-class="p-0">
<template #button-content> <template #button-content>
<Icon icon="three-dots-vertical"/> <Icon icon="three-dots-vertical" />
</template> </template>
<slot/> <slot />
</b-dropdown> </b-dropdown>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
export default Vue.extend({}); export default Vue.extend({})
</script> </script>

View File

@ -1,12 +1,12 @@
<template functional> <template functional>
<div> <div>
<slot v-if="props.data" :data="props.data"></slot> <slot v-if="props.data" :data="props.data" />
<div v-else class="text-center"> <div v-else class="text-center">
<span class="spinner-grow"/> <span class="spinner-grow" />
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
export default Vue.extend({}) export default Vue.extend({})
</script> </script>

View File

@ -2,7 +2,7 @@
<div class="tile card"> <div class="tile card">
<router-link class="tile-img" :to="props.to"> <router-link class="tile-img" :to="props.to">
<img v-if="props.image" :src="props.image"> <img v-if="props.image" :src="props.image">
<Icon v-else class="tile-img-fallback text-muted" icon="music-note-beamed"/> <Icon v-else class="tile-img-fallback text-muted" icon="music-note-beamed" />
</router-link> </router-link>
<div class="card-body"> <div class="card-body">
<div class="text-truncate font-weight-bold"> <div class="text-truncate font-weight-bold">

View File

@ -1,6 +1,6 @@
<template functional> <template functional>
<div class="tiles" :class="props.square ? 'tiles-square' : 'tiles-rect'"> <div class="tiles" :class="props.square ? 'tiles-square' : 'tiles-rect'">
<slot></slot> <slot />
</div> </div>
</template> </template>
<style lang="scss"> <style lang="scss">
@ -29,11 +29,11 @@
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from 'vue'
export default Vue.extend({ export default Vue.extend({
props: { props: {
square: { type: Boolean, default: false }, square: { type: Boolean, default: false },
} }
}); })
</script> </script>

View File

@ -1,10 +1,10 @@
import Vue from 'vue'; import Vue from 'vue'
import ExternalLink from "./ExternalLink.vue"; import ExternalLink from './ExternalLink.vue'
import Icon from "./Icon.vue"; import Icon from './Icon.vue'
import OverflowMenu from "./OverflowMenu.vue"; import OverflowMenu from './OverflowMenu.vue'
import Spinner from "./Spinner.vue"; import Spinner from './Spinner.vue'
import Tiles from "./Tiles.vue"; import Tiles from './Tiles.vue'
import Tile from "./Tile.vue"; import Tile from './Tile.vue'
const components = { const components = {
ExternalLink, ExternalLink,
@ -13,11 +13,11 @@ const components = {
Spinner, Spinner,
Tiles, Tiles,
Tile, Tile,
}; }
type Key = keyof typeof components; type Key = keyof typeof components;
Object.keys(components).forEach((_key) => { Object.keys(components).forEach((_key) => {
const key = _key as keyof typeof components; const key = _key as keyof typeof components
Vue.component(key, components[key]); Vue.component(key, components[key])
}); })

View File

@ -1,12 +1,11 @@
import Vue from 'vue'; import Vue from 'vue'
Vue.filter('duration', (value: number) => {
Vue.filter("duration", (value: number) => { const minutes = Math.floor(value / 60)
const minutes = Math.floor(value / 60); const seconds = Math.floor(value % 60)
const seconds = Math.floor(value % 60); return (minutes < 10 ? '0' : '') + minutes + ':' + (seconds < 10 ? '0' : '') + seconds
return (minutes < 10 ? '0' : '') + minutes + ':' + (seconds < 10 ? '0' : '') + seconds;
}) })
Vue.filter("dateTime", (value: string) => { Vue.filter('dateTime', (value: string) => {
return value; return value
}) })

View File

@ -1,4 +1,3 @@
import Vue from 'vue'
import Router from 'vue-router' import Router from 'vue-router'
import Login from '@/auth/Login.vue' import Login from '@/auth/Login.vue'
import Queue from '@/player/Queue.vue' import Queue from '@/player/Queue.vue'
@ -14,8 +13,7 @@ import Starred from '@/library/starred/Starred.vue'
import Playlist from '@/playlist/Playlist.vue' import Playlist from '@/playlist/Playlist.vue'
import PlaylistList from '@/playlist/PlaylistList.vue' import PlaylistList from '@/playlist/PlaylistList.vue'
import SearchResult from '@/search/SearchResult.vue' import SearchResult from '@/search/SearchResult.vue'
import { AuthService } from '@/auth/service'; import { AuthService } from '@/auth/service'
export function setupRouter(auth: AuthService) { export function setupRouter(auth: AuthService) {
const router = new Router({ const router = new Router({
@ -104,15 +102,15 @@ export function setupRouter(auth: AuthService) {
}) })
}, },
] ]
}); })
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.name !== 'login' && !auth.isAuthenticated()) { if (to.name !== 'login' && !auth.isAuthenticated()) {
next({name: 'login', query: { returnTo: to.fullPath }}); next({ name: 'login', query: { returnTo: to.fullPath } })
} else { } else {
next(); next()
} }
}); })
return router; return router
} }

View File

@ -1,10 +1,8 @@
import Vue from 'vue'
import Vuex, { Module } from 'vuex' import Vuex, { Module } from 'vuex'
import { ActionContext } from "vuex"
import { playerModule } from "@/player/store" import { playerModule } from '@/player/store'
import axios from 'axios'; import { AuthService } from '@/auth/service'
import { AuthService } from '@/auth/service'; import { API } from './api'
import { API } from './api';
interface State { interface State {
isLoggedIn: boolean; isLoggedIn: boolean;
@ -26,48 +24,48 @@ const setupRootModule = (authService: AuthService, api: API): Module<State, any>
}, },
mutations: { mutations: {
setError(state, error) { setError(state, error) {
state.error = error; state.error = error
}, },
clearError(state) { clearError(state) {
state.error = null; state.error = null
}, },
setLoginSuccess(state, { username, server }) { setLoginSuccess(state, { username, server }) {
state.isLoggedIn = true; state.isLoggedIn = true
state.username = username; state.username = username
state.server = server; state.server = server
}, },
toggleMenu(state) { toggleMenu(state) {
state.showMenu = !state.showMenu; state.showMenu = !state.showMenu
}, },
setPlaylists(state, playlists: any[]) { setPlaylists(state, playlists: any[]) {
state.playlists = playlists state.playlists = playlists
.sort((a: any, b: any) => b.changed.localeCompare(a.changed)); .sort((a: any, b: any) => b.changed.localeCompare(a.changed))
}, },
removePlaylist(state, id: string) { removePlaylist(state, id: string) {
state.playlists = state.playlists.filter(p => p.id !== id); state.playlists = state.playlists.filter(p => p.id !== id)
}, },
}, },
actions: { actions: {
loadPlaylists({ commit }) { loadPlaylists({ commit }) {
api.getPlaylists().then(result => { api.getPlaylists().then(result => {
commit("setPlaylists", result); commit('setPlaylists', result)
}) })
}, },
createPlaylist({ commit }, name) { createPlaylist({ commit }, name) {
api.createPlaylist(name).then(result => { api.createPlaylist(name).then(result => {
commit("setPlaylists", result); commit('setPlaylists', result)
}) })
}, },
addTrackToPlaylist({ }, { playlistId, trackId }) { addTrackToPlaylist({ }, { playlistId, trackId }) {
api.addToPlaylist(playlistId, trackId); api.addToPlaylist(playlistId, trackId)
}, },
deletePlaylist({ commit, state }, id) { deletePlaylist({ commit }, id) {
api.deletePlaylist(id).then(() => { api.deletePlaylist(id).then(() => {
commit("removePlaylist", id) commit('removePlaylist', id)
}) })
} }
}, },
}); })
export function setupStore(authService: AuthService, api: API) { export function setupStore(authService: AuthService, api: API) {
const store = new Vuex.Store({ const store = new Vuex.Store({
@ -84,9 +82,9 @@ export function setupStore(authService: AuthService, api: API) {
store.watch( store.watch(
(state) => state.isLoggedIn, (state) => state.isLoggedIn,
() => { () => {
store.dispatch("loadPlaylists") store.dispatch('loadPlaylists')
} }
); )
return store; return store
} }

View File

@ -1,13 +1,13 @@
import MD5 from 'md5-es'; import MD5 from 'md5-es'
export function randomString(): string { export function randomString(): string {
let arr = new Uint8Array(16); let arr = new Uint8Array(16)
window.crypto.getRandomValues(arr); window.crypto.getRandomValues(arr)
const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
arr = arr.map(x => validChars.charCodeAt(x % validChars.length)); arr = arr.map(x => validChars.charCodeAt(x % validChars.length))
return String.fromCharCode.apply(null, Array.from(arr)); return String.fromCharCode.apply(null, Array.from(arr))
} }
export function md5(str: string): string { export function md5(str: string): string {
return MD5.hash(str); return MD5.hash(str)
} }

View File

@ -1,5 +1,8 @@
module.exports = { module.exports = {
devServer: { devServer: {
disableHostCheck: true disableHostCheck: true,
overlay: {
errors: false
}
} }
} }

1602
yarn.lock

File diff suppressed because it is too large Load Diff