661 lines
20 KiB
QML
661 lines
20 KiB
QML
import QtQuick 2.15
|
|
import QtQuick.Controls 2.3
|
|
import QtQuick.Layouts 1.15
|
|
import org.kde.plasma.components 2.0 as PlasmaComponents
|
|
import org.kde.plasma.core 2.0 as PlasmaCore
|
|
import "songLibrary.js" as SongLibrary
|
|
|
|
Item {
|
|
id: mpdRoot
|
|
|
|
signal gotPlaylist(var plData)
|
|
signal onSaveQueueAsPlaylist(bool success)
|
|
signal playedPlaylist(string title)
|
|
|
|
property bool mpcAvailable: false
|
|
property bool mpcConnectionAvailable: false
|
|
property int mpdVolume: 0
|
|
property string scriptRoot
|
|
property bool mpdPlaying: false
|
|
property var library
|
|
property bool libraryRequested: false
|
|
property var mpdInfo: ({})
|
|
property var mpdOptions: ({})
|
|
property var mpdPlaylists: ({})
|
|
property var mpdQueue: []
|
|
readonly property string _songInfoQuery: '{[\x1Fartist\x1F:\x1F%artist%\x1F,][\x1Falbumartist\x1F:\x1F%albumartist%\x1F,][\x1Falbum\x1F:\x1F%album%\x1F,][\x1Ftracknumber\x1F:\x1F%track%\x1F,]\x1Ftitle\x1F:\x1F%title%\x1F,[\x1Fdate\x1F:\x1F%date%\x1F,]\x1Ftime\x1F:\x1F%time%\x1F,\x1Ffile\x1F:\x1F%file%\x1F,\x1Fposition\x1F:\x1F%position%\x1F},'
|
|
|
|
readonly property var mpdCmds: {
|
|
"cSongInfo": "-f '%1'",
|
|
"connectionCheck": "mpc --host=%1 status",
|
|
"lGet": "listall -f '%1'",
|
|
"mpcCheck": "which mpc",
|
|
"mpcIdleLoop": "idle player mixer playlist stored_playlist options",
|
|
"optGet": "status '{\x1Fconsume\x1F:\x1F%consume%\x1F,\x1Frandom\x1F:\x1F%random%\x1F,\x1Frepeat\x1F:\x1F%repeat%\x1F}'",
|
|
"optSet": "%1 %2",
|
|
"plLoad": "load %1",
|
|
"plGet": "playlist -f '%1' %2",
|
|
"plRm": "rm -- %1",
|
|
"plSave": "save -- %1",
|
|
"plsGet": "lsplaylist",
|
|
"qAdd": "add %1",
|
|
"qClear": "clear",
|
|
"qDel": "del %1",
|
|
"qGet": "playlist -f '%1'",
|
|
"qInsert": "insert %1",
|
|
"qMove": "move %1 %2",
|
|
"qNext": "next",
|
|
"qPlay": "play %1",
|
|
"qQueued": "queued -f '%1'",
|
|
"qToggle": "toggle",
|
|
"volumeGet": "volume",
|
|
"volumeSet": "volume %1"
|
|
}
|
|
|
|
/**
|
|
* Starts the bootstrap process of a fresh connection to the mpd instance
|
|
*/
|
|
function connect() {
|
|
if (mpcAvailable !== true) {
|
|
mpdRoot.checkMpcAvailable()
|
|
return
|
|
}
|
|
|
|
disconnect()
|
|
checkMpcConnectionAvailable()
|
|
}
|
|
|
|
|
|
/**
|
|
* Stops the current connection to the mpd instance
|
|
*/
|
|
function disconnect() {
|
|
executable.disconnect()
|
|
mpdRoot.mpcConnectionAvailable = false
|
|
mpdRootIdleLoopTimer.stop()
|
|
}
|
|
|
|
|
|
/**
|
|
* Check if mpc binary available on the host system
|
|
*/
|
|
function checkMpcAvailable() {
|
|
let callback = function (exitCode) {
|
|
if (exitCode !== 0) {
|
|
return
|
|
}
|
|
|
|
mpdRoot.mpcAvailable = true
|
|
mpdRoot.connect()
|
|
}
|
|
|
|
executable.exec(mpdCmds.mpcCheck, callback)
|
|
}
|
|
|
|
|
|
/**
|
|
* Checks if mpc is able to connect to mpd
|
|
*/
|
|
function checkMpcConnectionAvailable() {
|
|
if (mpcAvailable !== true) {
|
|
return
|
|
}
|
|
|
|
let callback = function (exitCode) {
|
|
if (exitCode !== 0) {
|
|
mpdRootNetworkTimeout.start()
|
|
|
|
return
|
|
}
|
|
|
|
mpdRootNetworkTimeout.interval = mpdRootNetworkTimeout.startInterval
|
|
mpdRootIdleLoopTimer.start()
|
|
mpcConnectionAvailable = true
|
|
update()
|
|
|
|
if (libraryRequested) {
|
|
getLibrary()
|
|
}
|
|
}
|
|
|
|
// Bypass the build-in mpc faclities, they are gatekept by the result of this.
|
|
executable.exec(mpdCmds.connectionCheck.arg(cfgMpdHost), callback)
|
|
}
|
|
|
|
function forceReloadEverything() {
|
|
if (library) {
|
|
mpdRoot.libraryRequested = true
|
|
}
|
|
connect()
|
|
}
|
|
|
|
/**
|
|
* Inits update of all mpd data required by our plasmoid
|
|
*/
|
|
// @TODO this should be disentangled and properly attached/requested by our now exiting
|
|
// different views
|
|
function update() {
|
|
mpdRoot.getVolume()
|
|
mpdRoot.getQueue()
|
|
// @SOMEDAY make code more robust
|
|
// Leave getInfo() after getQueue(), since it evaluates the queue content
|
|
mpdRoot.getInfo()
|
|
mpdRoot.getPlaylists()
|
|
mpdRoot.getOptions()
|
|
}
|
|
|
|
|
|
/**
|
|
* Download whole song library
|
|
*/
|
|
function getLibrary() {
|
|
if (!mpcConnectionAvailable) {
|
|
libraryRequested = true
|
|
}
|
|
executable.execMpc(mpdCmds.lGet.arg(_songInfoQuery), function (exitCode, stdout) {
|
|
if (exitCode !== 0) {
|
|
return
|
|
}
|
|
library = new SongLibrary.SongLibrary(songInfoQueryResponseToJson(stdout))
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Saves queue as playlist
|
|
*
|
|
* @param {sting} title playlist title in MPD
|
|
*/
|
|
function saveQueueAsPlaylist(title) {
|
|
executable.execMpc(mpdCmds.plSave.arg(bEsc(title)), function (exitCode) {
|
|
if (exitCode !== 0) {
|
|
return
|
|
}
|
|
onSaveQueueAsPlaylist(!exitCode)
|
|
})
|
|
}
|
|
|
|
|
|
/**
|
|
* Deletes a playlist
|
|
*
|
|
* @param {sting} title playlist title in MPD
|
|
*/
|
|
function removePlaylist(title) {
|
|
executable.execMpc(mpdCmds.plRm.arg(bEsc(title)))
|
|
}
|
|
|
|
|
|
/**
|
|
* Moves song in queue
|
|
*
|
|
* @param {int} from Position of the song to move (current)
|
|
* @param {int} to Positiong to move the song to (target)
|
|
*/
|
|
function moveInQueue(from, to) {
|
|
executable.execMpc(mpdCmds.qMove.arg(from).arg(to))
|
|
}
|
|
|
|
function getVolume() {
|
|
executable.execMpc(mpdCmds.volumeGet, function (exitCode, stdout) {
|
|
if (exitCode !== 0) {
|
|
return
|
|
}
|
|
|
|
let parsed = stdout.match(/volume:\W*(\d*)/)
|
|
if (!parsed) {
|
|
throw new Error("Invalid mpc response: No volume information in " + stdout)
|
|
}
|
|
mpdRoot.mpdVolume = parseInt(parsed[1])
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Set volume
|
|
*
|
|
* @param {string} Absolute <value> or +/-<value>
|
|
*/
|
|
function setVolume(value) {
|
|
executable.execMpc(mpdCmds.volumeSet.arg(value))
|
|
}
|
|
|
|
/**
|
|
* Get info of currently playing song
|
|
*
|
|
* When mpd is stopped it evalutates what is going to be played next on
|
|
* toggling "play".
|
|
*/
|
|
function getInfo() {
|
|
let cmd = mpdCmds.cSongInfo.arg(_songInfoQuery)
|
|
executable.execMpc(cmd, function (exitCode, stdout) {
|
|
if (exitCode !== 0) {
|
|
return
|
|
}
|
|
let info = stdout.split("\n")
|
|
|
|
// Normal playback
|
|
if (info.length > 2) {
|
|
mpdInfo = songInfoQueryResponseToJson(info.shift())[0]
|
|
mpdPlaying = info.shift().includes('[playing]')
|
|
|
|
return
|
|
}
|
|
|
|
// Qeueue is paused or in stopped state
|
|
mpdPlaying = false
|
|
|
|
// Queue is empty, nothing will be played on a toggle
|
|
if (mpdState.mpdQueue.length === 0) {
|
|
return
|
|
}
|
|
|
|
// Only one item on the queue, it must be played on a toggle
|
|
if (mpdState.mpdQueue.length === 1) {
|
|
mpdInfo = mpdQueue[0]
|
|
return
|
|
}
|
|
|
|
executable.execMpc(mpdCmds.qQueued.arg(_songInfoQuery), function (exitCode, stdout) {
|
|
if (exitCode !== 0) {
|
|
return
|
|
}
|
|
|
|
if (stdout === "") {
|
|
// More than one item in Queue but nothing queued. a) Queue is
|
|
// stopped and was never started before (1st item will be played)
|
|
// or b) we are at the last item.
|
|
// Since we only create case (a) we ignore (b) for our purposes.
|
|
mpdInfo = mpdQueue[0]
|
|
return
|
|
}
|
|
|
|
// Queue was started before, we just can't get the item directly,
|
|
// so we cheat by asking for the next one.
|
|
let queued = songInfoQueryResponseToJson(stdout)[0]
|
|
mpdInfo = mpdQueue[queued.position - 2]
|
|
})
|
|
})
|
|
}
|
|
|
|
function getQueue() {
|
|
let cmd = mpdCmds.qGet.arg(_songInfoQuery)
|
|
executable.execMpc(cmd, function (exitCode, stdout) {
|
|
if (exitCode !== 0) {
|
|
return
|
|
}
|
|
if (!stdout.length) {
|
|
// Empty queue
|
|
mpdQueue = []
|
|
return
|
|
}
|
|
let queue = songInfoQueryResponseToJson(stdout)
|
|
mpdRoot.mpdQueue = queue
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Add songs to the queue
|
|
*
|
|
* @param {array} array of mpd file IDs
|
|
* @param {string} insertion mode
|
|
* - "append" at end of queue
|
|
* - "insert" after currently playing track
|
|
*/
|
|
function addSongsToQueue(items, mode = "append") {
|
|
if (!Array.isArray(items)) {
|
|
throw new Error("Invalid argument: items must be an array")
|
|
}
|
|
|
|
let cmd
|
|
switch (mode) {
|
|
case "append":
|
|
cmd = mpdCmds.qAdd
|
|
break
|
|
case "insert":
|
|
cmd = mpdCmds.qInsert
|
|
break
|
|
default:
|
|
throw new Error("Invalid argument: unknown mode")
|
|
}
|
|
|
|
cmd = cmd.arg(items.map(function (item) { return bEsc(item) }).join(" "))
|
|
|
|
mpdCommandQueue.add(cmd)
|
|
}
|
|
|
|
function replaceQueue(items) {
|
|
clearQueue()
|
|
addSongsToQueue(items)
|
|
playInQueue(1)
|
|
}
|
|
|
|
/**
|
|
* Removes items from the queue
|
|
*
|
|
* @param {array} positions Positions of items to remove from the queue
|
|
*/
|
|
function removeFromQueue(positions) {
|
|
if (!Array.isArray(positions)) {
|
|
throw new Error("Invalid argument: positions must be an array")
|
|
}
|
|
executable.execMpc(mpdCmds.qDel.arg(positions.join(' ')))
|
|
}
|
|
|
|
function playNext() {
|
|
executable.execMpc(mpdCmds.qNext)
|
|
}
|
|
|
|
function toggle() {
|
|
executable.execMpc(mpdCmds.qToggle)
|
|
}
|
|
|
|
|
|
function clearQueue() {
|
|
// @BOGUS mpd/mpc doens't execute if used to fast
|
|
mpdCommandQueue.add(mpdCmds.qClear)
|
|
// executable.execMpc(mpdCmds.qClear)
|
|
}
|
|
|
|
|
|
/**
|
|
* Play specific item in queue
|
|
*
|
|
* @param {int} position Position in queue
|
|
*/
|
|
function playInQueue(position) {
|
|
mpdCommandQueue.add(mpdCmds.qPlay.arg(position))
|
|
// executable.execMpc(mpdCmds.qPlay.arg(position))
|
|
}
|
|
|
|
function getPlaylists() {
|
|
executable.execMpc(mpdCmds.plsGet, function (exitCode, stdout) {
|
|
if (exitCode !== 0) {
|
|
return
|
|
}
|
|
let playlists = stdout.split("\n")
|
|
playlists = playlists.filter(value => {
|
|
return value && !value.includes('m3u')
|
|
})
|
|
playlists = playlists.sort((a, b) => {
|
|
let textA = a.toUpperCase()
|
|
let textB = b.toUpperCase()
|
|
return (textA < textB) ? -1 : (textA > textB) ? 1 : 0
|
|
})
|
|
mpdRoot.mpdPlaylists = playlists
|
|
})
|
|
}
|
|
|
|
function getPlaylist(playlist) {
|
|
let cmd = mpdCmds.plGet.arg(_songInfoQuery).arg(bEsc(playlist))
|
|
let clb = function (exitCode, stdout) {
|
|
gotPlaylist(songInfoQueryResponseToJson(stdout))
|
|
}
|
|
executable.execMpc(cmd, clb)
|
|
}
|
|
|
|
function playPlaylist(playlist) {
|
|
clearQueue()
|
|
addPlaylistToQueue(playlist)
|
|
playInQueue(1)
|
|
playedPlaylist(playlist)
|
|
}
|
|
|
|
function getOptions() {
|
|
executable.execMpc(mpdCmds.optGet, function (exitCode, stdout) {
|
|
if (exitCode !== 0) {
|
|
return
|
|
}
|
|
mpdRoot.mpdOptions = songInfoQueryResponseToJson(stdout)[0]
|
|
})
|
|
}
|
|
|
|
function toggleOption(option) {
|
|
if (typeof option !== 'string') {
|
|
throw new Error("Invalid argument: mpd-options must be an string")
|
|
}
|
|
if (!['consume', 'random', 'repeat'].includes(option)) {
|
|
throw new Error("Invalid argument: mpd-option " + option)
|
|
}
|
|
|
|
let newState = mpdRoot.mpdOptions[option] === "on" ? "off" : "on"
|
|
|
|
executable.execMpc(mpdCmds.optSet.arg(option).arg(newState))
|
|
}
|
|
|
|
function addPlaylistToQueue(playlist) {
|
|
mpdCommandQueue.add(mpdCmds.plLoad.arg(bEsc(playlist)))
|
|
// executable.execMpc(mpdCmds.plLoad.arg(bEsc(playlist)))
|
|
}
|
|
|
|
function getCover(title, ctitle, root, prefix) {
|
|
let cmd = ''
|
|
cmd += '/usr/bin/env bash'
|
|
cmd += ' "' + mpdRoot.scriptRoot + '/downloadCover.sh"'
|
|
cmd += ' ' + cfgMpdHost
|
|
cmd += ' ' + bEsc(title)
|
|
cmd += ' "' + root + '"'
|
|
cmd += ' ' + prefix
|
|
cmd += ' "' + ctitle.replace('/', '\\\\/') + '"'
|
|
|
|
let clb = function (exitCode, stdout) {
|
|
if (exitCode !== 0) {
|
|
return
|
|
}
|
|
|
|
coverManager.markFetched(!stdout.includes("No data"))
|
|
}
|
|
executable.exec(cmd, clb)
|
|
}
|
|
|
|
function countQueue() {
|
|
return Object.keys(mpdRoot.mpdQueue).length
|
|
}
|
|
|
|
|
|
/**
|
|
* Escape special characters from strings before using as mpc arguments
|
|
*
|
|
* @param {string} str The string to quote
|
|
* @param {bool} quote Wrap the string in double quotes
|
|
* @return {string} The escaped string
|
|
*/
|
|
function bEsc(str, quote = true) {
|
|
if (typeof(str) !== "string") {
|
|
console.trace()
|
|
throw new Error("Invalid argument error: expected string, got " + typeof(str))
|
|
}
|
|
if (str === "") {
|
|
throw new Error("Invalid argument error: got empty string")
|
|
}
|
|
let specialChars = ['$', '`', '"', '\\']
|
|
let escapedStr = str.split('').map(character => {
|
|
if (specialChars.includes(character)) {
|
|
return '\\' + character
|
|
} else {
|
|
return character
|
|
}
|
|
}).join('')
|
|
if (quote) {
|
|
escapedStr = "\"" + escapedStr + "\""
|
|
}
|
|
return escapedStr
|
|
}
|
|
|
|
|
|
/**
|
|
* Parses a response made in the songInfoQuery-format to JSON
|
|
*
|
|
* Main task is to solve " quoting issues
|
|
*
|
|
* @param {string} response The raw text of the mpd response
|
|
* @return {array} Array of JSON objects each representing one song
|
|
*/
|
|
function songInfoQueryResponseToJson(response) {
|
|
// [profiling] parse takes 90%+ of time
|
|
return JSON.parse('[' + response.replace(/"/g, '\\"').replace(/\x1F/g, '"').replace(/,\n?$/, "") + ']')
|
|
}
|
|
|
|
|
|
/**
|
|
* Replace mpc error messages with our own
|
|
*
|
|
* @param {string} msg The mpc error message
|
|
*/
|
|
function fmtErrorMessage(msg) {
|
|
let fmtMsg = msg
|
|
if (fmtMsg.includes("No route to host")) {
|
|
fmtMsg = qsTr("Can't find the MPD-server. - Check the MPD-address in the widget configuration.")
|
|
} else if (fmtMsg.includes("Network is unreachable")) {
|
|
fmtMsg = qsTr("No network connection.")
|
|
} else if (fmtMsg.includes("no mpc in")) {
|
|
fmtMsg = qsTr("'mpc' binary wasn't found. - Please install mpc on your system. It should be available in your system's package manager.")
|
|
}
|
|
|
|
return fmtMsg
|
|
}
|
|
|
|
// Throttle commands so we don't miss results on the event loop because we
|
|
// sending to fast.
|
|
Timer {
|
|
id: mpdCommandQueue
|
|
|
|
property var cmdQueue: []
|
|
|
|
// The statusUpdateTimer is chained to this!
|
|
interval: 200
|
|
running: true
|
|
repeat: true
|
|
|
|
function add(cmd) {
|
|
cmdQueue.push(cmd)
|
|
}
|
|
|
|
onTriggered: {
|
|
if (cmdQueue.length === 0) {
|
|
return
|
|
}
|
|
|
|
let cmd = cmdQueue.shift()
|
|
executable.execMpc(cmd)
|
|
}
|
|
}
|
|
|
|
// If something is happening on the queue let's have it settle on the mpd side.
|
|
Timer {
|
|
id: statusUpdateTimer
|
|
// If we populate the queue and send play we have to wait long enough to catch
|
|
// our own cmd.
|
|
interval: 2 * mpdCommandQueue.interval
|
|
onTriggered: {
|
|
mpdRoot.getQueue()
|
|
// Mpc spams a new "playlist" event for every song added to the queue, so
|
|
// maybe dozens if e.g. an album/playlist is added. Sometimes that's too
|
|
// fast for us to catch the last "player" event indicated the new populate
|
|
// queue started. We have to check what is playing after the queue
|
|
// changes.
|
|
mpdRoot.getInfo()
|
|
}
|
|
}
|
|
|
|
// Mpc idle loop. After a mpc-event is registered and handled almost
|
|
// immediately reconnect the shut down connection.
|
|
Timer {
|
|
id: mpdRootIdleLoopTimer
|
|
interval: 10
|
|
onTriggered: {
|
|
let clb = function (exitCode, stdout) {
|
|
if (exitCode !== 0) {
|
|
return
|
|
}
|
|
|
|
// Restart the idle loop
|
|
mpdRootIdleLoopTimer.start()
|
|
|
|
if (stdout.includes('player')) {
|
|
mpdRoot.getInfo()
|
|
}
|
|
|
|
if (stdout.includes('mixer'))
|
|
mpdRoot.getVolume()
|
|
|
|
if (stdout.includes('options'))
|
|
mpdRoot.getOptions()
|
|
|
|
if (stdout.includes('playlist')) {
|
|
statusUpdateTimer.restart()
|
|
}
|
|
|
|
if (stdout.includes('stored_playlist')) {
|
|
mpdRoot.getPlaylists()
|
|
}
|
|
}
|
|
executable.execMpc(mpdCmds.mpcIdleLoop, clb)
|
|
}
|
|
}
|
|
|
|
// Handles network issues. E.g. if the network card needs a few seconds to
|
|
// become available after a system resume. Or the device is moved in and out
|
|
// of places with the mpd server (un)available.
|
|
Timer {
|
|
id: mpdRootNetworkTimeout
|
|
|
|
property int startInterval: 500
|
|
|
|
interval: startInterval
|
|
running: false
|
|
triggeredOnStart: true
|
|
onTriggered: {
|
|
disconnect()
|
|
|
|
// Gradually increase reconnect time until we find a minimum time
|
|
// necessary for a device stationary within the mpd network (desktop).
|
|
// At worst try a reconnect every minute (devices leaving the
|
|
// local network like laptops).
|
|
if (interval < 60000)
|
|
interval = interval + 500
|
|
|
|
mpdRoot.checkMpcConnectionAvailable()
|
|
}
|
|
}
|
|
|
|
// Watchdog for system sleep/wake cycles. If we detect a "lost timespan" we
|
|
// assume the mpc idle connection is no longer valid and needs a reconnect.
|
|
Timer {
|
|
id: mpdRootReconnectTimer
|
|
|
|
property int lastRun: Date.now() / 1000
|
|
|
|
interval: 2000
|
|
running: true
|
|
repeat: true
|
|
onTriggered: {
|
|
if ((2 * interval / 1000 + lastRun) < (Date.now() / 1000)) {
|
|
mpdRoot.forceReloadEverything()
|
|
}
|
|
|
|
lastRun = Date.now() / 1000
|
|
}
|
|
}
|
|
|
|
ExecMpc {
|
|
id: executable
|
|
}
|
|
|
|
Connections {
|
|
function onExited(exitCode, stdout, stderr, exitStatus, cmd) {
|
|
main.appLastError = ""
|
|
if (exitCode !== 0) {
|
|
if (stderr.includes("No data")) {
|
|
// "No data" answer from mpd is a succesfull request for us.
|
|
return
|
|
}
|
|
main.appLastError = fmtErrorMessage(stderr)
|
|
mpdRootNetworkTimeout.start()
|
|
|
|
return
|
|
}
|
|
main.appLastError = stderr || ""
|
|
}
|
|
|
|
target: executable
|
|
}
|
|
}
|