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'; import i18n from './js/i18n'; //Globals let ipcRenderer; //Axios let axiosInstance = axios.create({ baseURL: process.env.NODE_ENV === 'development' ? "http://localhost:10069" : `${window.location.origin}`, timeout: 16000, responseType: 'json' }); Vue.prototype.$axios = axiosInstance; //Duration formatter Vue.prototype.$duration = (ms) => { if (isNaN(ms) || ms < 1) return '0:00'; let s = Math.floor(ms / 1000); let hours = Math.floor(s / 3600); s %= 3600; let min = Math.floor(s / 60); let sec = s % 60; if (hours == 0) return `${min}:${sec.toString().padStart(2, '0')}`; return `${hours}:${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '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: process.env.NODE_ENV === 'development' ? "http://localhost:10069" : window.location.toString(), options: {path: '/socket'} })); Vue.config.productionTip = false; Vue.use(VueEsc); new Vue({ data: { //Globals settings: {}, profile: {}, authorized: false, loadingPromise: null, downloads: {}, //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 }, //0 - normal, 1 - repeat list, 2 - repeat track repeat: 0, shuffled: false, //Library cache libraryTracks: [], //Queue data queue: { data: [], index: -1, source: { text: 'None', source: 'none', data: 'none' } }, //Importer importer: { active: false, done: false, error: false, tracks: [] }, //Used to prevent double listen logging logListenId: null, globalSnackbar: null }, 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 || isNaN(t) || !t) return; //ms -> s this.audio.currentTime = (t / 1000); this.position = t; this.updateState(); }, //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 async skip(n) { let newIndex = this.queue.index + n; //Out of bounds if (newIndex < 0 || newIndex >= this.queue.data.length) return; await this.playIndex(newIndex); }, shuffle() { if (!this.shuffled) { //Save positions for (let i=0; i 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [this.queue.data[i], this.queue.data[j]] = [this.queue.data[j], this.queue.data[i]]; } //Update index this.queue.index = this.queue.data.findIndex(t => t.id == this.track.id); this.shuffled = true; return; } //Restore unshuffled queue if (this.shuffled) { this.queue.data.sort((a, b) => (a._position || 10000) - (b._position || 10000)); this.queue.index = this.queue.data.findIndex(t => t.id == this.track.id); this.shuffled = false; return; } }, //Skip wrapper skipNext() { this.skip(1); this.savePlaybackInfo(); }, 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 let playbackInfo = await this.loadPlaybackInfo(track.streamUrl, track.duration); if (!playbackInfo) { this.skipNext(); return; } this.playbackInfo = playbackInfo; //Stream URL let url = `${process.env.NODE_ENV === 'development' ? "http://localhost:10069" : 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(); //MediaSession this.updateMediaSession(); //Loads more tracks if end of list this.loadSTL(); }, //Configure html audio element configureAudio() { //Listen position updates this.audio.addEventListener('timeupdate', async () => { this.position = this.audio.currentTime * 1000; //Gapless playback if (this.position >= (this.duration() - (this.settings.crossfadeDuration + 7500)) && this.state == 2) { if (this.repeat != 2) this.loadGapless(); } //Crossfade if (this.settings.crossfadeDuration > 0 && this.position >= (this.duration() - this.settings.crossfadeDuration) && this.state == 2 && this.gapless.audio && !this.gapless.crossfade && this.gapless.track) { this.gapless.crossfade = true; let currentVolume = this.audio.volume; let oldAudio = this.audio; this.audio = this.gapless.audio; this.audio.play(); //Update meta this.playbackInfo = this.gapless.info; this.track = this.gapless.track; this.queue.index = this.gapless.index; this.configureAudio(); this.updateMediaSession(); this.audio.volume = 0.0; let volumeStep = currentVolume / (this.settings.crossfadeDuration / 50); for (let i=0; i<(this.settings.crossfadeDuration / 50); i++) { if ((oldAudio.volume - volumeStep) > 0) oldAudio.volume -= volumeStep; if ((this.audio.volume + volumeStep) >= 1.0 || (this.audio.volume + volumeStep) >= currentVolume) break; this.audio.volume += volumeStep; await new Promise((res) => setTimeout(() => res(), 50)); } //Restore original volume this.audio.voume = currentVolume; oldAudio.pause(); this.resetGapless(); this.updateState(); //Save await this.savePlaybackInfo(); } //Scrobble/LogListen if (this.position >= this.duration() * 0.75) { this.logListen(); } }); this.audio.muted = this.muted; //Set volume this.audio.volume = this.volume * this.volume; this.audio.addEventListener('ended', async () => { if (this.gapless.crossfade) return; //Repeat track if (this.repeat == 2) { this.seek(0); this.audio.play(); this.updateState(); return; } //Repeat list if (this.repeat == 1 && this.queue.index == this.queue.data.length - 1) { this.skip(-(this.queue.data.length - 1)); return; } //End of queue if (this.queue.index+1 == this.queue.data.length) { this.state = 1; return; } //Skip to next track this.skip(1); this.savePlaybackInfo(); }); }, //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.skipNext); 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; try { res = await this.$axios.get(infoUrl); } catch (_) { return null; } let info = res.data; //Generate qualityString switch (info.quality) { case 9: info.qualityString = 'FLAC ' + Math.round((info.size*8) / duration) + 'kbps'; break; case 3: info.qualityString = 'MP3 320kbps'; break; case 1: info.qualityString = 'MP3 128kbps'; break; } return info; }, //Reset gapless playback meta resetGapless() { this.gapless = {crossfade: false,promise: null,audio: null,info: null,track: null,index:null}; }, //Load next track for gapless async loadGapless() { if (this.loaders != 0 || this.gapless.promise || this.gapless.audio || this.gapless.crossfade) return; //Repeat list if (this.repeat == 1 && this.queue.index == this.queue.data.length - 1) { this.gapless.track = this.queue.data[0]; this.gapless.index = 0; } else { //Last song if (this.queue.index+1 >= this.queue.data.length) return; //Next song this.gapless.track = this.queue.data[this.queue.index + 1]; this.gapless.index = this.queue.index + 1; } //Save promise let resolve; this.gapless.promise = new Promise((res) => {resolve = res}); //Load meta let info = await this.loadPlaybackInfo(this.gapless.track.streamUrl, this.gapless.track.duration); if (!info) { this.resetGapless(); if (this.gapless.promise) resolve(); } this.gapless.info = info this.gapless.audio = new Audio(`${process.env.NODE_ENV === 'development' ? "http://localhost:10069" : window.location.origin}${info.url}`); this.gapless.audio.volume = 0.00; this.gapless.audio.preload = 'auto'; this.gapless.crossfade = false; //Might get canceled if (this.gapless.promise) resolve(); }, //Load more SmartTrackList tracks async loadSTL() { if (this.queue.data.length - 1 == this.queue.index && this.queue.source.source == 'smarttracklist') { let data = await this.$axios.get('/smarttracklist/' + this.queue.source.data); if (data.data) { this.queue.data = this.queue.data.concat(data.data); } this.savePlaybackInfo(); } }, //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) { ipcRenderer.send('updateSettings', this.settings); } }, async savePlaybackInfo() { let data = { queue: this.queue, position: this.position, track: this.track, repeat: this.repeat, shuffled: this.shuffled } await this.$axios.post('/playback', data); }, //Get downloads from server async getDownloads() { let res = await this.$axios.get('/downloads'); if (res.data) this.downloads = res.data; }, //Start stop downloading async toggleDownload() { if (this.downloads.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); }, //Log song listened to deezer, only if allowed async logListen() { if (this.logListenId == this.track.id) return; if (!this.track || !this.track.id) return; this.logListenId = this.track.id; await this.$axios.post(`/log`, this.track); }, //Send state update to integrations async updateState() { //Wait for duration if (this.state == 2 && (this.duration() == null || isNaN(this.duration()))) { setTimeout(() => { this.updateState(); }, 500); return; } this.$socket.emit('stateChange', { position: this.position, duration: this.duration(), state: this.state, track: this.track }); //Update in electron if (this.settings.electron) { ipcRenderer.send('playing', this.state == 2); } }, updateLanguage(l) { i18n.locale = l; } }, 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.$vuetify.theme.themes.dark.primary = this.settings.primaryColor; this.$vuetify.theme.themes.light.primary = this.settings.primaryColor; if (this.settings.lightTheme) this.$vuetify.theme.dark = false; i18n.locale = this.settings.language; 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; if (pd.data.repeat) this.repeat = pd.data.repeat; if (pd.data.shuffled) this.shuffled = pd.data.shuffled; 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 )); if (this.settings.electron) ipcRenderer = window.require('electron').ipcRenderer; //Setup electron callbacks if (this.settings.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 await this.getDownloads(); //Sockets //Queue change this.sockets.subscribe('downloads', (data) => { this.downloads = data; }); //Current download change this.sockets.subscribe('currentlyDownloading', (data) => { this.downloads.threads = data; }); //Play at offset (for integrations) this.sockets.subscribe('playOffset', async (data) => { this.queue.data.splice(this.queue.index + 1, 0, data.track); await this.skip(1); this.seek(data.position); }); //Importer //Start this.sockets.subscribe('importerInit', (data) => { this.importer = data; }); //New track imported this.sockets.subscribe('importerTrack', (data) => { this.importer.tracks.push(data); }); //Mark as done this.sockets.subscribe('importerDone', () => { this.importer.active = false; this.importer.done = true; }); this.sockets.subscribe('importerError', () => { this.importer.error = true; this.importer.active = false; this.importer.done = false; }); //Album this.sockets.subscribe('importerAlbum', a => { //Not downloading, got albumn if (a) { this.$router.push({ path: '/album', query: {album: JSON.stringify(a)} }); } //Mark done this.importer.error = false; this.importer.active = false; this.importer.done = false; }); 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; //Don't handle if specials if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; //K toggle playback if (e.code == "KeyK" || e.code == "Space") this.$root.toggle(); //L +10s (from YT) if (e.code == "KeyL") this.$root.seek((this.position + 10000)); //J -10s (from YT) if (e.code == "KeyJ") this.$root.seek((this.position - 10000)); //-> +5s (from YT) if (e.code == "ArrowRight") this.$root.seek((this.position + 5000)); //<- -5s (from YT) if (e.code == "ArrowLeft") this.$root.seek((this.position - 5000)); // ^ v - Volume if (e.code == 'ArrowUp') { if ((this.volume + 0.05) > 1) { this.volume = 1.00; return; } this.volume += 0.05; } if (e.code == 'ArrowDown') { if ((this.volume - 0.05) < 0) { this.volume = 0.00; return; } this.volume -= 0.05; } }); }, watch: { //Watch state for integrations state() { this.updateMediaSession(); this.updateState(); }, //Update volume with curve volume() { if (this.audio) this.audio.volume = this.volume * this.volume; } }, router, vuetify, i18n, render: function (h) { return h(App) } }).$mount('#app');