/* jshint esversion: 6 */ /** * Control the music on your Gadgetbridge-connected phone **/ { let autoClose = false // only if opened automatically let state = "" let info = { artist: "", album: "", track: "", n: 0, c: 0, } const screen = { width: g.getWidth(), height: g.getHeight(), center: g.getWidth()/2, middle: g.getHeight()/2, } const TIMEOUT = 5*1000*60 // auto close timeout: 5 minutes // drawText defaults const defaults = { time: { // top center color: -1, font: "Vector", size: 24, left: 10, top: 30, }, date: { // bottom center color: -1, font: "Vector", size: 16, bottom: 26, center: screen.width/2, }, num: { // top right font: "Vector", size: 30, top: 30, right: 15, }, track: { // center above middle font: "Vector", size: 40, // maximum size min_size: 25, // scroll (at maximum size) if this doesn't fit bottom: (screen.height/2)+10, center: screen.width/2, // Smaller interval+step might be smoother, but flickers :-( interval: 200, // scroll interval in ms step: 10, // scroll speed per interval }, artist: { // center below middle font: "Vector", size: 30, // maximum size middle: (screen.height/2)+17, center: screen.width/2, }, album: { // center below middle font: "Vector", size: 20, // maximum size middle: (screen.height/2)+18, // moved down if artist is present center: screen.width/2, }, // these work a bit different, as they apply to all controls controls: { color: "#008800", highlight: 200, // highlight pressed controls for this long, ms activeColor: "#ff0000", size: 20, // icons left: 10, // for right-side right: 20, // for left-side (more space because of +- buttons) top: 30, bottom: 30, font: "6x8", // volume buttons volSize: 2, // volume buttons }, } class Ticker { constructor(interval) { this.i = null this.interval = interval this.active = false } clear() { if (this.i) { clearInterval(this.i) } this.i = null } start() { this.active = true this.resume() } stop() { this.active = false this.clear() } pause() { this.clear() } resume() { this.clear() if (this.active && Bangle.isLCDOn()) { this.tick() this.i = setInterval(() => {this.tick()}, this.interval) } } } /** * Draw time and date */ class Clock extends Ticker { constructor() { super(1000) } tick() { g.reset() const now = new Date drawText("time", this.text(now)) drawText("date", require("locale").date(now, true)) } text(time) { const l = require("locale") const is12hour = (require("Storage").readJSON("setting.json", 1) || {})["12hour"] if (!is12hour) { return l.time(time, true) } const date12 = new Date(time.getTime()) const hours = date12.getHours() if (hours===0) { date12.setHours(12) } else if (hours>12) { date12.setHours(hours-12) } return l.time(date12, true)+l.meridian(time) } } /** * Update all info every second while fading out */ class Fader extends Ticker { constructor() { super(defaults.track.interval) // redraw at same speed as scroller } tick() { drawMusic() } start() { this.since = Date.now() super.start() } stop() { super.stop() this.since = Date.now() // force redraw at 100% brightness drawMusic() this.since = null } brightness() { if (fadeOut.since) { return Math.max(0, 1-((Date.now()-fadeOut.since)/TIMEOUT)) } return 1 } } /** * Scroll long track names */ class Scroller extends Ticker { constructor() { super(defaults.track.interval) } tick() { this.offset += defaults.track.step this.draw() } draw() { const s = defaults.track const sep = " " g.setFont(s.font, s.size) g.setColor(infoColor("track")) const text = sep+info.track, text2 = text.repeat(2), w1 = g.stringWidth(text), bottom = screen.height-s.bottom this.offset = this.offset%w1 g.setFontAlign(-1, 1) g.clearRect(0, bottom-s.size, screen.width, bottom) .drawString(text2, -this.offset, screen.height-s.bottom) } start() { this.offset = 0 super.start() } stop() { super.stop() const s = defaults.track, bottom = screen.height-s.bottom g.clearRect(0, bottom-s.size, screen.width, bottom) } } function drawInfo(name, options) { drawText(name, info[name], Object.assign({ color: infoColor(name), size: infoSize(name), force: fadeOut.active, }, options)) } let oldText = {} function drawText(name, text, options) { if (name in oldText && oldText[name].text===text && !(options || {}).force) { return // nothing to do } const s = Object.assign( // deep clone defaults to prevent them being overwritten with options JSON.parse(JSON.stringify(defaults[name])), options || {}, ) g.setColor(s.color) g.setFont(s.font, s.size) const ax = "left" in s ? -1 : ("right" in s ? 1 : 0), ay = "top" in s ? -1 : ("bottom" in s ? 1 : 0) g.setFontAlign(ax, ay) // drawString coordinates const x = "left" in s ? s.left : ("right" in s ? screen.width-s.right : s.center), y = "top" in s ? s.top : ("bottom" in s ? screen.height-s.bottom : s.middle) // bounding rectangle const w = g.stringWidth(text), h = g.getFontHeight(), left = "left" in s ? x : ("right" in s ? x-w : x-w/2), top = "top" in s ? y : ("bottom" in s ? y-h : y-h/2) if (name in oldText) { const old = oldText[name] // only clear if text/area has changed if (old.text!==text || old.left!==left || old.top!==top || old.w!==w || old.h!==h) { g.clearRect(old.left, old.top, old.left+old.w, old.top+old.h) } } if (text.length) { g.drawString(text, x, y) // remember which rectangle to clear before next draw oldText[name] = { text: text, left: left, top: top, w: w, h: h, } } else { delete oldText[name] } } /** * * @param text * @return {number} Maximum font size to make text fit on screen */ function fitText(text) { if (!text.length) { return Infinity } // Vector: make a guess, then shrink/grow until it fits const getWidth = (size) => g.setFont("Vector", size).stringWidth(text) , sw = screen.width let guess = Math.round(sw/(text.length*0.6)) if (getWidth(guess)===sw) { // good guess! return guess } if (getWidth(guess) target do { guess-- } while(getWidth(guess)>sw) return guess } /** * @param name * @return {number} Font size to use for given info */ function infoSize(name) { if (name==="num") { // fixed size return defaults[name].size } return Math.min( defaults[name].size, fitText(info[name]), ) } /** * @param name * @return {string} Semi-random color to use for given info */ let infoColors = {} function infoColor(name) { let h, s, v if (name==="num") { // always white h = 0 s = 0 } else { // complicated scheme to make color depend deterministically on info // s=1 and hue depends on the text, so we always get a bright color let text = "" switch(name) { case "track": text = info.track // fallthrough: also use album+artist case "album": text += info.album // fallthrough: also use artist case "artist": text += info.artist break default: text = info[name] } if (name in infoColors && infoColors[name].text===text && !fadeOut.active) { return infoColors[name].color } let code = 0 // just the sum of all ascii values of text text.split("").forEach(c => code += c.charCodeAt(0)) // dark magic h = code%360 s = 1 } v = fadeOut.brightness() const hsv2rgb = (h, s, v) => { const f = (n) => { 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)} } const rgb = hsv2rgb(h, s, v) const f2hex = (f) => ("00"+(Math.round(f*255)).toString(16)).substr(-2) const color = "#"+f2hex(rgb.r)+f2hex(rgb.g)+f2hex(rgb.b) infoColors[name] = color return color } let lastTrack function drawTrack() { // we try if we can squeeze this in with a slightly smaller font, but if // the title is too long we start up the scroller instead const trackInfo = ([info.artist, info.album, info.n, info.track]).join("-") if (trackInfo===lastTrack) { return // already visible } if (infoSize("track")0) { info.num = "#"+info.n if ("c" in info && info.c>0) { // I've seen { c:-1 } info.num += "/"+info.c } } } function drawMusic() { g.reset() setNumInfo() drawInfo("num") drawTrack() drawArtistAlbum() drawControls() } let tQuit function updateMusic() { // if paused for five minutes, load the clock // (but timeout resets if we get new info, even while paused) if (tQuit) { clearTimeout(tQuit) } tQuit = null if (state!=="play" && autoClose) { if (state==="stop") { // never actually happens with my phone :-( load() } else { // also quit when paused for a long time tQuit = setTimeout(load, TIMEOUT) fadeOut.start() } } else { fadeOut.stop() } drawMusic() } // create tickers const clock = new Clock() const fadeOut = new Fader() const scroller = new Scroller() //////////////////// // Events //////////////////// // pause timers while screen is off Bangle.on("lcdPower", on => { if (on) { clock.resume() scroller.resume() fadeOut.resume() } else { clock.pause() scroller.pause() fadeOut.pause() } }) let tLauncher // we put starting of watches inside a function, so we can defer it until we // asked the user about autoStart function startLauncherWatch() { // long-press: launcher // short-press: toggle play/pause setWatch(function() { if (tLauncher) { clearTimeout(tLauncher) } tLauncher = setTimeout(Bangle.showLauncher, 1000) }, BTN2, {repeat: true, edge: "rising"}) setWatch(function() { if (tLauncher) { clearTimeout(tLauncher) tLauncher = null } togglePlay() }, BTN2, {repeat: true, edge: "falling"}) } let tCommand = {} /** * Send command and highlight corresponding control * @param 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() }, defaults.controls.highlight) drawControls() } // BTN1/3: volume control (with repeat after long-press) let tVol, volCmd function volUp() { volStart("up") } function volDown() { volStart("down") } function volStart(dir) { const command = "volume"+dir stopVol() sendCommand(command) volCmd = command tVol = setTimeout(repeatVol, 500) } function repeatVol() { sendCommand(volCmd) tVol = setTimeout(repeatVol, 100) } function stopVol() { if (tVol) { clearTimeout(tVol) tVol = null } volCmd = null drawControls() } function startVolWatches() { setWatch(volUp, BTN1, {repeat: true, edge: "rising"}) setWatch(stopVol, BTN1, {repeat: true, edge: "falling"}) setWatch(volDown, BTN3, {repeat: true, edge: "rising"}) setWatch(stopVol, BTN3, {repeat: true, edge: "falling"}) } // touch/swipe: navigation function togglePlay() { sendCommand(state==="play" ? "pause" : "play") } function startTouchWatches() { Bangle.on("touch", function(side) { switch(side) { case 1: sendCommand(state==="play" ? "pause" : "previous") break case 2: sendCommand(state==="play" ? "next" : "play") break case 3: togglePlay() } }) Bangle.on("swipe", function(dir) { sendCommand(dir===1 ? "previous" : "next") }) } ///////////////////// // Startup ///////////////////// // check for saved music state (by widget) to load g.clear() global.gbmusic_active = true // we don't need our widget 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() { startVolWatches() startLauncherWatch() startTouchWatches() } function start() { // start listening for music updates const _GB = global.GB global.GB = (event) => { // we eat music events! switch(event.t) { case "musicinfo": info = event delete (info.t) break case "musicstate": state = event.state break default: // pass on other events if (_GB) { setTimeout(_GB, 0, event) } return // no drawMusic } updateMusic() } startWatches() drawMusic() clock.start() startEmulator() } 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 state = saved.state delete (saved) autoClose = true start() } else { const 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(function(autoStart) { s.autoStart = autoStart require("Storage").writeJSON("gbmusic.json", s) setTimeout(start, 0) }) } else { start() } } }