-- ============================================== -- ComputerCraft 在线音乐播放器 (优化版) -- 依赖: Basalt UI框架 + speakerlib + 网易云API -- ============================================== ----------------------------------------------------------------- -- 依赖自动下载与加载 ----------------------------------------------------------------- local basePath = "/" .. fs.getDir(shell.getRunningProgram()) local libPath = basePath .. "/lib" if not fs.isDir(libPath) then fs.makeDir(libPath) end local function downloadFile(url, path, desc) if fs.exists(path) then return true end term.setTextColor(colors.yellow) print("[下载] " .. desc .. "...") local ok, err = pcall(function() shell.run("wget", url, path) end) if ok and fs.exists(path) then term.setTextColor(colors.green) print("[成功] " .. desc .. " 下载完成") return true end term.setTextColor(colors.red) print("[失败] " .. desc .. " 下载失败: " .. tostring(err)) return false end -- 批量下载依赖 downloadFile("https://git.liulikeji.cn/GitHub/Basalt/releases/download/v1.6.6/basalt.lua", libPath.."/basalt.lua", "Basalt UI 框架") downloadFile("https://git.liulikeji.cn/xingluo/cc_speakerlib/raw/branch/main/speakerlib.lua", basePath.."/speakerlib.lua", "音频播放库") downloadFile("https://git.liulikeji.cn/xingluo/ComputerCraft-Music168-Player/raw/branch/main/MusicLyrics.lua", basePath.."/MusicLyrics.lua", "歌词显示模块") downloadFile("https://git.liulikeji.cn/xingluo/cc_rime/raw/branch/main/ime_lib.lua", basePath.."/ime_lib.lua", "中文输入法") downloadFile("https://git.liulikeji.cn/xingluo/ComputerCraft-Utf8/raw/branch/main/utf8display/utf8display.lua", basePath.."/utf8display.lua", "UTF-8 中文渲染") downloadFile("https://git.liulikeji.cn/GitHub/json.lua/raw/branch/master/json.lua", basePath.."/json.lua", "JSON 解析库") term.clear() term.setCursorPos(1,1) -- 导入核心库 local basalt = require(libPath:sub(2).."/basalt") local utf8display = require(basePath:sub(2).."/utf8display") local json = require(basePath:sub(2).."/json") local ime = require(basePath:sub(2).."/ime_lib") ----------------------------------------------------------------- -- 全局状态与配置 ----------------------------------------------------------------- local app = { serverUrl = "http://music168.liulikeji.cn/", imeApi = "http://rime.liulikeji.cn/query", speakerLibId = "music168_player", searchText = "", playGuiState = false, imeWindowOpen = false, errorWindowOpen = false, currentMusicName = nil, isLoading = false, loadingTimer = nil, loadingDots = 0, } local player = { data = { music = {}, playList = {}, playIndex = 0, mode = "list" }, state = { progress = 0, total = 0, paused = false, playing = false, volume = 1.0, }, isRunning = false, } ----------------------------------------------------------------- -- 工具函数 ----------------------------------------------------------------- local function formatTime(sec) sec = math.floor(tonumber(sec) or 0) return string.format("%02d:%02d", math.floor(sec / 60), sec % 60) end local function switchPage(pageIndex) for i = 1, #sub.UI do sub.UI[i]:hide() end for i = 1, #menuBut do menuBut[i]:setBackground(colors.lightGray) end if sub.UI[pageIndex] then sub.UI[pageIndex]:show() end if menuBut[pageIndex] then menuBut[pageIndex]:setBackground(colors.red) end end local function showError(title, message, callback) if app.errorWindowOpen then return end app.errorWindowOpen = true mainFrame:disable() local w, h = mainFrame:getWidth(), mainFrame:getHeight() local ew, eh = 38, 9 local ex = math.max(1, math.floor((w - ew) / 2) + 1) local ey = math.max(1, math.floor((h - eh) / 2) + 1) local errorFrame = mainFrame:addFrame() :setPosition(ex, ey) :setSize(ew, eh) :setBackground(colors.red) errorFrame:addLabel() :setText(title) :setPosition(2, 2) :setForeground(colors.white) local msgFrame = errorFrame:addFrame() :setPosition(2, 4) :setSize(ew - 4, eh - 6) :setBackground(colors.red) msgFrame:addProgram() :setSize(ew - 4, eh - 6) :execute(function() term.setBackgroundColor(colors.red) term.clear() utf8display.write(message, colors.white, colors.red) end) :disable() errorFrame:addButton() :setPosition(math.floor(ew / 2) - 3, eh - 1) :setSize(7, 1) :setText("确定") :setBackground(colors.gray) :setForeground(colors.white) :onClick(function() errorFrame:remove() app.errorWindowOpen = false mainFrame:enable() if type(callback) == "function" then callback() end end) end ----------------------------------------------------------------- -- UI 构建 ----------------------------------------------------------------- local mainFrame = basalt.createFrame() -- 主容器 local mainContainer = { mainFrame:addFrame() :setPosition(1, 1) :setSize("parent.w", "parent.h") :setBackground(colors.red), } -- 子页面容器 local sub = { UI = { -- 页面1: 搜索 mainContainer[1]:addFrame() :setPosition(1, 1) :setSize("parent.w", "parent.h - 2") :setBackground(colors.white), -- 页面2: 歌单 mainContainer[1]:addFrame() :setPosition(1, 1) :setSize("parent.w", "parent.h - 2") :setBackground(colors.white) :hide(), -- 页面3: 占位 mainContainer[1]:addFrame() :setPosition(1, 1) :setSize("parent.w", "parent.h - 2") :setBackground(colors.white) :hide(), -- 页面4: 占位 mainContainer[1]:addFrame() :setPosition(1, 1) :setSize("parent.w", "parent.h - 2") :setBackground(colors.white) :hide(), -- 页面5: 占位 mainContainer[1]:addFrame() :setPosition(1, 1) :setSize("parent.w", "parent.h - 2") :setBackground(colors.white) :hide(), }, -- 底部菜单栏 menu = { mainContainer[1]:addFrame() :setPosition(1, "parent.h") :setSize("parent.w", 1) :setBackground(colors.lightGray), }, -- 播放详情面板 BF = { mainFrame:addFrame() :setPosition(1, "parent.h + 1") :setSize("parent.w", "parent.h") :setBackground(colors.red), mainContainer[1]:addFrame() :setPosition(1, "parent.h - 1") :setSize("parent.w", 1) :setBackground(colors.lightGray) :hide(), }, -- 播放列表面板 playTable = { mainFrame:addFrame() :setPosition(2, "parent.h + 1") :setSize("parent.w - 2", 13) :setBackground(colors.orange), }, -- 输入法面板 IME = { mainFrame:addFrame() :setPosition(1, "parent.h + 1") :setSize(33, 13) :setBackground(colors.blue) :hide(), }, } -- 动画定义 local anim = { playUp = mainFrame:addAnimation():setObject(sub.BF[1]):move(1, 1, 0.3), playDown = mainFrame:addAnimation():setObject(sub.BF[1]):move(1, "parent.h + 1", 1), listUp = mainFrame:addAnimation():setObject(sub.playTable[1]):move(2, "parent.h - 12", 0.3), listDown = mainFrame:addAnimation():setObject(sub.playTable[1]):move(2, "parent.h + 1", 1), } -- 计算输入法窗口位置 local imeW, imeH = 33, 13 local imeX = math.max(1, math.floor((mainFrame:getWidth() - imeW) / 2) + 1) local imeY = math.max(1, math.floor((mainFrame:getHeight() - imeH) / 2) + 1) anim.imeUp = mainFrame:addAnimation():setObject(sub.IME[1]):move(imeX, imeY, 0.3) anim.imeDown = mainFrame:addAnimation():setObject(sub.IME[1]):move(imeX, "parent.h + 1", 0.3) ----------------------------------------------------------------- -- 播放详情UI ----------------------------------------------------------------- local playGui = {} do local bf = sub.BF[1] -- 收起按钮 playGui[1] = bf:addButton() :setPosition(1, 1) :setSize(3, 1) :setText("V") :setBackground(colors.red) :setForeground(colors.white) :onClick(function() anim.playDown:play() app.playGuiState = false mainContainer[1]:enable() end) -- 歌名 playGui[2] = bf:addLabel() :setText("No Music") :setPosition("parent.w / 2 - 4", 1) :setBackground(colors.red) :setForeground(colors.white) -- 歌曲ID playGui[3] = bf:addLabel() :setText("No Music") :setPosition("parent.w / 2 - 4", 2) :setBackground(colors.red) :setForeground(colors.white) -- 歌词显示区 playGui[4] = bf:addProgram() :setPosition(2, 4) :setSize("parent.w - 2", "parent.h - 8") -- 进度滑块 (替代原重复的进度条) playGui[5] = bf:addSlider() :setPosition(3, "parent.h - 3") :setSize("parent.w - 4", 1) :setMaxValue(100) :setIndex(1) :setBackground(colors.red) :setForeground(colors.white) -- 当前时间 playGui[6] = bf:addLabel() :setText("00:00") :setPosition(3, "parent.h - 2") :setSize(5, 1) :setForeground(colors.white) -- 总时长 playGui[7] = bf:addLabel() :setText("00:00") :setPosition("parent.w - 6", "parent.h - 2") :setSize(5, 1) :setForeground(colors.white) -- 音量滑块 playGui[8] = bf:addSlider() :setPosition(3, "parent.h - 1") :setSize(10, 1) :setMaxValue(100) :setIndex(33) :setBackground(colors.red) :setForeground(colors.white) :onChange(function(self) local vol = self:getValue() / 100 * 3 player.state.volume = vol os.queueEvent("speakerlib_volume", app.speakerLibId, vol) end) -- 上一首 playGui[9] = bf:addButton() :setPosition("parent.w / 2 - 6", "parent.h - 1") :setSize(2, 1) :setText("|\17") :setForeground(colors.white) :setBackground(colors.red) :onClick(prevTrack) -- 播放/暂停 playGui[10] = bf:addButton() :setPosition("parent.w / 2 - 1", "parent.h - 1") :setSize(2, 1) :setText("I>") :setForeground(colors.white) :setBackground(colors.red) :onClick(togglePlay) -- 下一首 playGui[11] = bf:addButton() :setPosition("parent.w / 2 + 4", "parent.h - 1") :setSize(2, 1) :setText("\16|") :setForeground(colors.white) :setBackground(colors.red) :onClick(nextTrack) -- 播放列表按钮 playGui[12] = bf:addButton() :setPosition("parent.w - 4", "parent.h - 1") :setSize(3, 1) :setText("=T=") :setForeground(colors.white) :setBackground(colors.red) :onClick(function() anim.listUp:play() mainContainer[1]:disable() sub.BF[1]:disable() end) end ----------------------------------------------------------------- -- 播放列表UI ----------------------------------------------------------------- local listGui = {} do local pl = sub.playTable[1] listGui[1] = pl:addButton() :setPosition("parent.w - 3", 1) :setSize(3, 1) :setText("V") :setBackground(colors.gray) :setForeground(colors.white) :onClick(function() if not app.playGuiState then mainContainer[1]:enable() end sub.BF[1]:enable() anim.listDown:play() end) listGui[2] = pl:addLabel() :setText("PlayList") :setPosition(1, 1) :setForeground(colors.white) listGui[3] = pl:addList() :setPosition(2, 3) :setSize("parent.w - 2", "parent.h - 2") :setScrollable(true) end ----------------------------------------------------------------- -- 底部菜单栏 ----------------------------------------------------------------- local menuBut = {} do local m = sub.menu[1] menuBut[1] = m:addButton() :setPosition(3, 1) :setSize(3, 1) :setText("{Q}") :setForeground(colors.white) :setBackground(colors.red) :onClick(function() switchPage(1) end) menuBut[2] = m:addButton() :setPosition(8, 1) :setSize(3, 1) :setText("{T}") :setForeground(colors.white) :setBackground(colors.lightGray) :onClick(function() switchPage(2) end) menuBut[3] = m:addButton() :setPosition(13, 1) :setSize(4, 1) :setText("{PH}") :setForeground(colors.white) :setBackground(colors.lightGray) :onClick(function() switchPage(3) end) menuBut[4] = m:addButton() :setPosition(18, 1) :setSize(3, 1) :setText("{G}") :setForeground(colors.white) :setBackground(colors.lightGray) :onClick(function() switchPage(4) end) menuBut[5] = m:addButton() :setPosition(23, 1) :setSize(3, 1) :setText("{Z}") :setForeground(colors.white) :setBackground(colors.lightGray) :onClick(function() switchPage(5) end) end ----------------------------------------------------------------- -- 搜索页UI ----------------------------------------------------------------- local searchGui = {} do local page = sub.UI[1] searchGui[1] = page:addButton() :setPosition(2, 1) :setSize("parent.w - 3", 1) :setText("点击输入搜索内容") :setBackground(colors.lightGray) :setForeground(colors.gray) :onClick(openImeWindow) searchGui[2] = page:addButton() :setPosition("parent.w - 1", 1) :setSize(1, 1) :setText("Q") :setForeground(colors.white) :setBackground(colors.lightGray) :onClick(function() doSearch(app.searchText, searchGui, "search") end) searchGui[3] = page:addFrame() :setPosition(1, 3) :setSize("parent.w", "parent.h - 3") :setBackground(colors.white) end ----------------------------------------------------------------- -- 歌单页UI ----------------------------------------------------------------- local playlistGui = {} do local page = sub.UI[2] playlistGui[1] = page:addInput() :setPosition(2, 1) :setSize("parent.w - 3", 1) :setForeground(colors.gray) :setBackground(colors.lightGray) :setDefaultText("输入歌单ID") playlistGui[2] = page:addButton() :setPosition("parent.w - 1", 1) :setSize(1, 1) :setText("Q") :setForeground(colors.white) :setBackground(colors.lightGray) :onClick(function() local id = playlistGui[1]:getValue() if id and id ~= "" then doSearch(id, playlistGui, "playlist") end end) playlistGui[3] = page:addFrame() :setPosition(1, 3) :setSize("parent.w", "parent.h - 3") :setBackground(colors.white) end -- 给3-5页加占位提示 for i = 3, 5 do sub.UI[i]:addLabel() :setText("功能开发中...") :setPosition("parent.w / 2 - 6", "parent.h / 2") :setForeground(colors.gray) end ----------------------------------------------------------------- -- 输入法逻辑 ----------------------------------------------------------------- local imeProgram = nil function openImeWindow() if app.imeWindowOpen then return end app.imeWindowOpen = true mainContainer[1]:disable() sub.IME[1]:show() anim.imeUp:play() if not imeProgram then imeProgram = sub.IME[1]:addProgram() :setPosition(2, 1) :setSize(31, 12) end imeProgram:execute(function() sleep(0.31) local root = term.current() local textWin = window.create(root, 1, 1, 31, 3) local mainWin = window.create(root, 1, 4, 31, 4) local keyWin = window.create(root, 1, 8, 31, 5) local result = ime.run({ main = { term = mainWin, BackgroundColor = colors.lightBlue }, text = { term = textWin }, keyboard = { term = keyWin }, InputText = app.searchText or "", API_URL = app.imeApi, utf8api = utf8display, cacheFont = false, }) os.queueEvent("music168_ime_done", result) end) end local function closeImeWindow() app.imeWindowOpen = false mainContainer[1]:enable() anim.imeDown:play() end ----------------------------------------------------------------- -- 音乐搜索与播放控制 ----------------------------------------------------------------- local listFrame = nil -- 搜索结果列表容器 -- 获取音乐播放链接 local function getMusicUrl(musicId) for i = 1, 3 do local ok, resp = pcall(function() return http.post( app.serverUrl .. "api/song/url", json.encode({ id = musicId }) ) end) if ok and resp then local body = resp.readAll() resp.close() if body then local ok2, data = pcall(json.decode, body) if ok2 and data and data.data and data.data[1] and data.data[1].url then return data.data[1].url end end end end showError("网络错误", "无法获取音乐播放地址,请检查网络后重试。") return nil end -- 搜索歌曲/歌单 function doSearch(input, gui, apiType) if not input or input == "" then return end local result = {} local success = false for attempt = 1, 3 do local ok, resp = pcall(function() local body = json.encode({ value = input, id = input, }) local url = apiType == "search" and app.serverUrl .. "api/search" or app.serverUrl .. "api/playlist/detail" return http.post(url, body) end) if ok and resp then local body = resp.readAll() resp.close() if body then local ok2, data = pcall(json.decode, body) if ok2 and data then local songs = {} if apiType == "search" then if data.result and data.result.songs then songs = data.result.songs end else if data.playlist and data.playlist.tracks then songs = data.playlist.tracks end end for idx, song in ipairs(songs) do local artist = (song.artists and song.artists[1] and song.artists[1].name) or (song.ar and song.ar[1] and song.ar[1].name) or "未知艺术家" result[idx] = { id = song.id, name = song.name, artist = artist, } end success = true break end end end end -- 渲染结果 if listFrame then listFrame:remove() end local container = gui[3] listFrame = container:addFrame() :setPosition(1, 1) :setSize("parent.w", "parent.h") :setBackground(colors.white) :setScrollable(true) if not success then showError("网络错误", "无法连接到音乐服务器,请检查网络后重试。") return end if #result == 0 then listFrame:addLabel() :setText("没有找到相关结果") :setPosition(2, 2) :setForeground(colors.gray) return end local y = 2 for idx, song in ipairs(result) do local item = listFrame:addFrame() :setPosition(2, y) :setSize("parent.w - 2", 4) :setBackground(colors.lightBlue) :onClick(function() stopPlayback() anim.playUp:play() app.playGuiState = true mainContainer[1]:disable() startPlay(song.name, song.id, result, idx) end) item:addLabel() :setText("Name: " .. song.name) :setPosition(1, 1) :setForeground(colors.white) item:addLabel() :setText("ID: " .. tostring(song.id) .. " Artist: " .. song.artist) :setPosition(1, 3) :setForeground(colors.white) y = y + 5 end end -- 停止当前播放 function stopPlayback() if not player.isRunning then return end player.isRunning = false os.queueEvent("speakerlib_stop", app.speakerLibId) -- 重置状态 player.state.progress = 0 player.state.total = 0 player.state.paused = false player.state.playing = false app.isLoading = false if app.loadingTimer then os.cancelTimer(app.loadingTimer) app.loadingTimer = nil end -- 停止歌词 if playGui[4] then playGui[4]:stop() end os.queueEvent("music168_play_stop") end -- 切换暂停/继续 function togglePlay() if not player.isRunning then return end if player.state.paused then os.queueEvent("speakerlib_resume", app.speakerLibId) player.state.paused = false player.data.play = true else os.queueEvent("speakerlib_pause", app.speakerLibId) player.state.paused = true player.data.play = false end end -- 上一首 function prevTrack() if #player.data.playList == 0 then return end local idx = player.data.playIndex - 1 if idx < 1 then idx = #player.data.playList end local song = player.data.playList[idx] stopPlayback() sleep(0.1) startPlay(song.name, song.id, player.data.playList, idx) end -- 下一首 function nextTrack() if #player.data.playList == 0 then return end local idx = player.data.playIndex + 1 if idx > #player.data.playList then idx = 1 end local song = player.data.playList[idx] stopPlayback() sleep(0.1) startPlay(song.name, song.id, player.data.playList, idx) end -- 开始播放指定歌曲 function startPlay(name, id, list, index) if player.isRunning then stopPlayback() end -- 重置UI状态 player.state.progress = 0 player.state.total = 0 playGui[5]:setIndex(1) playGui[6]:setText("00:00") playGui[7]:setText("00:00") -- 更新界面显示 local title = name or "Unknown" playGui[2]:setText(title) playGui[3]:setText(tostring(id)) app.currentMusicName = title -- 更新数据 player.data.music = { musicId = id, name = name } player.data.playList = list or {} player.data.playIndex = index or 1 player.isRunning = true -- 更新播放列表 listGui[3]:clear() for i, song in ipairs(player.data.playList) do