local createVersion = require('/apis/libversion'); local command = ...; local function printUsage() print('music usage:'); print(); print('\t\t\tmusic'); print('\t\t\tmusic version'); print('\t\t\tmusic help'); end if command == 'version' or command == '-version' or command == '--version' then print('v' .. createVersion().forSelf()); return; end if command == 'help' or command == '-help' or command == '--help' then printUsage(); return; end if command ~= nil and command ~= '' then printUsage(); return; end local createMusic = require('/apis/libmusic'); local music = createMusic(); local tracks = music.listTracks(); if #tracks == 0 then print('No music found in /data/musics'); return; end if not peripheral.find('speaker') then print('No speaker attached'); return; end local createEventLoop = require('/apis/eventloop'); local createTui = require('/apis/libtui'); local createAudio = require('/apis/libaudio'); local audio = createAudio(); local eventloop = createEventLoop(); local ui = createTui(eventloop); local Text = ui.Text; local Button = ui.Button; local Box = ui.Box; local List = ui.List; local player = music.createPlayer(eventloop); -- Ordered effect menu; `make` builds a fresh stateful filter (nil = bypass). local effects = { { label = 'Off', make = nil }, { label = 'Low-pass', make = function() return audio.lowPass(); end }, { label = 'High-pass', make = function() return audio.highPass(); end }, { label = 'Delay', make = function() return audio.delay(); end }, { label = 'Reverb', make = function() return audio.reverb(); end }, }; local effectIndex = 1; local currentFilter = nil; -- Rows of UI chrome around the track list (header, now-playing, controls, -- effects, footer, plus the track box border). Used to size the scroll window. local LIST_CHROME = 7; local selectedIndex = 1; -- list cursor local scrollOffset = 0; -- index of the first visible row - 1 local playingIndex = nil; -- track currently loaded in the player local autoplay = false; local function clampSelection() if selectedIndex < 1 then selectedIndex = 1; elseif selectedIndex > #tracks then selectedIndex = #tracks; end end -- Keeps the selected row inside the visible window. local function ensureVisible() local _, height = term.getSize(); local visible = math.max(1, height - LIST_CHROME); if selectedIndex <= scrollOffset then scrollOffset = selectedIndex - 1; elseif selectedIndex > scrollOffset + visible then scrollOffset = selectedIndex - visible; end end local function playIndex(index) if index < 1 or index > #tracks then return; end playingIndex = index; selectedIndex = index; player.play(tracks[index].path); ensureVisible(); ui.rerender(); end local function nextIndex() return (playingIndex or selectedIndex) % #tracks + 1; end local function previousIndex() return ((playingIndex or selectedIndex) - 2) % #tracks + 1; end player.onFinish(function() if autoplay then playIndex(nextIndex()); else playingIndex = nil; end ui.rerender(); end); -- Switches to effect `index`, building a fresh filter and handing it to the -- player (nil for 'Off' = bypass). local function applyEffect(index) effectIndex = (index - 1) % #effects + 1; local entry = effects[effectIndex]; currentFilter = entry.make and entry.make() or nil; player.setEffect(currentFilter); ui.rerender(); end local function cycleEffect() applyEffect(effectIndex + 1); end -- Steps the active filter's tunable parameter by one `spec.step` (dir = +/-1). local function adjustParam(dir) if not currentFilter then return; end local spec = currentFilter.spec; currentFilter.set(currentFilter.get() + dir * spec.step); ui.rerender(); end local function Header() return Box({ direction = 'row', bgColor = colors.gray, children = { Text('Music', { flex = 1, color = colors.white, bgColor = colors.gray, }), Button('X', { color = colors.white, bgColor = colors.red, onClick = function(tui) tui.exitUI('quit'); end, }), }, }); end local function TrackRow(index) local track = tracks[index]; local isCursor = index == selectedIndex; local isPlaying = index == playingIndex; local label = (isPlaying and '> ' or ' ') .. track.name; return Button(label, { color = isCursor and colors.black or colors.white, bgColor = isCursor and colors.white or colors.black, onClick = function() playIndex(index); end, }); end local function TrackList() local _, height = term.getSize(); local visible = math.max(1, height - LIST_CHROME); local items = {}; for offset = 1, visible do local index = scrollOffset + offset; if index > #tracks then break; end items[#items + 1] = TrackRow(index); end return List({ padding = 1, children = items, }); end local function statusLabel() local s = player.getStatus(); if s == 'playing' then return 'Playing'; end if s == 'paused' then return 'Paused'; end return 'Stopped'; end local function NowPlaying() local name = playingIndex and tracks[playingIndex].name or '(none)'; return Box({ direction = 'row', children = { Text(statusLabel() .. ': ' .. name, { flex = 1 }), }, }); end local function Controls() return Box({ direction = 'row', gap = 1, children = { Button('|<', { onClick = function() playIndex(previousIndex()); end, }), Button(player.getStatus() == 'playing' and 'Pause' or 'Play', { onClick = function() if player.getStatus() == 'stopped' then playIndex(selectedIndex); else player.togglePause(); ui.rerender(); end end, }), Button('Stop', { onClick = function() player.stop(); playingIndex = nil; ui.rerender(); end, }), Button('>|', { onClick = function() playIndex(nextIndex()); end, }), Button('Autoplay: ' .. (autoplay and 'on' or 'off'), { flex = 1, color = autoplay and colors.black or colors.white, bgColor = autoplay and colors.lime or colors.black, onClick = function() autoplay = not autoplay; ui.rerender(); end, }), }, }); end local function Effects() local entry = effects[effectIndex]; local children = { Button('FX: ' .. entry.label, { flex = 1, onClick = cycleEffect, }), }; if currentFilter then local spec = currentFilter.spec; local value = currentFilter.get(); local shown = spec.format and string.format(spec.format, value) or tostring(value); local unit = spec.unit ~= '' and (' ' .. spec.unit) or ''; children[#children + 1] = Button('-', { onClick = function() adjustParam(-1); end }); children[#children + 1] = Text(spec.param .. ': ' .. shown .. unit); children[#children + 1] = Button('+', { onClick = function() adjustParam(1); end }); end return Box({ direction = 'row', gap = 1, children = children, }); end local function Footer() return Text( 'enter play space pause s stop n/p next/prev f fx [ ] tune q quit', { color = colors.lightGray } ); end local function App() return Box({ direction = 'column', children = { Header(), Box({ flex = 1, border = true, title = 'Tracks', children = { TrackList(), }, }), NowPlaying(), Controls(), Effects(), Footer(), }, }); end eventloop.register('key', function(key) if key == keys.up then selectedIndex = selectedIndex - 1; clampSelection(); ensureVisible(); ui.rerender(); elseif key == keys.down then selectedIndex = selectedIndex + 1; clampSelection(); ensureVisible(); ui.rerender(); elseif key == keys.enter then playIndex(selectedIndex); elseif key == keys.space then if player.getStatus() == 'stopped' then playIndex(selectedIndex); else player.togglePause(); ui.rerender(); end elseif key == keys.s then player.stop(); playingIndex = nil; ui.rerender(); elseif key == keys.n then playIndex(nextIndex()); elseif key == keys.p then playIndex(previousIndex()); elseif key == keys.f then cycleEffect(); elseif key == keys.leftBracket then adjustParam(-1); elseif key == keys.rightBracket then adjustParam(1); elseif key == keys.q then ui.exitUI('quit'); end end); ui.render(App); player.stop();