freezerpc/app/client/src/App.vue

444 lines
13 KiB
Vue

<template>
<v-app v-esc='closePlayer'>
<!-- Fullscreen player overlay -->
<v-overlay :value='showPlayer' opacity='1.00' 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>{{$t('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>{{$t('Browse')}}</v-list-item-title>
</v-list-item>
<v-subheader inset>{{$t('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>{{$t('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>{{$t('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>{{$t('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>{{$t('Artists')}}</v-list-item-title>
</v-list-item>
<v-subheader inset>{{$t('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>{{$t('Settings')}}</v-list-item-title>
</v-list-item>
<!-- Downloads, shitty hack if downloads not yet loaded -->
<v-list-item link to='/downloads' v-if='$root.downloads.queue'>
<!-- Download icon -->
<v-list-item-icon v-if='!$root.downloads.downloading && $root.downloads.queue.length == 0'>
<v-icon>mdi-download</v-icon>
</v-list-item-icon>
<!-- Paused download -->
<v-list-item-icon v-if='!$root.downloads.downloading && $root.downloads.queue.length > 0'>
<v-icon>mdi-pause</v-icon>
</v-list-item-icon>
<!-- Download in progress -->
<v-list-item-icon v-if='$root.downloads.downloading'>
<v-progress-circular :value='downloadPercentage' style='top: -2px' class='text-caption'>
{{$root.downloads.queue.length + $root.downloads.threads.length}}
</v-progress-circular>
</v-list-item-icon>
<v-list-item-title>{{$t('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>
<!-- Search -->
<v-autocomplete
hide-details
prepend-inner-icon="mdi-magnify"
flat
single-line
solo
clearable
hide-no-data
:placeholder='$t("Search or paste Deezer URL. Use / to quickly focus.")'
:loading='searchLoading'
@keyup='search'
ref='searchBar'
v-model='searchQuery'
:search-input.sync='searchInput'
:items='suggestions'
></v-autocomplete>
</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.skipNext'>
<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: '',
searchLoading: false,
searchInput: null,
suggestions: [],
preventDoubleEnter: false
}
},
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);
},
async search(event) {
//KeyUp event, enter
if (event && event.keyCode !== 13) return;
//Prevent double navigation
if (this.preventDoubleEnter) return;
this.preventDoubleEnter = true;
setInterval(() => {this.preventDoubleEnter = false}, 50);
//Check if url
let query = this.searchInput;
if (query.startsWith('http')) {
this.searchLoading = true;
let url = new URL(query);
//Normal link
if (url.hostname == 'www.deezer.com' || url.hostname == 'deezer.com' || url.hostname == 'deezer.page.link') {
//Share link
if (url.hostname == 'deezer.page.link') {
let res = await this.$axios.get('/fullurl?url=' + encodeURIComponent(query));
url = new URL(res.data.url);
}
let supported = ['track', 'artist', 'album', 'playlist'];
let path = url.pathname.substring(1).split('/');
if (path.length == 3) path = path.slice(1);
let type = path[0];
if (supported.includes(type)) {
//Dirty lol
let res = await this.$axios('/' + path.join('/'));
if (res.data) {
//Add to queue
if (type == 'track') {
this.$root.queue.data.splice(this.$root.queue.index + 1, 0, res.data);
this.$root.skip(1);
}
//Show details page
if (type == 'artist' || type == 'album' || type == 'playlist') {
let query = {};
query[type] = JSON.stringify(res.data);
this.$router.push({path: `/${type}`, query: query});
}
}
}
}
this.searchLoading = false;
} else {
//Normal search
this.$router.push({path: '/search', query: {q: query}});
}
},
seek(val) {
this.$root.seek(Math.round((val / 100) * this.$root.duration()));
}
},
computed: {
qualityText() {
return `${this.$root.playbackInfo.qualityString}`;
},
downloadPercentage() {
if (!this.$root.downloads.downloading) return 0;
let downloaded = this.$root.downloads.threads.reduce((a, b) => a + b.downloaded, 0);
let size = this.$root.downloads.threads.reduce((a, b) => a + b.size, 0);
if (size == 0)
size = 1;
let p = (downloaded / size) * 100;
if (p > 100)
p = 100;
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', (e) => {
if (e.keyCode != 47) return;
this.$refs.searchBar.focus();
setTimeout(() => {
if (this.searchQuery.startsWith('/')) this.searchQuery = this.searchQuery.substring(1);
}, 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;
},
'$root.volume'() {
this.volume = this.$root.volume;
},
//Update position
'$root.position'() {
this.position = (this.$root.position / this.$root.duration()) * 100;
},
//Autofill
searchInput(query) {
//Filters
if (query && query.startsWith('/')) {
query = query.substring(1);
this.searchInput = query;
}
if (!query || (query && query.startsWith('http'))) {
this.searchLoading = false;
this.suggestions = [];
return;
}
if (!this.$root.settings.showAutocomplete) return;
this.searchLoading = true;
//Prevent spam
setTimeout(() => {
if (query != this.searchInput) return;
this.$axios.get('/suggestions/' + encodeURIComponent(query)).then((res) => {
if (query != this.searchInput) return;
this.suggestions = res.data;
this.searchLoading = false;
});
}, 300);
},
searchQuery(q) {
this.searchInput = q;
this.search(null);
}
}
};
</script>