pico-os-app 1 474 spotify-remote 1.1.1 Spotify Spotify 38543 479cc3ba91ef30b6be7e454b4327a3c9513f2de4a7d8c104139337d67eb0d3bb network スマホ/PCのSpotifyの再生を操作するリモコン(再生/停止・次へ・前へ・シーク・音量・端末の切り替え)。Premiumが必要 cdae7ba29d76143cb10ee4c5bf4c9c78c3a55dfd5d6cd0df25ff0cbacad30c5d kimu1109 - ed25519 cdae7ba29d76143cb10ee4c5bf4c9c78c3a55dfd5d6cd0df25ff0cbacad30c5d 5d3372b15270c1435503ddfa1f642fb0b6fc488d5f84ff1acf8a1606971b9425c3d2732d514e90e6ac5de674a756e321f46d8fa013d972310dff168d046f9e04 api.lua 7597 1f78f35b5524576125732e46221360966fac893bbad246bea63e9304d746a5a3 app.cfg 288 f5b94d5093edd1e0c0a2ade928bd1838bada79bc15949a2f7e586d9dda8bdb23 icon.pimg 467 c4cff7042f026ac7af3711b42a60a897bda300834fbcbf4bd4ab7155daccf894 main.lua 8481 f0ce5d12551e0f568a0268c2d79ae4dd85c7a4e88ec7b7849cc8d24c26089432 player.lua 11968 d710f2a33f6d0b294f4b2ad0b6a75d914cfd0677f2fd80c62127d1594cc48cae view.lua 9742 f97d1895603d1f33a9672348a9bf67cc055f0551250a3116b58928ca451f4b52 -- SpotifyのWeb APIとの通信: トークンの管理と要求。画面のことは知らない。 -- -- 最初のトークンは本体では取れない(OAuthの戻り先がHTTPSか127.0.0.1に限られる)ので、 -- PCで script/spotify_auth.py(picoos spotify-auth)を動かして、このアプリのフォルダへ -- spotify_auth.json を置いてもらう。読んだら暗号化して store.json へ移し、平文のファイルは消す。 local A = {} -- 向き先はアプリのフォルダの settings.cfg の api / token-url で差し替えられる(偽物のサーバで試すとき) A.API = pico.settings_get("api", "https://api.spotify.com/v1") A.TOKEN_URL = pico.settings_get("token-url", "https://accounts.spotify.com/api/token") A.AUTH_FILE = "spotify_auth.json" A.auth = nil -- { client_id, refresh_token, access_token, expires_at } A.revoked = false -- refresh token が使えなくなった(ログインし直しが要る) local refreshing = false local waiting = {} -- 時計が合っているか(合っていないとTLSが通らず、トークンの期限も分からない) function A.clock_ok() return pico.time() > 1700000000 end local function save() return pico.store_save({ auth = A.auth }, { encrypt = true }) end -- PCの spotify_auth.json があれば取り込む。無ければ保存済みのものを読む。 -- 戻り値: ログイン済みか, 知らせる文(無ければnil) function A.load() local path = pico.path_join(pico.app_dir(), A.AUTH_FILE) local text = pico.sd_read(path) if text then -- トークンは255バイトを超える(json_pick は文字列を255バイトで切る)ので json_decode で読む local r = pico.json_decode(text) if not (type(r) == "table" and type(r.client_id) == "string" and type(r.refresh_token) == "string") then return false, A.AUTH_FILE .. " を読めません。作り直してください" end A.auth = { client_id = r.client_id, refresh_token = r.refresh_token, access_token = type(r.access_token) == "string" and r.access_token or "", expires_at = math.tointeger(r.expires_at) or 0 } A.revoked = false if not save() then return true, "ログイン情報を保存できません(SDを確かめてください)" end pico.sd_remove(path) -- 平文の鍵をSDに残さない return true, "ログイン情報を取り込みました" end local st = pico.store_load() if type(st) == "table" and type(st.auth) == "table" and st.auth.refresh_token then A.auth = st.auth return true end A.auth = nil return false end function A.logout() A.auth = nil A.revoked = false pico.store_save({}, { encrypt = true }) end local function token_ok() return A.auth ~= nil and (A.auth.access_token or "") ~= "" and pico.time() < (A.auth.expires_at or 0) - 60 end local function finish(ok, why) refreshing = false local list = waiting waiting = {} for _, f in ipairs(list) do f(ok, why) end end -- アクセストークンを取り直す。fn(ok, why)。同時に頼まれたら1回の取り直しを待ち合わせる function A.refresh(fn) if not A.auth then return fn(false, "ログインしていません") end waiting[#waiting + 1] = fn if refreshing then return end refreshing = true local body = pico.form_encode({ grant_type = "refresh_token", refresh_token = A.auth.refresh_token, client_id = A.auth.client_id }) local id = pico.http_request("POST", A.TOKEN_URL, body, "application/x-www-form-urlencoded", function(ok, status, text, err) if not ok then return finish(false, "トークンを取れません: " .. tostring(err)) end -- アクセストークンは300文字ほどあり json_pick(255バイトまで)では切れるので json_decode で読む local r = text and pico.json_decode(text) if type(r) ~= "table" then r = {} end if status == 200 and type(r.access_token) == "string" and A.auth then A.auth.access_token = r.access_token A.auth.expires_at = pico.time() + (math.tointeger(r.expires_in) or 3600) -- PKCEのrefresh tokenは使うたびに新しいものが返ることがある。返ったら保存し直す if type(r.refresh_token) == "string" then A.auth.refresh_token = r.refresh_token end save() return finish(true) end if r.error == "invalid_grant" or r.error == "invalid_client" then A.revoked = true return finish(false, "ログインし直してください(" .. tostring(r.error_description or r.error) .. ")") end finish(false, "トークンを取れません(HTTP " .. tostring(status) .. ")") end, { keep_alive = true }) if not id then finish(false, "通信が混んでいます") end end -- APIを呼ぶ。fn(ok, status, body, err, info)。 -- 401(期限切れ)なら1回だけトークンを取り直して送り直す。json は送る本文(JSONの文字列)か nil function A.call(method, path, json, fn, opts) if not A.auth then return fn(false, 0, nil, "ログインしていません", {}) end local function send(retried) local id = pico.http_request(method, A.API .. path, json, json and "application/json" or nil, function(ok, status, body, err, _, info) if ok and status == 401 and not retried and A.auth then A.auth.access_token = "" return A.refresh(function(rok, why) if rok then send(true) else fn(false, 401, nil, why, {}) end end) end fn(ok, status, body, err, info or {}) end, { keep_alive = true, save_to = opts and opts.save_to, headers = { Authorization = "Bearer " .. (A.auth.access_token or "") } }) if not id then fn(false, 0, nil, "通信が混んでいます", {}) end end if token_ok() then return send(false) end A.refresh(function(ok, why) if ok then send(false) else fn(false, 0, nil, why, {}) end end) end -- 画像等をSDへ取ってくる(トークンは付けない)。fn(ok, why) function A.download(url, path, fn) local id = pico.http_request("GET", url, nil, nil, function(ok, status, _, err, _, info) fn(ok and status == 200 and info ~= nil and info.saved ~= nil, err or ("HTTP " .. tostring(status))) end, { keep_alive = true, save_to = path }) if not id then fn(false, "通信が混んでいます") end end -- 失敗の応答から利用者向けの理由を作る(Spotifyは {"error":{"status":..,"message":..,"reason":..}}) function A.error_text(status, body) local r = body and pico.json_pick(body, { "error.message", "error.reason" }) or {} local msg = r["error.message"] if status == 404 then return "再生中の端末がありません。Spotifyで再生するか「端末」で選んでください" end if status == 403 then if r["error.reason"] == "PREMIUM_REQUIRED" then return "操作にはSpotify Premiumが必要です" end return "この操作はできません" .. (msg and ("(" .. msg .. ")") or "") end if status == 429 then return "Spotifyが混んでいます。少し待ちます" end return "エラー(HTTP " .. tostring(status) .. ")" .. (msg and (": " .. msg) or "") end return A name=Spotify id=spotify-remote description=スマホ/PCのSpotifyの再生を操作するリモコン(再生/停止・次へ・前へ・シーク・音量・端末の切り替え)。Premiumが必要 version=1.1.1 icon=icon.pimg permission_network=true permission_sd_outside_app_dir=false 00t$                                                                          (###e-- Spotifyのリモコン。スマホ/PCのSpotifyで鳴っている再生を、再生/停止・次へ・前へ・シーク・音量・ -- シャッフル・リピート・端末の切り替えで操作する(この本体から音は出ない)。 -- -- api.lua … トークンの管理とWeb APIの呼び出し -- player.lua … 再生の状態を数秒ごとに取る・操作を送る -- view.lua … 画面を描く・タップの場所を決める -- -- 操作には Spotify Premium が要る。最初のログインはPCで picoos spotify-auth(api.lua の説明)。 local A = require("api") local P = require("player") local V = require("view") local X, Y, W, H = pico.content_rect() local TOP = 32 local canvas local logged_in = false local press = nil -- 押し始めた所の操作(シーク/音量はドラッグ) local shown_sec = -1 local function redraw() P.dirty = true end local function check_login() local ok, msg = A.load() logged_in = ok P.reset() if msg then P.say(msg, not ok, 5000) elseif not ok then P.say("") end redraw() end local function back() P.close() pico.pop() end -- ---- 端末・メニュー ---- -- ダイアログを閉じた直後に次のダイアログを出すときは1フレーム空ける local function later(fn) pico.after(30, fn) end local function choose_device() if not logged_in then return end P.say("端末を探しています…", false, 3000) P.devices(function(list, why) if not list then P.say(why, true, 5000) return end if #list == 0 then P.say("端末が見つかりません。スマホ/PCでSpotifyを開いてください", true, 6000) return end P.say("") local names = {} for i, d in ipairs(list) do names[i] = (d.active and "● " or "") .. d.name end local dlg = pico.show_choice("再生する端末", names) pico.on(dlg, "closed", function(_, ok, idx) if ok and list[idx + 1] then P.transfer(list[idx + 1].id) end end) end) end local function confirm_logout() local m = pico.show_message("ログアウトしますか?(保存したトークンを消します)", "いいえ", "はい") pico.on(m, "closed", function(_, yes) if yes then A.logout() logged_in = false P.reset() P.say("ログアウトしました", false, 4000) end end) end local function open_menu() local items = logged_in and { "端末を選ぶ", "今すぐ更新", "ログアウト" } or { "ログイン情報を読み込む" } local dlg = pico.show_choice("Spotify", items) pico.on(dlg, "closed", function(_, ok, idx) if not ok then return end if not logged_in then return check_login() end if idx == 0 then later(choose_device) elseif idx == 1 then P.poll_soon(0) elseif idx == 2 then later(confirm_logout) end end) end -- ---- 操作 ---- local function act(name, v) if name == "reload" then return check_login() end if not logged_in then return end if name == "play" then P.toggle() elseif name == "next" then P.next() elseif name == "prev" then P.prev() elseif name == "shuffle" then P.shuffle() elseif name == "repeat" then P.repeat_cycle() elseif name == "seek" and P.st then P.seek(v * P.st.duration) elseif name == "vol" then P.set_volume(v * 100) elseif name == "vol-" then P.volume_step(-5) elseif name == "vol+" then P.volume_step(5) end end local function on_press_start(_, x, y) local name, v = V.hit(x, y, logged_in) press = name if (name == "seek" and P.st and P.st.duration > 0) or (name == "vol" and P.st and P.st.can_volume) then V.drag = { kind = name, v = v } redraw() end end local function on_press_move(_, x, y) if not V.drag then return end local name, v = V.hit(x, y, logged_in) if name == V.drag.kind or v then V.drag.v = v or V.drag.v -- 動かしている欄だけ描き直す(ジャケットをSDから解き直さない) pico.mark_dirty((V.drag.kind == "seek" and V.progress_rect or V.volume_rect)()) end end local function on_press_end(_, x, y) if V.drag then local d = V.drag V.drag = nil act(d.kind, d.v) else local name, v = V.hit(x, y, logged_in) if name and name == press then act(name, v) end end press = nil redraw() end -- 物理キーボード(メディアキー・矢印・文字) local KEYS = { playpause = "play", [" "] = "play", k = "play", nexttrack = "next", n = "next", right = "next", prevtrack = "prev", p = "prev", left = "prev", s = "shuffle", r = "repeat", } local function on_key(key, mods) if mods.ctrl or mods.alt then return false end if key == "volumeup" or key == "up" then P.volume_step(5) return true end if key == "volumedown" or key == "down" then P.volume_step(-5) return true end if key == "mute" or key == "m" then P.mute() return true end if key == "d" then choose_device() return true end if key == "stop" then if P.st and P.st.playing then P.toggle() end return true end local name = KEYS[key] if name then act(name) return true end return false end -- 外部コントローラー local PAD = { a = "play", r = "next", l = "prev", x = "shuffle", y = "repeat" } local function read_pad() if not pico.pad_connected() then return end for button, name in pairs(PAD) do if pico.pad_pressed(button) then act(name) end end if pico.pad_pressed("up") then P.volume_step(5) end if pico.pad_pressed("down") then P.volume_step(-5) end if pico.pad_pressed("right") then act("next") end if pico.pad_pressed("left") then act("prev") end if pico.pad_pressed("select") then choose_device() end if pico.pad_pressed("start") then open_menu() end if pico.pad_pressed("b") or pico.pad_pressed("home") then back() end end function setup() -- 上の段: [戻る] Spotify [端末][他] local b = pico.create("Button") pico.set(b, "icon_id", 30) -- ArrowLeft pico.set(b, "font_size", 0) pico.set(b, "x", X + 4) pico.set(b, "y", Y + 3) pico.set(b, "w", 52) pico.set(b, "h", 26) pico.on(b, "press_end", back) local title = pico.create("Label") pico.set(title, "text", "Spotify") pico.set(title, "font_size", 0) pico.set(title, "x", X + 62) pico.set(title, "y", Y + 8) local dev = pico.create("Button") pico.set(dev, "text", "端末") pico.set(dev, "font_size", 0) pico.set(dev, "x", X + W - 108) pico.set(dev, "y", Y + 3) pico.set(dev, "w", 52) pico.set(dev, "h", 26) pico.on(dev, "press_end", choose_device) local menu = pico.create("Button") pico.set(menu, "text", "他") pico.set(menu, "font_size", 0) pico.set(menu, "x", X + W - 52) pico.set(menu, "y", Y + 3) pico.set(menu, "w", 48) pico.set(menu, "h", 26) pico.on(menu, "press_end", open_menu) canvas = pico.create("Canvas") pico.set(canvas, "x", X) pico.set(canvas, "y", Y + TOP) pico.set(canvas, "w", W) pico.set(canvas, "h", H - TOP) V.setup(X, Y + TOP, W, H - TOP) pico.on(canvas, "render", function() V.render(logged_in) end) pico.on(canvas, "press_start", on_press_start) pico.on(canvas, "press_move", on_press_move) pico.on(canvas, "press_end", on_press_end) pico.on_key(on_key) pico.on_back(back) check_login() end function loop() read_pad() if logged_in and A.revoked then -- refresh token が使えなくなった: 案内の画面へ(理由は下の欄に残る) logged_in = false P.reset() end P.update() if P.dirty then P.dirty = false if P.art_dirty or not logged_in then P.art_dirty = false pico.invalidate(canvas) else -- ジャケットは変わっていない: その欄にかからない所だけ描き直す(SDから解き直さない) for _, r in ipairs(V.rects_except_art()) do pico.mark_dirty(r[1], r[2], r[3], r[4]) end end shown_sec = P.st and (P.position() // 1000) or -1 end -- 再生中は1秒ごとに再生位置の欄だけ描き直す if P.st and P.st.playing and not V.drag then local sec = P.position() // 1000 if sec ~= shown_sec then shown_sec = sec pico.mark_dirty(V.progress_rect()) end end end -- 再生の状態を数秒ごとに取りに行き、操作(再生/停止・次へ・前へ・シーク・音量・シャッフル・リピート・端末)を送る。 -- 状態は P.st に置き、画面を描き直してほしいときは P.dirty を立てる(描くのは view.lua)。 -- ジャケットの欄も描き直すときだけ P.art_dirty も立てる(ジャケットはフルカラーで描くたびにSDから解き直し、 -- そのとき液晶が少しちらつくので、3秒ごとの取り直しや操作では描き直さない)。 local A = require("api") local P = {} P.ART = 80 -- ジャケット画像の大きさ(px。見せる枠) P.st = nil -- 再生の状態(nil = 再生中の端末が無い/まだ分からない) P.msg = "" -- 下の欄の1行 P.msg_err = false P.dirty = true P.art_dirty = true P.art = nil -- ジャケット画像のハンドル local POLL_MS = 3000 -- 再生中・止めているときの取り直しの間隔 local IDLE_MS = 10000 -- 端末が無いとき local next_poll = 0 local polling = false local file_mode = false -- 応答が16KiBを超えた(番組の説明が長い等)ので、SDへ保存してから読む local backoff = 0 local msg_until = nil local art_url = nil -- 今見せている(または取れなかった)画像のURL local art_loading = nil local vol_timer = nil local vol_pending = nil local muted_from = nil -- json_pick で読むパス(32個まで) local PATHS = { "is_playing", "progress_ms", "currently_playing_type", "shuffle_state", "repeat_state", "device.id", "device.name", "device.volume_percent", "device.supports_volume", "item.name", "item.duration_ms", "item.artists#", "item.artists.1.name", "item.artists.2.name", "item.album.name", "item.album.images#", "item.album.images.1.url", "item.album.images.2.url", "item.show.name", "item.images#", "item.images.1.url", "item.images.2.url", } -- 下の欄に出す。ms を付けるとその時間で消える(付けなければ次に状態が取れたら消える) function P.say(text, err, ms) text = text or "" if P.msg ~= text or P.msg_err ~= (err == true) then P.dirty = true end P.msg, P.msg_err = text, err == true msg_until = ms and (pico.millis() + ms) or nil end -- ms後(既定はすぐ)に状態を取り直す function P.poll_soon(ms) local t = pico.millis() + (ms or 0) if t < next_poll then next_poll = t end end -- 今の再生位置(ms)。前に取ってからの時間を足して見せる function P.position() local s = P.st if not s then return 0 end local p = s.progress if s.playing then p = p + (pico.millis() - s.at) end if s.duration > 0 and p > s.duration then p = s.duration end return p end -- 画像は横300px前後(2番目)を使う(1/4に縮めて75px)。1つしか無ければそれ local function pick_image(r, base) local n = r[base .. "#"] or 0 if n >= 2 then return r[base .. ".2.url"] end if n >= 1 then return r[base .. ".1.url"] end end local function parse(r) local s = { playing = r.is_playing == true, progress = r.progress_ms or 0, duration = r["item.duration_ms"] or 0, kind = r.currently_playing_type or "track", title = r["item.name"] or "", album = r["item.album.name"] or "", device = r["device.name"] or "", device_id = r["device.id"], volume = r["device.volume_percent"], shuffle = r.shuffle_state == true, rep = r.repeat_state or "off", at = pico.millis(), } s.can_volume = r["device.supports_volume"] ~= false and s.volume ~= nil local a1, a2 = r["item.artists.1.name"], r["item.artists.2.name"] if a1 then s.artist = a2 and (a1 .. ", " .. a2 .. ((r["item.artists#"] or 0) > 2 and " ほか" or "")) or a1 else s.artist = r["item.show.name"] or "" -- ポッドキャストは番組名 end if s.kind == "ad" then s.title = "(広告)" end s.art = pick_image(r, "item.album.images") or pick_image(r, "item.images") return s end local function art_path() return pico.path_join(pico.app_dir(), "art.jpg") end local function free_art() if P.art then pico.image_free(P.art) P.art = nil end end -- 曲が変わってジャケットのURLが変わったら取りに行く(1枚ずつ。失敗した同じURLは取り直さない) local function update_art(url) if url == art_url or art_loading then return end if not url then free_art() art_url = nil P.dirty = true P.art_dirty = true return end art_loading = url A.download(url, art_path(), function(ok) art_loading = nil free_art() art_url = url -- full_color: Canvas で描くと、16色の絵の上にSDから解きながら液晶へそのままの色で描かれる if ok then P.art = pico.image_load(art_path(), { max_w = P.ART, max_h = P.ART, full_color = true }) end P.dirty = true P.art_dirty = true end) end local function poll() if polling or not A.auth then return end local now = pico.millis() if not A.clock_ok() then P.say("時計が合うのを待っています(Wi-Fiにつないでください)") next_poll = now + 2000 return end polling = true next_poll = now + 60000 -- 応答が来るまでは次を出さない(来たら決め直す) local save = file_mode and pico.path_join(pico.app_dir(), "player.json") or nil A.call("GET", "/me/player?market=from_token&additional_types=episode", nil, function(ok, status, body, err) polling = false now = pico.millis() if not ok then if err == "書き込みに失敗" and not file_mode then -- 16KiBを超えた: 次はSDへ保存して読む file_mode = true next_poll = now return end backoff = math.min(backoff == 0 and 5000 or backoff * 2, 60000) P.say(tostring(err), true) next_poll = now + backoff return end backoff = 0 if status == 204 then if P.st then P.st = nil P.dirty = true end update_art(nil) P.say("再生中の端末がありません。Spotifyで再生するか「端末」で選んでください") next_poll = now + IDLE_MS return end if status ~= 200 then P.say(A.error_text(status, body), true) next_poll = now + (status == 429 and 30000 or IDLE_MS) return end local r if save then r = pico.sd_json_pick(save, PATHS) else r = pico.json_pick(body or "", PATHS) end if not r then P.say("応答を読めません", true) next_poll = now + POLL_MS return end local s = parse(r) if file_mode and s.kind == "track" then file_mode = false end if vol_pending then s.volume = vol_pending end -- 送る前の音量で戻さない P.st = s P.dirty = true if not msg_until then P.say("") end update_art(s.art) next_poll = now + POLL_MS end, { save_to = save }) end -- 毎フレーム呼ぶ function P.update() local now = pico.millis() if msg_until and now >= msg_until then P.say("") end if A.auth and now >= next_poll then poll() end end -- 操作を送る。成功したら少し待って状態を取り直す(Spotify側に反映されるまで少しかかる) local function action(method, path, json, wait_ms) A.call(method, path, json, function(ok, status, body, err) if not ok then P.say(tostring(err), true, 4000) elseif status >= 300 then P.say(A.error_text(status, body), true, 5000) end P.poll_soon(wait_ms or 700) end) end function P.toggle() local s = P.st if s and s.playing then s.progress = P.position() s.at = pico.millis() s.playing = false P.dirty = true action("PUT", "/me/player/pause") else if s then s.at = pico.millis() s.playing = true P.dirty = true end action("PUT", "/me/player/play") end end function P.next() action("POST", "/me/player/next", nil, 900) end -- 3秒より後なら曲の頭へ、それより前なら前の曲へ(よくある音楽アプリと同じ) function P.prev() if P.st and P.position() > 3000 then return P.seek(0) end action("POST", "/me/player/previous", nil, 900) end function P.seek(ms) local s = P.st if not s then return end ms = math.floor(math.max(0, math.min(ms, s.duration))) s.progress = ms s.at = pico.millis() P.dirty = true action("PUT", "/me/player/seek?position_ms=" .. ms) end function P.shuffle() local s = P.st if not s then return end s.shuffle = not s.shuffle P.dirty = true action("PUT", "/me/player/shuffle?state=" .. tostring(s.shuffle)) end local NEXT_REPEAT = { off = "context", context = "track", track = "off" } function P.repeat_cycle() local s = P.st if not s then return end s.rep = NEXT_REPEAT[s.rep] or "off" P.dirty = true action("PUT", "/me/player/repeat?state=" .. s.rep) end -- 音量(0〜100)。続けて変えても、最後の値だけを0.4秒後に送る function P.set_volume(v) local s = P.st if not (s and s.can_volume) then P.say("この端末は音量を変えられません", false, 3000) return end v = math.floor(math.max(0, math.min(100, v)) + 0.5) if s.volume ~= v then s.volume = v P.dirty = true end vol_pending = v if vol_timer then pico.cancel(vol_timer) end vol_timer = pico.after(400, function() vol_timer = nil local send = vol_pending action("PUT", "/me/player/volume?volume_percent=" .. send, nil, 1500) vol_pending = nil end) end function P.volume_step(d) if P.st and P.st.volume then P.set_volume(P.st.volume + d) end end function P.mute() local s = P.st if not (s and s.volume) then return end if s.volume > 0 then muted_from = s.volume P.set_volume(0) else P.set_volume(muted_from or 50) end end -- 端末の一覧。fn(list) list = { {id=, name=, active=}, ... } / fn(nil, 理由) function P.devices(fn) A.call("GET", "/me/player/devices", nil, function(ok, status, body, err) if not ok then return fn(nil, tostring(err)) end if status ~= 200 then return fn(nil, A.error_text(status, body)) end local paths = { "devices#" } for i = 1, 8 do paths[#paths + 1] = "devices." .. i .. ".id" paths[#paths + 1] = "devices." .. i .. ".name" paths[#paths + 1] = "devices." .. i .. ".is_active" end local r = pico.json_pick(body or "", paths) if not r then return fn(nil, "応答を読めません") end local list = {} for i = 1, math.min(r["devices#"] or 0, 8) do local id = r["devices." .. i .. ".id"] if id then list[#list + 1] = { id = id, name = r["devices." .. i .. ".name"] or "?", active = r["devices." .. i .. ".is_active"] == true } end end fn(list) end) end -- その端末へ再生を移す(止まっていれば再生も始める) function P.transfer(id) P.say("端末を切り替えています…", false, 3000) action("PUT", "/me/player", pico.json_encode({ device_ids = { id }, play = true }), 1500) end -- ログアウトや画面を離れるとき function P.reset() free_art() art_url = nil P.st = nil if vol_timer then pico.cancel(vol_timer) vol_timer = nil end vol_pending = nil next_poll = 0 P.dirty = true P.art_dirty = true end function P.close() P.reset() pico.http_cancel() pico.http_close() end return P -- 画面を描く・タップの場所から操作を決める。状態は player.lua(P)が持つ。 -- Canvas 1枚の render の中で描く(ボタンも自分で描く。部品ごとにButtonを作らない) local P = require("player") local V = {} -- パレットの番号(既定のパレット) local BLACK, DKGREEN, MAROON, LTGREY, DKGREY, GREEN, WHITE = 0, 2, 4, 7, 8, 10, 15 local FS = 0 -- 文字は16px local X, Y, W, H = 0, 0, 240, 268 -- 部品の位置(setup()で決める) local ART_X, ART_Y, TX, TW local BAR_Y, BAR_X0, BAR_X1 local CTRL_Y, CTRL_X local VOL_Y, VOL_X0, VOL_X1, VOL_MINUS, VOL_PLUS local MSG_Y V.drag = nil -- { kind = "seek"|"vol", v = 0〜1 }(つまみを動かしている間) function V.setup(x, y, w, h) X, Y, W, H = x, y, w, h ART_X, ART_Y = X + 8, Y + 6 TX = ART_X + P.ART + 8 TW = X + W - 6 - TX BAR_Y = Y + 104 BAR_X0, BAR_X1 = X + 14, X + W - 14 CTRL_Y = Y + 158 CTRL_X = { X + W * 1 // 10, X + W * 3 // 10, X + W // 2, X + W * 7 // 10, X + W * 9 // 10 } VOL_Y = Y + 210 VOL_MINUS, VOL_PLUS = X + 18, X + W - 64 VOL_X0, VOL_X1 = X + 38, X + W - 84 MSG_Y = Y + 232 end -- 幅に収まるよう後ろを切って "..." を付ける(同じ文字列を何度も測らないよう覚える) local fit_cache, fit_count = {}, 0 local function fit(text, w) if text == "" then return "" end local key = w .. "\0" .. text local hit = fit_cache[key] if hit then return hit end local out = text if pico.text_width(text, FS) > w then local n = utf8.len(text) or #text local lo, hi = 0, n while lo < hi do -- 収まる一番長い文字数を探す local mid = (lo + hi + 1) // 2 local cut = text:sub(1, (utf8.offset(text, mid + 1) or #text + 1) - 1) if pico.text_width(cut .. "...", FS) <= w then lo = mid else hi = mid - 1 end end out = text:sub(1, (utf8.offset(text, lo + 1) or #text + 1) - 1) .. "..." end if fit_count > 32 then fit_cache, fit_count = {}, 0 end fit_cache[key] = out fit_count = fit_count + 1 return out end V.fit = fit local function mmss(ms) local s = (ms or 0) // 1000 return string.format("%d:%02d", s // 60, s % 60) end local function draw_art() if P.art then local w, h = pico.image_size(P.art) pico.draw_image(P.art, ART_X + (P.ART - w) // 2, ART_Y + (P.ART - h) // 2) return end -- 画像が無いときは音符の絵 pico.fill_rect(ART_X, ART_Y, P.ART, P.ART, LTGREY) local cx, cy = ART_X + P.ART // 2, ART_Y + P.ART // 2 pico.fill_circle(cx - 8, cy + 14, 8, DKGREY) pico.fill_rect(cx - 1, cy - 20, 4, 36, DKGREY) pico.fill_triangle(cx + 3, cy - 20, cx + 17, cy - 10, cx + 3, cy - 6, DKGREY) end local function bar(x0, x1, y, v, color) pico.fill_rect(x0, y - 2, x1 - x0, 4, LTGREY) local kx = x0 + math.floor((x1 - x0) * math.max(0, math.min(1, v)) + 0.5) pico.fill_rect(x0, y - 2, kx - x0, 4, color) pico.fill_circle(kx, y, 6, color) end -- ジャケットの欄を除いた所(右の文字の欄と、その下全部)。ジャケットが変わらないときはここだけ描き直す。 -- 2つは辺で接するだけで重ならないので、1枚の矩形にまとめられない(まとめるとジャケットにかかる) function V.rects_except_art() local ax, ay = ART_X + P.ART, ART_Y + P.ART return { { ax, Y, X + W - ax, ay - Y }, { X, ay, W, Y + H - ay } } end -- 再生位置の欄(1秒ごとに描き直すのはここだけ) function V.progress_rect() return X, BAR_Y - 10, W, 34 end local function draw_progress(s) local pos = P.position() local v = (s.duration > 0) and (pos / s.duration) or 0 if V.drag and V.drag.kind == "seek" then v = V.drag.v pos = math.floor(v * s.duration) end bar(BAR_X0, BAR_X1, BAR_Y, v, s.duration > 0 and BLACK or LTGREY) pico.draw_text(BAR_X0, BAR_Y + 8, mmss(pos), DKGREY, FS) if s.duration > 0 then pico.draw_text(BAR_X1, BAR_Y + 8, mmss(s.duration), DKGREY, FS, "right") end end -- 操作のボタン(シャッフル・前・再生/停止・次・リピート) local function draw_controls(s) local y = CTRL_Y local on = s ~= nil local fg = on and BLACK or LTGREY -- シャッフル: 交差する2本の線と矢じり local x = CTRL_X[1] local c = (s and s.shuffle) and DKGREEN or (on and DKGREY or LTGREY) pico.draw_line(x - 10, y - 6, x + 8, y + 6, c, 2) pico.draw_line(x - 10, y + 6, x + 8, y - 6, c, 2) pico.fill_triangle(x + 11, y - 7, x + 5, y - 9, x + 7, y - 2, c) pico.fill_triangle(x + 11, y + 7, x + 5, y + 9, x + 7, y + 2, c) if s and s.shuffle then pico.fill_circle(x, y + 14, 2, DKGREEN) end -- 前: 縦棒と左向きの三角 x = CTRL_X[2] pico.fill_rect(x - 10, y - 9, 3, 18, fg) pico.fill_triangle(x + 9, y - 9, x + 9, y + 9, x - 6, y, fg) -- 再生/一時停止: 黒い丸に白で x = CTRL_X[3] pico.fill_circle(x, y, 22, on and BLACK or LTGREY) if s and s.playing then pico.fill_rect(x - 8, y - 9, 6, 18, WHITE) pico.fill_rect(x + 2, y - 9, 6, 18, WHITE) else pico.fill_triangle(x - 6, y - 11, x - 6, y + 11, x + 11, y, WHITE) end -- 次: 右向きの三角と縦棒 x = CTRL_X[4] pico.fill_triangle(x - 9, y - 9, x - 9, y + 9, x + 6, y, fg) pico.fill_rect(x + 7, y - 9, 3, 18, fg) -- リピート: 角の丸い輪と矢じり。曲のリピートは「1」 x = CTRL_X[5] local rep = s and s.rep or "off" c = (rep ~= "off") and DKGREEN or (on and DKGREY or LTGREY) pico.draw_rect(x - 10, y - 6, 20, 12, c) pico.draw_rect(x - 9, y - 5, 18, 10, c) pico.fill_triangle(x + 2, y - 11, x + 2, y - 1, x + 8, y - 6, c) if rep == "track" then pico.draw_text(x - 3, y - 7, "1", c, FS) end if rep ~= "off" then pico.fill_circle(x, y + 14, 2, DKGREEN) end end -- 音量の欄(つまみを動かしている間はここだけ描き直す) function V.volume_rect() return X, VOL_Y - 12, W, 24 end local function draw_volume(s) local can = s and s.can_volume local c = can and BLACK or LTGREY -- −と+ pico.draw_circle(VOL_MINUS, VOL_Y, 10, c) pico.fill_rect(VOL_MINUS - 5, VOL_Y - 1, 11, 3, c) pico.draw_circle(VOL_PLUS, VOL_Y, 10, c) pico.fill_rect(VOL_PLUS - 5, VOL_Y - 1, 11, 3, c) pico.fill_rect(VOL_PLUS - 1, VOL_Y - 5, 3, 11, c) local v = (s and s.volume or 0) / 100 if V.drag and V.drag.kind == "vol" then v = V.drag.v end bar(VOL_X0, VOL_X1, VOL_Y, v, can and DKGREEN or LTGREY) local label = can and (math.floor(v * 100 + 0.5) .. "%") or "音量 -" pico.draw_text(X + W - 6, VOL_Y - 8, label, can and BLACK or DKGREY, FS, "right") end local function draw_message() if P.msg == "" then return end pico.draw_text_wrapped(X + 6, MSG_Y, W - 12, P.msg, P.msg_err and MAROON or DKGREY, FS, "left") end -- ログインしていないときの案内 local function draw_setup() pico.draw_text_wrapped(X + 8, Y + 8, W - 16, "Spotifyにログインしていません。\n\n" .. "PCで次を実行し、作られた spotify_auth.json をSDの lua/apps/Spotify/ へ置いてください" .. "(USBドライブで繋げばそのまま書けます)。\n\n" .. "picoos spotify-auth --client-id --sd \n\n" .. "Client ID は developer.spotify.com のダッシュボードで作ったアプリのものです。", BLACK, FS, "left") -- 読み直すボタン local bx, by, bw, bh = V.reload_rect() pico.fill_rect(bx, by, bw, bh, BLACK) pico.draw_text(bx + bw // 2, by + 8, "読み込む", WHITE, FS, "center") draw_message() end function V.reload_rect() return X + W // 2 - 60, Y + H - 72, 120, 32 end function V.render(logged_in) pico.fill_rect(X, Y, W, H, WHITE) if not logged_in then return draw_setup() end local s = P.st draw_art() if s then pico.draw_text(TX, ART_Y, fit(s.title, TW), BLACK, FS) pico.draw_text(TX, ART_Y + 20, fit(s.artist, TW), BLACK, FS) pico.draw_text(TX, ART_Y + 40, fit(s.album, TW), DKGREY, FS) if s.device ~= "" then pico.draw_text(TX, ART_Y + 62, fit("端末: " .. s.device, TW), DKGREEN, FS) end draw_progress(s) else pico.draw_text(TX, ART_Y, "停止中", DKGREY, FS) bar(BAR_X0, BAR_X1, BAR_Y, 0, LTGREY) end draw_controls(s) draw_volume(s) draw_message() end -- タップした場所の操作。戻り値: 名前, 値(シーク/音量の割合) function V.hit(px, py, logged_in) if not logged_in then local bx, by, bw, bh = V.reload_rect() if px >= bx and px < bx + bw and py >= by and py < by + bh then return "reload" end return nil end if math.abs(py - BAR_Y) <= 14 and px >= BAR_X0 - 8 and px <= BAR_X1 + 8 then return "seek", math.max(0, math.min(1, (px - BAR_X0) / (BAR_X1 - BAR_X0))) end if math.abs(py - CTRL_Y) <= 26 then local names = { "shuffle", "prev", "play", "next", "repeat" } local best, bd = nil, 1e9 for i, cx in ipairs(CTRL_X) do local d = math.abs(px - cx) if d < bd then best, bd = names[i], d end end if bd <= W // 10 then return best end end if math.abs(py - VOL_Y) <= 14 then if math.abs(px - VOL_MINUS) <= 14 then return "vol-" end if math.abs(px - VOL_PLUS) <= 14 then return "vol+" end if px >= VOL_X0 - 8 and px <= VOL_X1 + 8 then return "vol", math.max(0, math.min(1, (px - VOL_X0) / (VOL_X1 - VOL_X0))) end end return nil end return V