forked from platypush/platypush
[#297] Mopidy/MPD refactor+migration, UI side.
This commit is contained in:
parent
e2246c8d30
commit
5d9a201a5b
24 changed files with 1209 additions and 591 deletions
|
@ -1,8 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="extension fade-in" :class="{hidden: !expanded}">
|
<div class="extension fade-in" :class="{hidden: !expanded}">
|
||||||
<div class="image-container" @click.prevent="onImageClick" v-if="status?.state !== 'stop'">
|
<div class="image-container"
|
||||||
<div class="remote-image-container" v-if="track?.image">
|
@click.prevent="searchAlbum"
|
||||||
<img class="image" :src="track.image" :alt="track.title">
|
v-if="status?.state !== 'stop'">
|
||||||
|
<div class="remote-image-container" v-if="trackImage">
|
||||||
|
<img class="image" :src="trackImage" :alt="track.title">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="icon-container" v-else>
|
<div class="icon-container" v-else>
|
||||||
|
@ -28,11 +30,17 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<VolumeSlider :value="status.volume" :range="volumeRange" :status="status"
|
<VolumeSlider
|
||||||
@mute="$emit('mute')" @unmute="$emit('unmute')"
|
:range="volumeRange"
|
||||||
@set-volume="$emit('set-volume', $event)" />
|
:status="status"
|
||||||
|
:value="status.volume"
|
||||||
|
@mute="$emit('mute')"
|
||||||
|
@set-volume="$emit('set-volume', $event)"
|
||||||
|
@unmute="$emit('unmute')" />
|
||||||
|
|
||||||
<ExtraControls :status="status" :buttons="buttons_"
|
<ExtraControls
|
||||||
|
:buttons="buttons_"
|
||||||
|
:status="status"
|
||||||
@consume="$emit('consume', !status.consume)"
|
@consume="$emit('consume', !status.consume)"
|
||||||
@random="$emit('random', !status.random)"
|
@random="$emit('random', !status.random)"
|
||||||
@repeat="$emit('repeat', !status.repeat)" />
|
@repeat="$emit('repeat', !status.repeat)" />
|
||||||
|
@ -50,19 +58,19 @@
|
||||||
|
|
||||||
<div class="track-container col-s-9 col-m-9 col-l-3">
|
<div class="track-container col-s-9 col-m-9 col-l-3">
|
||||||
<div class="track-info" v-if="track && status?.state !== 'stop'">
|
<div class="track-info" v-if="track && status?.state !== 'stop'">
|
||||||
<div class="img-container" v-if="track.image">
|
<div class="img-container" v-if="trackImage">
|
||||||
<img class="image from desktop" :src="track.image" :alt="track.title">
|
<img class="image from desktop" :src="trackImage" :alt="track.title">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="title-container">
|
<div class="title-container">
|
||||||
<div class="title" v-if="status.state === 'play' || status.state === 'pause'">
|
<div class="title" v-if="status.state === 'play' || status.state === 'pause'">
|
||||||
<a :href="$route.fullPath" v-text="track.title?.length ? track.title : '[No Title]'"
|
<a :href="$route.fullPath" v-text="track.title?.length ? track.title : '[No Title]'"
|
||||||
@click.prevent="$emit('search', {artist: track.artist, album: track.album})" v-if="track.album"></a>
|
@click.prevent="searchAlbum" v-if="track.album"></a>
|
||||||
<a :href="track.url" v-text="track.title?.length ? track.title : '[No Title]'" v-else-if="track.url"></a>
|
<a v-text="track.title?.length ? track.title : '[No Title]'" v-else-if="track.url"></a>
|
||||||
<span v-text="track.title?.length ? track.title : '[No Title]' " v-else></span>
|
<span v-text="track.title?.length ? track.title : '[No Title]' " v-else></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="artist" v-if="track.artist?.length && (status.state === 'play' || status.state === 'pause')">
|
<div class="artist" v-if="track.artist?.length && (status.state === 'play' || status.state === 'pause')">
|
||||||
<a :href="$route.fullPath" v-text="track.artist" @click.prevent="$emit('search', {artist: track.artist})"></a>
|
<a v-text="track.artist" @click.prevent="searchArtist"></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -143,6 +151,11 @@ export default {
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
image: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
|
||||||
// Enabled playback buttons
|
// Enabled playback buttons
|
||||||
buttons: {
|
buttons: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
@ -187,6 +200,10 @@ export default {
|
||||||
duration() {
|
duration() {
|
||||||
return this.status?.duration != null ? this.status.duration : this.track?.duration
|
return this.status?.duration != null ? this.status.duration : this.track?.duration
|
||||||
},
|
},
|
||||||
|
|
||||||
|
trackImage() {
|
||||||
|
return this.track?.image || this.image
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -194,9 +211,33 @@ export default {
|
||||||
return (new Date()).getTime() / 1000
|
return (new Date()).getTime() / 1000
|
||||||
},
|
},
|
||||||
|
|
||||||
onImageClick() {
|
searchAlbum() {
|
||||||
if (this.track?.artist && this.track?.album)
|
if (!(this.track?.artist && this.track?.album))
|
||||||
this.$emit('search', {artist: this.track.artist, album: this.track.album})
|
return
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
artist: this.track.artist,
|
||||||
|
album: this.track.album,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.track.album_uri)
|
||||||
|
args.uris = [this.track.album_uri]
|
||||||
|
|
||||||
|
this.$emit('search', args)
|
||||||
|
},
|
||||||
|
|
||||||
|
searchArtist() {
|
||||||
|
if (!this.track?.artist)
|
||||||
|
return
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
artist: this.track.artist,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.track.artist_uri)
|
||||||
|
args.uris = [this.track.album_uri]
|
||||||
|
|
||||||
|
this.$emit('search', args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -245,7 +286,9 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.extension {
|
.extension {
|
||||||
box-shadow: $border-shadow-bottom;
|
background: $media-ctrl-ext-bg;
|
||||||
|
box-shadow: $media-ctrl-ext-shadow;
|
||||||
|
border-radius: 1em 1em 0 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
display: none;
|
display: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -368,6 +411,7 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-container {
|
.track-container {
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
@ -411,6 +455,7 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-info {
|
.track-info {
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
@include until($desktop) {
|
@include until($desktop) {
|
||||||
|
|
|
@ -4,11 +4,23 @@
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-container">
|
<div class="controls-container">
|
||||||
<Controls :status="status" :track="track" :buttons="buttons" @play="$emit('play', $event)"
|
<Controls :buttons="buttons"
|
||||||
@pause="$emit('pause', $event)" @stop="$emit('stop')" @previous="$emit('previous')"
|
:image="image"
|
||||||
@next="$emit('next')" @seek="$emit('seek', $event)" @set-volume="$emit('set-volume', $event)"
|
:status="status"
|
||||||
@consume="$emit('consume', $event)" @repeat="$emit('repeat', $event)" @random="$emit('random', $event)"
|
:track="track"
|
||||||
@search="$emit('search', $event)" @mute="$emit('mute')" @unmute="$emit('unmute')" />
|
@consume="$emit('consume', $event)"
|
||||||
|
@mute="$emit('mute')"
|
||||||
|
@next="$emit('next')"
|
||||||
|
@pause="$emit('pause', $event)"
|
||||||
|
@play="$emit('play', $event)"
|
||||||
|
@previous="$emit('previous')"
|
||||||
|
@random="$emit('random', $event)"
|
||||||
|
@repeat="$emit('repeat', $event)"
|
||||||
|
@search="$emit('search', $event)"
|
||||||
|
@seek="$emit('seek', $event)"
|
||||||
|
@set-volume="$emit('set-volume', $event)"
|
||||||
|
@stop="$emit('stop')"
|
||||||
|
@unmute="$emit('unmute')" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -50,6 +62,11 @@ export default {
|
||||||
type: Object,
|
type: Object,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
image: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
|
||||||
buttons: {
|
buttons: {
|
||||||
type: Object,
|
type: Object,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1 +1,12 @@
|
||||||
$media-ctrl-panel-height: 5.5em;
|
$media-ctrl-panel-height: 5.5em;
|
||||||
|
$media-header-height: 3.3em;
|
||||||
|
$media-nav-width: 2.8em;
|
||||||
|
$filter-header-height: 3em;
|
||||||
|
$default-media-img-bg: #d0dad8;
|
||||||
|
$default-media-img-fg: white;
|
||||||
|
$media-ctrl-ext-bg: radial-gradient(white, #e0e3d0a0);
|
||||||
|
$media-ctrl-ext-shadow: 0 0 1px 2px inset #c7c8c8;
|
||||||
|
|
||||||
|
.fa-youtube {
|
||||||
|
color: #d21;
|
||||||
|
}
|
||||||
|
|
|
@ -146,7 +146,7 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "vars";
|
@import "~@/components/Media/vars";
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -446,7 +446,6 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "vars";
|
|
||||||
@import "~@/components/Media/vars";
|
@import "~@/components/Media/vars";
|
||||||
|
|
||||||
.media-plugin {
|
.media-plugin {
|
||||||
|
|
|
@ -219,7 +219,7 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "vars";
|
@import "~@/components/Media/vars";
|
||||||
|
|
||||||
.media-info {
|
.media-info {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="item media-item" :class="{selected: selected}" v-if="!hidden">
|
<div
|
||||||
|
class="item media-item"
|
||||||
|
:class="{selected: selected}"
|
||||||
|
@click.right.prevent="$refs.dropdown.toggle()"
|
||||||
|
v-if="!hidden">
|
||||||
<div class="thumbnail">
|
<div class="thumbnail">
|
||||||
<MediaImage :item="item" @play="$emit('play')" />
|
<MediaImage :item="item" @play="$emit('play')" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,7 +12,7 @@
|
||||||
<div class="row title">
|
<div class="row title">
|
||||||
<div class="col-11 left side" v-text="item.title" @click="$emit('select')" />
|
<div class="col-11 left side" v-text="item.title" @click="$emit('select')" />
|
||||||
<div class="col-1 right side">
|
<div class="col-1 right side">
|
||||||
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h">
|
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h" ref="dropdown">
|
||||||
<DropdownItem icon-class="fa fa-play" text="Play" @click="$emit('play')"
|
<DropdownItem icon-class="fa fa-play" text="Play" @click="$emit('play')"
|
||||||
v-if="item.type !== 'torrent'" />
|
v-if="item.type !== 'torrent'" />
|
||||||
<DropdownItem icon-class="fa fa-download" text="Download" @click="$emit('download')"
|
<DropdownItem icon-class="fa fa-download" text="Download" @click="$emit('download')"
|
||||||
|
@ -71,7 +75,7 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "vars";
|
@import "~@/components/Media/vars";
|
||||||
|
|
||||||
.media-item {
|
.media-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -62,7 +62,7 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "vars";
|
@import "~@/components/Media/vars";
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -64,7 +64,7 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import 'vars.scss';
|
@import "~@/components/Media/vars";
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
width: $media-nav-width;
|
width: $media-nav-width;
|
||||||
|
|
|
@ -107,8 +107,8 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "src/style/items";
|
@import "~@/style/items";
|
||||||
@import "vars";
|
@import "~@/components/Media/vars";
|
||||||
|
|
||||||
.media-results {
|
.media-results {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
$media-header-height: 3.3em;
|
|
||||||
$media-nav-width: 2.8em;
|
|
||||||
$filter-header-height: 3em;
|
|
||||||
$default-media-img-bg: #d0dad8;
|
|
||||||
$default-media-img-fg: white;
|
|
||||||
|
|
||||||
.fa-youtube {
|
|
||||||
color: #d21;
|
|
||||||
}
|
|
|
@ -0,0 +1,487 @@
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<MusicPlugin :plugin-name="pluginName"
|
||||||
|
:config="config"
|
||||||
|
:edited-playlist-tracks="editedPlaylistTracks"
|
||||||
|
:edited-playlist="editedPlaylist"
|
||||||
|
:images="images"
|
||||||
|
:library-results="libraryResults"
|
||||||
|
:loading="loading"
|
||||||
|
:path="path"
|
||||||
|
:playlists="playlists"
|
||||||
|
:search-results="searchResults"
|
||||||
|
:status="status"
|
||||||
|
:track="track"
|
||||||
|
:track-info="trackInfo"
|
||||||
|
:tracks="tracks"
|
||||||
|
@add-to-playlist="addToPlaylist"
|
||||||
|
@add-to-tracklist-from-edited-playlist="addToTracklistFromEditedPlaylist"
|
||||||
|
@add-to-tracklist="addToTracklist"
|
||||||
|
@cd="cd"
|
||||||
|
@clear="clear"
|
||||||
|
@consume="consume"
|
||||||
|
@info="trackInfo = $event"
|
||||||
|
@load-playlist="loadPlaylist"
|
||||||
|
@new-playing-track="refreshStatus(true, true, $event)"
|
||||||
|
@next="next"
|
||||||
|
@pause="pause"
|
||||||
|
@play-playlist="playPlaylist"
|
||||||
|
@play="play"
|
||||||
|
@playlist-add="playlistAdd"
|
||||||
|
@playlist-edit="playlistEditChanged"
|
||||||
|
@playlist-track-move="playlistTrackMove"
|
||||||
|
@playlist-update="refresh(true)"
|
||||||
|
@previous="previous"
|
||||||
|
@random="random"
|
||||||
|
@remove-from-playlist="removeFromPlaylist"
|
||||||
|
@remove-from-tracklist="removeFromTracklist"
|
||||||
|
@remove-playlist="removePlaylist"
|
||||||
|
@repeat="repeat"
|
||||||
|
@search-clear="searchResults = []"
|
||||||
|
@search="search"
|
||||||
|
@seek="seek"
|
||||||
|
@set-volume="setVolume"
|
||||||
|
@status-update="refreshStatus(true, true, $event)"
|
||||||
|
@stop="stop"
|
||||||
|
@swap-tracks="swapTracks"
|
||||||
|
@tracklist-move="moveTracklistTracks"
|
||||||
|
@tracklist-save="saveToPlaylist" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import MusicPlugin from "@/components/panels/Music/Index"
|
||||||
|
import Utils from "@/Utils"
|
||||||
|
import Loading from "@/components/Loading"
|
||||||
|
import Status from "@/mixins/Music/Status";
|
||||||
|
import { bus } from "@/bus";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {Loading, MusicPlugin},
|
||||||
|
mixins: [Status, Utils],
|
||||||
|
props: {
|
||||||
|
config: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
|
||||||
|
pluginName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchStatusOnUpdate: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
tracks: [],
|
||||||
|
playlists: [],
|
||||||
|
status_: {},
|
||||||
|
images: {},
|
||||||
|
editedPlaylist: null,
|
||||||
|
editedPlaylistTracks: [],
|
||||||
|
trackInfo: null,
|
||||||
|
searchResults: [],
|
||||||
|
libraryResults: [],
|
||||||
|
path: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
status() {
|
||||||
|
const status = {...this.status_}
|
||||||
|
// This is the standard case for new integrations, where elapsed isn't
|
||||||
|
// reported and time is not a string in the format `elapsed:duration`.
|
||||||
|
// In this case, time is elapsed time.
|
||||||
|
if (!status.elapsed && !isNaN(parseFloat(status.time)))
|
||||||
|
status.elapsed = status.time
|
||||||
|
|
||||||
|
return status
|
||||||
|
},
|
||||||
|
|
||||||
|
track() {
|
||||||
|
let pos = null
|
||||||
|
if (this.status?.playingPos != null)
|
||||||
|
pos = this.status.playingPos
|
||||||
|
else if (this.status?.track?.pos != null)
|
||||||
|
pos = this.status.track.pos
|
||||||
|
|
||||||
|
if (pos == null)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return this.tracks[pos]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async refreshTracks(background) {
|
||||||
|
if (!background)
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.tracks = await this.request(`${this.pluginName}.get_tracks`)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setStatusFromEvent(event) {
|
||||||
|
if (!event)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (event.status)
|
||||||
|
this.status_ = this.parseStatus(event.status)
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshStatus(background, isStatusUpdate, event) {
|
||||||
|
if (isStatusUpdate && !this.fetchStatusOnUpdate) {
|
||||||
|
this.setStatusFromEvent(event)
|
||||||
|
} else {
|
||||||
|
if (!background)
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.status_ = this.parseStatus(await this.request(`${this.pluginName}.status`))
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshCurrentImage()
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshCurrentImage() {
|
||||||
|
const curTrack = this.track?.uri || this.track?.file
|
||||||
|
if (!curTrack || curTrack in this.images)
|
||||||
|
return
|
||||||
|
|
||||||
|
await this.refreshImages([this.track])
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshImages(tracks) {
|
||||||
|
Object.entries(
|
||||||
|
await this.request(
|
||||||
|
`${this.pluginName}.get_images`, {
|
||||||
|
resources: [
|
||||||
|
...new Set(
|
||||||
|
tracks
|
||||||
|
.map((track) => track.uri || track.file)
|
||||||
|
.filter((uri) => uri && !(uri in this.images))
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).forEach(([uri, image]) => {
|
||||||
|
this.images[uri] = image
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshPlaylists(background) {
|
||||||
|
if (!background)
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.playlists = (await this.request(`${this.pluginName}.get_playlists`)).map((playlist) => {
|
||||||
|
return {
|
||||||
|
...playlist,
|
||||||
|
lastModified: playlist.last_modified,
|
||||||
|
}
|
||||||
|
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh(background) {
|
||||||
|
if (!background)
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
this.refreshTracks(background),
|
||||||
|
this.refreshStatus(background),
|
||||||
|
this.refreshPlaylists(background),
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async play(event) {
|
||||||
|
if (event?.pos != null) {
|
||||||
|
await this.request(`${this.pluginName}.play_pos`, {pos: event.pos})
|
||||||
|
} else if (event?.file) {
|
||||||
|
await this.request(`${this.pluginName}.play`, {resource: event.file})
|
||||||
|
} else {
|
||||||
|
await this.request(`${this.pluginName}.play`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshStatus(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async pause() {
|
||||||
|
await this.request(`${this.pluginName}.pause`)
|
||||||
|
await this.refreshStatus(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await this.request(`${this.pluginName}.stop`)
|
||||||
|
await this.refreshStatus(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async previous() {
|
||||||
|
await this.request(`${this.pluginName}.previous`)
|
||||||
|
await this.refreshStatus(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async next() {
|
||||||
|
await this.request(`${this.pluginName}.next`)
|
||||||
|
await this.refreshStatus(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
await this.request(`${this.pluginName}.clear`)
|
||||||
|
await Promise.all([this.refreshStatus(true), this.refreshTracks(true)])
|
||||||
|
},
|
||||||
|
|
||||||
|
async setVolume(volume) {
|
||||||
|
if (volume === this.status.volume)
|
||||||
|
return
|
||||||
|
|
||||||
|
await this.request(`${this.pluginName}.set_volume`, {volume: volume})
|
||||||
|
await this.refreshStatus(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async seek(pos) {
|
||||||
|
await this.request(`${this.pluginName}.seek`, {position: pos})
|
||||||
|
await this.refreshStatus(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async repeat(value) {
|
||||||
|
await this.request(`${this.pluginName}.repeat`, {value: !!parseInt(+value)})
|
||||||
|
await this.refreshStatus(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async random(value) {
|
||||||
|
await this.request(`${this.pluginName}.random`, {value: !!parseInt(+value)})
|
||||||
|
await this.refreshStatus(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async consume(value) {
|
||||||
|
await this.request(`${this.pluginName}.consume`, {value: !!parseInt(+value)})
|
||||||
|
await this.refreshStatus(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async addToTracklist(resource) {
|
||||||
|
if (resource.file)
|
||||||
|
resource = resource.file
|
||||||
|
|
||||||
|
await this.request(`${this.pluginName}.add`, {resource: resource})
|
||||||
|
await this.refresh(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async addToTracklistFromEditedPlaylist(event) {
|
||||||
|
const tracks = event?.tracks?.map(
|
||||||
|
(pos) => this.editedPlaylistTracks[pos]
|
||||||
|
)?.filter((track) => track?.file)?.map((track) => track.file)
|
||||||
|
|
||||||
|
if (!tracks?.length)
|
||||||
|
return
|
||||||
|
|
||||||
|
await Promise.all(tracks.map((track) => this.request(`${this.pluginName}.add`, {resource: track})))
|
||||||
|
await this.refresh(true)
|
||||||
|
|
||||||
|
if (event.play)
|
||||||
|
await this.request(`${this.pluginName}.play_pos`, {pos: this.tracks.length - tracks.length})
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeFromPlaylist(positions) {
|
||||||
|
await this.request(
|
||||||
|
`${this.pluginName}.remove_from_playlist`,
|
||||||
|
{resources: positions, playlist: this.playlists[this.editedPlaylist].name}
|
||||||
|
)
|
||||||
|
await this.playlistEditChanged(this.editedPlaylist)
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeFromTracklist(positions) {
|
||||||
|
await this.request(`${this.pluginName}.delete`, {positions: positions.sort()})
|
||||||
|
await this.refresh(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async swapTracks(positions) {
|
||||||
|
await this.request(`${this.pluginName}.move`, {from_pos: positions[0], to_pos: positions[1]})
|
||||||
|
await this.refresh(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async playPlaylist(position) {
|
||||||
|
await this._loadPlaylist(position, true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadPlaylist(position) {
|
||||||
|
await this._loadPlaylist(position, false)
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadPlaylist(position, play) {
|
||||||
|
const playlist = this.playlists[position]
|
||||||
|
await this.request(
|
||||||
|
`${this.pluginName}.load`, {
|
||||||
|
playlist: (playlist.uri || playlist.name), play: play
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await this.refresh(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async removePlaylist(position) {
|
||||||
|
const playlist = this.playlists[position]
|
||||||
|
if (!confirm(`Are you REALLY sure that you want to remove the playlist ${playlist.name}?`))
|
||||||
|
return
|
||||||
|
|
||||||
|
await this.request(`${this.pluginName}.delete_playlist`, {playlist: playlist.name})
|
||||||
|
await this.refreshPlaylists(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveToPlaylist(name) {
|
||||||
|
await this.request(`${this.pluginName}.save`, {name: name})
|
||||||
|
await this.refreshPlaylists(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
splitMoveTracksIntoChunks(event) {
|
||||||
|
// Split the selected source tracks into chunks containing consecutive
|
||||||
|
// tracks, since the music plugin move API exposes `start`, `end` and
|
||||||
|
// `position` parameters.
|
||||||
|
let chunk = [];
|
||||||
|
let offset = event.to;
|
||||||
|
const chunks = (event?.from || [])
|
||||||
|
.map((i) => parseInt(i))
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.reduce((acc, pos, idx) => {
|
||||||
|
if (idx === 0 || (chunk.length > 0 && pos === chunk[chunk.length - 1] + 1)) {
|
||||||
|
chunk.push(pos)
|
||||||
|
} else {
|
||||||
|
acc.push(chunk)
|
||||||
|
chunk = [pos]
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (chunk.length > 0)
|
||||||
|
chunks.push(chunk)
|
||||||
|
|
||||||
|
return chunks.map((chunk) => {
|
||||||
|
const start = chunk[0]
|
||||||
|
const end = chunk[chunk.length - 1] === chunk[0] ? chunk[0] : chunk[chunk.length - 1] + 1
|
||||||
|
let ret = {
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
position: offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += chunk.length
|
||||||
|
return ret
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async moveTracklistTracks(event) {
|
||||||
|
for (const chunk of this.splitMoveTracksIntoChunks(event)) {
|
||||||
|
await this.request(`${this.pluginName}.move`, chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.fetchStatusOnUpdate)
|
||||||
|
await this.refreshTracks(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
async playlistAdd(track) {
|
||||||
|
await this.request(
|
||||||
|
`${this.pluginName}.add_to_playlist`,
|
||||||
|
{resources: [track], playlist: this.playlists[this.editedPlaylist].name}
|
||||||
|
)
|
||||||
|
await this.playlistEditChanged(this.editedPlaylist)
|
||||||
|
},
|
||||||
|
|
||||||
|
async playlistEditChanged(playlist) {
|
||||||
|
this.editedPlaylist = playlist
|
||||||
|
if (playlist == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
this.editedPlaylistTracks = await this.request(
|
||||||
|
`${this.pluginName}.get_playlist`,
|
||||||
|
{playlist: this.playlists[playlist].name}
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addToPlaylist(event) {
|
||||||
|
await Promise.all(event.playlists.map(async (playlistIdx) => {
|
||||||
|
await this.request(`${this.pluginName}.add_to_playlist`, {
|
||||||
|
resources: [event.track.file],
|
||||||
|
playlist: this.playlists[playlistIdx].name
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.playlistEditChanged(playlistIdx)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
async playlistTrackMove(event) {
|
||||||
|
const playlist = this.playlists[event.playlist]
|
||||||
|
if (!playlist)
|
||||||
|
return
|
||||||
|
|
||||||
|
for (const chunk of this.splitMoveTracksIntoChunks(event)) {
|
||||||
|
await this.request(
|
||||||
|
`${this.pluginName}.playlist_move`, {
|
||||||
|
playlist: playlist.uri || playlist.name,
|
||||||
|
start: chunk.start,
|
||||||
|
end: chunk.end,
|
||||||
|
position: chunk.position,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.playlistEditChanged(event.playlist)
|
||||||
|
},
|
||||||
|
|
||||||
|
async search(query) {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.searchResults = await this.request(`${this.pluginName}.search`, {filter: query})
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async cd(path) {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
let uri = path
|
||||||
|
if (Array.isArray(path))
|
||||||
|
uri = path.length === 0 ? null : path[path.length - 1]
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.libraryResults = (
|
||||||
|
await this.request(`${this.pluginName}.browse`, {uri: uri})
|
||||||
|
).filter((result) => !result.playlist)
|
||||||
|
|
||||||
|
this.path = path
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
bus.on('connected', this.refresh)
|
||||||
|
this.refresh()
|
||||||
|
this.cd(this.path)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -1,10 +1,21 @@
|
||||||
<template>
|
<template>
|
||||||
<Loading v-if="loading" />
|
<Loading v-if="loading" />
|
||||||
|
|
||||||
<MediaView :plugin-name="pluginName" :status="status" :track="track" @play="$emit('play', $event)"
|
<MediaView :plugin-name="pluginName"
|
||||||
@pause="$emit('pause')" @stop="$emit('stop')" @previous="$emit('previous')" @next="$emit('next')"
|
:image="images[track?.uri || track?.file]"
|
||||||
@set-volume="$emit('set-volume', $event)" @seek="$emit('seek', $event)" @consume="$emit('consume', $event)"
|
:status="status"
|
||||||
@repeat="$emit('repeat', $event)" @random="$emit('random', $event)" @search="search" v-else>
|
:track="track"
|
||||||
|
@next="$emit('next')"
|
||||||
|
@pause="$emit('pause')"
|
||||||
|
@play="$emit('play', $event)"
|
||||||
|
@previous="$emit('previous')"
|
||||||
|
@random="$emit('random', $event)"
|
||||||
|
@repeat="$emit('repeat', $event)"
|
||||||
|
@search="search"
|
||||||
|
@seek="$emit('seek', $event)" @consume="$emit('consume', $event)"
|
||||||
|
@set-volume="$emit('set-volume', $event)"
|
||||||
|
@stop="$emit('stop')"
|
||||||
|
v-else>
|
||||||
<main>
|
<main>
|
||||||
<div class="nav-container mobile" v-if="navVisible">
|
<div class="nav-container mobile" v-if="navVisible">
|
||||||
<Nav :selected-view="selectedView"
|
<Nav :selected-view="selectedView"
|
||||||
|
@ -207,7 +218,6 @@ import Library from "@/components/panels/Music/Library";
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Music",
|
|
||||||
emits: [
|
emits: [
|
||||||
'add-to-playlist',
|
'add-to-playlist',
|
||||||
'add-to-tracklist',
|
'add-to-tracklist',
|
||||||
|
@ -268,6 +278,11 @@ export default {
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
images: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
|
||||||
editedPlaylistTracks: {
|
editedPlaylistTracks: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
|
@ -283,12 +298,18 @@ export default {
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
track: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
|
||||||
editedPlaylist: {
|
editedPlaylist: {
|
||||||
type: Number,
|
type: Number,
|
||||||
},
|
},
|
||||||
|
|
||||||
trackInfo: {
|
trackInfo: {
|
||||||
type: String,
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
|
||||||
searchResults: {
|
searchResults: {
|
||||||
|
@ -300,7 +321,8 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
path: {
|
path: {
|
||||||
type: String,
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
|
||||||
devices: {
|
devices: {
|
||||||
|
@ -326,15 +348,6 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
|
||||||
track() {
|
|
||||||
if (this.status?.playingPos == null)
|
|
||||||
return null
|
|
||||||
|
|
||||||
return this.tracks[this.status.playingPos]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
async onStatusEvent(event) {
|
async onStatusEvent(event) {
|
||||||
if (event.plugin_name !== this.pluginName)
|
if (event.plugin_name !== this.pluginName)
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
</MusicHeader>
|
</MusicHeader>
|
||||||
|
|
||||||
<div class="results">
|
<div class="results">
|
||||||
<div class="row track back-track" @click="back" v-if="path !== '/'">
|
<div class="row track back-track" @click="back" v-if="!isRoot">
|
||||||
<div class="icon-container">
|
<div class="icon-container">
|
||||||
<i class="icon fa fa-folder" />
|
<i class="icon fa fa-folder" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -28,13 +28,19 @@
|
||||||
v-for="(result, i) in results" :key="i" @click="resultClick(i, $event)">
|
v-for="(result, i) in results" :key="i" @click="resultClick(i, $event)">
|
||||||
<div class="col-10 left-side">
|
<div class="col-10 left-side">
|
||||||
<div class="icon-container">
|
<div class="icon-container">
|
||||||
<i class="icon fa fa-folder" v-if="result.directory" />
|
<i class="icon fa fa-folder" v-if="isDirectory(i)" />
|
||||||
|
<i class="icon fa fa-user" v-else-if="isArtist(i)" />
|
||||||
|
<i class="icon fa fa-compact-disc" v-else-if="isAlbum(i)" />
|
||||||
|
<i class="icon fa fa-list" v-else-if="isPlaylist(i)" />
|
||||||
<i class="icon fa fa-music" v-else-if="result.file" />
|
<i class="icon fa fa-music" v-else-if="result.file" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<span v-if="result.directory" v-text="result.directory.split('/').pop()" />
|
<span v-if="isDirectory(i)" v-text="result.name || result.directory.split('/').pop()" />
|
||||||
|
<span v-else-if="isArtist(i)" v-text="result.name || result.artist" />
|
||||||
|
<span v-else-if="isAlbum(i)" v-text="result.name || result.album" />
|
||||||
|
<span v-else-if="isPlaylist(i)" v-text="result.name || result.playlist" />
|
||||||
<span v-else-if="result.title" v-text="result.title" />
|
<span v-else-if="result.title" v-text="result.title" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -96,7 +102,8 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
path: {
|
path: {
|
||||||
type: String,
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
|
||||||
devices: {
|
devices: {
|
||||||
|
@ -144,6 +151,10 @@ export default {
|
||||||
(result?.directory || '').toLowerCase().indexOf(filter) >= 0
|
(result?.directory || '').toLowerCase().indexOf(filter) >= 0
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isRoot() {
|
||||||
|
return !this.path?.length || !this.path[0]?.length || this.path[0] === '/'
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -161,8 +172,9 @@ export default {
|
||||||
else
|
else
|
||||||
this.selectedResults.add(pos)
|
this.selectedResults.add(pos)
|
||||||
} else {
|
} else {
|
||||||
if (this.results[pos].directory) {
|
if (this.isDirectory(pos) || this.isArtist(pos) || this.isAlbum(pos) || this.isPlaylist(pos)) {
|
||||||
this.$emit('cd', this.results[pos].directory)
|
const dir = this.results[pos].uri || this.results[pos].directory
|
||||||
|
this.$emit('cd', [...this.path, dir])
|
||||||
} else {
|
} else {
|
||||||
this.selectedResults = new Set()
|
this.selectedResults = new Set()
|
||||||
if (this.selectedResults.has(pos))
|
if (this.selectedResults.has(pos))
|
||||||
|
@ -191,8 +203,26 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
back() {
|
back() {
|
||||||
const path = this.path.split('/')
|
if (this.isRoot)
|
||||||
this.$emit('cd', path.slice(0, path.length-1).join('/'))
|
return
|
||||||
|
|
||||||
|
this.$emit('cd', this.path.slice(0, -1))
|
||||||
|
},
|
||||||
|
|
||||||
|
isDirectory(i) {
|
||||||
|
return this.results[i].directory || this.results[i].type === 'directory'
|
||||||
|
},
|
||||||
|
|
||||||
|
isArtist(i) {
|
||||||
|
return this.results[i].type === 'artist'
|
||||||
|
},
|
||||||
|
|
||||||
|
isAlbum(i) {
|
||||||
|
return this.results[i].type === 'album'
|
||||||
|
},
|
||||||
|
|
||||||
|
isPlaylist(i) {
|
||||||
|
return this.results[i].type === 'playlist'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,41 +59,38 @@
|
||||||
@dragover="onTrackDragOver(i)"
|
@dragover="onTrackDragOver(i)"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
v-for="i in displayedTrackIndices"
|
v-for="i in displayedTrackIndices"
|
||||||
:set="track = tracks[i]"
|
|
||||||
:key="i"
|
:key="i"
|
||||||
:data-index="i"
|
:data-index="i"
|
||||||
:class="trackClass(i)"
|
:class="trackClass(i)"
|
||||||
@click="onTrackClick($event, i)"
|
@click.left="onTrackClick($event, i)"
|
||||||
|
@click.right.prevent="$refs['menu' + i][0].toggle($event)"
|
||||||
@dblclick="$emit('play', {pos: i})">
|
@dblclick="$emit('play', {pos: i})">
|
||||||
<div class="col-10">
|
<div class="col-10">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
{{ track.title || '[No Title]' }}
|
{{ tracks[i].title || '[No Title]' }}
|
||||||
<div class="playing-icon" :class="{paused: status?.state === 'pause'}"
|
<div class="playing-icon" :class="{paused: status?.state === 'pause'}" v-if="isPlayingTrack(i)">
|
||||||
v-if="status?.playingPos === i && (status?.state === 'play' || status?.state === 'pause')">
|
|
||||||
<span v-for="i in [...Array(3).keys()]" :key="i" />
|
<span v-for="i in [...Array(3).keys()]" :key="i" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="artist" v-if="track.artist">
|
<div class="artist" v-if="tracks[i].artist">
|
||||||
<a :href="$route.fullPath" v-text="track.artist"
|
<a v-text="tracks[i].artist" @click.prevent="searchArtist(tracks[i])" />
|
||||||
@click.prevent="$emit('search', {artist: track.artist})" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="album" v-if="track.album">
|
<div class="album" v-if="tracks[i].album">
|
||||||
<a :href="$route.fullPath" v-text="track.album"
|
<a v-text="tracks[i].album" @click.prevent="searchAlbum(tracks[i])" />
|
||||||
@click.prevent="$emit('search', {artist: track.artist, album: track.album})" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-2 right-side">
|
<div class="col-2 right-side">
|
||||||
<span class="duration" v-text="track.time ? convertTime(track.time) : '-:--'" />
|
<span class="duration" v-text="tracks[i].time ? convertTime(tracks[i].time) : '-:--'" />
|
||||||
|
|
||||||
<span class="actions">
|
<span class="actions">
|
||||||
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h">
|
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h" :ref="'menu' + i">
|
||||||
<DropdownItem text="Play" icon-class="fa fa-play" @click="onMenuPlay" />
|
<DropdownItem text="Play" icon-class="fa fa-play" @click="onMenuPlay(i)" />
|
||||||
<DropdownItem text="Add to queue" icon-class="fa fa-plus"
|
<DropdownItem text="Add to queue" icon-class="fa fa-plus"
|
||||||
@click="$emit('add-to-queue', [...(new Set([...selectedTracks, i]))])" v-if="withAddToQueue" />
|
@click="$emit('add-to-queue', [...(new Set([...selectedTracks, i]))])" v-if="withAddToQueue" />
|
||||||
<DropdownItem text="Add to playlist" icon-class="fa fa-list-ul" @click="$emit('add-to-playlist', track)" />
|
<DropdownItem text="Add to playlist" icon-class="fa fa-list-ul" @click="$emit('add-to-playlist', tracks[i])" />
|
||||||
<DropdownItem text="Remove" icon-class="fa fa-trash" @click="$emit('remove', [...(new Set([...selectedTracks, i]))])" />
|
<DropdownItem text="Remove" icon-class="fa fa-trash" @click="$emit('remove', [...(new Set([...selectedTracks, i]))])" />
|
||||||
<DropdownItem text="Info" icon-class="fa fa-info" @click="$emit('info', tracks[i])" />
|
<DropdownItem text="Info" icon-class="fa fa-info" @click="$emit('info', tracks[i])" />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
@ -260,6 +257,10 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
getTrackElements() {
|
||||||
|
return this.$refs.body.querySelectorAll('.track')
|
||||||
|
},
|
||||||
|
|
||||||
onTrackClick(event, pos) {
|
onTrackClick(event, pos) {
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
const selectedTracks = this.selectedTracks.sort()
|
const selectedTracks = this.selectedTracks.sort()
|
||||||
|
@ -293,10 +294,23 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isPlayingTrack(i) {
|
||||||
|
// If the state is not play or pause, then no track is playing.
|
||||||
|
if (this.status?.state !== 'play' && this.status?.state !== 'pause')
|
||||||
|
return false
|
||||||
|
|
||||||
|
// If withAddToQueue is not enabled, then we are on the tracklist view.
|
||||||
|
// The playing track is only highlighted if the track is playing.
|
||||||
|
return (
|
||||||
|
!this.withAddToQueue &&
|
||||||
|
this.status?.playingPos === i
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
trackClass(i) {
|
trackClass(i) {
|
||||||
return {
|
return {
|
||||||
selected: this.selectedTracksSet.has(i),
|
selected: this.selectedTracksSet.has(i),
|
||||||
active: !this.withAddToQueue && this.status?.playingPos === i,
|
active: this.isPlayingTrack(i),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -317,23 +331,53 @@ export default {
|
||||||
|
|
||||||
onTrackDragStart(track) {
|
onTrackDragStart(track) {
|
||||||
this.sourcePos = track
|
this.sourcePos = track
|
||||||
|
if (!this.selectedTracksSet.has(track))
|
||||||
|
this.selectedTracks = [track]
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const selectedTracks = [...this.getTrackElements()].filter(
|
||||||
|
(_, i) => this.selectedTracksSet.has(i)
|
||||||
|
)
|
||||||
|
|
||||||
|
selectedTracks.forEach((track) => track.classList.add('dragging'))
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
onTrackDragEnd() {
|
onTrackDragEnd() {
|
||||||
this.$refs.body.querySelectorAll('.track').forEach((track) => track.classList.remove('dragover'));
|
this.getTrackElements().forEach((track) => {
|
||||||
if (this.sourcePos == null || this.targetPos == null || this.sourcePos === this.targetPos)
|
track.classList.remove('dragover')
|
||||||
return
|
track.classList.remove('top')
|
||||||
|
track.classList.remove('bottom')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!(this.sourcePos == null || this.targetPos == null || this.sourcePos === this.targetPos)) {
|
||||||
|
const from = this.selectedTracks.length ? this.selectedTracks : [this.sourcePos]
|
||||||
|
this.$emit('move', {from: from, to: this.targetPos})
|
||||||
|
}
|
||||||
|
|
||||||
this.$emit('move', {from: this.sourcePos, to: this.targetPos})
|
|
||||||
this.sourcePos = null
|
this.sourcePos = null
|
||||||
this.targetPos = null
|
this.targetPos = null
|
||||||
|
this.selectedTracks = []
|
||||||
|
this.getTrackElements().forEach((track) => track.classList.remove('dragging'))
|
||||||
},
|
},
|
||||||
|
|
||||||
onTrackDragOver(track) {
|
onTrackDragOver(track) {
|
||||||
this.targetPos = track
|
this.targetPos = track
|
||||||
const tracks = this.$refs.body.querySelectorAll('.track')
|
const tracks = this.getTrackElements()
|
||||||
tracks.forEach((track) => track.classList.remove('dragover'));
|
const trackEl = [...tracks].find((t) => parseInt(t.dataset.index || -1) === track)
|
||||||
[...tracks][track].classList.add('dragover')
|
const minSelected = Math.min(...this.selectedTracks)
|
||||||
|
|
||||||
|
tracks.forEach((track) => {
|
||||||
|
track.classList.remove('dragover')
|
||||||
|
track.classList.remove('top')
|
||||||
|
track.classList.remove('bottom')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (track === minSelected)
|
||||||
|
return
|
||||||
|
|
||||||
|
trackEl.classList.add('dragover')
|
||||||
|
track > minSelected ? trackEl.classList.add('bottom') : trackEl.classList.add('top')
|
||||||
},
|
},
|
||||||
|
|
||||||
onScroll() {
|
onScroll() {
|
||||||
|
@ -388,15 +432,51 @@ export default {
|
||||||
this.mounted = true
|
this.mounted = true
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
searchArtist(track) {
|
||||||
|
const args = {}
|
||||||
|
if (track.artist_uri)
|
||||||
|
args.uris = [track.artist_uri]
|
||||||
|
|
||||||
|
if (track.artist)
|
||||||
|
args.artist = track.artist
|
||||||
|
else {
|
||||||
|
console.warn('No artist information available')
|
||||||
|
console.debug(track)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('search', args)
|
||||||
|
},
|
||||||
|
|
||||||
|
searchAlbum(track) {
|
||||||
|
const args = {}
|
||||||
|
if (track.album_uri)
|
||||||
|
args.uris = [track.album_uri]
|
||||||
|
|
||||||
|
if (track.artist && track.album) {
|
||||||
|
args.artist = track.artist
|
||||||
|
args.album = track.album
|
||||||
|
} else {
|
||||||
|
console.warn('No artist/album information available')
|
||||||
|
console.debug(track)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('search', args)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
|
// Add the scrolling listeners only if we are on the queue view.
|
||||||
|
if (!this.withAddToQueue) {
|
||||||
this.scrollToTrack()
|
this.scrollToTrack()
|
||||||
this.$watch(() => this.status, () => this.scrollToTrack())
|
this.$watch(() => this.status, () => this.scrollToTrack())
|
||||||
this.$watch(() => this.filter, (filter) => {
|
this.$watch(() => this.filter, (filter) => {
|
||||||
if (!filter?.length)
|
if (!filter?.length)
|
||||||
this.scrollToTrack()
|
this.scrollToTrack()
|
||||||
})
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -61,7 +61,10 @@
|
||||||
<div class="row playlist" :class="{hidden: !displayedPlaylists.has(i)}"
|
<div class="row playlist" :class="{hidden: !displayedPlaylists.has(i)}"
|
||||||
v-for="(playlist, i) in playlists" :key="i" @click="$emit('playlist-edit', i)"
|
v-for="(playlist, i) in playlists" :key="i" @click="$emit('playlist-edit', i)"
|
||||||
@dblclick="$emit('load', i)">
|
@dblclick="$emit('load', i)">
|
||||||
<div class="col-10">
|
<div class="col-10 name-container">
|
||||||
|
<div class="icon">
|
||||||
|
<i class="fa fa-list" />
|
||||||
|
</div>
|
||||||
<div class="name" v-text="playlist.name || '[No Name]'" />
|
<div class="name" v-text="playlist.name || '[No Name]'" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -251,9 +254,10 @@ export default {
|
||||||
if (this.sourcePos == null || this.targetPos == null || this.sourcePos === this.targetPos)
|
if (this.sourcePos == null || this.targetPos == null || this.sourcePos === this.targetPos)
|
||||||
return
|
return
|
||||||
|
|
||||||
this.$emit('track-move', {from: this.sourcePos, to: this.targetPos, playlist: this.editedPlaylist})
|
this.$emit('track-move', {from: this.selectedTracks, to: this.targetPos, playlist: this.editedPlaylist})
|
||||||
this.sourcePos = null
|
this.sourcePos = null
|
||||||
this.targetPos = null
|
this.targetPos = null
|
||||||
|
this.selectedTracks = []
|
||||||
},
|
},
|
||||||
|
|
||||||
onTrackDragOver(track) {
|
onTrackDragOver(track) {
|
||||||
|
@ -301,6 +305,22 @@ export default {
|
||||||
box-shadow: 0 2.5px 2px -1px $default-shadow-color;
|
box-shadow: 0 2.5px 2px -1px $default-shadow-color;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
.name-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-right: .5em;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $hover-bg;
|
background: $hover-bg;
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,12 +64,24 @@
|
||||||
<div class="row track" :class="{selected: selectedResults.has(i), hidden: !displayedTracks.has(i)}"
|
<div class="row track" :class="{selected: selectedResults.has(i), hidden: !displayedTracks.has(i)}"
|
||||||
v-for="(result, i) in results" :key="i" @click="resultClick(i, $event)">
|
v-for="(result, i) in results" :key="i" @click="resultClick(i, $event)">
|
||||||
<div class="col-10">
|
<div class="col-10">
|
||||||
<div class="title">
|
<div class="title-container">
|
||||||
{{ result.title || '[No Title]' }}
|
<div class="type" :title="result.type" v-if="result.type">
|
||||||
|
<i class="fa fa-user" v-if="result.type === 'artist'" />
|
||||||
|
<i class="fa fa-compact-disc" v-else-if="result.type === 'album'" />
|
||||||
|
<i class="fa fa-list" v-else-if="result.type === 'playlist'" />
|
||||||
|
<i class="fa fa-music" v-else />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="artist" v-text="result.artist" v-if="result.artist?.length" />
|
<div class="title">
|
||||||
<div class="album" v-text="result.album" v-if="result.album?.length" />
|
<span v-if="result.type === 'playlist'">{{ result.name || result.title || '[No Name]' }}</span>
|
||||||
|
<span v-else-if="result.type === 'artist'">{{ result.name || result.title || result.artist || '[No Name]' }}</span>
|
||||||
|
<span v-else-if="result.type === 'album'">{{ result.name || result.title || result.album || '[No Title]' }}</span>
|
||||||
|
<span v-else>{{ result.title || '[No Title]' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="artist" v-text="result.artist" v-if="result.artist?.length && result.type !== 'artist'" />
|
||||||
|
<div class="album" v-text="result.album" v-if="result.album?.length && result.type !== 'album'" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-2 right-side">
|
<div class="col-2 right-side">
|
||||||
|
@ -298,6 +310,16 @@ export default {
|
||||||
height: calc(100% - #{$music-header-height});
|
height: calc(100% - #{$music-header-height});
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
|
.title-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.type {
|
||||||
|
opacity: .85;
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.header) {
|
:deep(.header) {
|
||||||
|
|
|
@ -18,9 +18,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&.dragover {
|
&.dragover {
|
||||||
|
&.top {
|
||||||
border-top: 2px solid $default-hover-fg;
|
border-top: 2px solid $default-hover-fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.bottom {
|
||||||
|
border-bottom: 2px solid $default-hover-fg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
|
||||||
&::selection {
|
&::selection {
|
||||||
background: rgba(0, 0, 0, 0) !important;
|
background: rgba(0, 0, 0, 0) !important;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
<template>
|
||||||
|
<MusicPlugin plugin-name="music.mopidy" :fetch-status-on-update="false" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import MusicPlugin from "@/components/panels/Music/Common"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {MusicPlugin},
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -1,345 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<Loading v-if="loading" />
|
<MusicPlugin plugin-name="music.mpd" />
|
||||||
<MusicPlugin plugin-name="music.mpd" :loading="loading" :config="config" :tracks="tracks" :status="status"
|
|
||||||
:playlists="playlists" :edited-playlist="editedPlaylist" :edited-playlist-tracks="editedPlaylistTracks"
|
|
||||||
:track-info="trackInfo" :search-results="searchResults" :library-results="libraryResults" :path="path"
|
|
||||||
@play="play" @pause="pause" @stop="stop" @previous="previous" @next="next" @clear="clear"
|
|
||||||
@set-volume="setVolume" @seek="seek" @consume="consume" @random="random" @repeat="repeat"
|
|
||||||
@status-update="refreshStatus(true)" @playlist-update="refresh(true)"
|
|
||||||
@new-playing-track="refreshStatus(true)" @remove-from-tracklist="removeFromTracklist"
|
|
||||||
@add-to-tracklist="addToTracklist" @swap-tracks="swapTracks" @load-playlist="loadPlaylist"
|
|
||||||
@play-playlist="playPlaylist" @remove-playlist="removePlaylist" @tracklist-move="moveTracklistTracks"
|
|
||||||
@tracklist-save="saveToPlaylist" @playlist-edit="playlistEditChanged"
|
|
||||||
@add-to-tracklist-from-edited-playlist="addToTracklistFromEditedPlaylist"
|
|
||||||
@remove-from-playlist="removeFromPlaylist" @info="trackInfo = $event" @playlist-add="playlistAdd"
|
|
||||||
@add-to-playlist="addToPlaylist" @playlist-track-move="playlistTrackMove" @search="search"
|
|
||||||
@search-clear="searchResults = []" @cd="cd"/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import MusicPlugin from "@/components/panels/Music/Index";
|
import MusicPlugin from "@/components/panels/Music/Common"
|
||||||
import Utils from "@/Utils";
|
|
||||||
import Loading from "@/components/Loading";
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "MusicMpd",
|
components: {MusicPlugin},
|
||||||
components: {Loading, MusicPlugin},
|
|
||||||
mixins: [Utils],
|
|
||||||
props: {
|
|
||||||
config: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {},
|
|
||||||
},
|
|
||||||
|
|
||||||
pluginName: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
loading: false,
|
|
||||||
tracks: [],
|
|
||||||
playlists: [],
|
|
||||||
status: {},
|
|
||||||
editedPlaylist: null,
|
|
||||||
editedPlaylistTracks: [],
|
|
||||||
trackInfo: null,
|
|
||||||
searchResults: [],
|
|
||||||
libraryResults: [],
|
|
||||||
path: '/',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async refreshTracks(background) {
|
|
||||||
if (!background)
|
|
||||||
this.loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.tracks = await this.request('music.mpd.playlistinfo')
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async refreshStatus(background) {
|
|
||||||
if (!background)
|
|
||||||
this.loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.status = Object.entries(await this.request('music.mpd.status')).reduce((obj, [k, v]) => {
|
|
||||||
switch (k) {
|
|
||||||
case 'bitrate':
|
|
||||||
case 'volume':
|
|
||||||
obj[k] = parseInt(v)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'consume':
|
|
||||||
case 'random':
|
|
||||||
case 'repeat':
|
|
||||||
case 'single':
|
|
||||||
obj[k] = !!parseInt(v)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'song':
|
|
||||||
obj['playingPos'] = parseInt(v)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'time':
|
|
||||||
[obj['elapsed'], obj['duration']] = v.split(':').map(t => parseInt(t))
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'elapsed':
|
|
||||||
break
|
|
||||||
|
|
||||||
default:
|
|
||||||
obj[k] = v
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return obj
|
|
||||||
}, {})
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async refreshPlaylists(background) {
|
|
||||||
if (!background)
|
|
||||||
this.loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.playlists = (await this.request('music.mpd.listplaylists')).map((playlist) => {
|
|
||||||
return {
|
|
||||||
name: playlist.playlist,
|
|
||||||
lastModified: playlist['last-modified'],
|
|
||||||
}
|
|
||||||
}).sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async refresh(background) {
|
|
||||||
if (!background)
|
|
||||||
this.loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
this.refreshTracks(background),
|
|
||||||
this.refreshStatus(background),
|
|
||||||
this.refreshPlaylists(background),
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async play(event) {
|
|
||||||
if (event?.pos != null) {
|
|
||||||
await this.request('music.mpd.play_pos', {pos: event.pos})
|
|
||||||
} else if (event?.file) {
|
|
||||||
await this.request('music.mpd.play', {resource: event.file})
|
|
||||||
} else {
|
|
||||||
await this.request('music.mpd.play')
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.refreshStatus(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async pause() {
|
|
||||||
await this.request('music.mpd.pause')
|
|
||||||
await this.refreshStatus(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async stop() {
|
|
||||||
await this.request('music.mpd.stop')
|
|
||||||
await this.refreshStatus(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async previous() {
|
|
||||||
await this.request('music.mpd.previous')
|
|
||||||
await this.refreshStatus(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async next() {
|
|
||||||
await this.request('music.mpd.next')
|
|
||||||
await this.refreshStatus(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async clear() {
|
|
||||||
await this.request('music.mpd.clear')
|
|
||||||
await Promise.all([this.refreshStatus(true), this.refreshTracks(true)])
|
|
||||||
},
|
|
||||||
|
|
||||||
async setVolume(volume) {
|
|
||||||
if (volume === this.status.volume)
|
|
||||||
return
|
|
||||||
|
|
||||||
await this.request('music.mpd.set_volume', {volume: volume})
|
|
||||||
await this.refreshStatus(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async seek(pos) {
|
|
||||||
await this.request('music.mpd.seek', {position: pos})
|
|
||||||
await this.refreshStatus(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async repeat(value) {
|
|
||||||
await this.request('music.mpd.repeat', {value: parseInt(+value)})
|
|
||||||
await this.refreshStatus(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async random(value) {
|
|
||||||
await this.request('music.mpd.random', {value: parseInt(+value)})
|
|
||||||
await this.refreshStatus(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async consume(value) {
|
|
||||||
await this.request('music.mpd.consume', {value: parseInt(+value)})
|
|
||||||
await this.refreshStatus(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async addToTracklist(resource) {
|
|
||||||
if (resource.file)
|
|
||||||
resource = resource.file
|
|
||||||
|
|
||||||
await this.request('music.mpd.add', {resource: resource})
|
|
||||||
await this.refresh(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async addToTracklistFromEditedPlaylist(event) {
|
|
||||||
const tracks = event?.tracks?.map(
|
|
||||||
(pos) => this.editedPlaylistTracks[pos]
|
|
||||||
)?.filter((track) => track?.file)?.map((track) => track.file)
|
|
||||||
|
|
||||||
if (!tracks?.length)
|
|
||||||
return
|
|
||||||
|
|
||||||
await Promise.all(tracks.map((track) => this.request('music.mpd.add', {resource: track})))
|
|
||||||
await this.refresh(true)
|
|
||||||
|
|
||||||
if (event.play)
|
|
||||||
await this.request('music.mpd.play_pos', {pos: this.tracks.length - tracks.length})
|
|
||||||
},
|
|
||||||
|
|
||||||
async removeFromPlaylist(positions) {
|
|
||||||
await this.request('music.mpd.playlistdelete',
|
|
||||||
{pos: positions, name: this.playlists[this.editedPlaylist].name})
|
|
||||||
await this.playlistEditChanged(this.editedPlaylist)
|
|
||||||
},
|
|
||||||
|
|
||||||
async removeFromTracklist(positions) {
|
|
||||||
await this.request('music.mpd.delete', {positions: positions.sort()})
|
|
||||||
await this.refresh(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async swapTracks(positions) {
|
|
||||||
await this.request('music.mpd.move', {from_pos: positions[0], to_pos: positions[1]})
|
|
||||||
await this.refresh(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async playPlaylist(position) {
|
|
||||||
await this._loadPlaylist(position, true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadPlaylist(position) {
|
|
||||||
await this._loadPlaylist(position, false)
|
|
||||||
},
|
|
||||||
|
|
||||||
async _loadPlaylist(position, play) {
|
|
||||||
const playlist = this.playlists[position]
|
|
||||||
await this.request('music.mpd.load', {playlist: playlist.name, play: play})
|
|
||||||
await this.refresh(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async removePlaylist(position) {
|
|
||||||
const playlist = this.playlists[position]
|
|
||||||
if (!confirm(`Are you REALLY sure that you want to remove the playlist ${playlist.name}?`))
|
|
||||||
return
|
|
||||||
|
|
||||||
await this.request('music.mpd.rm', {playlist: playlist.name})
|
|
||||||
await this.refreshPlaylists(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveToPlaylist(name) {
|
|
||||||
await this.request('music.mpd.save', {name: name})
|
|
||||||
await this.refreshPlaylists(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async moveTracklistTracks(event) {
|
|
||||||
await this.request('music.mpd.move', {from_pos: event.from, to_pos: event.to})
|
|
||||||
await this.refreshTracks(true)
|
|
||||||
},
|
|
||||||
|
|
||||||
async playlistAdd(track) {
|
|
||||||
await this.request('music.mpd.playlistadd', {uri: track, name: this.playlists[this.editedPlaylist].name})
|
|
||||||
await this.playlistEditChanged(this.editedPlaylist)
|
|
||||||
},
|
|
||||||
|
|
||||||
async playlistEditChanged(playlist) {
|
|
||||||
this.editedPlaylist = playlist
|
|
||||||
if (playlist == null)
|
|
||||||
return
|
|
||||||
|
|
||||||
this.loading = true
|
|
||||||
try {
|
|
||||||
this.editedPlaylistTracks = await this.request('music.mpd.listplaylistinfo',
|
|
||||||
{name: this.playlists[playlist].name})
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async addToPlaylist(event) {
|
|
||||||
await Promise.all(event.playlists.map(async (playlistIdx) => {
|
|
||||||
await this.request('music.mpd.playlistadd', {
|
|
||||||
uri: event.track.file,
|
|
||||||
name: this.playlists[playlistIdx].name
|
|
||||||
})
|
|
||||||
|
|
||||||
await this.playlistEditChanged(playlistIdx)
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
async playlistTrackMove(event) {
|
|
||||||
await this.request('music.mpd.playlistmove', {
|
|
||||||
name: this.playlists[event.playlist].name,
|
|
||||||
from_pos: event.from,
|
|
||||||
to_pos: event.to,
|
|
||||||
})
|
|
||||||
|
|
||||||
await this.playlistEditChanged(event.playlist)
|
|
||||||
},
|
|
||||||
|
|
||||||
async search(query) {
|
|
||||||
this.loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.searchResults = await this.request('music.mpd.search', {filter: query})
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async cd(path) {
|
|
||||||
this.loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.libraryResults = (await this.request('music.mpd.lsinfo', {uri: path})).
|
|
||||||
filter((result) => !result.playlist)
|
|
||||||
|
|
||||||
this.path = path
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.refresh()
|
|
||||||
this.cd(this.path)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<Loading v-if="loading" />
|
<Loading v-if="loading" />
|
||||||
<div class="music" v-else>
|
<div class="music" v-else>
|
||||||
|
<div class="background" v-if="image">
|
||||||
|
<div class="image" :style="{backgroundImage: 'url(' + image + ')'}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="foreground">
|
||||||
|
<div class="top">
|
||||||
|
<div class="section" :class="{'has-image': !!image, 'has-progress': status?.state === 'play'}">
|
||||||
<div class="track">
|
<div class="track">
|
||||||
<div class="unknown" v-if="!status">[Unknown state]</div>
|
<div class="unknown" v-if="!status">[Unknown state]</div>
|
||||||
<div class="no-track" v-if="status && status.state === 'stop'">No media is being played</div>
|
<div class="no-track" v-if="status && status.state === 'stop'">No media is being played</div>
|
||||||
|
@ -8,52 +15,72 @@
|
||||||
<div class="title" v-if="status && status.state !== 'stop' && track && track.title" v-text="track.title"></div>
|
<div class="title" v-if="status && status.state !== 'stop' && track && track.title" v-text="track.title"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="time" v-if="status && status.state === 'play'">
|
<div class="progress-bar" v-if="status?.state === 'play'">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="progress-bar">
|
<ProgressBar
|
||||||
<div class="elapsed" :style="{width: track.time ? 100*(status.elapsed/track.time) + '%' : '100%'}"></div>
|
:duration="track.time"
|
||||||
<div class="total"></div>
|
:elapsed="status.elapsed"
|
||||||
</div>
|
:status="status"
|
||||||
</div>
|
@seek="seek" />
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6 time-elapsed" v-text="convertTime(status.elapsed)"></div>
|
|
||||||
<div class="col-6 time-total" v-if="track.time" v-text="convertTime(track.time)"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls" v-if="_withControls && status">
|
<div class="controls" v-if="_withControls && status">
|
||||||
<button @click="prev">
|
<button title="Previous" @click="prev">
|
||||||
<i class="fa fa-step-backward" />
|
<i class="fa fa-step-backward" />
|
||||||
</button>
|
</button>
|
||||||
<button class="play-pause" @click="playPause">
|
<button class="play-pause" @click="playPause"
|
||||||
|
:title="status.state === 'play' ? 'Pause' : 'Play'">
|
||||||
<i class="fa fa-pause" v-if="status.state === 'play'" />
|
<i class="fa fa-pause" v-if="status.state === 'play'" />
|
||||||
<i class="fa fa-play" v-else />
|
<i class="fa fa-play" v-else />
|
||||||
</button>
|
</button>
|
||||||
<button @click="stop" v-if="status.state !== 'stop'">
|
<button title="Stop" @click="stop" v-if="status.state !== 'stop'">
|
||||||
<i class="fa fa-stop" />
|
<i class="fa fa-stop" />
|
||||||
</button>
|
</button>
|
||||||
<button @click="next">
|
<button title="Next" @click="next">
|
||||||
<i class="fa fa-step-forward" />
|
<i class="fa fa-step-forward" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="playback-status" v-if="status">
|
<div class="bottom">
|
||||||
<div class="status-property col-4">
|
<div class="playback-status section" :class="{'has-image': !!image}" v-if="status">
|
||||||
<i class="fa fa-volume-up"></i> <span v-text="status.volume + '%'"></span>
|
<div class="status-property col-4 volume fade-in" v-if="!showVolumeBar">
|
||||||
|
<button title="Volume" @click="showVolumeBar = true">
|
||||||
|
<i class="fa fa-volume-up" />
|
||||||
|
{{ status.volume }}%
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-property col-4 volume fade-in" v-else>
|
||||||
|
<div class="row">
|
||||||
|
<i class="fa fa-volume-up" />
|
||||||
|
<Slider :range="[0, 100]" :value="status.volume" @change="setVolume" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-property col-2">
|
<div class="status-property col-2">
|
||||||
|
<button title="Random" @click="random">
|
||||||
<i class="fas fa-random" :class="{active: status.random}"></i>
|
<i class="fas fa-random" :class="{active: status.random}"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-property col-2">
|
<div class="status-property col-2">
|
||||||
|
<button title="Repeat" @click="repeat">
|
||||||
<i class="fas fa-redo" :class="{active: status.repeat}"></i>
|
<i class="fas fa-redo" :class="{active: status.repeat}"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-property col-2">
|
<div class="status-property col-2">
|
||||||
|
<button title="Single" @click="single">
|
||||||
<i class="fa fa-bullseye" :class="{active: status.single}"></i>
|
<i class="fa fa-bullseye" :class="{active: status.single}"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-property col-2">
|
<div class="status-property col-2">
|
||||||
|
<button title="Consume" @click="consume">
|
||||||
<i class="fa fa-utensils" :class="{active: status.consume}"></i>
|
<i class="fa fa-utensils" :class="{active: status.consume}"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -62,12 +89,21 @@
|
||||||
<script>
|
<script>
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
import Loading from "@/components/Loading";
|
import Loading from "@/components/Loading";
|
||||||
|
import Status from "@/mixins/Music/Status";
|
||||||
|
import ProgressBar from "@/components/Media/ProgressBar";
|
||||||
|
import Slider from "@/components/elements/Slider";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Music",
|
name: "Music",
|
||||||
components: {Loading},
|
components: {Loading, ProgressBar, Slider},
|
||||||
mixins: [Utils],
|
mixins: [Status, Utils],
|
||||||
props: {
|
props: {
|
||||||
|
// Music plugin to use (default: music.mopidy).
|
||||||
|
plugin: {
|
||||||
|
type: String,
|
||||||
|
default: 'music.mopidy',
|
||||||
|
},
|
||||||
|
|
||||||
// Refresh interval in seconds.
|
// Refresh interval in seconds.
|
||||||
refreshSeconds: {
|
refreshSeconds: {
|
||||||
type: Number,
|
type: Number,
|
||||||
|
@ -78,16 +114,18 @@ export default {
|
||||||
withControls: {
|
withControls: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
track: undefined,
|
track: null,
|
||||||
status: undefined,
|
status: {},
|
||||||
timer: undefined,
|
timer: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
musicPlugin: 'music.mpd',
|
showVolumeBar: false,
|
||||||
|
images: {},
|
||||||
|
maxImages: 100,
|
||||||
|
|
||||||
syncTime: {
|
syncTime: {
|
||||||
timestamp: null,
|
timestamp: null,
|
||||||
|
@ -100,6 +138,21 @@ export default {
|
||||||
_withControls() {
|
_withControls() {
|
||||||
return this.parseBoolean(this.withControls)
|
return this.parseBoolean(this.withControls)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_refreshSeconds() {
|
||||||
|
return parseFloat(this.refreshSeconds)
|
||||||
|
},
|
||||||
|
|
||||||
|
trackUri() {
|
||||||
|
return this.track?.uri || this.track?.file
|
||||||
|
},
|
||||||
|
|
||||||
|
image() {
|
||||||
|
if (this.status?.state === 'stop')
|
||||||
|
return null
|
||||||
|
|
||||||
|
return this.images[this.trackUri] || this.track?.image || this.status?.image
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -107,8 +160,8 @@ export default {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let status = await this.request(`${this.musicPlugin}.status`)
|
let status = await this.request(`${this.plugin}.status`) || {}
|
||||||
let track = await this.request(`${this.musicPlugin}.current_track`)
|
let track = await this.request(`${this.plugin}.current_track`)
|
||||||
|
|
||||||
this._parseStatus(status)
|
this._parseStatus(status)
|
||||||
this._parseTrack(track)
|
this._parseTrack(track)
|
||||||
|
@ -117,62 +170,49 @@ export default {
|
||||||
this.startTimer()
|
this.startTimer()
|
||||||
else if (status.state !== 'play' && this.timer)
|
else if (status.state !== 'play' && this.timer)
|
||||||
this.stopTimer()
|
this.stopTimer()
|
||||||
|
|
||||||
|
if (status.state !== 'stop' && !this.image)
|
||||||
|
await this.refreshImage()
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
convertTime(time) {
|
async refreshImage() {
|
||||||
time = parseFloat(time) // Normalize strings
|
if (!this.trackUri)
|
||||||
const t = {}
|
return
|
||||||
t.h = parseInt(time/3600)
|
|
||||||
t.m = parseInt(time/60 - t.h*60)
|
|
||||||
t.s = parseInt(time - (t.h*3600 + t.m*60))
|
|
||||||
|
|
||||||
for (const attr of ['m','s']) {
|
if (!this.images[this.trackUri]) {
|
||||||
t[attr] = '' + t[attr]
|
const trackImage = (
|
||||||
|
await this.request(`${this.plugin}.get_images`, {resources: [this.trackUri]})
|
||||||
|
)[this.trackUri]
|
||||||
|
|
||||||
|
if (Object.keys(this.images).length > this.maxImages) {
|
||||||
|
delete this.images[Object.keys(this.images)[0]]
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const attr of ['m','s']) {
|
this.images[this.trackUri] = trackImage
|
||||||
if (parseInt(t[attr]) < 10) {
|
|
||||||
t[attr] = '0' + t[attr]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ret = []
|
return this.images[this.trackUri]
|
||||||
if (parseInt(t.h)) {
|
|
||||||
ret.push(t.h)
|
|
||||||
}
|
|
||||||
|
|
||||||
ret.push(t.m, t.s)
|
|
||||||
return ret.join(':')
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async _parseStatus(status) {
|
async _parseStatus(status) {
|
||||||
if (!status || status.length === 0)
|
const statusPlugin = status.pluginName
|
||||||
status = await this.request(`${this.musicPlugin}.status`)
|
if (statusPlugin && this.plugin && statusPlugin !== this.plugin)
|
||||||
if (status?.pluginName)
|
return // Ignore status updates from other plugins
|
||||||
this.musicPlugin = status.pluginName
|
|
||||||
|
if (!status || Object.keys(status).length === 0)
|
||||||
|
status = await this.request(`${this.plugin}.status`) || {}
|
||||||
if (!this.status)
|
if (!this.status)
|
||||||
this.status = {}
|
this.status = {}
|
||||||
|
|
||||||
for (const [attr, value] of Object.entries(status)) {
|
this.status = this.parseStatus(status)
|
||||||
if (['consume','random','repeat','single','bitrate'].indexOf(attr) >= 0) {
|
|
||||||
this.status[attr] = !!parseInt(value)
|
|
||||||
} else if (['nextsong','nextsongid','playlist','playlistlength',
|
|
||||||
'volume','xfade','song','songid'].indexOf(attr) >= 0) {
|
|
||||||
this.status[attr] = parseInt(value)
|
|
||||||
} else if (['elapsed'].indexOf(attr) >= 0) {
|
|
||||||
this.status[attr] = parseFloat(value)
|
|
||||||
} else {
|
|
||||||
this.status[attr] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async _parseTrack(track) {
|
async _parseTrack(track) {
|
||||||
if (!track || track.length === 0) {
|
if (!track || track.length === 0) {
|
||||||
track = await this.request(`${this.musicPlugin}.current_track`)
|
track = await this.request(`${this.plugin}.current_track`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.track)
|
if (!this.track)
|
||||||
|
@ -197,8 +237,33 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async seek(position) {
|
||||||
|
await this.request(`${this.plugin}.seek`, {position: position})
|
||||||
|
},
|
||||||
|
|
||||||
|
async setVolume(event) {
|
||||||
|
await this.request(`${this.plugin}.set_volume`, {volume: event.target.value})
|
||||||
|
this.showVolumeBar = false
|
||||||
|
},
|
||||||
|
|
||||||
|
async random() {
|
||||||
|
await this.request(`${this.plugin}.random`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async repeat() {
|
||||||
|
await this.request(`${this.plugin}.repeat`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async consume() {
|
||||||
|
await this.request(`${this.plugin}.consume`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async single() {
|
||||||
|
await this.request(`${this.plugin}.single`)
|
||||||
|
},
|
||||||
|
|
||||||
async onNewPlayingTrack(event) {
|
async onNewPlayingTrack(event) {
|
||||||
let previousTrack = undefined
|
let previousTrack = null
|
||||||
|
|
||||||
if (this.track) {
|
if (this.track) {
|
||||||
previousTrack = {
|
previousTrack = {
|
||||||
|
@ -213,7 +278,7 @@ export default {
|
||||||
this.track = {}
|
this.track = {}
|
||||||
this._parseTrack(event.track)
|
this._parseTrack(event.track)
|
||||||
|
|
||||||
let status = event.status ? event.status : await this.request(`${this.musicPlugin}.status`)
|
let status = event.status ? event.status : await this.request(`${this.plugin}.status`)
|
||||||
this._parseStatus(status)
|
this._parseStatus(status)
|
||||||
this.startTimer()
|
this.startTimer()
|
||||||
|
|
||||||
|
@ -222,6 +287,9 @@ export default {
|
||||||
|| this.track.title !== previousTrack.title)) {
|
|| this.track.title !== previousTrack.title)) {
|
||||||
this.showNewTrackNotification()
|
this.showNewTrackNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.image)
|
||||||
|
await this.refreshImage()
|
||||||
},
|
},
|
||||||
|
|
||||||
onMusicStop(event) {
|
onMusicStop(event) {
|
||||||
|
@ -232,20 +300,26 @@ export default {
|
||||||
this.stopTimer()
|
this.stopTimer()
|
||||||
},
|
},
|
||||||
|
|
||||||
onMusicPlay(event) {
|
async onMusicPlay(event) {
|
||||||
this.status.state = 'play'
|
this.status.state = 'play'
|
||||||
this._parseStatus(event.status)
|
this._parseStatus(event.status)
|
||||||
this._parseTrack(event.track)
|
this._parseTrack(event.track)
|
||||||
this.startTimer()
|
this.startTimer()
|
||||||
|
|
||||||
|
if (!this.image)
|
||||||
|
await this.refreshImage()
|
||||||
},
|
},
|
||||||
|
|
||||||
onMusicPause(event) {
|
async onMusicPause(event) {
|
||||||
this.status.state = 'pause'
|
this.status.state = 'pause'
|
||||||
this._parseStatus(event.status)
|
this._parseStatus(event.status)
|
||||||
this._parseTrack(event.track)
|
this._parseTrack(event.track)
|
||||||
|
|
||||||
this.syncTime.timestamp = new Date()
|
this.syncTime.timestamp = new Date()
|
||||||
this.syncTime.elapsed = this.status.elapsed
|
this.syncTime.elapsed = this.status.elapsed
|
||||||
|
|
||||||
|
if (!this.image)
|
||||||
|
await this.refreshImage()
|
||||||
},
|
},
|
||||||
|
|
||||||
onSeekChange(event) {
|
onSeekChange(event) {
|
||||||
|
@ -336,8 +410,8 @@ export default {
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refresh()
|
this.refresh()
|
||||||
if (this.refreshSeconds) {
|
if (this._refreshSeconds) {
|
||||||
setInterval(this.refresh, parseInt((this.refreshSeconds*1000).toFixed(0)))
|
setInterval(this.refresh, this._refreshSeconds * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.subscribe(this.onNewPlayingTrack, 'widget-music-on-new-track', 'platypush.message.event.music.NewPlayingTrackEvent')
|
this.subscribe(this.onNewPlayingTrack, 'widget-music-on-new-track', 'platypush.message.event.music.NewPlayingTrackEvent')
|
||||||
|
@ -355,8 +429,8 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
$progress-bar-bg: #ddd;
|
|
||||||
$playback-status-color: #757f70;
|
$playback-status-color: #757f70;
|
||||||
|
$bottom-height: 2em;
|
||||||
|
|
||||||
.music {
|
.music {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -367,6 +441,82 @@ $playback-status-color: #757f70;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
.background {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
filter: brightness(0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin top {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground {
|
||||||
|
@include top;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
@include top;
|
||||||
|
height: calc(100% - #{$bottom-height});
|
||||||
|
|
||||||
|
.section {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&.has-image {
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 1em;
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-progress {
|
||||||
|
width: calc(100% - 1em);
|
||||||
|
padding: 0.25em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
width: 100%;
|
||||||
|
height: $bottom-height;
|
||||||
|
|
||||||
|
.section {
|
||||||
|
border-top: $default-border-2;
|
||||||
|
|
||||||
|
&.has-image {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.has-image {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.track {
|
.track {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
@ -387,53 +537,18 @@ $playback-status-color: #757f70;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1em;
|
|
||||||
font-size: 1.2em;
|
|
||||||
|
|
||||||
.row {
|
|
||||||
padding: 0 .5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-total {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
|
font-size: 1.2em;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
margin-top: 1.5em;
|
||||||
margin-bottom: .75em;
|
margin-bottom: .75em;
|
||||||
|
padding: 0 .5em;
|
||||||
.total {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
top: 0;
|
|
||||||
background: $progress-bar-bg;
|
|
||||||
border-radius: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.elapsed {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
top: 0;
|
|
||||||
background: $selected-bg;
|
|
||||||
border-radius: 0.5em;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playback-status {
|
.playback-status {
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
border-top: $default-border-2;
|
|
||||||
color: $playback-status-color;
|
color: $playback-status-color;
|
||||||
width: 100%;
|
|
||||||
height: 2em;
|
|
||||||
|
|
||||||
.status-property {
|
.status-property {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -444,26 +559,52 @@ $playback-status-color: #757f70;
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
color: $default-hover-fg;
|
color: $default-hover-fg;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $playback-status-color !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: $playback-status-color;
|
||||||
|
padding: 0.25em 0.5em;
|
||||||
|
border-top: 1px solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-top: 1px solid $default-hover-fg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
|
display: flex;
|
||||||
margin-top: .5em;
|
margin-top: .5em;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $default-hover-fg;
|
color: $default-hover-fg !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.play-pause {
|
&.play-pause {
|
||||||
color: $selected-fg;
|
color: $selected-fg !important;
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.volume {
|
||||||
|
.row {
|
||||||
|
width: calc(100% - 1em);
|
||||||
|
margin: 0 .5em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
66
platypush/backend/http/webapp/src/mixins/Music/Status.vue
Normal file
66
platypush/backend/http/webapp/src/mixins/Music/Status.vue
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
parseStatus(status) {
|
||||||
|
return Object.entries(status).reduce((obj, [k, v]) => {
|
||||||
|
switch (k) {
|
||||||
|
case 'bitrate':
|
||||||
|
case 'volume':
|
||||||
|
obj[k] = parseInt(v)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'consume':
|
||||||
|
case 'random':
|
||||||
|
case 'repeat':
|
||||||
|
case 'single':
|
||||||
|
obj[k] = !!parseInt(+v)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'playing_pos':
|
||||||
|
case 'song': // Legacy mpd format
|
||||||
|
obj.playingPos = parseInt(v)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'time':
|
||||||
|
if (v.split) { // Handle the `elapsed:duration` legacy mpd format
|
||||||
|
v = v.split(':')
|
||||||
|
|
||||||
|
if (v.length === 1) {
|
||||||
|
obj.elapsed = parseInt(v[0])
|
||||||
|
} else {
|
||||||
|
obj.elapsed = parseInt(v[0])
|
||||||
|
obj.duration = parseInt(v[1])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
obj.elapsed = v
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'track':
|
||||||
|
if (v?.time != null) {
|
||||||
|
obj.duration = v.time
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v?.playlistPos != null) {
|
||||||
|
obj.playingPos = v.pos
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'duration':
|
||||||
|
obj.duration = parseInt(v)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'elapsed':
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
obj[k] = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}, {})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -94,6 +94,7 @@ $default-hover-fg-2: #38cf80 !default;
|
||||||
$hover-fg: $default-hover-fg !default;
|
$hover-fg: $default-hover-fg !default;
|
||||||
$hover-bg: linear-gradient(90deg, rgba(190,246,218,1) 0%, rgba(229,251,240,1) 100%) !default;
|
$hover-bg: linear-gradient(90deg, rgba(190,246,218,1) 0%, rgba(229,251,240,1) 100%) !default;
|
||||||
$hover-bg-2: rgb(190,246,218) !default;
|
$hover-bg-2: rgb(190,246,218) !default;
|
||||||
|
$hover-bg-3: linear-gradient(90deg, rgba(180,246,238,1) 0%, rgba(219,255,245,1) 100%) !default;
|
||||||
$active-bg: #8fefb7 !default;
|
$active-bg: #8fefb7 !default;
|
||||||
|
|
||||||
/// Disabled
|
/// Disabled
|
||||||
|
|
|
@ -45,6 +45,10 @@ export default {
|
||||||
classes() {
|
classes() {
|
||||||
return this.class
|
return this.class
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_refreshSeconds() {
|
||||||
|
return parseFloat(this.refreshSeconds) || 0
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -56,7 +60,7 @@ export default {
|
||||||
return props
|
return props
|
||||||
},
|
},
|
||||||
|
|
||||||
parseTemplate(name, tmpl) {
|
parseTemplate(tmpl) {
|
||||||
const node = new DOMParser().parseFromString(tmpl, 'text/xml').childNodes[0]
|
const node = new DOMParser().parseFromString(tmpl, 'text/xml').childNodes[0]
|
||||||
const self = this
|
const self = this
|
||||||
this.style = node.attributes.style?.nodeValue
|
this.style = node.attributes.style?.nodeValue
|
||||||
|
@ -111,17 +115,17 @@ export default {
|
||||||
this.notifyError(`Dashboard ${name} not found`)
|
this.notifyError(`Dashboard ${name} not found`)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.parseTemplate(name, template)
|
this.parseTemplate(template)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshDashboard()
|
this.refreshDashboard()
|
||||||
if (this.refreshSeconds) {
|
if (this._refreshSeconds) {
|
||||||
const self = this
|
const self = this
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
self.refreshDashboard()
|
self.refreshDashboard()
|
||||||
}, parseInt((this.refreshSeconds*1000).toFixed(0)))
|
}, parseInt((this._refreshSeconds*1000).toFixed(0)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue