diff --git a/apps.json b/apps.json index a6f83749d..65d30658c 100644 --- a/apps.json +++ b/apps.json @@ -2971,5 +2971,26 @@ {"name":"stepo.app.js","url":"app.js"}, {"name":"stepo.img","url":"icon.js","evaluate":true} ] +}, +{ "id": "gbmusic", + "name": "Gadgetbridge Music Controls", + "shortName":"Music Controls", + "icon": "icon.png", + "version":"0.01", + "description": "Control the music on your Gadgetbridge-connected phone", + "tags": "tools,bluetooth,gadgetbridge,music", + "type":"app", + "allow_emulator": false, + "readme": "README.md", + "storage": [ + {"name":"gbmusic.app.js","url":"app.js"}, + {"name":"gbmusic.settings.js","url":"settings.js"}, + {"name":"gbmusic.wid.js","url":"widget.js"}, + {"name":"gbmusic.img","url":"icon.js","evaluate":true} + ], + "data": [ + {"name":"gbmusic.json"}, + {"name":"gbmusic.load.json"} + ] } ] diff --git a/apps/gbmusic/ChangeLog b/apps/gbmusic/ChangeLog new file mode 100644 index 000000000..ec66c5568 --- /dev/null +++ b/apps/gbmusic/ChangeLog @@ -0,0 +1 @@ +0.01: Initial version diff --git a/apps/gbmusic/README.md b/apps/gbmusic/README.md new file mode 100644 index 000000000..acb5f5dfe --- /dev/null +++ b/apps/gbmusic/README.md @@ -0,0 +1,38 @@ +# Gadgetbridge Music Controls + +If you have an Android phone with Gadgetbridge, this app allows you to view +and control music playback. + +![Screenshot: playing](screenshot.png) ![Screenshot: paused](screenshot_2.png) + +Download the [latest Gadgetbridge for Android here](https://f-droid.org/packages/nodomain.freeyourgadget.gadgetbridge/). + +## Features + +* Dynamic colors based on Track/Artist/Album name +* Scrolling display for long titles +* Automatic start when music plays +* Time and date display + +## Settings + +The app can automatically load when you play music and close when the music stops. +You can change this under `Settings`->`App/Widget Settings`->`Music Controls`. +(If the app opened automatically, it closes after music has been paused for 5 minutes.) + +## Controls + +### Buttons +* Button 1: Volume up (hold to repeat) +* Button 2: Toggle play/pause, long-press for menu +* Button 3: Volume down (hold to repeat, but remember that holding for too long resets your watch) + +### Touch +* Left: pause/previous song +* Right: next song/resume +* Center: toggle play/pause +* Swipe: next/previous song + +## Creator + +Richard de Boer diff --git a/apps/gbmusic/app.js b/apps/gbmusic/app.js new file mode 100644 index 000000000..ab26c22ee --- /dev/null +++ b/apps/gbmusic/app.js @@ -0,0 +1,691 @@ +/* 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() + } + } +} diff --git a/apps/gbmusic/icon.js b/apps/gbmusic/icon.js new file mode 100644 index 000000000..5a83430a9 --- /dev/null +++ b/apps/gbmusic/icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwhC/AH4AihvQCynd7oXThoWBC6YVCC6QVEC6BCDC6QVHC5wWJC/4VHC6oJCC6QSDC6QJFC54JHC5oNIC/4X/BpkNA4IXTCwL0GC5z1EC8JVHIwgXJKpAXOBpAXlBpQJELxgXdBQaONBwyxCaZQ9LdZYXWKpgYNCygA/AGYA==")) diff --git a/apps/gbmusic/icon.png b/apps/gbmusic/icon.png new file mode 100644 index 000000000..43d24afa2 Binary files /dev/null and b/apps/gbmusic/icon.png differ diff --git a/apps/gbmusic/screenshot.png b/apps/gbmusic/screenshot.png new file mode 100644 index 000000000..569a6a2c5 Binary files /dev/null and b/apps/gbmusic/screenshot.png differ diff --git a/apps/gbmusic/screenshot_2.png b/apps/gbmusic/screenshot_2.png new file mode 100644 index 000000000..f19f8f428 Binary files /dev/null and b/apps/gbmusic/screenshot_2.png differ diff --git a/apps/gbmusic/settings.js b/apps/gbmusic/settings.js new file mode 100644 index 000000000..ae8fc5991 --- /dev/null +++ b/apps/gbmusic/settings.js @@ -0,0 +1,38 @@ +/** + * @param {function} back Use back() to return to settings menu + */ +(function(back) { + const SETTINGS_FILE = "gbmusic.json", + storage = require("Storage"), + translate = require("locale").translate + + // initialize with default settings... + let s = { + autoStart: true, + } + // ...and overwrite them with any saved values + // This way saved values are preserved if a new version adds more settings + const saved = storage.readJSON(SETTINGS_FILE, 1) || {} + for(const key in saved) { + s[key] = saved[key] + } + + // creates a function to safe a specific setting, e.g. save('autoStart')(true) + function save(key) { + return function(value) { + s[key] = value + storage.write(SETTINGS_FILE, s) + } + } + + const menu = { + "": {"title": "Music Control"}, + "< Back": back, + "Auto start": { + value: s.autoStart, + format: v => translate(v ? "Yes" : "No"), + onchange: save("autoStart"), + } + } + E.showMenu(menu) +}) diff --git a/apps/gbmusic/widget.js b/apps/gbmusic/widget.js new file mode 100644 index 000000000..1a55490b5 --- /dev/null +++ b/apps/gbmusic/widget.js @@ -0,0 +1,38 @@ +(() => { + if (global.gbmusic_active || !(require("Storage").readJSON("gbmusic.json", 1) || {}).autoStart) { + return + } + + let state, info + function checkMusic() { + if (state!=="play" || !info) { + return + } + // playing music: launch music app + require("Storage").writeJSON("gbmusic.load.json", { + state: state, + info: info, + }) + load("gbmusic.app.js") + } + + const _GB = global.GB + global.GB = (event) => { + // we eat music events! + switch(event.t) { + case "musicinfo": + info = event + delete(info.t) + checkMusic() + break + case "musicstate": + state = event.state + checkMusic() + break + default: + if (_GB) { + setTimeout(_GB, 0, event) + } + } + } +})()