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>
|
||||
<div class="extension fade-in" :class="{hidden: !expanded}">
|
||||
<div class="image-container" @click.prevent="onImageClick" v-if="status?.state !== 'stop'">
|
||||
<div class="remote-image-container" v-if="track?.image">
|
||||
<img class="image" :src="track.image" :alt="track.title">
|
||||
<div class="image-container"
|
||||
@click.prevent="searchAlbum"
|
||||
v-if="status?.state !== 'stop'">
|
||||
<div class="remote-image-container" v-if="trackImage">
|
||||
<img class="image" :src="trackImage" :alt="track.title">
|
||||
</div>
|
||||
|
||||
<div class="icon-container" v-else>
|
||||
|
@ -28,11 +30,17 @@
|
|||
</div>
|
||||
|
||||
<div class="row">
|
||||
<VolumeSlider :value="status.volume" :range="volumeRange" :status="status"
|
||||
@mute="$emit('mute')" @unmute="$emit('unmute')"
|
||||
@set-volume="$emit('set-volume', $event)" />
|
||||
<VolumeSlider
|
||||
:range="volumeRange"
|
||||
: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)"
|
||||
@random="$emit('random', !status.random)"
|
||||
@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-info" v-if="track && status?.state !== 'stop'">
|
||||
<div class="img-container" v-if="track.image">
|
||||
<img class="image from desktop" :src="track.image" :alt="track.title">
|
||||
<div class="img-container" v-if="trackImage">
|
||||
<img class="image from desktop" :src="trackImage" :alt="track.title">
|
||||
</div>
|
||||
|
||||
<div class="title-container">
|
||||
<div class="title" v-if="status.state === 'play' || status.state === 'pause'">
|
||||
<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>
|
||||
<a :href="track.url" v-text="track.title?.length ? track.title : '[No Title]'" v-else-if="track.url"></a>
|
||||
@click.prevent="searchAlbum" v-if="track.album"></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>
|
||||
</div>
|
||||
<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>
|
||||
|
@ -143,6 +151,11 @@ export default {
|
|||
default: () => {},
|
||||
},
|
||||
|
||||
image: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Enabled playback buttons
|
||||
buttons: {
|
||||
type: Object,
|
||||
|
@ -187,6 +200,10 @@ export default {
|
|||
duration() {
|
||||
return this.status?.duration != null ? this.status.duration : this.track?.duration
|
||||
},
|
||||
|
||||
trackImage() {
|
||||
return this.track?.image || this.image
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
@ -194,9 +211,33 @@ export default {
|
|||
return (new Date()).getTime() / 1000
|
||||
},
|
||||
|
||||
onImageClick() {
|
||||
if (this.track?.artist && this.track?.album)
|
||||
this.$emit('search', {artist: this.track.artist, album: this.track.album})
|
||||
searchAlbum() {
|
||||
if (!(this.track?.artist && 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 {
|
||||
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;
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
|
@ -368,6 +411,7 @@ button {
|
|||
}
|
||||
|
||||
.track-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 0;
|
||||
|
@ -411,6 +455,7 @@ button {
|
|||
}
|
||||
|
||||
.track-info {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
@include until($desktop) {
|
||||
|
|
|
@ -4,11 +4,23 @@
|
|||
<slot />
|
||||
</div>
|
||||
<div class="controls-container">
|
||||
<Controls :status="status" :track="track" :buttons="buttons" @play="$emit('play', $event)"
|
||||
@pause="$emit('pause', $event)" @stop="$emit('stop')" @previous="$emit('previous')"
|
||||
@next="$emit('next')" @seek="$emit('seek', $event)" @set-volume="$emit('set-volume', $event)"
|
||||
@consume="$emit('consume', $event)" @repeat="$emit('repeat', $event)" @random="$emit('random', $event)"
|
||||
@search="$emit('search', $event)" @mute="$emit('mute')" @unmute="$emit('unmute')" />
|
||||
<Controls :buttons="buttons"
|
||||
:image="image"
|
||||
:status="status"
|
||||
:track="track"
|
||||
@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>
|
||||
</template>
|
||||
|
@ -50,6 +62,11 @@ export default {
|
|||
type: Object,
|
||||
},
|
||||
|
||||
image: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
buttons: {
|
||||
type: Object,
|
||||
},
|
||||
|
|
|
@ -1 +1,12 @@
|
|||
$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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "vars";
|
||||
@import "~@/components/Media/vars";
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
|
|
|
@ -446,7 +446,6 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "vars";
|
||||
@import "~@/components/Media/vars";
|
||||
|
||||
.media-plugin {
|
||||
|
|
|
@ -219,7 +219,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "vars";
|
||||
@import "~@/components/Media/vars";
|
||||
|
||||
.media-info {
|
||||
width: 100%;
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<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">
|
||||
<MediaImage :item="item" @play="$emit('play')" />
|
||||
</div>
|
||||
|
@ -8,7 +12,7 @@
|
|||
<div class="row title">
|
||||
<div class="col-11 left side" v-text="item.title" @click="$emit('select')" />
|
||||
<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')"
|
||||
v-if="item.type !== 'torrent'" />
|
||||
<DropdownItem icon-class="fa fa-download" text="Download" @click="$emit('download')"
|
||||
|
@ -71,7 +75,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "vars";
|
||||
@import "~@/components/Media/vars";
|
||||
|
||||
.media-item {
|
||||
display: flex;
|
||||
|
|
|
@ -62,7 +62,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "vars";
|
||||
@import "~@/components/Media/vars";
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
|
|
|
@ -64,7 +64,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import 'vars.scss';
|
||||
@import "~@/components/Media/vars";
|
||||
|
||||
nav {
|
||||
width: $media-nav-width;
|
||||
|
|
|
@ -107,8 +107,8 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "src/style/items";
|
||||
@import "vars";
|
||||
@import "~@/style/items";
|
||||
@import "~@/components/Media/vars";
|
||||
|
||||
.media-results {
|
||||
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>
|
||||
<Loading v-if="loading" />
|
||||
|
||||
<MediaView :plugin-name="pluginName" :status="status" :track="track" @play="$emit('play', $event)"
|
||||
@pause="$emit('pause')" @stop="$emit('stop')" @previous="$emit('previous')" @next="$emit('next')"
|
||||
@set-volume="$emit('set-volume', $event)" @seek="$emit('seek', $event)" @consume="$emit('consume', $event)"
|
||||
@repeat="$emit('repeat', $event)" @random="$emit('random', $event)" @search="search" v-else>
|
||||
<MediaView :plugin-name="pluginName"
|
||||
:image="images[track?.uri || track?.file]"
|
||||
:status="status"
|
||||
: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>
|
||||
<div class="nav-container mobile" v-if="navVisible">
|
||||
<Nav :selected-view="selectedView"
|
||||
|
@ -92,7 +103,7 @@
|
|||
:devices="devices"
|
||||
:selected-device="selectedDevice"
|
||||
:active-device="activeDevice"
|
||||
:show-nav-button="!navVisible"
|
||||
:show-nav-button="!navVisible"
|
||||
v-else-if="selectedView === 'library'"
|
||||
@search="search"
|
||||
@clear="$emit('search-clear')"
|
||||
|
@ -207,7 +218,6 @@ import Library from "@/components/panels/Music/Library";
|
|||
import Utils from "@/Utils";
|
||||
|
||||
export default {
|
||||
name: "Music",
|
||||
emits: [
|
||||
'add-to-playlist',
|
||||
'add-to-tracklist',
|
||||
|
@ -268,6 +278,11 @@ export default {
|
|||
default: () => [],
|
||||
},
|
||||
|
||||
images: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
|
||||
editedPlaylistTracks: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
|
@ -283,12 +298,18 @@ export default {
|
|||
default: () => {},
|
||||
},
|
||||
|
||||
track: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
|
||||
editedPlaylist: {
|
||||
type: Number,
|
||||
},
|
||||
|
||||
trackInfo: {
|
||||
type: String,
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
|
||||
searchResults: {
|
||||
|
@ -300,7 +321,8 @@ export default {
|
|||
},
|
||||
|
||||
path: {
|
||||
type: String,
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
devices: {
|
||||
|
@ -326,15 +348,6 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
track() {
|
||||
if (this.status?.playingPos == null)
|
||||
return null
|
||||
|
||||
return this.tracks[this.status.playingPos]
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async onStatusEvent(event) {
|
||||
if (event.plugin_name !== this.pluginName)
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
</MusicHeader>
|
||||
|
||||
<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">
|
||||
<i class="icon fa fa-folder" />
|
||||
</div>
|
||||
|
@ -28,13 +28,19 @@
|
|||
v-for="(result, i) in results" :key="i" @click="resultClick(i, $event)">
|
||||
<div class="col-10 left-side">
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
|
@ -96,7 +102,8 @@ export default {
|
|||
},
|
||||
|
||||
path: {
|
||||
type: String,
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
devices: {
|
||||
|
@ -144,6 +151,10 @@ export default {
|
|||
(result?.directory || '').toLowerCase().indexOf(filter) >= 0
|
||||
}))
|
||||
},
|
||||
|
||||
isRoot() {
|
||||
return !this.path?.length || !this.path[0]?.length || this.path[0] === '/'
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
@ -161,8 +172,9 @@ export default {
|
|||
else
|
||||
this.selectedResults.add(pos)
|
||||
} else {
|
||||
if (this.results[pos].directory) {
|
||||
this.$emit('cd', this.results[pos].directory)
|
||||
if (this.isDirectory(pos) || this.isArtist(pos) || this.isAlbum(pos) || this.isPlaylist(pos)) {
|
||||
const dir = this.results[pos].uri || this.results[pos].directory
|
||||
this.$emit('cd', [...this.path, dir])
|
||||
} else {
|
||||
this.selectedResults = new Set()
|
||||
if (this.selectedResults.has(pos))
|
||||
|
@ -191,8 +203,26 @@ export default {
|
|||
},
|
||||
|
||||
back() {
|
||||
const path = this.path.split('/')
|
||||
this.$emit('cd', path.slice(0, path.length-1).join('/'))
|
||||
if (this.isRoot)
|
||||
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)"
|
||||
draggable="true"
|
||||
v-for="i in displayedTrackIndices"
|
||||
:set="track = tracks[i]"
|
||||
:key="i"
|
||||
:data-index="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})">
|
||||
<div class="col-10">
|
||||
<div class="title">
|
||||
{{ track.title || '[No Title]' }}
|
||||
<div class="playing-icon" :class="{paused: status?.state === 'pause'}"
|
||||
v-if="status?.playingPos === i && (status?.state === 'play' || status?.state === 'pause')">
|
||||
{{ tracks[i].title || '[No Title]' }}
|
||||
<div class="playing-icon" :class="{paused: status?.state === 'pause'}" v-if="isPlayingTrack(i)">
|
||||
<span v-for="i in [...Array(3).keys()]" :key="i" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="artist" v-if="track.artist">
|
||||
<a :href="$route.fullPath" v-text="track.artist"
|
||||
@click.prevent="$emit('search', {artist: track.artist})" />
|
||||
<div class="artist" v-if="tracks[i].artist">
|
||||
<a v-text="tracks[i].artist" @click.prevent="searchArtist(tracks[i])" />
|
||||
</div>
|
||||
|
||||
<div class="album" v-if="track.album">
|
||||
<a :href="$route.fullPath" v-text="track.album"
|
||||
@click.prevent="$emit('search', {artist: track.artist, album: track.album})" />
|
||||
<div class="album" v-if="tracks[i].album">
|
||||
<a v-text="tracks[i].album" @click.prevent="searchAlbum(tracks[i])" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h">
|
||||
<DropdownItem text="Play" icon-class="fa fa-play" @click="onMenuPlay" />
|
||||
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h" :ref="'menu' + i">
|
||||
<DropdownItem text="Play" icon-class="fa fa-play" @click="onMenuPlay(i)" />
|
||||
<DropdownItem text="Add to queue" icon-class="fa fa-plus"
|
||||
@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="Info" icon-class="fa fa-info" @click="$emit('info', tracks[i])" />
|
||||
</Dropdown>
|
||||
|
@ -260,6 +257,10 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
getTrackElements() {
|
||||
return this.$refs.body.querySelectorAll('.track')
|
||||
},
|
||||
|
||||
onTrackClick(event, pos) {
|
||||
if (event.shiftKey) {
|
||||
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) {
|
||||
return {
|
||||
selected: this.selectedTracksSet.has(i),
|
||||
active: !this.withAddToQueue && this.status?.playingPos === i,
|
||||
active: this.isPlayingTrack(i),
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -317,23 +331,53 @@ export default {
|
|||
|
||||
onTrackDragStart(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() {
|
||||
this.$refs.body.querySelectorAll('.track').forEach((track) => track.classList.remove('dragover'));
|
||||
if (this.sourcePos == null || this.targetPos == null || this.sourcePos === this.targetPos)
|
||||
return
|
||||
this.getTrackElements().forEach((track) => {
|
||||
track.classList.remove('dragover')
|
||||
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.targetPos = null
|
||||
this.selectedTracks = []
|
||||
this.getTrackElements().forEach((track) => track.classList.remove('dragging'))
|
||||
},
|
||||
|
||||
onTrackDragOver(track) {
|
||||
this.targetPos = track
|
||||
const tracks = this.$refs.body.querySelectorAll('.track')
|
||||
tracks.forEach((track) => track.classList.remove('dragover'));
|
||||
[...tracks][track].classList.add('dragover')
|
||||
const tracks = this.getTrackElements()
|
||||
const trackEl = [...tracks].find((t) => parseInt(t.dataset.index || -1) === track)
|
||||
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() {
|
||||
|
@ -388,15 +432,51 @@ export default {
|
|||
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() {
|
||||
this.scrollToTrack()
|
||||
this.$watch(() => this.status, () => this.scrollToTrack())
|
||||
this.$watch(() => this.filter, (filter) => {
|
||||
if (!filter?.length)
|
||||
this.scrollToTrack()
|
||||
})
|
||||
// Add the scrolling listeners only if we are on the queue view.
|
||||
if (!this.withAddToQueue) {
|
||||
this.scrollToTrack()
|
||||
this.$watch(() => this.status, () => this.scrollToTrack())
|
||||
this.$watch(() => this.filter, (filter) => {
|
||||
if (!filter?.length)
|
||||
this.scrollToTrack()
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -61,7 +61,10 @@
|
|||
<div class="row playlist" :class="{hidden: !displayedPlaylists.has(i)}"
|
||||
v-for="(playlist, i) in playlists" :key="i" @click="$emit('playlist-edit', 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>
|
||||
|
||||
|
@ -251,9 +254,10 @@ export default {
|
|||
if (this.sourcePos == null || this.targetPos == null || this.sourcePos === this.targetPos)
|
||||
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.targetPos = null
|
||||
this.selectedTracks = []
|
||||
},
|
||||
|
||||
onTrackDragOver(track) {
|
||||
|
@ -301,6 +305,22 @@ export default {
|
|||
box-shadow: 0 2.5px 2px -1px $default-shadow-color;
|
||||
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 {
|
||||
background: $hover-bg;
|
||||
}
|
||||
|
|
|
@ -64,12 +64,24 @@
|
|||
<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)">
|
||||
<div class="col-10">
|
||||
<div class="title">
|
||||
{{ result.title || '[No Title]' }}
|
||||
<div class="title-container">
|
||||
<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 class="title">
|
||||
<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" />
|
||||
<div class="album" v-text="result.album" v-if="result.album?.length" />
|
||||
<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 class="col-2 right-side">
|
||||
|
@ -298,6 +310,16 @@ export default {
|
|||
height: calc(100% - #{$music-header-height});
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
|
||||
.title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.type {
|
||||
opacity: .85;
|
||||
margin-right: .5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.header) {
|
||||
|
|
|
@ -18,7 +18,17 @@
|
|||
}
|
||||
|
||||
&.dragover {
|
||||
border-top: 2px solid $default-hover-fg;
|
||||
&.top {
|
||||
border-top: 2px solid $default-hover-fg;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
border-bottom: 2px solid $default-hover-fg;
|
||||
}
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
&::selection {
|
||||
|
|
|
@ -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>
|
||||
<Loading v-if="loading" />
|
||||
<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"/>
|
||||
<MusicPlugin plugin-name="music.mpd" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MusicPlugin from "@/components/panels/Music/Index";
|
||||
import Utils from "@/Utils";
|
||||
import Loading from "@/components/Loading";
|
||||
import MusicPlugin from "@/components/panels/Music/Common"
|
||||
|
||||
export default {
|
||||
name: "MusicMpd",
|
||||
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)
|
||||
},
|
||||
components: {MusicPlugin},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,59 +1,86 @@
|
|||
<template>
|
||||
<Loading v-if="loading" />
|
||||
<div class="music" v-else>
|
||||
<div class="track">
|
||||
<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="artist" v-if="status && status.state !== 'stop' && track && track.artist" v-text="track.artist"></div>
|
||||
<div class="title" v-if="status && status.state !== 'stop' && track && track.title" v-text="track.title"></div>
|
||||
<div class="background" v-if="image">
|
||||
<div class="image" :style="{backgroundImage: 'url(' + image + ')'}" />
|
||||
</div>
|
||||
|
||||
<div class="time" v-if="status && status.state === 'play'">
|
||||
<div class="row">
|
||||
<div class="progress-bar">
|
||||
<div class="elapsed" :style="{width: track.time ? 100*(status.elapsed/track.time) + '%' : '100%'}"></div>
|
||||
<div class="total"></div>
|
||||
<div class="foreground">
|
||||
<div class="top">
|
||||
<div class="section" :class="{'has-image': !!image, 'has-progress': status?.state === 'play'}">
|
||||
<div class="track">
|
||||
<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="artist" v-if="status && status.state !== 'stop' && track && track.artist" v-text="track.artist"></div>
|
||||
<div class="title" v-if="status && status.state !== 'stop' && track && track.title" v-text="track.title"></div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar" v-if="status?.state === 'play'">
|
||||
<div class="row">
|
||||
<ProgressBar
|
||||
:duration="track.time"
|
||||
:elapsed="status.elapsed"
|
||||
:status="status"
|
||||
@seek="seek" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls" v-if="_withControls && status">
|
||||
<button title="Previous" @click="prev">
|
||||
<i class="fa fa-step-backward" />
|
||||
</button>
|
||||
<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-play" v-else />
|
||||
</button>
|
||||
<button title="Stop" @click="stop" v-if="status.state !== 'stop'">
|
||||
<i class="fa fa-stop" />
|
||||
</button>
|
||||
<button title="Next" @click="next">
|
||||
<i class="fa fa-step-forward" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 class="bottom">
|
||||
<div class="playback-status section" :class="{'has-image': !!image}" v-if="status">
|
||||
<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="controls" v-if="_withControls && status">
|
||||
<button @click="prev">
|
||||
<i class="fa fa-step-backward" />
|
||||
</button>
|
||||
<button class="play-pause" @click="playPause">
|
||||
<i class="fa fa-pause" v-if="status.state === 'play'" />
|
||||
<i class="fa fa-play" v-else />
|
||||
</button>
|
||||
<button @click="stop" v-if="status.state !== 'stop'">
|
||||
<i class="fa fa-stop" />
|
||||
</button>
|
||||
<button @click="next">
|
||||
<i class="fa fa-step-forward" />
|
||||
</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 class="playback-status" v-if="status">
|
||||
<div class="status-property col-4">
|
||||
<i class="fa fa-volume-up"></i> <span v-text="status.volume + '%'"></span>
|
||||
</div>
|
||||
|
||||
<div class="status-property col-2">
|
||||
<i class="fas fa-random" :class="{active: status.random}"></i>
|
||||
</div>
|
||||
<div class="status-property col-2">
|
||||
<i class="fas fa-redo" :class="{active: status.repeat}"></i>
|
||||
</div>
|
||||
<div class="status-property col-2">
|
||||
<i class="fa fa-bullseye" :class="{active: status.single}"></i>
|
||||
</div>
|
||||
<div class="status-property col-2">
|
||||
<i class="fa fa-utensils" :class="{active: status.consume}"></i>
|
||||
<div class="status-property col-2">
|
||||
<button title="Random" @click="random">
|
||||
<i class="fas fa-random" :class="{active: status.random}"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="status-property col-2">
|
||||
<button title="Repeat" @click="repeat">
|
||||
<i class="fas fa-redo" :class="{active: status.repeat}"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="status-property col-2">
|
||||
<button title="Single" @click="single">
|
||||
<i class="fa fa-bullseye" :class="{active: status.single}"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="status-property col-2">
|
||||
<button title="Consume" @click="consume">
|
||||
<i class="fa fa-utensils" :class="{active: status.consume}"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -62,12 +89,21 @@
|
|||
<script>
|
||||
import Utils from "@/Utils";
|
||||
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 {
|
||||
name: "Music",
|
||||
components: {Loading},
|
||||
mixins: [Utils],
|
||||
components: {Loading, ProgressBar, Slider},
|
||||
mixins: [Status, Utils],
|
||||
props: {
|
||||
// Music plugin to use (default: music.mopidy).
|
||||
plugin: {
|
||||
type: String,
|
||||
default: 'music.mopidy',
|
||||
},
|
||||
|
||||
// Refresh interval in seconds.
|
||||
refreshSeconds: {
|
||||
type: Number,
|
||||
|
@ -78,16 +114,18 @@ export default {
|
|||
withControls: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
track: undefined,
|
||||
status: undefined,
|
||||
timer: undefined,
|
||||
track: null,
|
||||
status: {},
|
||||
timer: null,
|
||||
loading: false,
|
||||
musicPlugin: 'music.mpd',
|
||||
showVolumeBar: false,
|
||||
images: {},
|
||||
maxImages: 100,
|
||||
|
||||
syncTime: {
|
||||
timestamp: null,
|
||||
|
@ -100,6 +138,21 @@ export default {
|
|||
_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: {
|
||||
|
@ -107,8 +160,8 @@ export default {
|
|||
this.loading = true
|
||||
|
||||
try {
|
||||
let status = await this.request(`${this.musicPlugin}.status`)
|
||||
let track = await this.request(`${this.musicPlugin}.current_track`)
|
||||
let status = await this.request(`${this.plugin}.status`) || {}
|
||||
let track = await this.request(`${this.plugin}.current_track`)
|
||||
|
||||
this._parseStatus(status)
|
||||
this._parseTrack(track)
|
||||
|
@ -117,62 +170,49 @@ export default {
|
|||
this.startTimer()
|
||||
else if (status.state !== 'play' && this.timer)
|
||||
this.stopTimer()
|
||||
|
||||
if (status.state !== 'stop' && !this.image)
|
||||
await this.refreshImage()
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
convertTime(time) {
|
||||
time = parseFloat(time) // Normalize strings
|
||||
const t = {}
|
||||
t.h = parseInt(time/3600)
|
||||
t.m = parseInt(time/60 - t.h*60)
|
||||
t.s = parseInt(time - (t.h*3600 + t.m*60))
|
||||
async refreshImage() {
|
||||
if (!this.trackUri)
|
||||
return
|
||||
|
||||
for (const attr of ['m','s']) {
|
||||
t[attr] = '' + t[attr]
|
||||
}
|
||||
if (!this.images[this.trackUri]) {
|
||||
const trackImage = (
|
||||
await this.request(`${this.plugin}.get_images`, {resources: [this.trackUri]})
|
||||
)[this.trackUri]
|
||||
|
||||
for (const attr of ['m','s']) {
|
||||
if (parseInt(t[attr]) < 10) {
|
||||
t[attr] = '0' + t[attr]
|
||||
if (Object.keys(this.images).length > this.maxImages) {
|
||||
delete this.images[Object.keys(this.images)[0]]
|
||||
}
|
||||
|
||||
this.images[this.trackUri] = trackImage
|
||||
}
|
||||
|
||||
const ret = []
|
||||
if (parseInt(t.h)) {
|
||||
ret.push(t.h)
|
||||
}
|
||||
|
||||
ret.push(t.m, t.s)
|
||||
return ret.join(':')
|
||||
return this.images[this.trackUri]
|
||||
},
|
||||
|
||||
async _parseStatus(status) {
|
||||
if (!status || status.length === 0)
|
||||
status = await this.request(`${this.musicPlugin}.status`)
|
||||
if (status?.pluginName)
|
||||
this.musicPlugin = status.pluginName
|
||||
const statusPlugin = status.pluginName
|
||||
if (statusPlugin && this.plugin && statusPlugin !== this.plugin)
|
||||
return // Ignore status updates from other plugins
|
||||
|
||||
if (!status || Object.keys(status).length === 0)
|
||||
status = await this.request(`${this.plugin}.status`) || {}
|
||||
if (!this.status)
|
||||
this.status = {}
|
||||
|
||||
for (const [attr, value] of Object.entries(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
|
||||
}
|
||||
}
|
||||
this.status = this.parseStatus(status)
|
||||
},
|
||||
|
||||
async _parseTrack(track) {
|
||||
if (!track || track.length === 0) {
|
||||
track = await this.request(`${this.musicPlugin}.current_track`)
|
||||
track = await this.request(`${this.plugin}.current_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) {
|
||||
let previousTrack = undefined
|
||||
let previousTrack = null
|
||||
|
||||
if (this.track) {
|
||||
previousTrack = {
|
||||
|
@ -213,7 +278,7 @@ export default {
|
|||
this.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.startTimer()
|
||||
|
||||
|
@ -222,6 +287,9 @@ export default {
|
|||
|| this.track.title !== previousTrack.title)) {
|
||||
this.showNewTrackNotification()
|
||||
}
|
||||
|
||||
if (!this.image)
|
||||
await this.refreshImage()
|
||||
},
|
||||
|
||||
onMusicStop(event) {
|
||||
|
@ -232,20 +300,26 @@ export default {
|
|||
this.stopTimer()
|
||||
},
|
||||
|
||||
onMusicPlay(event) {
|
||||
async onMusicPlay(event) {
|
||||
this.status.state = 'play'
|
||||
this._parseStatus(event.status)
|
||||
this._parseTrack(event.track)
|
||||
this.startTimer()
|
||||
|
||||
if (!this.image)
|
||||
await this.refreshImage()
|
||||
},
|
||||
|
||||
onMusicPause(event) {
|
||||
async onMusicPause(event) {
|
||||
this.status.state = 'pause'
|
||||
this._parseStatus(event.status)
|
||||
this._parseTrack(event.track)
|
||||
|
||||
this.syncTime.timestamp = new Date()
|
||||
this.syncTime.elapsed = this.status.elapsed
|
||||
|
||||
if (!this.image)
|
||||
await this.refreshImage()
|
||||
},
|
||||
|
||||
onSeekChange(event) {
|
||||
|
@ -336,8 +410,8 @@ export default {
|
|||
|
||||
mounted() {
|
||||
this.refresh()
|
||||
if (this.refreshSeconds) {
|
||||
setInterval(this.refresh, parseInt((this.refreshSeconds*1000).toFixed(0)))
|
||||
if (this._refreshSeconds) {
|
||||
setInterval(this.refresh, this._refreshSeconds * 1000)
|
||||
}
|
||||
|
||||
this.subscribe(this.onNewPlayingTrack, 'widget-music-on-new-track', 'platypush.message.event.music.NewPlayingTrackEvent')
|
||||
|
@ -355,8 +429,8 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$progress-bar-bg: #ddd;
|
||||
$playback-status-color: #757f70;
|
||||
$bottom-height: 2em;
|
||||
|
||||
.music {
|
||||
width: 100%;
|
||||
|
@ -367,6 +441,82 @@ $playback-status-color: #757f70;
|
|||
justify-content: center;
|
||||
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 {
|
||||
text-align: center;
|
||||
|
||||
|
@ -387,53 +537,18 @@ $playback-status-color: #757f70;
|
|||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
margin-top: 1em;
|
||||
height: 1em;
|
||||
font-size: 1.2em;
|
||||
|
||||
.row {
|
||||
padding: 0 .5em;
|
||||
}
|
||||
|
||||
.time-total {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 1em;
|
||||
position: relative;
|
||||
margin-bottom: .75em;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
position: relative;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: .75em;
|
||||
padding: 0 .5em;
|
||||
}
|
||||
|
||||
.playback-status {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
border-top: $default-border-2;
|
||||
color: $playback-status-color;
|
||||
width: 100%;
|
||||
height: 2em;
|
||||
|
||||
.status-property {
|
||||
display: flex;
|
||||
|
@ -444,25 +559,51 @@ $playback-status-color: #757f70;
|
|||
|
||||
.active {
|
||||
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 {
|
||||
display: flex;
|
||||
margin-top: .5em;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $default-hover-fg;
|
||||
}
|
||||
&:hover {
|
||||
color: $default-hover-fg !important;
|
||||
}
|
||||
|
||||
&.play-pause {
|
||||
color: $selected-fg;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
&.play-pause {
|
||||
color: $selected-fg !important;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.volume {
|
||||
.row {
|
||||
width: calc(100% - 1em);
|
||||
margin: 0 .5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
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-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-3: linear-gradient(90deg, rgba(180,246,238,1) 0%, rgba(219,255,245,1) 100%) !default;
|
||||
$active-bg: #8fefb7 !default;
|
||||
|
||||
/// Disabled
|
||||
|
|
|
@ -45,6 +45,10 @@ export default {
|
|||
classes() {
|
||||
return this.class
|
||||
},
|
||||
|
||||
_refreshSeconds() {
|
||||
return parseFloat(this.refreshSeconds) || 0
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
@ -56,7 +60,7 @@ export default {
|
|||
return props
|
||||
},
|
||||
|
||||
parseTemplate(name, tmpl) {
|
||||
parseTemplate(tmpl) {
|
||||
const node = new DOMParser().parseFromString(tmpl, 'text/xml').childNodes[0]
|
||||
const self = this
|
||||
this.style = node.attributes.style?.nodeValue
|
||||
|
@ -111,17 +115,17 @@ export default {
|
|||
this.notifyError(`Dashboard ${name} not found`)
|
||||
}
|
||||
|
||||
this.parseTemplate(name, template)
|
||||
this.parseTemplate(template)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.refreshDashboard()
|
||||
if (this.refreshSeconds) {
|
||||
if (this._refreshSeconds) {
|
||||
const self = this
|
||||
setInterval(() => {
|
||||
self.refreshDashboard()
|
||||
}, parseInt((this.refreshSeconds*1000).toFixed(0)))
|
||||
}, parseInt((this._refreshSeconds*1000).toFixed(0)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue