mirror of
https://github.com/Reuh/daccord.git
synced 2025-10-27 12:49:30 +00:00
432 lines
12 KiB
Text
432 lines
12 KiB
Text
--- Yet another MPD client, but this time I wrote it so it's the best.
|
|
-- Focused on an instant and hopefully powerful search.
|
|
-- Currently only a console interface is available.
|
|
--
|
|
-- Dependencies: candran, luasocket, lcurses
|
|
--
|
|
-- Highly unintuitive keybinding:
|
|
-- * Up goes up, down goes down.
|
|
-- * Tab switch between search and current playlist.
|
|
-- * Control+W quits.
|
|
-- * Control+Space toggle play/pause.
|
|
-- * In search:
|
|
-- * Control+A add all the results to the playlist.
|
|
-- * Enter add currently selected song to the playlist.
|
|
-- * Type "TagName=" to trigger tag completion: select a tag and press Enter to select it. Or type it manually and exit tag completion by typing a space.
|
|
-- * Type "File=" to trigger filename search.
|
|
-- * In current playlist:
|
|
-- * Enter play currently selected song.
|
|
-- * Delete remove selected song from playlist.
|
|
-- * Control+Delete clear the playlist.
|
|
--
|
|
-- Most of what was initialy planned isn't implemented yet. Hopefully all will be finished before you're six feet under.
|
|
--
|
|
-- Curently implemented:
|
|
-- * Play/pause, current song status
|
|
-- * Current playlist, jump to song, remove song
|
|
-- * Instant search among all tags or specific tags
|
|
-- * Instant tag value completion
|
|
-- * Overly optimistic planned features list in a comment in the main file
|
|
--
|
|
-- Stuff which is planned:
|
|
-- * Sticker search
|
|
-- * Search requests where keyword and tag selectors can be assigned with a $probabilty, generating infinite playlists (composer=chopin$.5)
|
|
-- * Relational operators for tags and stickers (song ratings) (rating>.5)
|
|
-- * Random, single, consume and other MPD play mode are determined by :keywords in the search query (:rand, :limit=5, :asc=Track, etc.)
|
|
-- * The endgoal would be that playlists would be entirely determined and contained in a single search query (add some saved storage interface).
|
|
-- Which we would therfore mean we can regenerate them on the fly when the MPD database is updated, or probabilities where used in the query.
|
|
-- * A non-console GUI. Should be doable considering everything is neatly contained in gui.can, but damn are thoses text widget I made weird.
|
|
-- * A real documentation.
|
|
-- * Tag!=thing, modified since, and, not, or
|
|
-- * File browser
|
|
--
|
|
-- This version of the software is licensed under the terms of the Apache License, version 2 (https://www.apache.org/licenses/LICENSE-2.0.txt).
|
|
|
|
-- Copyright 2017-2018 Étienne "Reuh" Fildadut
|
|
--
|
|
-- Licensed under the Apache License, Version 2.0 (the "License");
|
|
-- you may not use this file except in compliance with the License.
|
|
-- You may obtain a copy of the License at
|
|
--
|
|
-- http://www.apache.org/licenses/LICENSE-2.0
|
|
--
|
|
-- Unless required by applicable law or agreed to in writing, software
|
|
-- distributed under the License is distributed on an "AS IS" BASIS,
|
|
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
-- See the License for the specific language governing permissions and
|
|
-- limitations under the License.
|
|
|
|
require("candran").setup()
|
|
let gui = require("gui")
|
|
let mpc = require("mpc")
|
|
|
|
-- Constants
|
|
let VERSION = "0.0.4" -- Major.Minor.IdkIChangedSomethingButLetsNotTalkAboutItTooMuch
|
|
-- Major bump means that everything major that was planned before I decided to keep programming this shit was implemented, and forms, like, a cohesive whole.
|
|
-- Minor bump means I implemented something that's actually useful. Stuff may break, because why not.
|
|
-- The other thing bump means I implemented or fixed something. But that's not worth excitement.
|
|
|
|
-- Configuration file. Yeah.
|
|
let config = {
|
|
-- MPD server
|
|
host = "localhost",
|
|
port = 6600,
|
|
password = "", -- leave empty if you don't use a password
|
|
-- Default behaviour
|
|
filenameSearch = true, -- instant search search also search in filenames for untitled tracks (not only when using the file= syntax), slightly slower when handling large searches
|
|
-- Interface
|
|
songDisplay = { "Track", { "Title", "Name", "file" }, "Artist", "Album" } -- list of tags or list of alternative tags (first one to exist will be used) to display for each song
|
|
}
|
|
(loadfile("config.lua", "t", config) or () end)() -- GATHER UP EVERYONE! I WANT YOU TO MEET... THE AMAZING CONFIG FILE LOADER!
|
|
|
|
-- Returns list of fields to display for the song s
|
|
let songTable = (s)
|
|
let t = {}
|
|
for i, field in ipairs(config.songDisplay) do
|
|
if type(field) ~= "table" then field = { field } end
|
|
t[i] = "∅"
|
|
for _, option in ipairs(field) do
|
|
if s[option] then
|
|
t[i] = tostring(s[option])
|
|
break
|
|
end
|
|
end
|
|
end
|
|
return t
|
|
end
|
|
|
|
-- Connect!
|
|
mpc.log = () end
|
|
mpc = mpc(config.host, config.port)
|
|
if config.password ~= "" then
|
|
mpc:password(config.password)
|
|
end
|
|
|
|
-- Valid tags list
|
|
let tags = [
|
|
let r, l = mpc:tagtypes()
|
|
for _, t in ipairs(l) do
|
|
push t.tagtype
|
|
end
|
|
]
|
|
|
|
-- State
|
|
let tagCompleting = {
|
|
tag = nil, -- tag name
|
|
start = nil, -- start position in search input for tag=thing
|
|
stop = nil -- end position
|
|
}
|
|
|
|
let results, playlist = {}, {} -- current result in the search view / playlist (list of songs)
|
|
let state = "stop" -- state
|
|
|
|
gui {
|
|
{
|
|
type = "tabs",
|
|
focused = true,
|
|
|
|
width = "extend",
|
|
height = "extend",
|
|
|
|
-- Search
|
|
{
|
|
{
|
|
id = "prompt",
|
|
|
|
type = "input",
|
|
focused = true,
|
|
|
|
width = "extend",
|
|
height = "1em",
|
|
|
|
onTextInput = :()
|
|
let list = @byId("list")
|
|
list:clear()
|
|
|
|
-- Tag complete
|
|
tagCompleting.tag = nil
|
|
if @sub(1, @cursorPosition):match("[A-Za-z_]+=[^\" ]*$") or @sub(1, @cursorPosition):match("[A-Za-z_]+=\"[^\"]*$") then
|
|
let start, sel, val, stop
|
|
if @sub(1, @cursorPosition):match("[A-Za-z_]+=[^\" ]*$") then
|
|
start, sel, val, stop = @sub(1, @cursorPosition):match("()([A-Za-z_]+)=([^\" ]*)()$")
|
|
else
|
|
start, sel, val, stop = @sub(1, @cursorPosition):match("()([A-Za-z_]+)=\"([^\"]*)()$")
|
|
end
|
|
|
|
-- music tags
|
|
for _, tag in ipairs(tags) do
|
|
if tag:lower() == sel:lower() then
|
|
results = {}
|
|
|
|
let r, songs = mpc:list(tag)
|
|
if r then
|
|
for _, s in ipairs(songs) do
|
|
if s[tag]:lower():match(val:lower()) then -- filter val
|
|
table.insert(results, s)
|
|
list:insert{tostring(s[tag])}
|
|
end
|
|
end
|
|
end
|
|
|
|
tagCompleting.tag = tag
|
|
tagCompleting.start = start
|
|
tagCompleting.stop = stop
|
|
|
|
break
|
|
end
|
|
end
|
|
|
|
-- file search
|
|
if sel:lower() == "file" then
|
|
results = {}
|
|
|
|
list:setPump(10, :(start, stop)
|
|
let r, songs = mpc:search("(file == %q)":format(val), "window", "%s:%s":format(start-1, stop))
|
|
if r then
|
|
for _, s in ipairs(songs) do
|
|
table.insert(results, s)
|
|
list:insert{tostring(s.file)}
|
|
end
|
|
end
|
|
end)
|
|
|
|
tagCompleting.tag = "file"
|
|
tagCompleting.start = start
|
|
tagCompleting.stop = stop
|
|
end
|
|
-- Song search
|
|
else
|
|
results = {
|
|
_filenameSearchOffset = 0, -- where the filename search begin in the result list
|
|
_filenameSearchStart = 0 -- where the search window should start in the filename search
|
|
}
|
|
|
|
-- Build query
|
|
let query = {}
|
|
|
|
-- Any selectors
|
|
let withoutSel = @content:gsub("[A-Za-z_]+=[^\" ]+", ""):gsub("[A-Za-z_]+=\"[^\"]*\"", "")
|
|
for word in withoutSel:gmatch("[^%s]+") do
|
|
table.insert(query, "(any == %q)":format(word))
|
|
end
|
|
|
|
-- Tag selectors
|
|
for tag, val in @content:gmatch("([A-Za-z_]+)=([^\" ]+)") do
|
|
table.insert(query, "(%s == %q)":format(tag, val))
|
|
end
|
|
for tag, val in @content:gmatch("([A-Za-z_]+)=\"([^\"]*)\"") do
|
|
table.insert(query, "(%s == %q)":format(tag, val))
|
|
end
|
|
|
|
-- Limit
|
|
table.insert(query, "window")
|
|
table.insert(query, "0:0")
|
|
|
|
-- And they pumped...
|
|
list:setPump(10, :(start, stop)
|
|
let filenameSearchStop -- where the filename search window should end
|
|
|
|
-- only search if didn't reache filename search
|
|
if results._filenameSearchStart == 0 then
|
|
-- Update limit
|
|
query[#query] = "%s:%s":format(start-1, stop)
|
|
|
|
-- Search
|
|
let r, songs = mpc:search(unpack(query))
|
|
if r then
|
|
-- Update widget
|
|
for _, s in ipairs(songs) do
|
|
table.insert(results, songs)
|
|
@insert(songTable(s))
|
|
end
|
|
|
|
-- Fill what's left with filename search
|
|
if config.filenameSearch and #results < stop then
|
|
results._filenameSearchOffset = #results
|
|
filenameSearchStop = stop-results._filenameSearchOffset+results._filenameSearchStart
|
|
end
|
|
end
|
|
else
|
|
filenameSearchStop = stop-results._filenameSearchOffset+results._filenameSearchStart
|
|
end
|
|
|
|
-- Filename search
|
|
if filenameSearchStop then
|
|
for i=1, #query-2, 1 do
|
|
query[i] = query[i]:gsub("^%(any", "(file")
|
|
end
|
|
|
|
-- Loop to fill as much as possible (since we skip tracks with a title)
|
|
repeat
|
|
query[#query] = "%s:%s":format(results._filenameSearchStart, filenameSearchStop)
|
|
results._filenameSearchStart = filenameSearchStop
|
|
|
|
let r, songs = mpc:search(unpack(query))
|
|
if r then
|
|
for _, newSong in ipairs(songs) do
|
|
if not newSong.Title then
|
|
table.insert(results, newSong)
|
|
@insert(songTable(newSong))
|
|
end
|
|
end
|
|
end
|
|
|
|
filenameSearchStop = stop-results._filenameSearchOffset+results._filenameSearchStart
|
|
until filenameSearchStop == results._filenameSearchStart or #songs == 0
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
},
|
|
{
|
|
type = "fill",
|
|
|
|
width = "extend",
|
|
height = "1em",
|
|
|
|
fill = "ACS_HLINE"
|
|
},
|
|
{
|
|
id = "list",
|
|
|
|
type = "list",
|
|
focused = true,
|
|
|
|
width = "extend",
|
|
height = "extend",
|
|
|
|
onSelect = :(l)
|
|
-- Tag complete
|
|
if tagCompleting.tag then
|
|
let prompt = @byId("prompt")
|
|
if results[l[1]][tagCompleting.tag]:match(" ") then
|
|
prompt:replace(tagCompleting.start, tagCompleting.stop, tagCompleting.tag.."=\""..results[l[1]][tagCompleting.tag].."\"")
|
|
else
|
|
prompt:replace(tagCompleting.start, tagCompleting.stop, tagCompleting.tag.."="..results[l[1]][tagCompleting.tag])
|
|
end
|
|
-- Song search
|
|
else
|
|
for _, i in ipairs(l) do
|
|
if results[i] then
|
|
mpc:add(results[i].file)
|
|
end
|
|
end
|
|
@byId("playlist"):updateAfter(1)
|
|
|
|
let status = @byId("status")
|
|
status:set("Added "..#l.." songs to the current playlist") -- FIXME
|
|
end
|
|
end
|
|
}
|
|
},
|
|
|
|
-- Playlist
|
|
{
|
|
{
|
|
id = "playlist",
|
|
|
|
type = "list",
|
|
focused = true,
|
|
|
|
width = "extend",
|
|
height = "extend",
|
|
|
|
updateInterval = 5,
|
|
|
|
pump = :(start, stop)
|
|
let r, songs = mpc:playlistinfo("%s:%s":format(start-1, stop))
|
|
if r then
|
|
for i=1, stop-start+1, 1 do
|
|
if songs[i] then
|
|
playlist[start+i-1] = songs[i]
|
|
let item = songTable(songs[i])
|
|
if @content[start+i-1] then
|
|
@replace(start+i-1, item)
|
|
else
|
|
@insert(start+i-1, item)
|
|
end
|
|
else
|
|
playlist[start+i-1] = 0
|
|
@remove(start+i-1)
|
|
start -= 1
|
|
end
|
|
end
|
|
end
|
|
end,
|
|
|
|
onUpdate = :()
|
|
@repump()
|
|
end,
|
|
|
|
onSelect = :(l)
|
|
if playlist[l[1]] then
|
|
mpc:playid(playlist[l[1]].Id)
|
|
end
|
|
end,
|
|
|
|
onControl = :(control)
|
|
if control == "delete" then
|
|
mpc:deleteid(playlist[@selected].Id)
|
|
@remove(@selected)
|
|
elseif control == "clear" then
|
|
mpc:clear()
|
|
@clear()
|
|
end
|
|
end
|
|
}
|
|
}
|
|
},
|
|
-- Status bar
|
|
{
|
|
id = "play-position",
|
|
type = "slider",
|
|
|
|
width = "extend",
|
|
height = "1em"
|
|
},
|
|
{
|
|
id = "status",
|
|
type = "label",
|
|
focused = true,
|
|
|
|
width = "extend",
|
|
height = "1em",
|
|
|
|
content = "No current song",
|
|
|
|
updateInterval = 1,
|
|
|
|
onUpdate = :()
|
|
let r, s = mpc:currentsong()
|
|
if r then
|
|
if s.file then
|
|
@set(table.concat(songTable(s), " - "))
|
|
else
|
|
@set("daccord v"..VERSION.." - nothing playing")
|
|
end
|
|
end
|
|
|
|
let r, s = mpc:status()
|
|
if r then
|
|
state = s.state
|
|
pos = @byId("play-position")
|
|
pos:setHead(state == "play" and "▶️" or "⏸")
|
|
pos:setMax(s.duration)
|
|
pos:set(s.elapsed)
|
|
end
|
|
end,
|
|
|
|
onControl = :(control)
|
|
if control == "space" then
|
|
if state == "play" then
|
|
mpc:pause()
|
|
else
|
|
mpc:play()
|
|
end
|
|
end
|
|
@onUpdate()
|
|
end
|
|
},
|
|
|
|
onClose = :() @exit() end
|
|
}
|