/* jshint esversion: 6 */ /** * Control the music on your Gadgetbridge-connected phone **/ let auto = false; // auto close if opened automatically let stat = ""; const POUT = 300000; // auto close timeout when paused: 5 minutes (in ms) const IOUT = 3600000; // auto close timeout for inactivity: 1 hour (in ms) const BANGLE2 = process.env.HWVERSION===2; /** * @param {string} text * @return {number} Maximum font size to make text fit on screen */ function fitText(text) { if (!text.length) { return Infinity; } // make a guess, then shrink/grow until it fits const w = Bangle.appRect.w, test = (s) => g.setFont("Vector", s).stringWidth(text); let best = Math.floor(100*w/test(100)); if (test(best)===w) { // good guess! return best; } if (test(best) w do { best--; } while(test(best)>w); return best; } /** * @param {string} text * @return {number} Randomish but deterministic number from 0-360 for text */ function textCode(text) { "ram"; let code = 0; for(let i = 0; i 1-c); } return "#"+f2hex(rgb[0])+f2hex(rgb[1])+f2hex(rgb[2]); } else { // pick any hue, adjust for brightness const h = code%360, s = 0.7, b = brightness(); return E.HSBtoRGB(h/360, s, b); } } /** * Render scrolling title * @param l */ function rScroller(l) { g.setFont("Vector", Math.round(g.getHeight()*l.fsz.slice(0, -1)/100)); const w = g.stringWidth(l.label)+40, y = l.y+l.h/2; l.offset = l.offset%w; g.setClipRect(l.x, l.y, l.x+l.w-1, l.y+l.h-1) .setColor(l.col) .setFontAlign(-1, 0) // left center .clearRect(l.x, l.y, l.x+l.w-1, l.y+l.h-1) .drawString(l.label, l.x-l.offset+40, y) .drawString(l.label, l.x-l.offset+40+w, y); } /** * Render title * @param l */ function rTitle(l) { if (l.offset!==null) { rScroller(l); // already scrolling return; } let size = fitText(l.label); if (sizel.h) { size = l.h; } g.setFont("Vector", size) .setFontAlign(0, -1) // center top .drawString(l.label, l.x+l.w/2, l.y); } /** * Render icon * @param l */ function rIcon(l) { const x2 = l.x+l.w-1, y2 = l.y+l.h-1; switch(l.icon) { case "pause": const w13 = l.w/3; g.drawRect(l.x, l.y, l.x+w13, y2); g.drawRect(l.x+l.w-w13, l.y, x2, y2); break; case "play": g.drawPoly([ l.x, l.y, x2, l.y+l.h/2, l.x, y2, ], true); break; case "previous": const w15 = l.w*1/5; g.drawPoly([ x2, l.y, l.x+w15, l.y+l.h/2, x2, y2, ], true); g.drawRect(l.x, l.y, l.x+w15, y2); break; case "next": const w45 = l.w*4/5; g.drawPoly([ l.x, l.y, l.x+w45, l.y+l.h/2, l.x, y2, ], true); g.drawRect(l.x+w45, l.y, x2, y2); break; default: // red X console.log(`Unknown icon: ${l.icon}`); g.setColor("#f00") .drawRect(l.x, l.y, x2, y2) .drawLine(l.x, l.y, x2, y2) .drawLine(l.x, y2, x2, l.y); } } let layout; function makeUI() { global.gbmusic_active = true; // we don't need our widget (needed for <2.09 devices) Bangle.loadWidgets(); Bangle.drawWidgets(); delete (global.gbmusic_active); const Layout = require("Layout"); layout = new Layout({ type: "v", c: [ { type: "h", fillx: 1, c: [ {id: "time", type: "txt", label: "88:88", valign: -1, halign: -1, font: "8%", bgCol: g.theme.bg}, {fillx: 1}, {id: "num", type: "txt", label: "88:88", valign: -1, halign: 1, font: "12%", bgCol: g.theme.bg}, BANGLE2 ? {} : {id: "up", type: "txt", label: " +", font: "6x8:2"}, ], }, {id: "title", type: "custom", label: "", fillx: 1, filly: 2, offset: null, font: "Vector:20%", render: rTitle, bgCol: g.theme.bg}, {id: "artist", type: "custom", label: "", fillx: 1, filly: 1, size: 30, render: rInfo, bgCol: g.theme.bg}, {id: "album", type: "custom", label: "", fillx: 1, filly: 1, size: 20, render: rInfo, bgCol: g.theme.bg}, {height: 10}, { type: "h", c: [ {width: 3}, {id: "prev", type: "custom", height: 15, width: 15, icon: "previous", render: rIcon, bgCol: g.theme.bg}, {id: "date", type: "txt", halign: 0, valign: 1, label: "", font: "8%", fillx: 1, bgCol: g.theme.bg}, {id: "next", type: "custom", height: 15, width: 15, icon: "next", render: rIcon, bgCol: g.theme.bg}, BANGLE2 ? {width: 3} : {id: "down", type: "txt", label: " -", font: "6x8:2"}, ], }, {height: 10}, ], }, {lazy: true}); layout.render(); } /////////////////////// // Self-repeating timeouts /////////////////////// // Clock let tock = -1; function tick() { if (!BANGLE2 && !Bangle.isLCDOn()) { return; } const now = new Date(); if (now.getHours()*60+now.getMinutes()!==tock) { drawDateTime(); tock = now.getHours()*60+now.getMinutes(); } setTimeout(tick, 1000); // we only show minute precision anyway } // Fade out while paused and auto closing let fade = null; function fadeOut() { if (BANGLE2 || !Bangle.isLCDOn() || !fade) { return; } layout.render(); setTimeout(fadeOut, 500); } function brightness() { if (!fade) { return 1; } return Math.max(0, 1-((Date.now()-fade)/POUT)); } // Scroll long track names // use an interval to get smooth movement let iScroll; function scroll() { layout.title.offset += 10; rScroller(layout.title); } function scrollStart() { if (layout.title.offset!==null) { return; // already started } layout.title.offset = 0; if (BANGLE2 || Bangle.isLCDOn()) { if (!iScroll) { iScroll = setInterval(scroll, 200); } rScroller(layout.title); } } function scrollStop() { if (iScroll) { clearInterval(iScroll); iScroll = null; } layout.title.offset = null; } //////////////////// // Drawing functions //////////////////// /** * Draw date and time */ function drawDateTime() { const now = new Date(); const l = require("locale"); const is12 = (require("Storage").readJSON("setting.json", 1) || {})["12hour"]; if (is12) { const d12 = new Date(now.getTime()); const hour = d12.getHours(); if (hour===0) { d12.setHours(12); } else if (hour>12) { d12.setHours(hour-12); } layout.time.label = l.time(d12, true)+l.meridian(now); } else { layout.time.label = l.time(now, true); } layout.date.label = require("locale").date(now, true); layout.render(); } function drawControls() { let l = layout; const cc = a => (a ? "#f00" : "#0f0"); // control color: red for active, green for inactive if (!BANGLE2) { l.up.col = cc("volumeup" in tCommand); l.down.col = cc("volumedown" in tCommand); } l.prev.icon = (stat==="play") ? "pause" : "prev"; l.prev.col = cc("prev" in tCommand || "pause" in tCommand); l.next.icon = (stat==="play") ? "next" : "play"; l.next.col = cc("next" in tCommand || "play" in tCommand); layout.render(); } //////////////////////// // GB event handlers /////////////////////// /** * Mangle track number and total count for display */ function formatNum(info) { let num = ""; if ("n" in info && info.n>0) { num = "#"+info.n; if ("c" in info && info.c>0) { // I've seen { c:-1 } num += "/"+info.c; } } return num; } /** * Update music info * @param {Object} info - Gadgetbridge musicinfo event */ function musicInfo(info) { scrollStop(); layout.title.label = info.track || ""; layout.album.label = info.album || ""; layout.artist.label = info.artist || ""; // color depends on all labels layout.title.col = infoColor("title"); layout.album.col = infoColor("album"); layout.artist.col = infoColor("artist"); layout.num.label = formatNum(info); layout.render(); rTitle(layout.title); // force redraw of title, or scroller might break // reset auto exit interval if (tIxt) { clearTimeout(tIxt); tIxt = null; } if (auto && stat==="play") { // if inactive for double song duration (or an hour if unknown), load the clock // i.e. phone finished playing without bothering to notify the watch tIxt = setTimeout(load, (info.dur*2000) || IOUT); } } let tPxt, tIxt; // Timeouts to eXiT when Paused/Inactive for too long /** * Update music state * @param {Object} e - Gadgetbridge musicstate event */ function musicState(e) { stat = e.state; // if paused for five minutes, load the clock // (but timeout resets if we get new info, even while paused) if (tPxt) { clearTimeout(tPxt); tPxt = null; } if (tIxt) { clearTimeout(tIxt); tIxt = null; } fade = null; if (auto) { // auto opened -> auto close switch(stat) { case "stop": // never actually happens with my phone :-( load(); break; case "play": // if inactive for double song duration (or an hour if unknown), load the clock // i.e. phone finished playing without bothering to notify the watch tIxt = setTimeout(load, (e.dur*2000) || IOUT); break; case "pause": default: // quit when paused for a long time // also fade out track info while waiting for this tPxt = setTimeout(load, POUT); fade = Date.now(); fadeOut(); break; } } if (BANGLE2 || Bangle.isLCDOn()) { drawControls(); } } //////////////////// // Events //////////////////// // we put starting of watches inside a function, so we can defer it until // we asked the user about autoStart /** * Start watching for BTN2 presses */ let tPress, nPress = 0; function startButtonWatches() { let btn = BTN1; if (!BANGLE2) { // BTN1/3: volume control // Wait for falling edge to avoid messing with volume while long-pressing BTN3 // to reload the watch (and same for BTN2 for consistency) setWatch(() => { sendCommand("volumeup"); }, BTN1, {repeat: true, edge: "falling"}); setWatch(() => { sendCommand("volumedown"); }, BTN3, {repeat: true, edge: "falling"}); btn = BTN2; } // middle button: long-press for launcher, otherwise depends on number of presses setWatch(() => { if (nPress===0) { tPress = setTimeout(() => {Bangle.showLauncher();}, 3000); } }, btn, {repeat: true, edge: "rising"}); const s = require("Storage").readJSON("gbmusic.json", 1) || {}; if (s.simpleButton) { setWatch(() => { clearTimeout(tPress); togglePlay(); }, btn, {repeat: true, edge: "falling"}); } else { setWatch(() => { nPress++; clearTimeout(tPress); tPress = setTimeout(handleButton2Press, 500); }, btn, {repeat: true, edge: "falling"}); } } function handleButton2Press() { tPress = null; switch(nPress) { case 1: togglePlay(); break; case 2: sendCommand("next"); break; case 3: sendCommand("previous"); break; default: // invalid Bangle.buzz(50); } nPress = 0; } let tCommand = {}; /** * Send command and highlight corresponding control * @param {string} command - "play"/"pause"/"next"/"previous"/"volumeup"/"volumedown" */ function sendCommand(command) { Bluetooth.println(JSON.stringify({t: "music", n: command})); // for control color if (command in tCommand) { clearTimeout(tCommand[command]); } tCommand[command] = setTimeout(function() { delete tCommand[command]; drawControls(); }, 200); drawControls(); } // touch/swipe: navigation function togglePlay() { sendCommand(stat==="play" ? "pause" : "play"); } function pausePrev() { sendCommand(stat==="play" ? "pause" : "previous"); } function nextPlay() { sendCommand(stat==="play" ? "next" : "play"); } /** * Setup touch+swipe for Bangle.js 1 */ function touch1() { Bangle.on("touch", side => { if (!Bangle.isLCDOn()) {return;} // for <2v10 firmware switch(side) { case 1: pausePrev(); break; case 2: nextPlay(); break; default: togglePlay(); break; } }); Bangle.on("swipe", dir => { if (!Bangle.isLCDOn()) {return;} // for <2v10 firmware sendCommand(dir===1 ? "previous" : "next"); }); } /** * Setup touch+swipe for Bangle.js 2 */ function touch2() { Bangle.on("touch", (side, xy) => { const ar = Bangle.appRect; if (xy.xar.x+ar.w*2/3) { nextPlay(); } else { togglePlay(); } }); // swiping let drag; Bangle.on("drag", e => { if (!drag) { // start dragging drag = {x: e.x, y: e.y}; } else if (!e.b) { // released const dx = e.x-drag.x, dy = e.y-drag.y; drag = null; if (Math.abs(dx)>Math.abs(dy)+10) { // horizontal sendCommand(dx>0 ? "previous" : "next"); } else if (Math.abs(dy)>Math.abs(dx)+10) { // vertical sendCommand(dy>0 ? "volumedown" : "volumeup"); } } }); } function startTouchWatches() { if (BANGLE2) { touch2(); } else { touch1(); } } function startLCDWatch() { if (BANGLE2) { return; // always keep drawing } Bangle.on("lcdPower", (on) => { if (on) { // redraw and resume scrolling tick(); layout.render(); fadeOut(); if (offset.offset!==null) { if (!iScroll) { iScroll = setInterval(scroll, 200); } } } else { // pause scrolling if (iScroll) { clearInterval(iScroll); iScroll = null; } } }); } ///////////////////// // Startup ///////////////////// g.clear(); function startEmulator() { if (typeof Bluetooth==="undefined" || typeof Bluetooth.println==="undefined") { // emulator! Bluetooth = { println: (line) => {console.log("Bluetooth:", line);}, }; // some example info GB({"t": "musicinfo", "artist": "Some Artist Name", "album": "The Album Name", "track": "The Track Title Goes Here", "dur": 241, "c": 2, "n": 2}); GB({"t": "musicstate", "state": "play", "position": 0, "shuffle": 1, "repeat": 1}); } } function startWatches() { startButtonWatches(); startTouchWatches(); startLCDWatch(); } function start() { makeUI(); // start listening for music updates const _GB = global.GB; global.GB = (event) => { // we eat music events! switch(event.t) { case "musicinfo": musicInfo(event); break; case "musicstate": musicState(event); break; default: // pass on other events if (_GB) { setTimeout(_GB, 0, event); } return; } }; startWatches(); tick(); startEmulator(); } function init() { // check for saved music status (by widget) to load let saved = require("Storage").readJSON("gbmusic.load.json", true); require("Storage").erase("gbmusic.load.json"); if (saved) { // autoloaded: load state was saved by widget auto = true; start(); musicInfo(saved.info); musicState(saved.state); return; } let s = require("Storage").readJSON("gbmusic.json", 1) || {}; if ("autoStart" in s) { start(); return; } // user opened the app, but has not picked a autoStart setting yet // ask them about autoloading now E.showPrompt( "Automatically load\n"+ "when playing music?\n" ).then(choice => { s.autoStart = choice; require("Storage").writeJSON("gbmusic.json", s); setTimeout(start, 0); }); } init();