356 lines
8.5 KiB
Lua
356 lines
8.5 KiB
Lua
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();
|