First release
This commit is contained in:
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>
|
Reference in New Issue
Block a user