[#297] Mopidy/MPD refactor+migration, UI side.

This commit is contained in:
Fabio Manganiello 2024-04-04 01:07:05 +02:00
parent e2246c8d30
commit 5d9a201a5b
Signed by: blacklight
GPG key ID: D90FBA7F76362774
24 changed files with 1209 additions and 591 deletions

View file

@ -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) {

View file

@ -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,
}, },

View file

@ -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;
}

View file

@ -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%;

View file

@ -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 {

View file

@ -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%;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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%;

View file

@ -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;
}

View file

@ -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>

View file

@ -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"
@ -92,7 +103,7 @@
:devices="devices" :devices="devices"
:selected-device="selectedDevice" :selected-device="selectedDevice"
:active-device="activeDevice" :active-device="activeDevice"
:show-nav-button="!navVisible" :show-nav-button="!navVisible"
v-else-if="selectedView === 'library'" v-else-if="selectedView === 'library'"
@search="search" @search="search"
@clear="$emit('search-clear')" @clear="$emit('search-clear')"
@ -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)

View file

@ -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'
}, },
}, },
} }

View file

@ -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() {
this.scrollToTrack() // Add the scrolling listeners only if we are on the queue view.
this.$watch(() => this.status, () => this.scrollToTrack()) if (!this.withAddToQueue) {
this.$watch(() => this.filter, (filter) => { this.scrollToTrack()
if (!filter?.length) this.$watch(() => this.status, () => this.scrollToTrack())
this.scrollToTrack() this.$watch(() => this.filter, (filter) => {
}) if (!filter?.length)
this.scrollToTrack()
})
}
}, },
} }
</script> </script>

View file

@ -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;
} }

View file

@ -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 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>
<div class="artist" v-text="result.artist" v-if="result.artist?.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" /> <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) {

View file

@ -18,7 +18,17 @@
} }
&.dragover { &.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 { &::selection {

View file

@ -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>

View file

@ -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>

View file

@ -1,59 +1,86 @@
<template> <template>
<Loading v-if="loading" /> <Loading v-if="loading" />
<div class="music" v-else> <div class="music" v-else>
<div class="track"> <div class="background" v-if="image">
<div class="unknown" v-if="!status">[Unknown state]</div> <div class="image" :style="{backgroundImage: 'url(' + image + ')'}" />
<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>
<div class="time" v-if="status && status.state === 'play'"> <div class="foreground">
<div class="row"> <div class="top">
<div class="progress-bar"> <div class="section" :class="{'has-image': !!image, 'has-progress': status?.state === 'play'}">
<div class="elapsed" :style="{width: track.time ? 100*(status.elapsed/track.time) + '%' : '100%'}"></div> <div class="track">
<div class="total"></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="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> </div>
<div class="row"> <div class="bottom">
<div class="col-6 time-elapsed" v-text="convertTime(status.elapsed)"></div> <div class="playback-status section" :class="{'has-image': !!image}" v-if="status">
<div class="col-6 time-total" v-if="track.time" v-text="convertTime(track.time)"></div> <div class="status-property col-4 volume fade-in" v-if="!showVolumeBar">
</div> <button title="Volume" @click="showVolumeBar = true">
</div> <i class="fa fa-volume-up" />
&nbsp; {{ status.volume }}%
</button>
</div>
<div class="controls" v-if="_withControls && status"> <div class="status-property col-4 volume fade-in" v-else>
<button @click="prev"> <div class="row">
<i class="fa fa-step-backward" /> <i class="fa fa-volume-up" /> &nbsp;
</button> <Slider :range="[0, 100]" :value="status.volume" @change="setVolume" />
<button class="play-pause" @click="playPause"> </div>
<i class="fa fa-pause" v-if="status.state === 'play'" /> </div>
<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="playback-status" v-if="status"> <div class="status-property col-2">
<div class="status-property col-4"> <button title="Random" @click="random">
<i class="fa fa-volume-up"></i>&nbsp; <span v-text="status.volume + '%'"></span> <i class="fas fa-random" :class="{active: status.random}"></i>
</div> </button>
</div>
<div class="status-property col-2"> <div class="status-property col-2">
<i class="fas fa-random" :class="{active: status.random}"></i> <button title="Repeat" @click="repeat">
</div> <i class="fas fa-redo" :class="{active: status.repeat}"></i>
<div class="status-property col-2"> </button>
<i class="fas fa-redo" :class="{active: status.repeat}"></i> </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>
</div> </button>
<div class="status-property col-2"> </div>
<i class="fa fa-utensils" :class="{active: status.consume}"></i> <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> </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]
for (const attr of ['m','s']) { if (Object.keys(this.images).length > this.maxImages) {
if (parseInt(t[attr]) < 10) { delete this.images[Object.keys(this.images)[0]]
t[attr] = '0' + t[attr]
} }
this.images[this.trackUri] = trackImage
} }
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 { .progress-bar {
width: 100%; width: 100%;
margin-top: 1em; height: 1em;
font-size: 1.2em; font-size: 1.2em;
position: relative;
.row { margin-top: 1.5em;
padding: 0 .5em; margin-bottom: .75em;
} 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;
}
}
} }
.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,25 +559,51 @@ $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;
} }
} }
} }

View 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>

View file

@ -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

View file

@ -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)))
} }
} }
} }