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