forked from FOSS/BangleApps
commit
29ab659f99
21
apps.json
21
apps.json
|
@ -2971,5 +2971,26 @@
|
||||||
{"name":"stepo.app.js","url":"app.js"},
|
{"name":"stepo.app.js","url":"app.js"},
|
||||||
{"name":"stepo.img","url":"icon.js","evaluate":true}
|
{"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"}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
0.01: Initial version
|
|
@ -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.
|
||||||
|
|
||||||
|
 
|
||||||
|
|
||||||
|
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 <rigrig+banglejs@tubul.net>
|
|
@ -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)<sw) {
|
||||||
|
do {
|
||||||
|
guess++
|
||||||
|
} while(getWidth(guess)<=sw)
|
||||||
|
return guess-1
|
||||||
|
}
|
||||||
|
// width > 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")<defaults.track.min_size) {
|
||||||
|
scroller.start()
|
||||||
|
} else {
|
||||||
|
scroller.stop()
|
||||||
|
drawInfo("track")
|
||||||
|
}
|
||||||
|
lastTrack = trackInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawArtistAlbum() {
|
||||||
|
// we just use small enough fonts to make these always fit
|
||||||
|
let album_middle = defaults.album.middle
|
||||||
|
const artist_size = infoSize("artist")
|
||||||
|
if (info.artist) {
|
||||||
|
album_middle += defaults.artist.size
|
||||||
|
}
|
||||||
|
drawInfo("artist", {
|
||||||
|
size: artist_size,
|
||||||
|
})
|
||||||
|
drawInfo("album", {
|
||||||
|
middle: album_middle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const icons = {
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
function controlColor(control) {
|
||||||
|
const s = defaults.controls
|
||||||
|
if (volCmd && control===volCmd) {
|
||||||
|
// volume button kept pressed down
|
||||||
|
return s.activeColor
|
||||||
|
}
|
||||||
|
return (control in tCommand) ? s.activeColor : s.color
|
||||||
|
}
|
||||||
|
function drawControl(control, x, y) {
|
||||||
|
g.setColor(controlColor(control))
|
||||||
|
const s = defaults.controls.size
|
||||||
|
if (state!==controlState) {
|
||||||
|
g.clearRect(x, y, x+s, y+s)
|
||||||
|
}
|
||||||
|
icons[control](x, y, s)
|
||||||
|
}
|
||||||
|
let controlState
|
||||||
|
function drawControls() {
|
||||||
|
const s = defaults.controls
|
||||||
|
if (state==="play") {
|
||||||
|
// left touch
|
||||||
|
drawControl("pause", s.left, screen.height-(s.bottom+s.size))
|
||||||
|
// right touch
|
||||||
|
drawControl("next", screen.width-(s.right+s.size), screen.height-(s.bottom+s.size))
|
||||||
|
} else {
|
||||||
|
drawControl("previous", s.left, screen.height-(s.bottom+s.size))
|
||||||
|
drawControl("play", screen.width-(s.right+s.size), screen.height-(s.bottom+s.size))
|
||||||
|
}
|
||||||
|
g.setFont("6x8", s.volSize)
|
||||||
|
// BTN1
|
||||||
|
g.setFontAlign(1, -1)
|
||||||
|
g.setColor(controlColor("volumeup"))
|
||||||
|
g.drawString("+", screen.width, s.top)
|
||||||
|
// BTN2
|
||||||
|
g.setFontAlign(1, 1)
|
||||||
|
g.setColor(controlColor("volumedown"))
|
||||||
|
g.drawString("-", screen.width, screen.height-s.bottom)
|
||||||
|
controlState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNumInfo() {
|
||||||
|
info.num = ""
|
||||||
|
if ("n" in info && info.n>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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
require("heatshrink").decompress(atob("mEwwhC/AH4AihvQCynd7oXThoWBC6YVCC6QVEC6BCDC6QVHC5wWJC/4VHC6oJCC6QSDC6QJFC54JHC5oNIC/4X/BpkNA4IXTCwL0GC5z1EC8JVHIwgXJKpAXOBpAXlBpQJELxgXdBQaONBwyxCaZQ9LdZYXWKpgYNCygA/AGYA=="))
|
Binary file not shown.
After Width: | Height: | Size: 725 B |
Binary file not shown.
After Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 6.3 KiB |
|
@ -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)
|
||||||
|
})
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
Loading…
Reference in New Issue