* 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
379 lines
16 KiB
Luau
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)
|