Files
community-plugins/phone-connect/panel.luau
T
CSY2569andGitHub c3f96b59e7 add icefish/phone-connect v0.1.0 (#155)
* add icefish/phone-connect v0.1.0

* update phone-connect README with screenshot

* fix phone-connect: add thumbnail.webp and missing en.json keys

* fix phone-connect: remove unused assets and screenshot.png

* fix phone-connect: CI compliance fixes

* fix phone-connect: command seq and MPRIS performance

* fix phone-connect: MPRIS sequential + media action delay

* fix phone-connect: seek format + live position polling

* fix phone-connect: use Set position for media seek

* fix phone-connect: shell-quote seek variant

* fix phone-connect: seek position tracking

* fix phone-connect: final compliance — remove LICENSE, update thumbnail, fix tags
2026-07-31 08:17:12 -04:00

379 lines
16 KiB
Luau

-- panel.luau
-- Phone Connect - details panel (layered info-style UI, 720x600).
--
-- Layout: header + device hero + info grid span the top (full width); below
-- them a two-column row: left (flex, scrollable) holds media control + SMS
-- composer; right (fixed width) holds action buttons stacked vertically.
-- Reads state from the service entry; sends commands via "pc.cmd".
-- Local translation: reads from pc.trTable (published by service) so users
-- can switch language at runtime, unlike noctalia.tr() which follows system locale.
local function t(key)
local table = noctalia.state.get("pc.trTable") or {}
return table[key] or key
end
local PLUGIN_ID = "icefish/phone-connect"
-- ── Command channel ─────────────────────────────────────────────────────────
local function sendCmd(cmd)
-- time-based seq prevents silent drops after hot-reload
cmd.seq = math.floor(os.clock() * 1000000)
noctalia.state.set("pc.cmd", cmd)
end
-- ── SMS composer local state (ui.input is uncontrolled) ─────────────────────
local smsDestination = ""
local smsMessage = ""
-- ── Helpers ─────────────────────────────────────────────────────────────────
local function glyphForType(t)
if t == "tablet" then return "device-tablet" end
if t == "laptop" then return "device-laptop" end
if t == "desktop" or t == "computer" then return "device-desktop" end
if t == "tv" then return "device-tv" end
return "device-mobile"
end
local function batteryGlyph(charge, charging)
if charging then return "battery-charging" end
if type(charge) ~= "number" then return "battery" end
if charge >= 90 then return "battery" end
if charge >= 60 then return "battery-3" end
if charge >= 30 then return "battery-2" end
if charge > 0 then return "battery-1" end
return "battery-off"
end
local function infoCell(label, value, glyph)
return ui.column({ gap = 2, padding = 6, radius = 6, fill = "surface/0.5", flexGrow = 1 }, {
ui.row({ gap = 6, align = "center" }, {
ui.glyph({ name = glyph, size = 13, color = "on_surface_variant" }),
ui.label({ text = label, fontSize = 10, color = "on_surface_variant" }),
}),
ui.label({ text = value, fontSize = 13, fontWeight = "medium", color = "on_surface" }),
})
end
-- ── Device hero card (top, full width) ──────────────────────────────────────
local function heroCard(d)
local reachable = d.isReachable == true
local paired = d.isPaired == true
local aliasMap = noctalia.state.get("pc.aliasMap") or {}
local name = aliasMap[d.id] or "Your-Phone"
local glyph = glyphForType(d.type)
local imgMap = noctalia.state.get("pc.imageMap") or {}
local imgPath = imgMap[d.id]
-- verify custom image exists
if imgPath and imgPath ~= "" and not noctalia.fileExists(imgPath) then
imgPath = nil
end
local statusText, statusColor
if not reachable then
statusText = paired and t("status.offline") or t("status.not_paired")
statusColor = "on_surface_variant"
else
statusText = t("status.connected")
statusColor = "primary"
end
local charge = d.batteryCharge
local chargeText = (type(charge) == "number" and charge >= 0)
and (tostring(charge) .. "%") or "--"
local chargeColor = "on_surface"
if type(charge) == "number" and charge <= 20 then chargeColor = "error" end
return ui.column({ gap = 8, padding = 14, radius = 10, fill = "surface/0.7",
border = "primary/0.2", borderWidth = 1 }, {
ui.row({ gap = 14, align = "center" }, {
imgPath and ui.image({ path = imgPath, width = 52, height = 52, radius = 26, fit = "cover" })
or ui.box({ width = 52, height = 52, radius = 26, fill = "primary/0.15" }, {
ui.glyph({ name = glyph, size = 28, color = "primary" }),
}),
ui.column({ gap = 4, flexGrow = 1 }, {
ui.label({ text = name, fontSize = 17, fontWeight = "bold", color = "on_surface" }),
ui.box({ radius = 10, fill = statusColor .. "/0.15", padding = 4 }, {
ui.label({ text = statusText, fontSize = 11, color = statusColor }),
}),
}),
ui.column({ gap = 2, align = "center" }, {
ui.glyph({ name = batteryGlyph(charge, d.batteryCharging), size = 22,
color = chargeColor }),
ui.label({ text = chargeText, fontSize = 13, fontWeight = "bold", color = chargeColor }),
}),
}),
})
end
-- ── Info grid (top, full width) ──────────────────────────────────────────────
local function infoGrid(d)
local charge = d.batteryCharge
local chargeVal = (type(charge) == "number" and charge >= 0)
and (tostring(charge) .. "%" .. (d.batteryCharging and " ⚡" or "")) or "--"
local netVal = (d.networkType and d.networkType ~= "")
and (d.networkType .. (type(d.networkStrength) == "number"
and d.networkStrength >= 0 and (" • " .. tostring(d.networkStrength) .. "/4") or ""))
or "--"
local pairVal = d.isPaired and t("panel.paired") or t("panel.unpaired")
local typeVal = d.type or t("panel.unknown")
return ui.row({ gap = 8 }, {
infoCell(t("panel.battery"), chargeVal, "battery"),
infoCell(t("panel.network"), netVal, "antenna"),
infoCell(t("panel.pairing"), pairVal, "link"),
infoCell(t("panel.type"), typeVal, "device-mobile"),
})
end
-- ── Right column: action buttons stacked vertically ─────────────────────────
local function vActionBtn(text, glyph, onClick, variant)
return ui.button({ text = text, glyph = glyph, variant = variant or "outline",
controlSize = "md", onClick = onClick })
end
local function rightColumn(d)
local reachable = d.isReachable == true
local paired = d.isPaired == true
local btns = {}
if reachable and paired then
table.insert(btns, vActionBtn(t("action.ring"), "bell-ringing",
function() sendCmd({ op = "ring", device = d.id }) end, "primary"))
table.insert(btns, vActionBtn(t("action.ping"), "message",
function() sendCmd({ op = "ping", device = d.id }) end))
table.insert(btns, vActionBtn(t("action.browse"), "folder",
function() sendCmd({ op = "browse", device = d.id }) end))
table.insert(btns, vActionBtn(t("action.sms"), "message-2",
function() sendCmd({ op = "launch_sms_app", device = d.id }) end))
if noctalia.getConfig("enable_clipboard_action") then
table.insert(btns, vActionBtn(t("action.clipboard"), "clipboard",
function() sendCmd({ op = "clipboard", device = d.id }) end))
end
table.insert(btns, vActionBtn(t("action.share"), "share",
function()
local clip = noctalia.clipboardText()
if clip then sendCmd({ op = "share_text", device = d.id, text = clip }) end
end))
table.insert(btns, vActionBtn(t("action.unpair"), "link-off",
function() sendCmd({ op = "unpair", device = d.id }) end, "ghost"))
elseif not paired then
table.insert(btns, vActionBtn(t("action.pair"), "link",
function() sendCmd({ op = "pair", device = d.id }) end, "primary"))
else
table.insert(btns, vActionBtn(t("action.unpair"), "link-off",
function() sendCmd({ op = "unpair", device = d.id }) end, "ghost"))
end
return ui.column({ width = 110, gap = 8, padding = 8, align = "stretch" }, btns)
end
-- ── Left column: media + SMS (scrollable) ──────────────────────────────────
local function fmtTime(ms)
if type(ms) ~= "number" or ms <= 0 then return "0:00" end
local s = math.floor(ms / 1000)
local m = math.floor(s / 60)
s = s % 60
return string.format("%d:%02d", m, s)
end
local function mediaSection(d)
local title = d.mediatitle
if not title or title == "" then return nil end
local artist = d.mediaartist or ""
local album = d.mediaalbum or ""
local playing = d.mediaisPlaying == true
local volume = tonumber(d.mediavolume) or 0
local length = tonumber(d.medialength) or 0
local position = tonumber(d.mediaposition) or 0
local canSeek = d.mediacanSeek == true
local artUrl = d.mediaArtPath
-- only use art if the file exists (phone path via SFTP mount)
if artUrl and artUrl ~= "" and not noctalia.fileExists(artUrl) then
artUrl = nil
end
local header = ui.row({ gap = 6, align = "center" }, {
ui.glyph({ name = "music", size = 13, color = "primary" }),
ui.label({ text = t("panel.now_playing"), fontSize = 10,
color = "primary", fontWeight = "bold", flexGrow = 1 }),
})
local titleBlock
if artUrl then
titleBlock = ui.row({ gap = 8, align = "center" }, {
ui.image({ path = artUrl, width = 32, height = 32, radius = 4, fit = "cover" }),
ui.column({ gap = 1, flexGrow = 1 }, {
ui.label({ text = title, fontSize = 12, fontWeight = "medium", color = "on_surface" }),
ui.label({ text = artist, fontSize = 10, color = "on_surface_variant" }),
ui.label({ text = album, fontSize = 9, color = "on_surface_variant" }),
}),
})
else
titleBlock = ui.column({ gap = 1 }, {
ui.label({ text = title, fontSize = 12, fontWeight = "medium", color = "on_surface" }),
ui.label({ text = artist, fontSize = 10, color = "on_surface_variant" }),
ui.label({ text = album, fontSize = 9, color = "on_surface_variant" }),
})
end
local transport = ui.row({ gap = 4, align = "center" }, {
ui.button({ glyph = "player-skip-back", variant = "ghost", controlSize = "sm",
onClick = function() sendCmd({ op = "media", device = d.id, action = "Previous" }) end }),
ui.button({ glyph = playing and "player-pause" or "player-play",
variant = "primary", controlSize = "sm",
onClick = function() sendCmd({ op = "media", device = d.id, action = "PlayPause" }) end }),
ui.button({ glyph = "player-skip-forward", variant = "ghost", controlSize = "sm",
onClick = function() sendCmd({ op = "media", device = d.id, action = "Next" }) end }),
ui.button({ glyph = "player-stop", variant = "ghost", controlSize = "sm",
onClick = function() sendCmd({ op = "media", device = d.id, action = "Stop" }) end }),
})
local progress
if canSeek and length > 0 then
local seekPos = position
progress = ui.column({ gap = 2 }, {
ui.slider({ min = 0, max = length, step = 1000, value = position,
controlSize = "sm",
onChange = function(v)
seekPos = tonumber(v) or position
end,
onDragEnd = function()
sendCmd({ op = "media_seek", device = d.id, offset = tostring(seekPos) })
end }),
ui.row({ justify = "space_between" }, {
ui.label({ text = fmtTime(position), fontSize = 9, color = "on_surface_variant" }),
ui.label({ text = fmtTime(length), fontSize = 9, color = "on_surface_variant" }),
}),
})
end
local children = { header, titleBlock, transport }
if progress then table.insert(children, progress) end
return ui.column({ gap = 6, padding = 8, radius = 8, fill = "primary/0.08" }, children)
end
local function smsComposer(d)
return ui.column({ gap = 8, padding = 12, radius = 8, fill = "surface/0.5",
border = "on_surface/0.1", borderWidth = 1 }, {
ui.label({ text = t("panel.sms"), fontSize = 12, fontWeight = "bold",
color = "on_surface" }),
ui.input({ key = "sms-dest", placeholder = t("panel.sms_dest"),
value = smsDestination, controlSize = "sm",
onChange = function(v) smsDestination = v end }),
ui.input({ key = "sms-msg", placeholder = t("panel.sms_msg"),
value = smsMessage, controlSize = "sm",
onChange = function(v) smsMessage = v end }),
ui.button({ text = t("panel.sms_send"), glyph = "send",
variant = "primary", controlSize = "sm",
onClick = function()
if smsDestination ~= "" and smsMessage ~= "" then
sendCmd({ op = "sms_send", device = d.id,
destination = smsDestination, text = smsMessage })
smsMessage = ""
render()
end
end }),
})
end
local function leftColumn(d)
local items = {}
local media = mediaSection(d)
if media then table.insert(items, media) end
if d.isReachable and d.isPaired then
table.insert(items, smsComposer(d))
end
-- if nothing to show, a placeholder so the column isn't empty
if #items == 0 then
table.insert(items, ui.column({ gap = 8, padding = 20, align = "center" }, {
ui.glyph({ name = "music-off", size = 28, color = "on_surface_variant" }),
ui.label({ text = t("panel.no_media"), color = "on_surface_variant" }),
}))
end
return ui.column({ width = 304, flexGrow = 1 }, {
ui.scroll({ flexGrow = 1, gap = 12, padding = 0 }, items),
})
end
-- ── Device switcher (bottom, full width, only if >1 device) ─────────────────
local function switcher(devices, order, sel)
if #order <= 1 then return nil end
local items = {}
for _, id in ipairs(order) do
local d = devices[id]
if d then
local isSel = id == sel
table.insert(items, ui.row({
key = "sw-" .. id, gap = 8, align = "center", padding = 8, radius = 6,
fill = isSel and "primary/0.12" or "surface/0.4",
onClick = function() sendCmd({ op = "select", device = id }) end,
}, {
ui.glyph({ name = glyphForType(d.type), size = 14,
color = d.isReachable and "primary" or "on_surface_variant" }),
ui.label({ text = aliasMap[id] or "Your-Phone", fontSize = 12, flexGrow = 1, color = "on_surface" }),
ui.label({ text = (type(d.batteryCharge) == "number" and d.batteryCharge >= 0)
and (tostring(d.batteryCharge) .. "%") or "", fontSize = 10,
color = "on_surface_variant" }),
}))
end
end
return ui.column({ gap = 4, padding = 8, radius = 8, fill = "surface/0.3" }, items)
end
-- ── Main render ─────────────────────────────────────────────────────────────
function render()
local backend = noctalia.state.get("pc.backend") or { available = false }
local devices = noctalia.state.get("pc.devices") or {}
local order = noctalia.state.get("pc.order") or {}
local sel = noctalia.state.get("pc.selected")
if not sel or not devices[sel] then sel = order[1] end
local body = {}
-- header (full width top)
table.insert(body, ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "device-mobile", size = 18, color = "primary" }),
ui.label({ text = t("panel.title"), fontSize = 16, fontWeight = "bold",
color = "on_surface", flexGrow = 1 }),
ui.button({ glyph = "refresh", variant = "ghost", controlSize = "sm",
onClick = function() sendCmd({ op = "refresh" }) end }),
ui.button({ glyph = "close", variant = "ghost", controlSize = "sm",
onClick = function() panel.close() end }),
}))
if not backend.available then
table.insert(body, ui.column({ gap = 8, padding = 40, align = "center", flexGrow = 1 }, {
ui.glyph({ name = "phone-off", size = 40, color = "on_surface_variant" }),
ui.label({ text = t("status.no_backend"), color = "on_surface_variant" }),
}))
elseif not sel or not devices[sel] then
table.insert(body, ui.column({ gap = 8, padding = 40, align = "center", flexGrow = 1 }, {
ui.glyph({ name = "phone-off", size = 40, color = "on_surface_variant" }),
ui.label({ text = t("panel.empty"), color = "on_surface_variant" }),
}))
else
local d = devices[sel]
-- top: hero + info (full width)
table.insert(body, heroCard(d))
table.insert(body, infoGrid(d))
-- middle: two-column row (left content flex, right actions fixed)
table.insert(body, ui.row({ gap = 14, flexGrow = 1, align = "stretch", justify = "center" }, {
leftColumn(d),
rightColumn(d),
}))
-- bottom: device switcher (only if multiple devices)
local sw = switcher(devices, order, sel)
if sw then table.insert(body, sw) end
end
panel.render(ui.column({ gap = 10, padding = 12 }, body))
end
function onOpen(_context) render() end
function onClose() end
noctalia.state.watch("pc.devices", render)
noctalia.state.watch("pc.order", render)
noctalia.state.watch("pc.selected", render)
noctalia.state.watch("pc.backend", render)
noctalia.state.watch("pc.imageMap", render)
noctalia.state.watch("pc.trTable", render)
noctalia.state.watch("pc.aliasMap", render)