From a4465cb532d7d6691dcc1d39cd60b96bc41ff7c4 Mon Sep 17 00:00:00 2001 From: Richard de Boer Date: Sun, 14 Mar 2021 22:28:44 +0100 Subject: [PATCH] new app: gbmusic: Gadgetbridge Music Controls --- apps.json | 21 ++ apps/gbmusic/ChangeLog | 1 + apps/gbmusic/README.md | 38 ++ apps/gbmusic/app.js | 691 ++++++++++++++++++++++++++++++++++ apps/gbmusic/icon.js | 1 + apps/gbmusic/icon.png | Bin 0 -> 725 bytes apps/gbmusic/screenshot.png | Bin 0 -> 6368 bytes apps/gbmusic/screenshot_2.png | Bin 0 -> 6475 bytes apps/gbmusic/settings.js | 38 ++ apps/gbmusic/widget.js | 38 ++ 10 files changed, 828 insertions(+) create mode 100644 apps/gbmusic/ChangeLog create mode 100644 apps/gbmusic/README.md create mode 100644 apps/gbmusic/app.js create mode 100644 apps/gbmusic/icon.js create mode 100644 apps/gbmusic/icon.png create mode 100644 apps/gbmusic/screenshot.png create mode 100644 apps/gbmusic/screenshot_2.png create mode 100644 apps/gbmusic/settings.js create mode 100644 apps/gbmusic/widget.js 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 0000000000000000000000000000000000000000..43d24afa24ee13398e33e5dc1dd2fc1be2b5a3ce GIT binary patch literal 725 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 a3+1QBa$V}sa_XN8z|9-x*DJ0$Km9L_s?FR0 literal 0 HcmV?d00001 diff --git a/apps/gbmusic/screenshot_2.png b/apps/gbmusic/screenshot_2.png new file mode 100644 index 0000000000000000000000000000000000000000..f19f8f42832ebe1ccfe754c023cb72b33df0fb9a GIT binary patch literal 6475 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 VR({+!hjmE;Fu4K0UU>}}`9FUF6_x-1 literal 0 HcmV?d00001 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) + } + } + } +})()