/* jshint esversion: 6 */ /** * Control the music on your Gadgetbridge-connected phone **/ let auto = false; // auto close if opened automatically let stat = ""; let info = { artist: "", album: "", track: "", n: 0, c: 0, }; const POUT = 300000; // auto close timeout when paused: 5 minutes (in ms) const IOUT = 3600000; // auto close timeout for inactivity: 1 hour (in ms) /////////////////////// // Self-repeating timeouts /////////////////////// // Clock let tock = -1; function tick() { if (!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 (!Bangle.isLCDOn() || !fade) { return; } drawMusic(false); // don't clear: draw over existing text to prevent flicker 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 offset = null, // scroll Offset: null = no scrolling iScroll; function scroll() { offset += 10; drawScroller(); } function scrollStart() { if (offset!==null) { return; // already started } offset = 0; if (Bangle.isLCDOn()) { if (!iScroll) { iScroll = setInterval(scroll, 200); } drawScroller(); } } function scrollStop() { if (iScroll) { clearInterval(iScroll); iScroll = null; } offset = null; } /** * @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 test = (s) => g.setFont("Vector", s).stringWidth(text); let best = Math.floor(24000/test(100)); if (test(best)===240) { // good guess! return best; } if (test(best)<240) { do { best++; } while(test(best)<=240); return best-1; } // width > 240 do { best--; } while(test(best)>240); 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 { const k = (n+h/60)%6; return v-v*s*Math.max(Math.min(k, 4-k, 1), 0); }; return {r: f(5), g: f(3), b: f(1)}; } function f2hex(f) { return ("00"+(Math.round(f*255)).toString(16)).substr(-2); } /** * @param {string} name - musicinfo property "num"/"artist"/"album"/"track" * @return {string} Semi-random color to use for given info */ function infoColor(name) { let h, s, v; if (name==="num") { // always white h = 0; s = 0; } else { // make color depend deterministically on info let code = textCode(info[name]); switch(name) { case "track": // also use album code += textCode(info.album); // fallthrough case "album": // also use artist code += textCode(info.artist); } h = code%360; s = 0.7; } v = brightness(); const rgb = hsv2rgb(h, s, v); return "#"+f2hex(rgb.r)+f2hex(rgb.g)+f2hex(rgb.b); } /** * Remember track color until info changes * Because we need this every time we move the scroller * @return {string} */ function trackColor() { if (!("track_color" in info) || fade) { info.track_color = infoColor("track"); } return info.track_color; } //////////////////// // 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"]; let time; 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); } time = l.time(d12, true)+l.meridian(now); } else { time = l.time(now, true); } g.reset(); g.setFont("Vector", 24) .setFontAlign(-1, -1) // top left .clearRect(10, 30, 119, 54) .drawString(time, 10, 30); const date = require("locale").date(now, true); g.setFont("Vector", 16) .setFontAlign(0, 1) // bottom center .setClipRect(35, 198, 199, 214) .clearRect(31, 198, 199, 214) .drawString(date, 119, 240-26); } /** * Draw track number and total count * @param {boolean} clr - Clear area before redrawing? */ function drawNum(clr) { 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; } } g.reset(); g.setFont("Vector", 30) .setFontAlign(1, -1); // top right if (clr) { g.clearRect(225, 30, 120, 60); } g.drawString(num, 225, 30); } /** * Clear rectangle used by track title */ function clearTrack() { g.clearRect(0, 60, 239, 119); } /** * Draw track title * @param {boolean} clr - Clear area before redrawing? */ function drawTrack(clr) { let size = fitText(info.track); if (size<25) { // the title is too long: start the scroller scrollStart(); return; } else { scrollStop(); } // stationary track if (size>40) { size = 40; } g.reset(); g.setFont("Vector", size) .setFontAlign(0, 1) // center bottom .setColor(trackColor()); if (clr) { clearTrack(); } g.drawString(info.track, 119, 109); } /** * Draw scrolling track title */ function drawScroller() { g.reset(); g.setFont("Vector", 40); const w = g.stringWidth(info.track)+40; offset = offset%w; g.setFontAlign(-1, 1) // left bottom .setColor(trackColor()); clearTrack(); g.drawString(info.track, -offset+40, 109) .drawString(info.track, -offset+40+w, 109); } /** * Draw track artist and album * @param {boolean} clr - Clear area before redrawing? */ function drawArtistAlbum(clr) { // we just use small enough fonts to make these always fit // calculate stuff before clear+redraw const aCol = infoColor("artist"); const bCol = infoColor("album"); let aSiz = fitText(info.artist); if (aSiz>30) { aSiz = 30; } let bSiz = fitText(info.album); if (bSiz>20) { bSiz = 20; } g.reset(); if (clr) { g.clearRect(0, 120, 240, 189); } let top = 124; if (info.artist) { g.setFont("Vector", aSiz) .setFontAlign(0, -1) // center top .setColor(aCol) .drawString(info.artist, 119, top); top += aSiz+4; // fit album neatly under artist } if (info.album) { g.setFont("Vector", bSiz) .setFontAlign(0, -1) // center top .setColor(bCol) .drawString(info.album, 119, top); } } /** * * @param {string} icon Icon name * @param {number} x * @param {number} y * @param {number} s Icon size */ function drawIcon(icon, x, y, s) { ({ pause: function(x, y, s) { const w1 = s/3; g.drawRect(x, y, x+w1, y+s); g.drawRect(x+s-w1, y, x+s, y+s); }, play: function(x, y, s) { g.drawPoly([ x, y, x+s, y+s/2, x, y+s, ], true); }, previous: function(x, y, s) { const w2 = s*1/5; g.drawPoly([ x+s, y, x+w2, y+s/2, x+s, y+s, ], true); g.drawRect(x, y, x+w2, y+s); }, next: function(x, y, s) { const w2 = s*4/5; g.drawPoly([ x, y, x+w2, y+s/2, x, y+s, ], true); g.drawRect(x+w2, y, x+s, y+s); }, })[icon](x, y, s); } function controlColor(ctrl) { return (ctrl in tCommand) ? "#ff0000" : "#008800"; } function drawControl(ctrl, x, y) { g.setColor(controlColor(ctrl)); const s = 20; if (stat!==controlState) { g.clearRect(x, y, x+s, y+s); } drawIcon(ctrl, x, y, s); } let controlState; function drawControls() { g.reset(); if (stat==="play") { // left touch drawControl("pause", 10, 190); // right touch drawControl("next", 200, 190); } else { drawControl("previous", 10, 190); drawControl("play", 200, 190); } g.setFont("6x8", 2); // BTN1 g.setFontAlign(1, -1); g.setColor(controlColor("volumeup")); g.drawString("+", 240, 30); // BTN2 g.setFontAlign(1, 1); g.setColor(controlColor("volumedown")); g.drawString("-", 240, 210); controlState = stat; } /** * @param {boolean} [clr=true] Clear area before redrawing? */ function drawMusic(clr) { clr = !(clr===false); // undefined means yes drawNum(clr); drawTrack(clr); drawArtistAlbum(clr); } //////////////////////// // GB event handlers /////////////////////// /** * Update music info * @param {Object} e - Gadgetbridge musicinfo event */ function musicInfo(e) { info = e; delete (info.t); offset = null; if (Bangle.isLCDOn()) { drawMusic(); } 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; delete info.track_color; 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, (info.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 (Bangle.isLCDOn()) { drawMusic(false); // redraw in case we were fading out but resumed play 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() { // 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"}); // BTN2: long-press for launcher, otherwise depends on number of presses setWatch(() => { if (nPress===0) { tPress = setTimeout(() => {Bangle.showLauncher();}, 3000); } }, BTN2, {repeat: true, edge: "rising"}); setWatch(() => { nPress++; clearTimeout(tPress); tPress = setTimeout(handleButton2Press, 500); }, BTN2, {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 controlColor 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 startTouchWatches() { Bangle.on("touch", side => { if (!Bangle.isLCDOn()) {return;} // for <2v10 firmware switch(side) { case 1: sendCommand(stat==="play" ? "pause" : "previous"); break; case 2: sendCommand(stat==="play" ? "next" : "play"); break; case 3: togglePlay(); } }); Bangle.on("swipe", dir => { if (!Bangle.isLCDOn()) {return;} // for <2v10 firmware sendCommand(dir===1 ? "previous" : "next"); }); } function startLCDWatch() { Bangle.on("lcdPower", (on) => { if (on) { // redraw and resume scrolling tick(); drawMusic(); drawControls(); fadeOut(); if (offset!==null) { drawScroller(); if (!iScroll) { iScroll = setInterval(scroll, 200); } } } else { // pause scrolling if (iScroll) { clearInterval(iScroll); iScroll = null; } } }); } ///////////////////// // Startup ///////////////////// // check for saved music stat (by widget) to load g.clear(); global.gbmusic_active = true; // we don't need our widget (needed for <2.09 devices) Bangle.loadWidgets(); Bangle.drawWidgets(); delete (global.gbmusic_active); function startEmulator() { if (typeof Bluetooth==="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() { // 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; } }; drawMusic(); drawControls(); startWatches(); tick(); startEmulator(); } function init() { let saved = require("Storage").readJSON("gbmusic.load.json", true); require("Storage").erase("gbmusic.load.json"); if (saved) { // autoloaded: load state was saved by widget info = saved.info; stat = saved.state; delete saved; auto = true; start(); } else { delete saved; let s = require("Storage").readJSON("gbmusic.json", 1) || {}; if (!("autoStart" in s)) { // user opened the app, but has not picked a 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); delete s; setTimeout(start, 0); }); } else { delete s; start(); } } } init();