First release
This commit is contained in:
343
app/client/src/App.vue
Normal file
343
app/client/src/App.vue
Normal file
@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<v-app v-esc='closePlayer'>
|
||||
|
||||
<!-- Fullscreen player overlay -->
|
||||
<v-overlay :value='showPlayer' opacity='0.97' z-index="100">
|
||||
<FullscreenPlayer @close='closePlayer' @volumeChange='volume = $root.volume'></FullscreenPlayer>
|
||||
</v-overlay>
|
||||
|
||||
<!-- Drawer/Navigation -->
|
||||
<v-navigation-drawer
|
||||
permanent
|
||||
fixed
|
||||
app
|
||||
mini-variant
|
||||
expand-on-hover
|
||||
><v-list nav dense>
|
||||
|
||||
<!-- Profile -->
|
||||
<v-list-item two-line v-if='$root.profile && $root.profile.picture' class='miniVariant px-0'>
|
||||
<v-list-item-avatar>
|
||||
<img :src='$root.profile.picture.thumb'>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{$root.profile.name}}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{$root.profile.id}}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Home link -->
|
||||
<v-list-item link to='/home'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-home</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>Home</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Browse link -->
|
||||
<v-list-item link to='/page?target=channels%2Fexplore'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-earth</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>Browse</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-subheader inset>Library</v-subheader>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<!-- Tracks -->
|
||||
<v-list-item link to='/library/tracks'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-music-note</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>Tracks</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Playlists -->
|
||||
<v-list-item link to='/library/playlists'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-playlist-music</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>Playlists</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Albums -->
|
||||
<v-list-item link to='/library/albums'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-album</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>Albums</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Artists -->
|
||||
<v-list-item link to='/library/artists'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-account-music</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>Artists</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-subheader inset>More</v-subheader>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<!-- Settings -->
|
||||
<v-list-item link to='/settings'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-cog</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>Settings</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Downloads -->
|
||||
<v-list-item link to='/downloads'>
|
||||
|
||||
<!-- Download icon -->
|
||||
<v-list-item-icon v-if='!$root.download && $root.downloads.length == 0'>
|
||||
<v-icon>mdi-download</v-icon>
|
||||
</v-list-item-icon>
|
||||
|
||||
<!-- Paused download -->
|
||||
<v-list-item-icon v-if='!$root.download && $root.downloads.length > 0'>
|
||||
<v-icon>mdi-pause</v-icon>
|
||||
</v-list-item-icon>
|
||||
|
||||
<!-- Download in progress -->
|
||||
<v-list-item-icon v-if='$root.download'>
|
||||
<v-progress-circular :value='downloadPercentage' style='top: -2px' class='text-caption'>
|
||||
{{$root.downloads.length + 1}}
|
||||
</v-progress-circular>
|
||||
</v-list-item-icon>
|
||||
|
||||
<v-list-item-title>Downloads</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-app-bar app dense>
|
||||
|
||||
<v-btn icon @click='previous'>
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click='next'>
|
||||
<v-icon>mdi-arrow-right</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-text-field
|
||||
hide-details
|
||||
flat
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
single-line
|
||||
solo
|
||||
v-model="searchQuery"
|
||||
ref='searchBar'
|
||||
@keyup='search'>
|
||||
</v-text-field>
|
||||
</v-app-bar>
|
||||
|
||||
<!-- Main -->
|
||||
<v-main>
|
||||
<v-container
|
||||
class='overflow-y-auto'
|
||||
fluid
|
||||
style='height: calc(100vh - 118px);'>
|
||||
|
||||
<keep-alive include='Search,PlaylistPage,HomeScreen,DeezerPage'>
|
||||
<router-view></router-view>
|
||||
</keep-alive>
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<!-- Footer -->
|
||||
<v-footer fixed app height='70' class='pa-0'>
|
||||
|
||||
<v-progress-linear
|
||||
height='5'
|
||||
:value='position'
|
||||
style='cursor: pointer;'
|
||||
class='seekbar'
|
||||
@change='seek'
|
||||
background-opacity='0'>
|
||||
</v-progress-linear>
|
||||
|
||||
<v-row no-gutters align='center' ref='footer' class='ma-1'>
|
||||
|
||||
<!-- No track loaded -->
|
||||
<v-col class='col-5 d-none d-sm-flex' v-if='!this.$root.track'>
|
||||
<h3 class='pl-4'>Freezer</h3>
|
||||
</v-col>
|
||||
|
||||
<!-- Track Info -->
|
||||
<v-col class='d-none d-sm-flex' cols='5' v-if='this.$root.track'>
|
||||
<v-img
|
||||
:src='$root.track.albumArt.thumb'
|
||||
height="56"
|
||||
max-width="60"
|
||||
contain>
|
||||
</v-img>
|
||||
<div class='text-truncate flex-column d-flex'>
|
||||
<span class='text-subtitle-1 pl-2 text-no-wrap'>{{this.$root.track.title}}</span>
|
||||
<span class='text-subtitle-2 pl-2 text-no-wrap'>{{this.$root.track.artistString}}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Controls -->
|
||||
<v-col class='text-center' cols='12' sm='auto'>
|
||||
<v-btn icon large @click.stop='$root.skip(-1)'>
|
||||
<v-icon>mdi-skip-previous</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon x-large @click.stop='$root.toggle'>
|
||||
<v-icon v-if='!$root.isPlaying()'>mdi-play</v-icon>
|
||||
<v-icon v-if='$root.isPlaying()'>mdi-pause</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon large @click.stop='$root.skip(1)'>
|
||||
<v-icon>mdi-skip-next</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
|
||||
<!-- Right side -->
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-col cols='0' md='auto' class='d-none d-sm-none d-md-flex justify-center px-2' v-if='this.$root.track'>
|
||||
<span class='text-subtitle-2'>
|
||||
{{$duration($root.position)}} <span class='px-4'>{{qualityText}}</span>
|
||||
</span>
|
||||
</v-col>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<!-- Volume -->
|
||||
<v-col cols='auto' class='d-none d-sm-flex px-2' @click.stop>
|
||||
|
||||
<div style='width: 180px;' class='d-flex'>
|
||||
<v-slider
|
||||
dense
|
||||
hide-details
|
||||
min='0.00'
|
||||
max='1.00'
|
||||
step='0.01'
|
||||
v-model='volume'
|
||||
:prepend-icon='$root.muted ? "mdi-volume-off" : "mdi-volume-high"'
|
||||
@click:prepend='$root.toggleMute()'
|
||||
>
|
||||
<template v-slot:append>
|
||||
<div style='padding-top: 4px;'>
|
||||
{{Math.round(volume * 100)}}%
|
||||
</div>
|
||||
</template>
|
||||
</v-slider>
|
||||
</div>
|
||||
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
</v-footer>
|
||||
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style lang='scss'>
|
||||
@import 'styles/scrollbar.scss';
|
||||
.v-navigation-drawer__content {
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
</style>
|
||||
<style lang='scss' scoped>
|
||||
.seekbar {
|
||||
transition: none !important;
|
||||
}
|
||||
.seekbar .v-progress-linear__determinate {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import FullscreenPlayer from '@/views/FullscreenPlayer.vue';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
FullscreenPlayer
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
volume: this.$root.volume,
|
||||
showPlayer: false,
|
||||
position: '0.00',
|
||||
searchQuery: '',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Hide fullscreen player overlay
|
||||
closePlayer() {
|
||||
if (this.showPlayer) this.showPlayer = false;
|
||||
this.volume = this.$root.volume;
|
||||
},
|
||||
//Navigation
|
||||
previous() {
|
||||
if (window.history.length == 3) return;
|
||||
this.$router.go(-1);
|
||||
},
|
||||
next() {
|
||||
this.$router.go(1);
|
||||
},
|
||||
search(event) {
|
||||
//KeyUp event, enter
|
||||
if (event.keyCode !== 13) return;
|
||||
this.$router.push({path: '/search', query: {q: this.searchQuery}});
|
||||
},
|
||||
seek(val) {
|
||||
this.$root.seek(Math.round((val / 100) * this.$root.duration()));
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
qualityText() {
|
||||
return `${this.$root.playbackInfo.format} ${this.$root.playbackInfo.quality}`;
|
||||
},
|
||||
downloadPercentage() {
|
||||
if (!this.$root.download) return 0;
|
||||
let p = (this.$root.download.downloaded / this.$root.download.size) * 100;
|
||||
if (isNaN(p)) return 0;
|
||||
return Math.round(p);
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
//onClick for footer
|
||||
this.$refs.footer.addEventListener('click', () => {
|
||||
if (this.$root.track) this.showPlayer = true;
|
||||
});
|
||||
|
||||
// /search
|
||||
document.addEventListener('keypress', (event) => {
|
||||
if (event.keyCode != 47) return;
|
||||
this.$refs.searchBar.focus();
|
||||
setTimeout(() => {
|
||||
this.searchQuery = this.searchQuery.replace(new RegExp('/', 'g'), '');
|
||||
}, 40);
|
||||
});
|
||||
|
||||
//Wait for volume to load
|
||||
if (this.$root.loadingPromise) await this.$root.loadingPromise;
|
||||
this.volume = this.$root.volume;
|
||||
},
|
||||
created() {
|
||||
//Go to login if unauthorized
|
||||
if (!this.$root.authorized) {
|
||||
this.$router.push('/login');
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
volume() {
|
||||
if (this.$root.audio) this.$root.audio.volume = this.volume;
|
||||
this.$root.volume = this.volume;
|
||||
},
|
||||
//Update position
|
||||
'$root.position'() {
|
||||
this.position = (this.$root.position / this.$root.duration()) * 100;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
152
app/client/src/components/AlbumTile.vue
Normal file
152
app/client/src/components/AlbumTile.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-list-item two-line @click='click' v-if='!card'>
|
||||
<v-hover v-slot:default='{hover}'>
|
||||
<v-list-item-avatar>
|
||||
<v-img :src='album.art.thumb'></v-img>
|
||||
<v-overlay absolute :value='hover'>
|
||||
<v-btn icon large @click.stop='play'>
|
||||
<v-icon>mdi-play</v-icon>
|
||||
</v-btn>
|
||||
</v-overlay>
|
||||
</v-list-item-avatar>
|
||||
</v-hover>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{album.title}}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{album.artistString}}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<v-list-item-action>
|
||||
<!-- Context menu -->
|
||||
<v-menu v-model='menu' offset-y offset-x absolue>
|
||||
<template v-slot:activator="{on, attrs}">
|
||||
<v-btn v-on='on' v-bind='attrs' icon>
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list dense>
|
||||
<!-- Play album -->
|
||||
<v-list-item dense @click='play'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-play</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Play</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<!-- Add to library -->
|
||||
<v-list-item dense @click='addLibrary'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-heart</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Add to library</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<!-- Download -->
|
||||
<v-list-item dense @click='download'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-download</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Download</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
|
||||
<v-card v-if='card' max-width='175px' max-height='210px' @click='click'>
|
||||
<v-hover v-slot:default='{hover}'>
|
||||
<div>
|
||||
|
||||
<v-img :src='album.art.thumb'>
|
||||
</v-img>
|
||||
|
||||
<v-overlay absolute :value='hover' opacity='0.5'>
|
||||
<v-btn fab small color='white' @click.stop='play'>
|
||||
<v-icon color='black'>mdi-play</v-icon>
|
||||
</v-btn>
|
||||
</v-overlay>
|
||||
|
||||
</div>
|
||||
</v-hover>
|
||||
|
||||
|
||||
<div class='pa-2 text-subtitle-2 text-center text-truncate'>{{album.title}}</div>
|
||||
|
||||
</v-card>
|
||||
|
||||
<DownloadDialog :tracks='album.tracks' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DownloadDialog from '@/components/DownloadDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'AlbumTile',
|
||||
components: {DownloadDialog},
|
||||
data() {
|
||||
return {
|
||||
menu: false,
|
||||
hover: false,
|
||||
downloadDialog: false
|
||||
}
|
||||
},
|
||||
props: {
|
||||
album: Object,
|
||||
card: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async play() {
|
||||
let album = this.album;
|
||||
//Load album from API if tracks are missing
|
||||
if (album.tracks.length == 0) {
|
||||
let data = await this.$axios.get(`/album/${album.id}`)
|
||||
album = data.data;
|
||||
}
|
||||
//Error handling
|
||||
if (!album) return;
|
||||
|
||||
this.$root.queueSource = {
|
||||
text: album.title,
|
||||
source: 'album',
|
||||
data: album.id
|
||||
};
|
||||
this.$root.replaceQueue(album.tracks);
|
||||
this.$root.playIndex(0);
|
||||
},
|
||||
//On click navigate to details
|
||||
click() {
|
||||
this.$router.push({
|
||||
path: '/album',
|
||||
query: {album: JSON.stringify(this.album)}
|
||||
});
|
||||
this.$emit('clicked')
|
||||
},
|
||||
addLibrary() {
|
||||
this.$axios.put(`/library/album?id=${this.album.id}`);
|
||||
},
|
||||
//Add to downloads
|
||||
async download() {
|
||||
//Fetch tracks if missing
|
||||
let tracks = this.album.tracks;
|
||||
if (!tracks || tracks.length == 0) {
|
||||
let data = await this.$axios.get(`/album/${this.album.id}`)
|
||||
tracks = data.data.tracks;
|
||||
}
|
||||
this.album.tracks = tracks;
|
||||
this.downloadDialog = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
84
app/client/src/components/ArtistTile.vue
Normal file
84
app/client/src/components/ArtistTile.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-list-item @click='click' v-if='!card' :class='{dense: tiny}'>
|
||||
<v-list-item-avatar v-if='!tiny'>
|
||||
<v-img :src='artist.picture.thumb'></v-img>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{artist.name}}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if='!tiny'>{{$abbreviation(artist.fans)}} fans</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<v-list-item-action>
|
||||
<!-- Context menu -->
|
||||
<v-menu v-model='menu' offset-y offset-x absolue>
|
||||
<template v-slot:activator="{on, attrs}">
|
||||
<v-btn v-on='on' v-bind='attrs' icon>
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list dense>
|
||||
<!-- Add library -->
|
||||
<v-list-item dense @click='addLibrary'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-heart</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Add to library</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Card version -->
|
||||
<v-card max-height='200px' max-width='200px' v-if='card' @click='click'>
|
||||
<div class='d-flex justify-center'>
|
||||
<v-avatar size='150' class='ma-1'>
|
||||
<v-img :src='artist.picture.thumb'>
|
||||
</v-img>
|
||||
</v-avatar>
|
||||
</div>
|
||||
|
||||
<div class='pa-2 text-subtitle-2 text-center text-truncate'>{{artist.name}}</div>
|
||||
</v-card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ArtistTile',
|
||||
data() {
|
||||
return {
|
||||
menu: false
|
||||
}
|
||||
},
|
||||
props: {
|
||||
artist: Object,
|
||||
card: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tiny: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addLibrary() {
|
||||
this.$axios.put(`/library/artist&id=${this.artist.id}`);
|
||||
},
|
||||
click() {
|
||||
//Navigate to details
|
||||
this.$router.push({
|
||||
path: '/artist',
|
||||
query: {artist: JSON.stringify(this.artist)}
|
||||
});
|
||||
this.$emit('clicked');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
35
app/client/src/components/DeezerChannel.vue
Normal file
35
app/client/src/components/DeezerChannel.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<v-card
|
||||
width='225px'
|
||||
height='100px'
|
||||
:img='channel.image.thumb'
|
||||
@click='click'
|
||||
>
|
||||
<v-container fill-height class='justify-center'>
|
||||
<v-card-title class='font-weight-black text-truncate text-h6 pa-1'>{{channel.title}}</v-card-title>
|
||||
</v-container>
|
||||
|
||||
</v-card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DeezerChannel',
|
||||
props: {
|
||||
channel: Object
|
||||
},
|
||||
methods: {
|
||||
click() {
|
||||
console.log(this.channel.target);
|
||||
this.$router.push({
|
||||
path: '/page',
|
||||
query: {target: this.channel.target}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
103
app/client/src/components/DownloadDialog.vue
Normal file
103
app/client/src/components/DownloadDialog.vue
Normal file
@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<v-dialog v-model='show' max-width='420'>
|
||||
<v-card>
|
||||
|
||||
<v-card-title class='headline'>
|
||||
Download {{tracks.length}} tracks
|
||||
</v-card-title>
|
||||
<v-card-text class='pb-0'>
|
||||
|
||||
<v-select
|
||||
label='Quality'
|
||||
persistent-hint
|
||||
:items='qualities'
|
||||
v-model='qualityString'
|
||||
:hint='"Estimated size: " + $filesize(estimatedSize)'
|
||||
></v-select>
|
||||
|
||||
<v-checkbox
|
||||
v-model='autostart'
|
||||
label='Start downloading'
|
||||
></v-checkbox>
|
||||
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn text @click='$emit("close")'>Cancel</v-btn>
|
||||
<v-btn text @click='download'>Download</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DownloadDialog',
|
||||
props: {
|
||||
tracks: Array,
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
shown: true,
|
||||
qualities: [
|
||||
'Settings quality',
|
||||
'MP3 128kbps',
|
||||
'MP3 320kbps',
|
||||
'FLAC ~1441kbps'
|
||||
],
|
||||
qualityString: 'Settings quality',
|
||||
autostart: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Get quality int from select
|
||||
qualityInt() {
|
||||
let i = this.qualities.indexOf(this.qualityString);
|
||||
if (i == 1) return 1;
|
||||
if (i == 2) return 3;
|
||||
if (i == 3) return 9;
|
||||
return this.$root.settings.downloadsQuality;
|
||||
},
|
||||
//Add files to download queue
|
||||
async download() {
|
||||
if (this.qualities.indexOf(this.qualityString) == 0 || !this.qualityString) {
|
||||
await this.$axios.post(`/downloads`, this.tracks);
|
||||
} else {
|
||||
await this.$axios.post(`/downloads?q=${this.qualityInt()}`, this.tracks);
|
||||
}
|
||||
|
||||
if (this.autostart) this.$axios.put('/download');
|
||||
this.$emit("close");
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
estimatedSize() {
|
||||
let qi = this.qualityInt();
|
||||
let duration = this.tracks.reduce((a, b) => a + (b.duration / 1000), 0);
|
||||
|
||||
//Magic numbers = bitrate / 8 * 1024 = bytes per second
|
||||
switch (qi) {
|
||||
case 1:
|
||||
return duration * 16384;
|
||||
case 3:
|
||||
return duration * 40960;
|
||||
case 9:
|
||||
//FLAC is 1144, because more realistic
|
||||
return duration * 146432;
|
||||
}
|
||||
return duration * this.$root.settings.downloadsQuality;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
45
app/client/src/components/LibraryAlbums.vue
Normal file
45
app/client/src/components/LibraryAlbums.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<v-list>
|
||||
|
||||
<v-overlay v-if='loading'>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
|
||||
<v-lazy max-height="100" v-for='album in albums' :key='album.id'>
|
||||
<AlbumTile :album='album'></AlbumTile>
|
||||
</v-lazy>
|
||||
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AlbumTile from '@/components/AlbumTile.vue';
|
||||
|
||||
export default {
|
||||
name: 'LibraryAlbums',
|
||||
data() {
|
||||
return {
|
||||
albums: [],
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Load data
|
||||
async load() {
|
||||
this.loading = true;
|
||||
let res = await this.$axios.get(`/library/albums`);
|
||||
if (res.data && res.data.data) {
|
||||
this.albums = res.data.data;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
components: {
|
||||
AlbumTile
|
||||
},
|
||||
mounted() {
|
||||
//Initial load
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
</script>
|
45
app/client/src/components/LibraryArtists.vue
Normal file
45
app/client/src/components/LibraryArtists.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<v-list>
|
||||
|
||||
<v-overlay v-if='loading'>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
|
||||
<v-lazy max-height="100" v-for='artist in artists' :key='artist.id'>
|
||||
<ArtistTile :artist='artist'></ArtistTile>
|
||||
</v-lazy>
|
||||
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ArtistTile from '@/components/ArtistTile.vue';
|
||||
|
||||
export default {
|
||||
name: 'LibraryArtists',
|
||||
components: {
|
||||
ArtistTile
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
artists: [],
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Load data
|
||||
async load() {
|
||||
this.loading = true;
|
||||
let res = await this.$axios.get(`/library/artists`);
|
||||
if (res.data && res.data.data) {
|
||||
this.artists = res.data.data;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
//Initial load
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
</script>
|
69
app/client/src/components/LibraryPlaylists.vue
Normal file
69
app/client/src/components/LibraryPlaylists.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<v-list>
|
||||
|
||||
<v-overlay v-if='loading'>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
|
||||
<!-- Create playlist -->
|
||||
<v-btn class='ma-2 ml-3' color='primary' @click='popup = true'>
|
||||
<v-icon left>mdi-playlist-plus</v-icon>
|
||||
Create new playlist
|
||||
</v-btn>
|
||||
|
||||
<v-dialog max-width="400px" v-model='popup'>
|
||||
<PlaylistPopup @created='playlistCreated'></PlaylistPopup>
|
||||
</v-dialog>
|
||||
|
||||
|
||||
<v-lazy max-height="100" v-for='(playlist, index) in playlists' :key='playlist.id'>
|
||||
<PlaylistTile :playlist='playlist' @remove='removed(index)'></PlaylistTile>
|
||||
</v-lazy>
|
||||
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PlaylistTile from '@/components/PlaylistTile.vue';
|
||||
import PlaylistPopup from '@/components/PlaylistPopup.vue';
|
||||
|
||||
export default {
|
||||
name: 'LibraryPlaylists',
|
||||
components: {
|
||||
PlaylistTile, PlaylistPopup
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
playlists: [],
|
||||
loading: false,
|
||||
popup: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Load data
|
||||
async load() {
|
||||
this.loading = true;
|
||||
let res = await this.$axios.get(`/library/playlists`);
|
||||
if (res.data && res.data.data) {
|
||||
this.playlists = res.data.data;
|
||||
}
|
||||
this.loading = false;
|
||||
|
||||
},
|
||||
//Playlist created, update list
|
||||
playlistCreated() {
|
||||
this.popup = false;
|
||||
this.playlists = [];
|
||||
this.load();
|
||||
},
|
||||
//On playlist remove
|
||||
removed(i) {
|
||||
this.playlists.splice(i, 1);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
//Initial load
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
</script>
|
98
app/client/src/components/LibraryTracks.vue
Normal file
98
app/client/src/components/LibraryTracks.vue
Normal file
@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<v-list :height='height' class='overflow-y-auto' v-scroll.self='scroll'>
|
||||
<v-lazy
|
||||
v-for='(track, index) in tracks'
|
||||
:key='index + "t" + track.id'
|
||||
max-height="100"
|
||||
><TrackTile :track='track' @click='play(index)' @remove='removedTrack(index)'>
|
||||
</TrackTile>
|
||||
</v-lazy>
|
||||
|
||||
<div class='text-center' v-if='loading'>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</div>
|
||||
|
||||
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TrackTile from '@/components/TrackTile.vue';
|
||||
|
||||
export default {
|
||||
name: 'LibraryTracks',
|
||||
components: {
|
||||
TrackTile
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
tracks: [],
|
||||
count: 0
|
||||
}
|
||||
},
|
||||
props: {
|
||||
height: String
|
||||
},
|
||||
methods: {
|
||||
scroll(event) {
|
||||
let loadOffset = event.target.scrollHeight - event.target.offsetHeight - 100;
|
||||
if (event.target.scrollTop > loadOffset) {
|
||||
if (!this.loading) this.load();
|
||||
}
|
||||
},
|
||||
//Load initial data
|
||||
initialLoad() {
|
||||
this.loading = true;
|
||||
this.$axios.get(`/library/tracks`).then((res) => {
|
||||
this.tracks = res.data.data;
|
||||
this.count = res.data.count;
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
//Load more tracks
|
||||
load() {
|
||||
if (this.tracks.length >= this.count) return;
|
||||
this.loading = true;
|
||||
//Library Favorites = playlist
|
||||
let id = this.$root.profile.favoritesPlaylist;
|
||||
let offset = this.tracks.length;
|
||||
this.$axios.get(`/playlist/${id}?start=${offset}`).then((res) => {
|
||||
this.tracks.push(...res.data.tracks);
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
//Load all tracks
|
||||
async loadAll() {
|
||||
this.loading = true;
|
||||
let id = this.$root.profile.favoritesPlaylist;
|
||||
let res = await this.$axios.get(`/playlist/${id}?full=iguess`);
|
||||
if (res.data && res.data.tracks) {
|
||||
this.tracks.push(...res.data.tracks.slice(this.tracks.length));
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
//Play track
|
||||
async play(index) {
|
||||
if (this.tracks.length < this.count) {
|
||||
await this.loadAll();
|
||||
}
|
||||
|
||||
this.$root.queue.source = {
|
||||
text: 'Loved tracks',
|
||||
source: 'playlist',
|
||||
data: this.$root.profile.favoritesPlaylist
|
||||
};
|
||||
this.$root.replaceQueue(this.tracks);
|
||||
this.$root.playIndex(index);
|
||||
},
|
||||
removedTrack(index) {
|
||||
this.tracks.splice(index, 1);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initialLoad();
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
109
app/client/src/components/Lyrics.vue
Normal file
109
app/client/src/components/Lyrics.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div :style='"max-height: " + height' class='overflow-y-auto' ref='content'>
|
||||
<div class='text-center my-4'>
|
||||
<v-progress-circular indeterminate v-if='loading'></v-progress-circular>
|
||||
</div>
|
||||
|
||||
<div v-if='!loading && lyrics' class='text-center'>
|
||||
<div v-for='(lyric, index) in lyrics.lyrics' :key='lyric.offset' class='my-8 mx-4'>
|
||||
<span
|
||||
class='my-8'
|
||||
:class='{"text-h6 font-weight-regular": !playingNow(index), "text-h5 font-weight-bold": playingNow(index)}'
|
||||
:ref='"l"+index'
|
||||
>
|
||||
{{lyric.text}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if='!loading && !lyrics' class='pa-4 text-center'>
|
||||
<span class='red--text text-h5'>
|
||||
Error loading lyrics or lyrics not found!
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Lyrics',
|
||||
props: {
|
||||
songId: String,
|
||||
height: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
cSongId: this.songId,
|
||||
loading: true,
|
||||
lyrics: null,
|
||||
currentLyricIndex: 0,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Load data from API
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.lyrics = null;
|
||||
try {
|
||||
|
||||
let res = await this.$axios.get(`/lyrics/${this.songId}`);
|
||||
if (res.data) this.lyrics = res.data;
|
||||
|
||||
} catch (e) {true;}
|
||||
this.loading = false;
|
||||
},
|
||||
//Wether current lyric is playing rn
|
||||
playingNow(i) {
|
||||
if (!this.$root.audio) return false;
|
||||
//First & last lyric check
|
||||
if (i == this.lyrics.lyrics.length - 1) {
|
||||
if (this.lyrics.lyrics[i].offset <= this.$root.position) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.$root.position >= this.lyrics.lyrics[i].offset && this.$root.position < this.lyrics.lyrics[i+1].offset) return true;
|
||||
return false;
|
||||
},
|
||||
//Get index of current lyric
|
||||
currentLyric() {
|
||||
if (!this.$root.audio) return 0;
|
||||
return this.lyrics.lyrics.findIndex((l) => {
|
||||
return this.playingNow(this.lyrics.lyrics.indexOf(l));
|
||||
});
|
||||
},
|
||||
//Scroll to currently playing lyric
|
||||
scrollLyric() {
|
||||
if (!this.lyrics) return;
|
||||
|
||||
//Prevent janky scrolling
|
||||
if (this.currentLyricIndex == this.currentLyric()) return;
|
||||
this.currentLyricIndex = this.currentLyric();
|
||||
|
||||
//Roughly middle
|
||||
let offset = window.innerHeight / 2 - 500;
|
||||
|
||||
this.$refs.content.scrollTo({
|
||||
top: this.$refs["l"+this.currentLyricIndex][0].offsetTop + offset,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.load();
|
||||
},
|
||||
watch: {
|
||||
songId() {
|
||||
//Load on song id change
|
||||
if (this.cSongId != this.songId) {
|
||||
this.cSongId = this.songId;
|
||||
this.load();
|
||||
}
|
||||
},
|
||||
'$root.position'() {
|
||||
this.scrollLyric();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
106
app/client/src/components/PlaylistPopup.vue
Normal file
106
app/client/src/components/PlaylistPopup.vue
Normal file
@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<!-- Create playlist -->
|
||||
<v-card class='text-center pa-2' v-if='!addToPlaylist'>
|
||||
<v-card-text>
|
||||
<p primary-title class='display-1'>Create playlist</p>
|
||||
<v-text-field label='Title' class='ma-2' v-model='title'></v-text-field>
|
||||
<v-textarea class='mx-2' v-model='description' label='Description' rows='1' auto-grow></v-textarea>
|
||||
<v-select class='mx-2' v-model='type' :items='types' label='Type'></v-select>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn class='primary' :loading='createLoading' @click='create'>Create</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<!-- Add to playlist -->
|
||||
<v-card class='text-center pa-2' v-if='addToPlaylist'>
|
||||
<v-card-text>
|
||||
<p primary-title class='display-1'>Add to playlist</p>
|
||||
<v-btn block class='mb-1' @click='addToPlaylist = false'>
|
||||
<v-icon left>mdi-playlist-plus</v-icon>
|
||||
Create New
|
||||
</v-btn>
|
||||
<v-list>
|
||||
<div v-for='playlist in playlists' :key='playlist.id'>
|
||||
<v-list-item
|
||||
v-if='playlist.user.id == $root.profile.id'
|
||||
@click='addTrack(playlist)'
|
||||
dense>
|
||||
<v-list-item-avatar>
|
||||
<v-img :src='playlist.image.thumb'></v-img>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-title>{{playlist.title}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</div>
|
||||
|
||||
<v-progress-circular indeterminate v-if='loading'></v-progress-circular>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PlaylistPopup',
|
||||
data() {
|
||||
return {
|
||||
//Make mutable
|
||||
addToPlaylist: this.track?true:false,
|
||||
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'Private',
|
||||
types: ['Private', 'Public'],
|
||||
createLoading: false,
|
||||
|
||||
loading: false,
|
||||
playlists: []
|
||||
}
|
||||
},
|
||||
props: {
|
||||
track: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Create playlist
|
||||
async create() {
|
||||
this.createLoading = true;
|
||||
|
||||
await this.$axios.post('/playlist', {
|
||||
description: this.description,
|
||||
title: this.title,
|
||||
type: this.type.toLowerCase(),
|
||||
track: this.track ? this.track.id : null
|
||||
});
|
||||
|
||||
this.createLoading = false;
|
||||
this.$emit('created');
|
||||
this.$emit('close');
|
||||
},
|
||||
//Add track to playlist
|
||||
async addTrack(playlist) {
|
||||
await this.$axios.post(`/playlist/${playlist.id}/tracks`, {track: this.track.id});
|
||||
this.$emit('close');
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
//Load playlists, if adding to playlist
|
||||
if (this.track) {
|
||||
this.loading = true;
|
||||
|
||||
let res = await this.$axios.get(`/library/playlists`);
|
||||
this.playlists = res.data.data;
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
166
app/client/src/components/PlaylistTile.vue
Normal file
166
app/client/src/components/PlaylistTile.vue
Normal file
@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- List tile -->
|
||||
<v-list-item @click='click' v-if='!card'>
|
||||
<v-hover v-slot:default='{hover}'>
|
||||
<v-list-item-avatar>
|
||||
<v-img :src='playlist.image.thumb'></v-img>
|
||||
<v-overlay absolute :value='hover'>
|
||||
<v-btn icon large @click.stop='play'>
|
||||
<v-icon>mdi-play</v-icon>
|
||||
</v-btn>
|
||||
</v-overlay>
|
||||
</v-list-item-avatar>
|
||||
</v-hover>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{playlist.title}}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{$numberString(playlist.trackCount)}} tracks</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<v-list-item-action>
|
||||
<!-- Context menu -->
|
||||
<v-menu v-model='menu' offset-y offset-x absolue>
|
||||
<template v-slot:activator="{on, attrs}">
|
||||
<v-btn v-on='on' v-bind='attrs' icon>
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list dense>
|
||||
<!-- Play -->
|
||||
<v-list-item dense @click='play'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-play</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Play</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Remove -->
|
||||
<v-list-item dense v-if='canRemove' @click='remove'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-playlist-remove</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Remove</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Download -->
|
||||
<v-list-item dense @click='download'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-download</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Download</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Card -->
|
||||
<v-card v-if='card' max-width='175px' max-height='175px' @click='click' rounded>
|
||||
<v-hover v-slot:default='{hover}'>
|
||||
<div>
|
||||
|
||||
<v-img :src='playlist.image.thumb'>
|
||||
</v-img>
|
||||
|
||||
<v-overlay absolute :value='hover' opacity='0.5'>
|
||||
<v-btn fab small color='white' @click.stop='play'>
|
||||
<v-icon color='black'>mdi-play</v-icon>
|
||||
</v-btn>
|
||||
</v-overlay>
|
||||
|
||||
</div>
|
||||
</v-hover>
|
||||
</v-card>
|
||||
|
||||
<DownloadDialog :tracks='tracks' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DownloadDialog from '@/components/DownloadDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'PlaylistTile',
|
||||
components: {DownloadDialog},
|
||||
data() {
|
||||
return {
|
||||
menu: false,
|
||||
hover: false,
|
||||
downloadDialog: false,
|
||||
tracks: null
|
||||
}
|
||||
},
|
||||
props: {
|
||||
playlist: Object,
|
||||
card: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async play() {
|
||||
let playlist = this.playlist;
|
||||
//Load playlist tracks
|
||||
if (playlist.tracks.length != playlist.trackCount) {
|
||||
let data = await this.$axios.get(`/playlist/${playlist.id}?full=iguess`);
|
||||
playlist = data.data;
|
||||
}
|
||||
//Error handling
|
||||
if (!playlist) return;
|
||||
|
||||
this.$root.queue.source = {
|
||||
text: playlist.title,
|
||||
source: 'playlist',
|
||||
data: playlist.id
|
||||
};
|
||||
this.$root.replaceQueue(playlist.tracks);
|
||||
this.$root.playIndex(0);
|
||||
},
|
||||
//On click navigate to details
|
||||
click() {
|
||||
this.$router.push({
|
||||
path: '/playlist',
|
||||
query: {playlist: JSON.stringify(this.playlist)}
|
||||
});
|
||||
},
|
||||
async remove() {
|
||||
//Delete own playlist
|
||||
if (this.playlist.user.id == this.$root.profile.id) {
|
||||
await this.$axios.delete(`/playlist/${this.playlist.id}`);
|
||||
} else {
|
||||
//Remove from library
|
||||
await this.$axios.get('/library/playlist&id=' + this.playlist.id);
|
||||
}
|
||||
|
||||
this.$emit('remove');
|
||||
},
|
||||
async download() {
|
||||
let tracks = this.playlist.tracks;
|
||||
if (tracks.length < this.playlist.trackCount) {
|
||||
let data = await this.$axios.get(`/playlist/${this.playlist.id}?full=iguess`);
|
||||
tracks = data.data.tracks;
|
||||
}
|
||||
this.tracks = tracks;
|
||||
this.downloadDialog = true;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canRemove() {
|
||||
//Own playlist
|
||||
if (this.$root.profile.id == this.playlist.user.id) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
</script>
|
50
app/client/src/components/SmartTrackList.vue
Normal file
50
app/client/src/components/SmartTrackList.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<v-card max-width='175px' max-height='210px' @click='play' :loading='loading'>
|
||||
<v-img :src='stl.cover.thumb'>
|
||||
</v-img>
|
||||
|
||||
<div class='pa-2 text-subtitle-2 text-center text-truncate'>{{stl.title}}</div>
|
||||
</v-card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SmartTrackList',
|
||||
props: {
|
||||
stl: Object
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Load stt as source
|
||||
async play() {
|
||||
this.loading = true;
|
||||
|
||||
//Load data
|
||||
let res = await this.$axios.get('/smarttracklist/' + this.stl.id);
|
||||
if (!res.data) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
//Send to player
|
||||
this.$root.queue.source = {
|
||||
text: this.stl.title,
|
||||
source: 'smarttracklist',
|
||||
data: this.stl.id
|
||||
};
|
||||
this.$root.replaceQueue(res.data);
|
||||
this.$root.playIndex(0);
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
207
app/client/src/components/TrackTile.vue
Normal file
207
app/client/src/components/TrackTile.vue
Normal file
@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<v-list-item two-line @click='$emit("click")'>
|
||||
<v-list-item-avatar>
|
||||
<v-img :src='track.albumArt.thumb'></v-img>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title
|
||||
:class='{"primary--text": track.id == ($root.track ? $root.track : {id: null}).id}'
|
||||
>{{track.title}}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{track.artistString}}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<v-list-item-action>
|
||||
<!-- Quick add/remoev to library -->
|
||||
<v-btn @click.stop='addLibrary' icon v-if='!isLibrary'>
|
||||
<v-icon>mdi-heart</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click.stop='removeLibrary' icon v-if='isLibrary'>
|
||||
<v-icon>mdi-heart-remove</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
<v-list-item-action>
|
||||
<!-- Quick add to playlist -->
|
||||
<v-btn @click.stop='popup = true' icon>
|
||||
<v-icon>mdi-playlist-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
<v-list-item-action>
|
||||
<!-- Context menu -->
|
||||
<v-menu v-model='menu' offset-y offset-x absolue>
|
||||
<template v-slot:activator="{on, attrs}">
|
||||
<v-btn v-on='on' v-bind='attrs' icon>
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list dense>
|
||||
<!-- Play Next -->
|
||||
<v-list-item dense @click='playNext'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-playlist-plus</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Play next</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<!-- Add to end of queue -->
|
||||
<v-list-item dense @click='addQueue'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-playlist-plus</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Add to queue</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<!-- Add to library -->
|
||||
<v-list-item dense @click='addLibrary' v-if='!isLibrary'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-heart</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Add to library</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<!-- Remove from library -->
|
||||
<v-list-item dense @click='removeLibrary' v-if='isLibrary'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-heart-remove</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Remove from library</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<!-- Add to playlist -->
|
||||
<v-list-item dense @click='popup = true' v-if='!playlistId'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-playlist-plus</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Add to playlist</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<!-- Remove from playlist -->
|
||||
<v-list-item dense @click='removePlaylist' v-if='playlistId'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-playlist-remove</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Remove from playlist</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<!-- Go to album -->
|
||||
<v-list-item dense @click='goAlbum'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-album</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Go to "{{track.album.title}}"</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<!-- Go to artists -->
|
||||
<v-list-item
|
||||
dense
|
||||
@click='goArtist(artist)'
|
||||
v-for="artist in track.artists"
|
||||
:key='"ART" + artist.id'
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-account-music</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Go to "{{artist.name}}"</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Download -->
|
||||
<v-list-item dense @click='download'>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-download</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Download</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-list-item-action>
|
||||
|
||||
<!-- Add to playlist dialog -->
|
||||
<v-dialog max-width="400px" v-model='popup'>
|
||||
<PlaylistPopup :track='this.track' @close='popup = false'></PlaylistPopup>
|
||||
</v-dialog>
|
||||
|
||||
<DownloadDialog :tracks='[track]' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
|
||||
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PlaylistPopup from '@/components/PlaylistPopup.vue';
|
||||
import DownloadDialog from '@/components/DownloadDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'TrackTile',
|
||||
components: {
|
||||
PlaylistPopup, DownloadDialog
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
menu: false,
|
||||
popup: false,
|
||||
downloadDialog: false,
|
||||
isLibrary: this.$root.libraryTracks.includes(this.track.id)
|
||||
}
|
||||
},
|
||||
props: {
|
||||
track: Object,
|
||||
//If specified, track can be removed
|
||||
playlistId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
//Add track next to queue
|
||||
playNext() {
|
||||
this.$root.addTrackIndex(this.track, this.$root.queueIndex+1);
|
||||
},
|
||||
addQueue() {
|
||||
this.$root.queue.push(this.track);
|
||||
},
|
||||
addLibrary() {
|
||||
this.isLibrary = true;
|
||||
this.$root.libraryTracks.push(this.track.id);
|
||||
this.$axios.put(`/library/tracks?id=${this.track.id}`);
|
||||
},
|
||||
goAlbum() {
|
||||
this.$router.push({
|
||||
path: '/album',
|
||||
query: {album: JSON.stringify(this.track.album)}
|
||||
});
|
||||
},
|
||||
goArtist(a) {
|
||||
this.$router.push({
|
||||
path: '/artist',
|
||||
query: {artist: JSON.stringify(a)}
|
||||
});
|
||||
},
|
||||
async removeLibrary() {
|
||||
this.isLibrary = false;
|
||||
this.$root.libraryTracks.splice(this.$root.libraryTracks.indexOf(this.track.id), 1);
|
||||
await this.$axios.delete(`/library/tracks?id=${this.track.id}`);
|
||||
this.$emit('remove');
|
||||
},
|
||||
//Remove from playlist
|
||||
async removePlaylist() {
|
||||
await this.$axios.delete(`/playlist/${this.playlistId}/tracks`, {
|
||||
data: {track: this.track.id}
|
||||
});
|
||||
this.$emit('remove');
|
||||
},
|
||||
//Download track
|
||||
async download() {
|
||||
this.downloadDialog = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
86
app/client/src/js/router.js
Normal file
86
app/client/src/js/router.js
Normal file
@ -0,0 +1,86 @@
|
||||
import Vue from 'vue';
|
||||
import VueRouter from 'vue-router';
|
||||
|
||||
import Login from '@/views/Login.vue';
|
||||
import HomeScreen from '@/views/HomeScreen.vue';
|
||||
import Search from '@/views/Search.vue';
|
||||
import Library from '@/views/Library.vue';
|
||||
import AlbumPage from '@/views/AlbumPage.vue';
|
||||
import PlaylistPage from '@/views/PlaylistPage.vue';
|
||||
import ArtistPage from '@/views/ArtistPage.vue';
|
||||
import Settings from '@/views/Settings.vue';
|
||||
import DeezerPage from '@/views/DeezerPage.vue';
|
||||
import DownloadsPage from '@/views/DownloadsPage.vue';
|
||||
|
||||
Vue.use(VueRouter);
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/home',
|
||||
component: HomeScreen
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
component: Login
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
component: Search,
|
||||
props: (route) => {
|
||||
return {query: route.query.q}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
component: Library,
|
||||
},
|
||||
//Library short links
|
||||
{path: '/library/tracks', component: Library, props: () => {return {routeTab: 'tracks'}}},
|
||||
{path: '/library/albums', component: Library, props: () => {return {routeTab: 'albums'}}},
|
||||
{path: '/library/artists', component: Library, props: () => {return {routeTab: 'artists'}}},
|
||||
{path: '/library/playlists', component: Library, props: () => {return {routeTab: 'playlists'}}},
|
||||
{
|
||||
path: '/album',
|
||||
component: AlbumPage,
|
||||
props: (route) => {
|
||||
return {albumData: JSON.parse(route.query.album)}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/playlist',
|
||||
component: PlaylistPage,
|
||||
props: (route) => {
|
||||
return {playlistData: JSON.parse(route.query.playlist)}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/artist',
|
||||
component: ArtistPage,
|
||||
props: (route) => {
|
||||
return {artistData: JSON.parse(route.query.artist)}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: Settings
|
||||
},
|
||||
{
|
||||
path: '/page',
|
||||
component: DeezerPage,
|
||||
props: (route) => {
|
||||
return {target: route.query.target}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/downloads',
|
||||
component: DownloadsPage,
|
||||
}
|
||||
];
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'hash',
|
||||
base: process.env.BASE_URL,
|
||||
routes
|
||||
});
|
||||
|
||||
export default router;
|
13
app/client/src/js/vuetify.js
Normal file
13
app/client/src/js/vuetify.js
Normal file
@ -0,0 +1,13 @@
|
||||
import Vue from 'vue';
|
||||
import Vuetify from 'vuetify/lib';
|
||||
|
||||
import 'roboto-fontface/css/roboto/roboto-fontface.css';
|
||||
import '@mdi/font/css/materialdesignicons.css';
|
||||
|
||||
Vue.use(Vuetify);
|
||||
|
||||
export default new Vuetify({
|
||||
theme: {
|
||||
dark: true
|
||||
}
|
||||
});
|
427
app/client/src/main.js
Normal file
427
app/client/src/main.js
Normal file
@ -0,0 +1,427 @@
|
||||
import Vue from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './js/router';
|
||||
import vuetify from './js/vuetify';
|
||||
import axios from 'axios';
|
||||
import VueEsc from 'vue-esc';
|
||||
import VueSocketIO from 'vue-socket.io';
|
||||
|
||||
//Globals
|
||||
//Axios
|
||||
let axiosInstance = axios.create({
|
||||
baseURL: `${window.location.origin}`,
|
||||
timeout: 16000,
|
||||
responseType: 'json'
|
||||
});
|
||||
Vue.prototype.$axios = axiosInstance;
|
||||
|
||||
//Duration formatter
|
||||
Vue.prototype.$duration = (s) => {
|
||||
let pad = (n, z = 2) => ('00' + n).slice(-z);
|
||||
return ((s%3.6e6)/6e4 | 0) + ':' + pad((s%6e4)/1000|0);
|
||||
};
|
||||
|
||||
//Abbrevation
|
||||
Vue.prototype.$abbreviation = (n) => {
|
||||
if (!n || n == 0) return '0';
|
||||
var base = Math.floor(Math.log(Math.abs(n))/Math.log(1000));
|
||||
var suffix = 'KMB'[base-1];
|
||||
return suffix ? String(n/Math.pow(1000,base)).substring(0,3)+suffix : ''+n;
|
||||
}
|
||||
|
||||
//Add thousands commas
|
||||
Vue.prototype.$numberString = (n) => {
|
||||
if (!n || n == 0) return '0';
|
||||
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
//Filesize
|
||||
Vue.prototype.$filesize = (bytes) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
//Sockets
|
||||
Vue.use(new VueSocketIO({
|
||||
connection: window.location.origin
|
||||
}));
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
Vue.use(VueEsc);
|
||||
|
||||
new Vue({
|
||||
data: {
|
||||
//Globals
|
||||
settings: {},
|
||||
profile: {},
|
||||
authorized: false,
|
||||
loadingPromise: null,
|
||||
|
||||
//Downloads
|
||||
downloading: false,
|
||||
downloads: [],
|
||||
download: null,
|
||||
|
||||
//Player
|
||||
track: null,
|
||||
audio: null,
|
||||
volume: 0.00,
|
||||
//0 = Stopped, 1 = Paused, 2 = Playing, 3 = Loading
|
||||
state: 0,
|
||||
loaders: 0,
|
||||
playbackInfo: {},
|
||||
position: 0,
|
||||
muted: false,
|
||||
//Gapless playback meta
|
||||
gapless: {
|
||||
promise: null,
|
||||
audio: null,
|
||||
info: null,
|
||||
track: null
|
||||
},
|
||||
|
||||
//Library cache
|
||||
libraryTracks: [],
|
||||
|
||||
//Queue data
|
||||
queue: {
|
||||
data: [],
|
||||
index: -1,
|
||||
source: {
|
||||
text: 'None',
|
||||
source: 'none',
|
||||
data: 'none'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// PLAYBACK METHODS
|
||||
isPlaying() {
|
||||
return this.state == 2;
|
||||
},
|
||||
|
||||
play() {
|
||||
if (!this.audio || this.state != 1) return;
|
||||
this.audio.play();
|
||||
this.state = 2;
|
||||
},
|
||||
pause() {
|
||||
if (!this.audio || this.state != 2) return;
|
||||
this.audio.pause();
|
||||
this.state = 1;
|
||||
},
|
||||
toggle() {
|
||||
if (this.isPlaying()) return this.pause();
|
||||
this.play();
|
||||
},
|
||||
seek(t) {
|
||||
if (!this.audio) return;
|
||||
//ms -> s
|
||||
this.audio.currentTime = (t / 1000);
|
||||
},
|
||||
|
||||
//Current track duration
|
||||
duration() {
|
||||
//Prevent 0 division
|
||||
if (!this.audio) return 1;
|
||||
return this.audio.duration * 1000;
|
||||
},
|
||||
|
||||
//Replace queue, has to make clone of data to not keep references
|
||||
replaceQueue(newQueue) {
|
||||
this.queue.data = Object.assign([], newQueue);
|
||||
},
|
||||
//Add track to queue at index
|
||||
addTrackIndex(track, index) {
|
||||
this.queue.data.splice(index, 0, track);
|
||||
},
|
||||
|
||||
//Play at index in queue
|
||||
async playIndex(index) {
|
||||
if (index >= this.queue.data.length || index < 0) return;
|
||||
this.queue.index = index;
|
||||
await this.playTrack(this.queue.data[this.queue.index]);
|
||||
this.play();
|
||||
this.savePlaybackInfo();
|
||||
},
|
||||
//Skip n tracks, can be negative
|
||||
skip(n) {
|
||||
let newIndex = this.queue.index + n;
|
||||
//Out of bounds
|
||||
if (newIndex < 0 || newIndex >= this.queue.data.length) return;
|
||||
this.playIndex(newIndex);
|
||||
},
|
||||
toggleMute() {
|
||||
if (this.audio) this.audio.muted = !this.audio.muted;
|
||||
this.muted = !this.muted;
|
||||
},
|
||||
|
||||
async playTrack(track) {
|
||||
if (!track || !track.streamUrl) return;
|
||||
this.resetGapless();
|
||||
|
||||
this.track = track;
|
||||
this.loaders++;
|
||||
this.state = 3;
|
||||
//Stop audio
|
||||
let autoplay = (this.state == 2);
|
||||
if (this.audio) this.audio.pause();
|
||||
if (this.audio) this.audio.currentTime = 0;
|
||||
|
||||
//Load track meta
|
||||
this.playbackInfo = await this.loadPlaybackInfo(track.streamUrl, track.duration);
|
||||
|
||||
//Stream URL
|
||||
let url = `${window.location.origin}${this.playbackInfo.url}`;
|
||||
//Cancel loading
|
||||
this.loaders--;
|
||||
if (this.loaders > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Audio
|
||||
this.audio = new Audio(url);
|
||||
this.configureAudio();
|
||||
this.state = 1;
|
||||
if (autoplay) this.play();
|
||||
},
|
||||
//Configure html audio element
|
||||
configureAudio() {
|
||||
//Listen position updates
|
||||
this.audio.addEventListener('timeupdate', () => {
|
||||
this.position = this.audio.currentTime * 1000;
|
||||
|
||||
//Gapless playback
|
||||
if (this.position >= (this.duration() - 5000) && this.state == 2) {
|
||||
this.loadGapless();
|
||||
}
|
||||
});
|
||||
this.audio.muted = this.muted;
|
||||
this.audio.volume = this.volume;
|
||||
|
||||
this.audio.addEventListener('ended', async () => {
|
||||
//Load gapless
|
||||
if (this.gapless.promise || this.gapless.audio) {
|
||||
this.state = 3;
|
||||
if (this.gapless.promise) await this.gapless.promise;
|
||||
|
||||
this.audio = this.gapless.audio;
|
||||
this.playbackInfo = this.gapless.info;
|
||||
this.track = this.gapless.track;
|
||||
this.queue.index++;
|
||||
this.resetGapless();
|
||||
|
||||
this.configureAudio();
|
||||
//Play
|
||||
this.state = 2;
|
||||
this.audio.play();
|
||||
await this.savePlaybackInfo();
|
||||
return;
|
||||
}
|
||||
//Skip to next track
|
||||
this.skip(1);
|
||||
this.savePlaybackInfo();
|
||||
});
|
||||
this.updateMediaSession();
|
||||
},
|
||||
//Update media session with current track metadata
|
||||
updateMediaSession() {
|
||||
if (!this.track || !('mediaSession' in navigator)) return;
|
||||
// eslint-disable-next-line no-undef
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: this.track.title,
|
||||
artist: this.track.artistString,
|
||||
album: this.track.album.title,
|
||||
artwork: [
|
||||
{src: this.getImageUrl(this.track.albumArt, 256), sizes: '256x256', type: 'image/jpeg'},
|
||||
{src: this.getImageUrl(this.track.albumArt, 512), sizes: '512x512', type: 'image/jpeg'}
|
||||
]
|
||||
});
|
||||
//Controls
|
||||
navigator.mediaSession.setActionHandler('play', this.play);
|
||||
navigator.mediaSession.setActionHandler('pause', this.pause);
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => this.skip(1));
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => this.skip(-1));
|
||||
},
|
||||
//Get Deezer CDN image url
|
||||
getImageUrl(img, size = 256) {
|
||||
return `https://e-cdns-images.dzcdn.net/images/${img.type}/${img.hash}/${size}x${size}-000000-80-0-0.jpg`
|
||||
},
|
||||
|
||||
async loadPlaybackInfo(streamUrl, duration) {
|
||||
//Get playback info
|
||||
let quality = this.settings.streamQuality;
|
||||
let infoUrl = `/streaminfo/${streamUrl}?q=${quality}`;
|
||||
let res = await this.$axios.get(infoUrl);
|
||||
let info = res.data;
|
||||
//Calculate flac bitrate
|
||||
if (!info.quality.includes('kbps')) {
|
||||
info.quality = Math.round((parseInt(info.quality, 10)*8) / duration) + 'kbps';
|
||||
}
|
||||
return info;
|
||||
},
|
||||
|
||||
//Reset gapless playback meta
|
||||
resetGapless() {
|
||||
this.gapless = {promise: null,audio: null,info: null,track: null};
|
||||
},
|
||||
//Load next track for gapless
|
||||
async loadGapless() {
|
||||
if (this.loaders != 0 || this.gapless.promise || this.gapless.audio) return;
|
||||
//Last song
|
||||
if (this.queue.index+1 >= this.queue.data.length) return;
|
||||
|
||||
//Save promise
|
||||
let resolve;
|
||||
this.gapless.promise = new Promise((res) => {resolve = res});
|
||||
|
||||
//Load meta
|
||||
this.gapless.track = this.queue.data[this.queue.index + 1];
|
||||
let info = await this.loadPlaybackInfo(this.gapless.track.streamUrl, this.gapless.track.duration);
|
||||
this.gapless.info = info
|
||||
this.gapless.audio = new Audio(`${window.location.origin}${info.url}`);
|
||||
|
||||
//Might get canceled
|
||||
if (this.gapless.promise) resolve();
|
||||
},
|
||||
|
||||
//Update & save settings
|
||||
async saveSettings() {
|
||||
this.settings.volume = this.volume;
|
||||
await this.$axios.post('/settings', this.settings);
|
||||
|
||||
//Update settings in electron
|
||||
if (this.settings.electron) {
|
||||
const {ipcRenderer} = window.require('electron');
|
||||
ipcRenderer.send('updateSettings', this.settings);
|
||||
}
|
||||
},
|
||||
|
||||
async savePlaybackInfo() {
|
||||
let data = {
|
||||
queue: this.queue,
|
||||
position: this.position,
|
||||
track: this.track
|
||||
}
|
||||
await this.$axios.post('/playback', data);
|
||||
},
|
||||
//Get downloads from server
|
||||
async getDownloads() {
|
||||
let res = await this.$axios.get('/downloads');
|
||||
this.downloading = res.data.downloading;
|
||||
this.downloads = res.data.downloads;
|
||||
},
|
||||
//Start stop downloading
|
||||
async toggleDownload() {
|
||||
if (this.downloading) {
|
||||
await this.$axios.delete('/download');
|
||||
} else {
|
||||
await this.$axios.put('/download');
|
||||
}
|
||||
},
|
||||
|
||||
//Deezer doesn't give information if items are in library, so it has to be cachced
|
||||
async cacheLibrary() {
|
||||
let res = await this.$axios.get(`/playlist/${this.profile.favoritesPlaylist}?full=idk`);
|
||||
this.libraryTracks = res.data.tracks.map((t) => t.id);
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
//Load settings, create promise so `/login` can await it
|
||||
let r;
|
||||
this.loadingPromise = new Promise((resolve) => r = resolve);
|
||||
let res = await this.$axios.get('/settings');
|
||||
this.settings = res.data;
|
||||
this.volume = this.settings.volume;
|
||||
|
||||
//Restore playback data
|
||||
let pd = await this.$axios.get('/playback');
|
||||
if (pd.data != {}) {
|
||||
if (pd.data.queue) this.queue = pd.data.queue;
|
||||
if (pd.data.track) this.track = pd.data.track;
|
||||
this.playTrack(this.track).then(() => {
|
||||
this.seek(pd.data.position);
|
||||
});
|
||||
}
|
||||
|
||||
//Check for electron (src: npm isElectron)
|
||||
this.settings.electron = ((
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.process === 'object' &&
|
||||
window.process.type === 'renderer') || (
|
||||
typeof navigator === 'object' && typeof navigator.userAgent === 'string' &&
|
||||
navigator.userAgent.indexOf('Electron') >= 0
|
||||
));
|
||||
|
||||
//Setup electron callbacks
|
||||
if (this.settings.electron) {
|
||||
const {ipcRenderer} = window.require('electron');
|
||||
|
||||
//Save files on exit
|
||||
ipcRenderer.on('onExit', async () => {
|
||||
this.pause();
|
||||
await this.saveSettings();
|
||||
await this.savePlaybackInfo();
|
||||
ipcRenderer.send('onExit', '');
|
||||
});
|
||||
|
||||
//Control from electron
|
||||
ipcRenderer.on('togglePlayback', () => {
|
||||
this.toggle();
|
||||
});
|
||||
ipcRenderer.on('skipNext', () => {
|
||||
this.skip(1);
|
||||
});
|
||||
ipcRenderer.on('skipPrev', () => {
|
||||
this.skip(-1);
|
||||
})
|
||||
}
|
||||
|
||||
//Get downloads
|
||||
this.getDownloads();
|
||||
|
||||
//Sockets
|
||||
//Queue change
|
||||
this.sockets.subscribe('downloads', (data) => {
|
||||
this.downloading = data.downloading;
|
||||
this.downloads = data.downloads;
|
||||
});
|
||||
//Current download change
|
||||
this.sockets.subscribe('download', (data) => {
|
||||
this.download = data;
|
||||
});
|
||||
|
||||
r();
|
||||
},
|
||||
mounted() {
|
||||
//Save settings on unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.savePlaybackInfo();
|
||||
this.saveSettings();
|
||||
});
|
||||
//Save size
|
||||
window.addEventListener('resize', () => {
|
||||
this.settings.width = window.innerWidth;
|
||||
this.settings.height = window.innerHeight;
|
||||
});
|
||||
|
||||
//Keystrokes
|
||||
document.addEventListener('keyup', (e) => {
|
||||
//Don't handle keystrokes in text fields
|
||||
if (e.target.tagName == "INPUT") return;
|
||||
//K toggle playback
|
||||
//e.keyCode === 32
|
||||
if (e.keyCode === 75 || e.keyCode === 107) this.$root.toggle();
|
||||
//L +10s (from YT)
|
||||
if (e.keyCode === 108 || e.keyCode === 76) this.$root.seek((this.position + 10000));
|
||||
//J -10s (from YT)
|
||||
if (e.keyCode === 106 || e.keyCode === 74) this.$root.seek((this.position - 10000));
|
||||
});
|
||||
},
|
||||
|
||||
router,
|
||||
vuetify,
|
||||
render: function (h) { return h(App) }
|
||||
}).$mount('#app');
|
125
app/client/src/views/AlbumPage.vue
Normal file
125
app/client/src/views/AlbumPage.vue
Normal file
@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card class='d-flex'>
|
||||
<v-img
|
||||
:src='album.art.full'
|
||||
:lazy-src="album.art.thumb"
|
||||
max-height="100%"
|
||||
max-width="35vh"
|
||||
contain
|
||||
></v-img>
|
||||
|
||||
<div class='pl-4'>
|
||||
<v-overlay absolute :value="loading" z-index="3" opacity='0.9'>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
<h1>{{album.title}}</h1>
|
||||
<h3>{{album.artistString}}</h3>
|
||||
<div class='mt-2' v-if='!loading'>
|
||||
<span class='text-subtitle-2'>{{album.tracks.length}} tracks</span><br>
|
||||
<span class='text-subtitle-2'>Duration: {{duration}}</span><br>
|
||||
<span class='text-subtitle-2'>{{$numberString(album.fans)}} fans</span><br>
|
||||
<span class='text-subtitle-2'>Released: {{album.releaseDate}}</span><br>
|
||||
</div>
|
||||
|
||||
<div class='my-2'>
|
||||
<v-btn color='primary' class='mx-1' @click='play'>
|
||||
<v-icon left>mdi-play</v-icon>
|
||||
Play
|
||||
</v-btn>
|
||||
<v-btn color='red' class='mx-1' @click='library' :loading='libraryLoading'>
|
||||
<v-icon left>mdi-heart</v-icon>
|
||||
Library
|
||||
</v-btn>
|
||||
<v-btn color='green' class='mx-1' @click='download'>
|
||||
<v-icon left>mdi-download</v-icon>
|
||||
Download
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<h1 class='mt-2'>Tracks</h1>
|
||||
<v-list avatar v-if='album.tracks.length > 0'>
|
||||
<TrackTile
|
||||
v-for='(track, index) in album.tracks'
|
||||
:key='track.id'
|
||||
:track='track'
|
||||
@click='playTrack(index)'
|
||||
>
|
||||
</TrackTile>
|
||||
</v-list>
|
||||
|
||||
<DownloadDialog :tracks='album.tracks' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TrackTile from '@/components/TrackTile.vue';
|
||||
import DownloadDialog from '@/components/DownloadDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'AlbumPage',
|
||||
components: {
|
||||
TrackTile, DownloadDialog
|
||||
},
|
||||
props: {
|
||||
albumData: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
//Props cannot be edited
|
||||
album: this.albumData,
|
||||
loading: false,
|
||||
libraryLoading: false,
|
||||
downloadDialog: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Load album and play at index
|
||||
playTrack(index) {
|
||||
this.$root.queue.source = {
|
||||
text: this.album.title,
|
||||
source: 'album',
|
||||
data: this.album.id
|
||||
};
|
||||
this.$root.replaceQueue(this.album.tracks);
|
||||
this.$root.playIndex(index);
|
||||
},
|
||||
//Play from beggining
|
||||
play() {
|
||||
this.playTrack(0);
|
||||
},
|
||||
//Add to library
|
||||
async library() {
|
||||
this.libraryLoading = true;
|
||||
await this.$axios.put(`/library/album?id=${this.album.id}`);
|
||||
this.libraryLoading = false;
|
||||
},
|
||||
async download() {
|
||||
this.downloadDialog = true;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
//Load album from api if tracks and meta is missing
|
||||
if (this.album.tracks.length == 0) {
|
||||
this.loading = true;
|
||||
let data = await this.$axios.get(`/album/${this.album.id}`);
|
||||
if (data && data.data && data.data.tracks) {
|
||||
this.album = data.data;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
duration() {
|
||||
let durations = this.album.tracks.map((t) => t.duration);
|
||||
let duration = durations.reduce((a, b) => a + b, 0);
|
||||
return this.$duration(duration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
</script>
|
155
app/client/src/views/ArtistPage.vue
Normal file
155
app/client/src/views/ArtistPage.vue
Normal file
@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<v-card class='d-flex'>
|
||||
<v-img
|
||||
:src='artist.picture.full'
|
||||
:lazy-src="artist.picture.thumb"
|
||||
max-height="100%"
|
||||
max-width="35vh"
|
||||
contain
|
||||
></v-img>
|
||||
|
||||
<div class='pl-4'>
|
||||
<v-overlay absolute :value="loading" z-index="3" opacity='0.9'>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
<h1>{{artist.name}}</h1>
|
||||
<div class='mt-2' v-if='!loading'>
|
||||
<span class='text-subtitle-2'>{{artist.albumCount}} albums</span><br>
|
||||
<span class='text-subtitle-2'>{{$numberString(artist.fans)}} fans</span><br>
|
||||
</div>
|
||||
|
||||
<div class='my-2'>
|
||||
<v-btn color='primary' class='mx-1' @click='play'>
|
||||
<v-icon left>mdi-play</v-icon>
|
||||
Play top
|
||||
</v-btn>
|
||||
<v-btn color='red' class='mx-1' @click='library' :loading='libraryLoading'>
|
||||
<v-icon left>mdi-heart</v-icon>
|
||||
Library
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<h1 class='my-2'>Top tracks</h1>
|
||||
<v-list class='overflow-y-auto' height="300px">
|
||||
<div
|
||||
v-for='(track, index) in artist.topTracks'
|
||||
:key='"top-" + track.id'
|
||||
>
|
||||
<TrackTile
|
||||
v-if='index < 3 || (index >= 3 && allTopTracks)'
|
||||
:track='track'
|
||||
@click='playIndex(index)'
|
||||
></TrackTile>
|
||||
|
||||
<v-list-item v-if='!allTopTracks && index == 3' @click='allTopTracks = true'>
|
||||
<v-list-item-title>Show all top tracks</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
</div>
|
||||
|
||||
</v-list>
|
||||
|
||||
<h1 class='my-2'>Albums</h1>
|
||||
<v-list class='overflow-y-auto' style='max-height: 500px' @scroll.native="scroll">
|
||||
<AlbumTile
|
||||
v-for='album in artist.albums'
|
||||
:key='album.id'
|
||||
:album='album'
|
||||
></AlbumTile>
|
||||
|
||||
<div class='text-center my-2' v-if='loadingMore'>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</div>
|
||||
</v-list>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TrackTile from '@/components/TrackTile.vue';
|
||||
import AlbumTile from '@/components/AlbumTile.vue';
|
||||
|
||||
export default {
|
||||
name: 'ArtistPage',
|
||||
components: {
|
||||
TrackTile, AlbumTile
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
//Because props are const
|
||||
artist: this.artistData,
|
||||
loading: false,
|
||||
libraryLoading: false,
|
||||
allTopTracks: false,
|
||||
loadingMore: false
|
||||
}
|
||||
},
|
||||
props: {
|
||||
artistData: Object
|
||||
},
|
||||
methods: {
|
||||
playIndex(index) {
|
||||
this.$root.queue.source = {
|
||||
text: this.artist.name,
|
||||
source: 'top',
|
||||
data: this.artist.id
|
||||
};
|
||||
this.$root.replaceQueue(this.artist.topTracks);
|
||||
this.$root.playIndex(index);
|
||||
},
|
||||
play() {
|
||||
this.playIndex(0);
|
||||
},
|
||||
//Add to library
|
||||
async library() {
|
||||
this.libraryLoading = true;
|
||||
await this.$axios.put(`/library/artist?id=${this.artist.id}`);
|
||||
this.libraryLoading = false;
|
||||
},
|
||||
async load() {
|
||||
//Load meta and tracks
|
||||
if (this.artist.topTracks.length == 0) {
|
||||
this.loading = true;
|
||||
let data = await this.$axios.get(`/artist/${this.artist.id}`);
|
||||
if (data && data.data && data.data.topTracks) {
|
||||
this.artist = data.data;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async loadMoreAlbums() {
|
||||
if (this.artist.albumCount <= this.artist.albums.length) return;
|
||||
this.loadingMore = true;
|
||||
|
||||
//Load more albums from API
|
||||
let res = await this.$axios.get(`/albums/${this.artist.id}?start=${this.artist.albums.length}`);
|
||||
if (res.data) {
|
||||
this.artist.albums.push(...res.data);
|
||||
}
|
||||
|
||||
this.loadingMore = false;
|
||||
},
|
||||
//On scroll load more albums
|
||||
scroll(event) {
|
||||
let loadOffset = event.target.scrollHeight - event.target.offsetHeight - 150;
|
||||
if (event.target.scrollTop > loadOffset) {
|
||||
if (!this.loadingMore && !this.loading) this.loadMoreAlbums();
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.load();
|
||||
},
|
||||
watch: {
|
||||
artistData(v) {
|
||||
if (v.id == this.artist.id) return;
|
||||
this.artist = v;
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
88
app/client/src/views/DeezerPage.vue
Normal file
88
app/client/src/views/DeezerPage.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div style='overflow-x: hidden;'>
|
||||
<!-- Loading & Error -->
|
||||
<v-overlay opacity='0.95' z-index='0' v-if='loading || error'>
|
||||
<v-progress-circular indeterminate v-if='loading'></v-progress-circular>
|
||||
<v-icon class='red--text' v-if='error'>
|
||||
mdi-alert-circle
|
||||
</v-icon>
|
||||
</v-overlay>
|
||||
|
||||
|
||||
<div v-if='data'>
|
||||
<div v-for='(section, sectionIndex) in data.sections' :key='"section"+sectionIndex' class='mb-8'>
|
||||
|
||||
<h1 class='py-2'>{{section.title}}</h1>
|
||||
<div class='d-flex' style='overflow-x: auto; overflow-y: hidden;'>
|
||||
<div v-for='(item, index) in section.items' :key='"item"+index' class='mr-4 my-2'>
|
||||
<PlaylistTile v-if='item.type == "playlist"' :playlist='item.data' card></PlaylistTile>
|
||||
<ArtistTile v-if='item.type == "artist"' :artist='item.data' card></ArtistTile>
|
||||
<DeezerChannel v-if='item.type == "channel"' :channel='item.data'></DeezerChannel>
|
||||
<AlbumTile v-if='item.type == "album"' :album='item.data' card></AlbumTile>
|
||||
<SmartTrackList v-if='item.type == "smarttracklist" || item.type == "flow"' :stl='item.data'></SmartTrackList>
|
||||
</div>
|
||||
<div v-if='section.hasMore' class='mx-2 align-center justify-center d-flex'>
|
||||
<v-btn @click='showMore(section)' color='primary'>
|
||||
Show more
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PlaylistTile from '@/components/PlaylistTile.vue';
|
||||
import ArtistTile from '@/components/ArtistTile.vue';
|
||||
import DeezerChannel from '@/components/DeezerChannel.vue';
|
||||
import AlbumTile from '@/components/AlbumTile.vue';
|
||||
import SmartTrackList from '@/components/SmartTrackList.vue';
|
||||
|
||||
export default {
|
||||
name: 'DeezerPage',
|
||||
components: {PlaylistTile, ArtistTile, DeezerChannel, AlbumTile, SmartTrackList},
|
||||
props: {
|
||||
target: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
data: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
Ctarget: this.target
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Load data
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.data = null;
|
||||
let data = await this.$axios.get(`/page?target=${this.target}`);
|
||||
this.data = data.data;
|
||||
this.loading = false;
|
||||
},
|
||||
//Show more items
|
||||
showMore(section) {
|
||||
this.$router.push({
|
||||
path: '/page',
|
||||
query: {target: section.target}
|
||||
});
|
||||
}
|
||||
},
|
||||
//Load data on load
|
||||
created() {
|
||||
this.load();
|
||||
},
|
||||
watch: {
|
||||
//Check if target changed to not use cached version
|
||||
target() {
|
||||
if (this.target == this.Ctarget) return;
|
||||
this.Ctarget = this.target;
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
71
app/client/src/views/DownloadsPage.vue
Normal file
71
app/client/src/views/DownloadsPage.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<h1 class='pb-2'>Downloads</h1>
|
||||
|
||||
<v-card v-if='$root.download' max-width='100%'>
|
||||
|
||||
<v-list-item three-line>
|
||||
<v-list-item-avatar>
|
||||
<v-img :src='$root.download.track.albumArt.thumb'></v-img>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{$root.download.track.title}}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
Downloaded: {{$filesize($root.download.downloaded)}} / {{$filesize($root.download.size)}}<br>
|
||||
Path: {{$root.download.path}}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-card>
|
||||
|
||||
<h1 class='pb-2'>Queue:</h1>
|
||||
<div class='text-h6 pb-2 d-flex'>Total: {{$root.downloads.length}}
|
||||
<v-btn @click='$root.toggleDownload' class='ml-2' color='primary'>
|
||||
<div v-if='$root.downloading'>
|
||||
<v-icon>mdi-stop</v-icon>
|
||||
Stop
|
||||
</div>
|
||||
<div v-if='!$root.downloading'>
|
||||
<v-icon>mdi-download</v-icon>
|
||||
Start
|
||||
</div>
|
||||
</v-btn>
|
||||
<!-- Open dir -->
|
||||
<v-btn @click='openDir' class='ml-2' v-if='$root.settings.electron'>
|
||||
<v-icon>mdi-folder</v-icon>
|
||||
Show folder
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Downloads -->
|
||||
<v-list dense>
|
||||
<div v-for='download in $root.downloads' :key='download.id'>
|
||||
<v-list-item dense>
|
||||
<v-list-item-avatar>
|
||||
<v-img :src='download.track.albumArt.thumb'></v-img>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{download.track.title}}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{download.track.artistString}}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</v-list>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'DownloadsPage',
|
||||
methods: {
|
||||
//Open downloads directory using electron
|
||||
openDir() {
|
||||
const {ipcRenderer} = window.require('electron');
|
||||
ipcRenderer.send('openDownloadsDir');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
291
app/client/src/views/FullscreenPlayer.vue
Normal file
291
app/client/src/views/FullscreenPlayer.vue
Normal file
@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class='main pa-0'>
|
||||
|
||||
<v-app-bar dense>
|
||||
<v-btn icon @click='close'>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<v-toolbar-title>Playing from: {{$root.queue.source.text}}</v-toolbar-title>
|
||||
</v-app-bar>
|
||||
|
||||
<!-- Split to half -->
|
||||
<v-row class='pa-2' no-gutters justify="center">
|
||||
<!-- Left side (track info...) -->
|
||||
<v-col class='col-6 text-center' align-self="center">
|
||||
<v-img
|
||||
:src='$root.track.albumArt.full'
|
||||
:lazy-src="$root.track.albumArt.thumb"
|
||||
aspect-ratio="1"
|
||||
max-height="calc(90vh - 285px)"
|
||||
class='ma-4'
|
||||
contain>
|
||||
</v-img>
|
||||
<h1 class='text-no-wrap text-truncate'>{{$root.track.title}}</h1>
|
||||
<h2 class='primary--text text-no-wrap text-truncate'>{{$root.track.artistString}}</h2>
|
||||
|
||||
<!-- Slider, timestamps -->
|
||||
<v-row no-gutters class='py-2'>
|
||||
<v-col class='text-center' align-self="center">
|
||||
<span>{{$duration(position * 1000)}}</span>
|
||||
</v-col>
|
||||
<v-col class='col-8'>
|
||||
<v-slider
|
||||
min='0'
|
||||
step='1'
|
||||
:max='this.$root.duration() / 1000'
|
||||
@click='seekEvent'
|
||||
@start='seeking = true'
|
||||
@end='seek'
|
||||
:value='position'
|
||||
ref='seeker'
|
||||
class='seekbar'
|
||||
hide-details>
|
||||
</v-slider>
|
||||
</v-col>
|
||||
<v-col class='text-center' align-self="center">
|
||||
<span>{{$duration($root.duration())}}</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Controls -->
|
||||
<v-row no-gutters class='ma-4'>
|
||||
<v-col>
|
||||
<v-btn icon x-large @click='$root.skip(-1)'>
|
||||
<v-icon size='42px'>mdi-skip-previous</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn icon x-large @click='$root.toggle()'>
|
||||
<v-icon size='56px' v-if='!$root.isPlaying()'>mdi-play</v-icon>
|
||||
<v-icon size='56px' v-if='$root.isPlaying()'>mdi-pause</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn icon x-large @click='$root.skip(1)'>
|
||||
<v-icon size='42px'>mdi-skip-next</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Bottom actions -->
|
||||
<div class='d-flex my-1 mx-8 '>
|
||||
|
||||
<v-btn icon @click='addLibrary'>
|
||||
<v-icon v-if='!inLibrary'>mdi-heart</v-icon>
|
||||
<v-icon v-if='inLibrary'>mdi-heart-remove</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click='playlistPopup = true'>
|
||||
<v-icon>mdi-playlist-plus</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click='download'>
|
||||
<v-icon>mdi-download</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<!-- Volume -->
|
||||
<v-slider
|
||||
min='0.00'
|
||||
:prepend-icon='$root.muted ? "mdi-volume-off" : "mdi-volume-high"'
|
||||
max='1.00'
|
||||
step='0.01'
|
||||
v-model='$root.audio.volume'
|
||||
class='px-8'
|
||||
style='padding-top: 2px;'
|
||||
@change='updateVolume'
|
||||
@click:prepend='$root.toggleMute()'
|
||||
>
|
||||
<template v-slot:append>
|
||||
<div style='position: absolute; padding-top: 4px;'>
|
||||
{{Math.round($root.audio.volume * 100)}}%
|
||||
</div>
|
||||
</template>
|
||||
</v-slider>
|
||||
</div>
|
||||
|
||||
|
||||
</v-col>
|
||||
|
||||
<!-- Right side -->
|
||||
<v-col class='col-6 pt-4'>
|
||||
<v-tabs v-model='tab'>
|
||||
<v-tab key='queue'>
|
||||
Queue
|
||||
</v-tab>
|
||||
<v-tab key='info'>
|
||||
Info
|
||||
</v-tab>
|
||||
<v-tab key='lyrics'>
|
||||
Lyrics
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-items v-model='tab'>
|
||||
<!-- Queue tab -->
|
||||
<v-tab-item key='queue'>
|
||||
<v-list two-line avatar class='overflow-y-auto' style='max-height: calc(100vh - 140px)'>
|
||||
<v-lazy
|
||||
min-height="1"
|
||||
transition="fade-transition"
|
||||
v-for="(track, index) in $root.queue.data"
|
||||
:key='index + "q" + track.id'
|
||||
><TrackTile
|
||||
:track='track'
|
||||
@click='$root.playIndex(index)'
|
||||
></TrackTile>
|
||||
</v-lazy>
|
||||
|
||||
</v-list>
|
||||
</v-tab-item>
|
||||
<!-- Info tab -->
|
||||
<v-tab-item key='info'>
|
||||
<v-list two-line avatar class='overflow-y-auto text-center' style='max-height: calc(100vh - 140px)'>
|
||||
<h1>{{$root.track.title}}</h1>
|
||||
<!-- Album -->
|
||||
<h3>Album:</h3>
|
||||
<AlbumTile
|
||||
:album='$root.track.album'
|
||||
@clicked='$emit("close")'
|
||||
></AlbumTile>
|
||||
<!-- Artists -->
|
||||
<h3>Artists:</h3>
|
||||
<v-list dense>
|
||||
<ArtistTile
|
||||
v-for='(artist, index) in $root.track.artists'
|
||||
:artist='artist'
|
||||
:key="index + 'a' + artist.id"
|
||||
@clicked='$emit("close")'
|
||||
tiny
|
||||
class='text-left'
|
||||
></ArtistTile>
|
||||
</v-list>
|
||||
<!-- Meta -->
|
||||
<h3>Duration: <span>{{$duration($root.track.duration)}}</span></h3>
|
||||
<h3>Track number: {{$root.track.trackNumber}}</h3>
|
||||
<h3>Disk number: {{$root.track.diskNumber}}</h3>
|
||||
<h3>Explicit: {{$root.track.explicit?"Yes":"No"}}</h3>
|
||||
<h3>Source: {{$root.playbackInfo.source}}</h3>
|
||||
<h3>Format: {{$root.playbackInfo.format}}</h3>
|
||||
<h3>Quality: {{$root.playbackInfo.quality}}</h3>
|
||||
</v-list>
|
||||
</v-tab-item>
|
||||
<!-- Lyrics -->
|
||||
<v-tab-item key='lyrics'>
|
||||
<Lyrics :songId='$root.track.id' height='calc(100vh - 140px)'></Lyrics>
|
||||
</v-tab-item>
|
||||
|
||||
</v-tabs-items>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
<!-- Add to playlist dialog -->
|
||||
<v-dialog max-width="400px" v-model='playlistPopup'>
|
||||
<PlaylistPopup :track='$root.track' @close='playlistPopup = false'></PlaylistPopup>
|
||||
</v-dialog>
|
||||
|
||||
<DownloadDialog :tracks='[$root.track]' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 864px) {
|
||||
.imagescale {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 600px) {
|
||||
.imagescale {
|
||||
max-height: 45vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import TrackTile from '@/components/TrackTile.vue';
|
||||
import ArtistTile from '@/components/ArtistTile.vue';
|
||||
import AlbumTile from '@/components/AlbumTile.vue';
|
||||
import PlaylistPopup from '@/components/PlaylistPopup.vue';
|
||||
import Lyrics from '@/components/Lyrics.vue';
|
||||
import DownloadDialog from '@/components/DownloadDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'FullscreenPlayer',
|
||||
components: {
|
||||
TrackTile, ArtistTile, AlbumTile, PlaylistPopup, Lyrics, DownloadDialog
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
//Position used in seconds, because of CPU usage
|
||||
position: this.$root.position / 1000,
|
||||
seeking: false,
|
||||
tab: null,
|
||||
inLibrary: this.$root.track.library ? true:false,
|
||||
playlistPopup: false,
|
||||
downloadDialog: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Emit close event
|
||||
close() {
|
||||
this.$emit('close');
|
||||
},
|
||||
seek(v) {
|
||||
this.$root.seek(v * 1000);
|
||||
this.seeking = false;
|
||||
},
|
||||
//Mouse event seek
|
||||
seekEvent(v) {
|
||||
let seeker = this.$refs.seeker;
|
||||
let offsetp = (v.pageX - seeker.$el.offsetLeft) / seeker.$el.clientWidth;
|
||||
let pos = offsetp * this.$root.duration();
|
||||
this.$root.seek(pos);
|
||||
this.position = pos;
|
||||
this.seeking = false;
|
||||
},
|
||||
//Add/Remove track from library
|
||||
async addLibrary() {
|
||||
if (this.inLibrary) {
|
||||
await this.$axios.delete(`/library/track?id=` + this.$root.track.id);
|
||||
this.inLibrary = false;
|
||||
//Remove from cache
|
||||
this.$root.libraryTracks.splice(this.$root.libraryTracks.indexOf(this.$root.track.id), 1);
|
||||
return;
|
||||
}
|
||||
await this.$axios.put('/library/track?id=' + this.$root.track.id);
|
||||
this.$root.libraryTracks.push(this.$root.track.id);
|
||||
this.inLibrary = true;
|
||||
},
|
||||
//Download current track
|
||||
async download() {
|
||||
this.downloadDialog = true;
|
||||
},
|
||||
//Save volume
|
||||
updateVolume(v) {
|
||||
this.$root.volume = v;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
computed: {
|
||||
},
|
||||
watch: {
|
||||
//Update add to library button on track change
|
||||
'$root.track'() {
|
||||
this.inLibrary = this.$root.libraryTracks.includes(this.$root.track.id);
|
||||
},
|
||||
'$root.position'() {
|
||||
if (!this.seeking) this.position = this.$root.position / 1000;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
25
app/client/src/views/HomeScreen.vue
Normal file
25
app/client/src/views/HomeScreen.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<DeezerPage target='home'></DeezerPage>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DeezerPage from '@/views/DeezerPage.vue';
|
||||
|
||||
export default {
|
||||
name: 'HomeScreen',
|
||||
components: {DeezerPage},
|
||||
data() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
},
|
||||
created() {
|
||||
}
|
||||
}
|
||||
</script>
|
83
app/client/src/views/Library.vue
Normal file
83
app/client/src/views/Library.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Library</h1>
|
||||
|
||||
<v-tabs v-model='tab'>
|
||||
<v-tab key='tracks'>
|
||||
Tracks
|
||||
</v-tab>
|
||||
<v-tab key='albums'>
|
||||
Albums
|
||||
</v-tab>
|
||||
<v-tab key='artists'>
|
||||
Artists
|
||||
</v-tab>
|
||||
<v-tab key='playlists'>
|
||||
Playlists
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-items v-model='tab'>
|
||||
<!-- Tracks -->
|
||||
<v-tab-item key='tracks'>
|
||||
<LibraryTracks height='calc(100vh - 240px)'></LibraryTracks>
|
||||
</v-tab-item>
|
||||
|
||||
<!-- Albums -->
|
||||
<v-tab-item key='albums'>
|
||||
<LibraryAlbums></LibraryAlbums>
|
||||
</v-tab-item>
|
||||
|
||||
<!-- Artists -->
|
||||
<v-tab-item key='artists'>
|
||||
<LibraryArtists></LibraryArtists>
|
||||
</v-tab-item>
|
||||
|
||||
<!-- Playlists -->
|
||||
<v-tab-item key='playlists'>
|
||||
<LibraryPlaylists></LibraryPlaylists>
|
||||
</v-tab-item>
|
||||
</v-tabs-items>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LibraryTracks from '@/components/LibraryTracks.vue';
|
||||
import LibraryAlbums from '@/components/LibraryAlbums.vue';
|
||||
import LibraryArtists from '@/components/LibraryArtists.vue';
|
||||
import LibraryPlaylists from '@/components/LibraryPlaylists.vue';
|
||||
|
||||
export default {
|
||||
name: 'Library',
|
||||
components: {
|
||||
LibraryTracks, LibraryAlbums, LibraryArtists, LibraryPlaylists
|
||||
},
|
||||
props: {
|
||||
routeTab: {
|
||||
default: 'tracks',
|
||||
type: String
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: null,
|
||||
tabs: ['tracks', 'albums', 'artists', 'playlists'],
|
||||
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
},
|
||||
mounted() {
|
||||
//Make mutable
|
||||
this.tab = this.tabs.indexOf(this.routeTab);
|
||||
},
|
||||
watch: {
|
||||
//Update when navigating from drawer
|
||||
routeTab() {
|
||||
this.tab = this.tabs.indexOf(this.routeTab);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
126
app/client/src/views/Login.vue
Normal file
126
app/client/src/views/Login.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<v-overlay opacity='1.0' z-index='666'>
|
||||
|
||||
<!-- Fullscreen loader -->
|
||||
<div v-if='authorizing && !error'>
|
||||
<v-progress-circular indeterminate>
|
||||
</v-progress-circular>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<v-card class='text-center pa-4' v-if='error'>
|
||||
<h1 class='text--red'>Error logging in!</h1>
|
||||
<h3>Please try again later, or try another account.</h3>
|
||||
<v-btn large class='my-4' @click='logout'>
|
||||
<v-icon left>mdi-logout-variant</v-icon>
|
||||
Logout
|
||||
</v-btn>
|
||||
</v-card>
|
||||
|
||||
<!-- Login form -->
|
||||
<div v-if='showForm' class='text-center'>
|
||||
<v-img src='banner.png' contain max-width='400px' class='py-8'></v-img>
|
||||
|
||||
<h3>Please login using your Deezer account:</h3>
|
||||
<v-btn large class='my-2 mb-4 primary' @click='browserLogin'>
|
||||
<v-icon left>mdi-open-in-app</v-icon>
|
||||
Login using browser
|
||||
</v-btn>
|
||||
|
||||
<h3 class='mt-4'>...or paste your ARL/Token below:</h3>
|
||||
<v-text-field label='ARL/Token' v-model='arl'>
|
||||
</v-text-field>
|
||||
|
||||
<v-btn large class='my-4 primary' :loading='authorizing' @click='login'>
|
||||
<v-icon left>mdi-login-variant</v-icon>Login
|
||||
</v-btn>
|
||||
|
||||
<br>
|
||||
<span class='mt-8 text-caption'>
|
||||
By using this program, you disagree with Deezer's ToS.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
</v-overlay>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Login',
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
arl: '',
|
||||
showForm: false,
|
||||
authorizing: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async login() {
|
||||
this.showForm = false;
|
||||
this.authorizing = true;
|
||||
|
||||
if (this.arl && this.arl != '') {
|
||||
//Save arl
|
||||
this.$root.settings.arl = this.arl;
|
||||
}
|
||||
|
||||
//Authorize
|
||||
try {
|
||||
await this.$axios.post('/authorize', {arl: this.$root.settings.arl});
|
||||
this.$root.authorized = true;
|
||||
|
||||
} catch (e) {
|
||||
this.error = true;
|
||||
}
|
||||
|
||||
//Get profile on sucess
|
||||
if (this.$root.authorized) {
|
||||
//Save
|
||||
await this.$root.saveSettings();
|
||||
|
||||
//Load profile
|
||||
let res = await this.$axios.get('/profile');
|
||||
this.$root.profile = res.data;
|
||||
this.$router.push('/home');
|
||||
//Cache library
|
||||
this.$root.cacheLibrary();
|
||||
}
|
||||
|
||||
this.authorizing = false;
|
||||
},
|
||||
//Log out, show login form
|
||||
logout() {
|
||||
this.error = false;
|
||||
this.arl = '';
|
||||
this.$root.settings.arl = '';
|
||||
this.showForm = true;
|
||||
},
|
||||
//Login using browser
|
||||
browserLogin() {
|
||||
if (!this.$root.settings.electron) return alert('Only in Electron version!');
|
||||
|
||||
const {ipcRenderer} = window.require('electron');
|
||||
ipcRenderer.on('browserLogin', (event, newArl) => {
|
||||
this.arl = newArl;
|
||||
this.login();
|
||||
});
|
||||
ipcRenderer.send('browserLogin');
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
//Wait for settings to load
|
||||
if (this.$root.loadingPromise) {
|
||||
this.authorizing = true;
|
||||
await this.$root.loadingPromise;
|
||||
this.authorizing = false;
|
||||
}
|
||||
this.showForm = true;
|
||||
|
||||
if (this.$root.settings.arl) {
|
||||
this.login();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
176
app/client/src/views/PlaylistPage.vue
Normal file
176
app/client/src/views/PlaylistPage.vue
Normal file
@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<v-list height='calc(100vh - 145px)' class='overflow-y-auto' v-scroll.self='scroll'>
|
||||
<v-card class='d-flex'>
|
||||
<v-img
|
||||
:src='playlist.image.full'
|
||||
:lazy-src="playlist.image.thumb"
|
||||
max-height="100%"
|
||||
max-width="35vh"
|
||||
contain
|
||||
></v-img>
|
||||
|
||||
<div class='pl-4'>
|
||||
<v-overlay absolute :value="loading" z-index="3" opacity='0.9'>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
<h1>{{playlist.title}}</h1>
|
||||
<h3>{{playlist.user.name}}</h3>
|
||||
<h5>{{playlist.description}}</h5>
|
||||
<div class='mt-2' v-if='!loading'>
|
||||
<span class='text-subtitle-2'>{{playlist.trackCount}} tracks</span><br>
|
||||
<span class='text-subtitle-2'>Duration: {{$duration(playlist.duration)}}</span><br>
|
||||
<span class='text-subtitle-2'>{{$numberString(playlist.fans)}} fans</span><br>
|
||||
</div>
|
||||
|
||||
<div class='my-1'>
|
||||
<v-btn color='primary' class='mr-1' @click='play'>
|
||||
<v-icon left>mdi-play</v-icon>
|
||||
Play
|
||||
</v-btn>
|
||||
<v-btn color='red' class='mx-1' @click='library' :loading='libraryLoading'>
|
||||
<v-icon left>mdi-heart</v-icon>
|
||||
Library
|
||||
</v-btn>
|
||||
<v-btn color='green' class='mx-1' @click='download'>
|
||||
<v-icon left>mdi-download</v-icon>
|
||||
Download
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<h1 class='my-2 px-2'>Tracks</h1>
|
||||
<v-lazy
|
||||
v-for='(track, index) in playlist.tracks'
|
||||
:key='index.toString() + "-" + track.id'
|
||||
><TrackTile
|
||||
:track='track'
|
||||
@click='playIndex(index)'
|
||||
:playlistId='playlist.id'
|
||||
@remove='trackRemoved(index)'
|
||||
></TrackTile>
|
||||
</v-lazy>
|
||||
|
||||
<div class='text-center' v-if='loadingTracks'>
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</div>
|
||||
|
||||
<DownloadDialog :tracks='playlist.tracks' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
|
||||
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TrackTile from '@/components/TrackTile.vue';
|
||||
import DownloadDialog from '@/components/DownloadDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'PlaylistTile',
|
||||
components: {
|
||||
TrackTile, DownloadDialog
|
||||
},
|
||||
props: {
|
||||
playlistData: Object
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
//Props cannot be modified
|
||||
playlist: this.playlistData,
|
||||
//Initial loading
|
||||
loading: false,
|
||||
loadingTracks: false,
|
||||
//Add to library button
|
||||
libraryLoading: false,
|
||||
downloadDialog: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async playIndex(index) {
|
||||
//Load tracks
|
||||
if (this.playlist.tracks.length < this.playlist.trackCount) {
|
||||
await this.loadAllTracks();
|
||||
}
|
||||
|
||||
this.$root.queue.source = {
|
||||
text: this.playlist.title,
|
||||
source: 'playlist',
|
||||
data: this.playlist.id
|
||||
};
|
||||
this.$root.replaceQueue(this.playlist.tracks);
|
||||
this.$root.playIndex(index);
|
||||
},
|
||||
play() {
|
||||
this.playIndex(0);
|
||||
},
|
||||
scroll(event) {
|
||||
let loadOffset = event.target.scrollHeight - event.target.offsetHeight - 100;
|
||||
if (event.target.scrollTop > loadOffset) {
|
||||
if (!this.loadingTracks && !this.loading) this.loadTracks();
|
||||
}
|
||||
},
|
||||
|
||||
//Lazy loading
|
||||
async loadTracks() {
|
||||
if (this.playlist.tracks.length >= this.playlist.trackCount) return;
|
||||
this.loadingTracks = true;
|
||||
|
||||
let offset = this.playlist.tracks.length;
|
||||
let res = await this.$axios.get(`/playlist/${this.playlist.id}?start=${offset}`);
|
||||
if (res.data && res.data.tracks) {
|
||||
this.playlist.tracks.push(...res.data.tracks);
|
||||
}
|
||||
this.loadingTracks = false;
|
||||
},
|
||||
|
||||
//Load all the tracks
|
||||
async loadAllTracks() {
|
||||
this.loadingTracks = true;
|
||||
let data = await this.$axios.get(`/playlist/${this.playlist.id}?full=iguess`);
|
||||
if (data && data.data && data.data.tracks) {
|
||||
this.playlist.tracks.push(...data.data.tracks.slice(this.playlist.tracks.length));
|
||||
}
|
||||
this.loadingTracks = false;
|
||||
},
|
||||
async library() {
|
||||
this.libraryLoading = true;
|
||||
await this.$axios.put(`/library/playlist?id=${this.playlist.id}`);
|
||||
this.libraryLoading = false;
|
||||
},
|
||||
|
||||
async initialLoad() {
|
||||
//Load meta and intial tracks
|
||||
if (this.playlist.tracks.length < this.playlist.trackCount) {
|
||||
this.loading = true;
|
||||
let data = await this.$axios.get(`/playlist/${this.playlist.id}?start=0`);
|
||||
if (data && data.data && data.data.tracks) {
|
||||
this.playlist = data.data;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
//On track removed
|
||||
trackRemoved(index) {
|
||||
this.playlist.tracks.splice(index, 1);
|
||||
},
|
||||
async download() {
|
||||
//Load all tracks
|
||||
if (this.playlist.tracks.length < this.playlist.trackCount) {
|
||||
await this.loadAllTracks();
|
||||
}
|
||||
this.downloadDialog = true;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initialLoad();
|
||||
},
|
||||
watch: {
|
||||
//Reload on playlist change from drawer
|
||||
playlistData(n, o) {
|
||||
if (n.id == o.id) return;
|
||||
this.playlist = this.playlistData;
|
||||
this.loading = false;
|
||||
this.initialLoad();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
151
app/client/src/views/Search.vue
Normal file
151
app/client/src/views/Search.vue
Normal file
@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<h1 class='pb-2'>Search results for: "{{query}}"</h1>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<v-overlay opacity='0.9' :value='loading' z-index='3'>
|
||||
<v-progress-circular indeterminate>
|
||||
|
||||
</v-progress-circular>
|
||||
</v-overlay>
|
||||
|
||||
<!-- Error overlay -->
|
||||
<v-overlay opacity='0.9' :value='error' z-index="3">
|
||||
<h1 class='red--text'>Error loading data!</h1><br>
|
||||
<h3>Try again later!</h3>
|
||||
</v-overlay>
|
||||
|
||||
<!-- Tabs -->
|
||||
<v-tabs v-model="tab">
|
||||
<v-tabs-slider></v-tabs-slider>
|
||||
<v-tab key="tracks">
|
||||
<v-icon left>mdi-music-note</v-icon>Tracks
|
||||
</v-tab>
|
||||
<v-tab>
|
||||
<v-icon left>mdi-album</v-icon>Albums
|
||||
</v-tab>
|
||||
<v-tab>
|
||||
<v-icon left>mdi-account-music</v-icon>Artists
|
||||
</v-tab>
|
||||
<v-tab>
|
||||
<v-icon left>mdi-playlist-music</v-icon>Playlists
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-items v-model="tab">
|
||||
<!-- Tracks -->
|
||||
<v-tab-item key="tracks">
|
||||
<div v-if="data && data.tracks">
|
||||
<v-list avatar>
|
||||
<TrackTile
|
||||
v-for="(track, i) in data.tracks"
|
||||
:track="track"
|
||||
:key="track.id"
|
||||
@click="playTrack(i)"
|
||||
></TrackTile>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
<!-- Albums -->
|
||||
<v-tab-item key="albums">
|
||||
<div v-if="data && data.albums">
|
||||
<v-list avatar>
|
||||
<AlbumTile
|
||||
v-for="(album) in data.albums"
|
||||
:album="album"
|
||||
:key="album.id"
|
||||
></AlbumTile>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
<!-- Artists -->
|
||||
<v-tab-item key="artists">
|
||||
<div v-if="data && data.artists">
|
||||
<v-list avatar>
|
||||
<ArtistTile
|
||||
v-for="(artist) in data.artists"
|
||||
:artist="artist"
|
||||
:key="artist.id"
|
||||
></ArtistTile>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
<!-- Playlists -->
|
||||
<v-tab-item key="playlists">
|
||||
<div v-if="data && data.playlists">
|
||||
<v-list avatar>
|
||||
<PlaylistTile
|
||||
v-for="(playlist) in data.playlists"
|
||||
:playlist="playlist"
|
||||
:key="playlist.id"
|
||||
></PlaylistTile>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
</v-tabs-items>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TrackTile from "@/components/TrackTile.vue";
|
||||
import AlbumTile from "@/components/AlbumTile.vue";
|
||||
import ArtistTile from '@/components/ArtistTile.vue';
|
||||
import PlaylistTile from '@/components/PlaylistTile.vue';
|
||||
|
||||
export default {
|
||||
name: "Search",
|
||||
components: {
|
||||
TrackTile, AlbumTile, ArtistTile, PlaylistTile
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
data: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
tab: null
|
||||
};
|
||||
},
|
||||
props: {
|
||||
query: String
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.data = null;
|
||||
this.loading = true;
|
||||
//Call API
|
||||
this.$axios
|
||||
.get("/search", {
|
||||
params: { q: this.query }
|
||||
})
|
||||
.then(data => {
|
||||
this.data = data.data;
|
||||
this.loading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
this.error = true;
|
||||
});
|
||||
},
|
||||
//On click for track tile
|
||||
playTrack(i) {
|
||||
this.$root.queue.source = {
|
||||
text: "Search",
|
||||
source: "search",
|
||||
data: this.query
|
||||
};
|
||||
this.$root.replaceQueue(this.data.tracks);
|
||||
this.$root.playIndex(i);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
//Reload on new search query
|
||||
query() {
|
||||
this.load();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.load();
|
||||
}
|
||||
};
|
||||
</script>
|
181
app/client/src/views/Settings.vue
Normal file
181
app/client/src/views/Settings.vue
Normal file
@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class='pb-2'>Settings</h1>
|
||||
<v-list>
|
||||
<v-select
|
||||
class='px-4'
|
||||
label='Streaming Quality'
|
||||
persistent-hint
|
||||
:items='qualities'
|
||||
@change='updateStreamingQuality'
|
||||
v-model='streamingQuality'
|
||||
></v-select>
|
||||
|
||||
<v-select
|
||||
class='px-4'
|
||||
label='Download Quality'
|
||||
persistent-hint
|
||||
:items='qualities'
|
||||
@change='updateDownloadQuality'
|
||||
v-model='downloadQuality'
|
||||
></v-select>
|
||||
|
||||
<!-- Download path -->
|
||||
<v-text-field
|
||||
class='px-4'
|
||||
label='Downloads Directory'
|
||||
v-model='$root.settings.downloadsPath'
|
||||
append-icon='mdi-open-in-app'
|
||||
@click:append='selectDownloadPath'
|
||||
></v-text-field>
|
||||
|
||||
<!-- Create artist folder -->
|
||||
<v-list-item>
|
||||
<v-list-item-action>
|
||||
<v-checkbox v-model='$root.settings.createArtistFolder'></v-checkbox>
|
||||
</v-list-item-action>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Create folders for artists</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<!-- Create album folder -->
|
||||
<v-list-item>
|
||||
<v-list-item-action>
|
||||
<v-checkbox v-model='$root.settings.createAlbumFolder'></v-checkbox>
|
||||
</v-list-item-action>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Create folders for albums</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Download naming -->
|
||||
<v-text-field
|
||||
class='px-4 mb-2'
|
||||
label='Download Filename'
|
||||
persistent-hint
|
||||
v-model='$root.settings.downloadFilename'
|
||||
hint='Variables: %title%, %artists%, %artist%, %feats%, %trackNumber%, %0trackNumber%, %album%'
|
||||
></v-text-field>
|
||||
|
||||
<!-- Minimize to tray -->
|
||||
<v-list-item v-if='$root.settings.electron'>
|
||||
<v-list-item-action>
|
||||
<v-checkbox v-model='$root.settings.minimizeToTray'></v-checkbox>
|
||||
</v-list-item-action>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Minimize to tray</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<!-- Close on exit -->
|
||||
<v-list-item v-if='$root.settings.electron'>
|
||||
<v-list-item-action>
|
||||
<v-checkbox v-model='$root.settings.closeOnExit'></v-checkbox>
|
||||
</v-list-item-action>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Close on exit</v-list-item-title>
|
||||
<v-list-item-subtitle>Don't minimize to tray</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Logout -->
|
||||
<v-btn block color='red' class='mt-4' @click='logout'>
|
||||
<v-icon>mdi-logout</v-icon>
|
||||
Logout
|
||||
</v-btn>
|
||||
|
||||
</v-list>
|
||||
|
||||
<v-btn class='my-4' large color='primary' :loading='saving' block @click='save'>
|
||||
<v-icon>mdi-content-save</v-icon>
|
||||
Save
|
||||
</v-btn>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Settings',
|
||||
data() {
|
||||
return {
|
||||
saving: false,
|
||||
qualities: [
|
||||
'MP3 128kbps',
|
||||
'MP3 320kbps',
|
||||
'FLAC ~1441kbps'
|
||||
],
|
||||
streamingQuality: null,
|
||||
downloadQuality: null,
|
||||
devToolsCounter: 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
//Save settings
|
||||
save() {
|
||||
this.saving = true;
|
||||
this.$root.saveSettings();
|
||||
//Artificial wait to make it seem like something happened.
|
||||
setTimeout(() => {this.saving = false;}, 500);
|
||||
},
|
||||
getQuality(v) {
|
||||
let i = this.qualities.indexOf(v);
|
||||
if (i == 0) return 1;
|
||||
if (i == 1) return 3;
|
||||
if (i == 2) return 9;
|
||||
return 3;
|
||||
},
|
||||
//Update streaming quality
|
||||
updateStreamingQuality(v) {
|
||||
this.$root.settings.streamQuality = this.getQuality(v);
|
||||
},
|
||||
updateDownloadQuality(v) {
|
||||
this.$root.settings.downloadsQuality = this.getQuality(v);
|
||||
},
|
||||
//Quality to show currently selected quality
|
||||
getPresetQuality(q) {
|
||||
if (q == 9) return this.qualities[2];
|
||||
if (q == 3) return this.qualities[1];
|
||||
if (q == 1) return this.qualities[0];
|
||||
return this.qualities[1];
|
||||
},
|
||||
//Select download path, electron only
|
||||
selectDownloadPath() {
|
||||
//Electron check
|
||||
if (!this.$root.settings.electron) {
|
||||
alert('Available only in Electron version!');
|
||||
return;
|
||||
}
|
||||
const {ipcRenderer} = window.require('electron');
|
||||
ipcRenderer.on('selectDownloadPath', (event, newPath) => {
|
||||
if (newPath) this.$root.settings.downloadsPath = newPath;
|
||||
});
|
||||
ipcRenderer.send('selectDownloadPath');
|
||||
},
|
||||
async logout() {
|
||||
this.$root.settings.arl = null;
|
||||
await this.$root.saveSettings();
|
||||
location.reload();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.streamingQuality = this.getPresetQuality(this.$root.settings.streamQuality);
|
||||
this.downloadQuality = this.getPresetQuality(this.$root.settings.downloadsQuality);
|
||||
|
||||
//Press 'f' 10 times, to open dev tools
|
||||
document.addEventListener('keyup', (event) => {
|
||||
if (event.keyCode === 70) {
|
||||
this.devToolsCounter += 1;
|
||||
} else {
|
||||
this.devToolsCounter = 0;
|
||||
}
|
||||
if (this.devToolsCounter == 10) {
|
||||
this.devToolsCounter = 0;
|
||||
if (this.$root.settings.electron) {
|
||||
const {remote} = window.require('electron');
|
||||
remote.getCurrentWindow().toggleDevTools();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
Reference in New Issue
Block a user