Merge branch 'master' of github.com:espruino/BangleApps

pull/689/head
Gordon Williams 2021-03-16 10:33:12 +00:00
commit 6b39352894
16 changed files with 1320 additions and 0 deletions

View File

@ -2971,5 +2971,47 @@
{"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"}
]
},
{
"id": "battleship",
"name":"Battleship",
"icon":"battleship-icon.png",
"version": "0.01",
"readme": "README.md",
"description": "The classic game of battleship",
"tags": "game",
"allow_emulator": true,
"storage": [
{
"name": "battleship.app.js",
"url": "battleship.js"
},
{
"name": "battleship.img",
"url": "battleship-icon.js",
"evaluate": true
}
]
}
]

View File

@ -0,0 +1 @@
0.01: New App!

18
apps/battleship/README.md Normal file
View File

@ -0,0 +1,18 @@
# Battleship
The classic game of battleship.
## Usage
In the beginning, each player is required to place
all ships in his fleet on the field.
Navigation of the cursor is performed using BTN1 and
BTN3 as well as left and right on the touch screen.
To place a ship use BTN2 to initialize a placement
and BTN2 again to complete it.
In the next phase the players take alternating turns
in trying to hit an opposing ship.
After a player succeeds in sinking the entire opposing
fleet the game ends.

View File

@ -0,0 +1 @@
require("heatshrink").decompress(atob("mEwyBC/AH4A/AH4A/AH4A/AH4AEgeGAIVmAogBHBoRV/LZQBaLf4BK9EMlMMpQBClIJBMf5dM08Utcdh0luABNCIMUpYZBMO5bK1hZPAJYdBMecDswxFhkqktQLrYBEqAlBMI1mXdq5dYpxhqFYsc5pdnAIM16VeuQ1FLs67pAIM9+WP7GHrFN2JhjEYsMlRdtv9Yu34v8YlnNHolmL8GnktQLtkXt3XuwBB/ADBhfIYLq9FimsLtd2+9m21u25hFouQIIq9eLtVu+1eu1myxhIYLi9GtZdps3Wq11u83w94t3WMIZfDmsOL78dhxdo2tOmhZBy/5AIILCYYhfBr0TIopfUswZDLsc+iRRBr2Vp0UL4X2L4teL4JhEAIMD05fYPIfoLstWuk9+dGiZhDu83w+Ys3Wr11MI8UlJfbhkqLsdOqk16U96ZhHAINWqoBBMI8kxZfcpRddmvRLoNGmct2M12RhLqxhKlmsL/ZhDkuRloBBL4JhRL4JhCkhfdlJffAIhhajmLL7cD9BfkuBfCMJlGMJEUhJfcwxflMLFUgenL7sdhxhpnvxp0RnvSMIXzMI89yJFFL7MUpZfnmuxq0xAIbDEMI0k1hdXMJGnL9HRL5BhD+RhBovzHoJfcszBE1hhnovxp0RYoMtyJhG+ck9qhEL7DBIqBhnAIlxMInSNIK9dL5GGhkqLMstyEt2BhJilKXr5hJimsLsdGiABBnvwMIsc5hdjMJXNL7812BfDopfEFoJdnL4VmYc2QXYJdBMoJdC1gxFL8phJhkqktQYr4hBEoJdtMJcD07FdXIWnLuJjGG4xjCpcc9xZPCIMMpZb5YpwBF9EMlMMpQBClIJBC5hd2YpwBWLfZldKf4A/AH4A/AH4A/AH4A/AAo"))

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,130 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="battleship-icon.svg"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
id="svg8"
version="1.1"
viewBox="0 0 12.7 12.7"
height="48"
width="48"
inkscape:export-filename="battleship-icon.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<defs
id="defs2">
<linearGradient
id="linearGradient848"
inkscape:collect="always">
<stop
id="stop844"
offset="0"
style="stop-color:#88e7cd;stop-opacity:1;" />
<stop
id="stop846"
offset="1"
style="stop-color:#88e7cd;stop-opacity:0;" />
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="6.3499999"
x2="6.3499999"
y1="3.0260465"
x1="9.7076998"
id="linearGradient850"
xlink:href="#linearGradient848"
inkscape:collect="always" />
</defs>
<sodipodi:namedview
inkscape:window-maximized="1"
inkscape:window-y="-9"
inkscape:window-x="-9"
inkscape:window-height="1013"
inkscape:window-width="1920"
units="px"
showgrid="false"
inkscape:document-rotation="0"
inkscape:current-layer="layer1"
inkscape:document-units="px"
inkscape:cy="50.37804"
inkscape:cx="120.0705"
inkscape:zoom="3.959798"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#ffffff"
id="base" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
inkscape:groupmode="layer"
inkscape:label="Layer 1">
<path
id="path833"
d="M 11.074702,6.3499999 A 4.7247024,4.7247024 0 0 1 6.3499999,11.074702 4.7247024,4.7247024 0 0 1 1.6252975,6.3499999 4.7247024,4.7247024 0 0 1 6.3499999,1.6252975 4.7247024,4.7247024 0 0 1 11.074702,6.3499999 Z"
style="fill:#23ae87;fill-opacity:1;stroke:none;stroke-width:0.765;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 9.7076995,3.0260466 A 4.7247024,4.7247024 0 0 1 9.8531236,9.5203211 L 6.3499999,6.3499999 Z"
sodipodi:arc-type="slice"
sodipodi:end="0.73556977"
sodipodi:start="5.5028377"
sodipodi:ry="4.7247024"
sodipodi:rx="4.7247024"
sodipodi:cy="6.3499999"
sodipodi:cx="6.3499999"
sodipodi:type="arc"
style="fill:url(#linearGradient850);fill-opacity:1;stroke:none;stroke-width:0.765;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle835" />
<circle
style="fill:none;stroke-width:0.765;stroke-linecap:round;stroke-linejoin:round;stroke:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
id="circle837"
cx="6.3499999"
cy="6.3499999"
r="4.7247024" />
<path
id="path852"
d="M 6.3499999,6.3499999 9.7076994,3.0260467"
style="fill:#00ff00;stroke:#93ffc9;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:none;fill-opacity:1;stroke:#007033;stroke-width:0.765;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 11.074702,6.3499999 A 4.7247024,4.7247024 0 0 1 6.3499999,11.074702 4.7247024,4.7247024 0 0 1 1.6252975,6.3499999 4.7247024,4.7247024 0 0 1 6.3499999,1.6252975 4.7247024,4.7247024 0 0 1 11.074702,6.3499999 Z"
id="path842" />
<circle
r="0.46234927"
cy="5.3087621"
cx="8.8007536"
id="path839"
style="fill:#93ffc9;fill-opacity:1;stroke:none;stroke-width:0.38708;stroke-linecap:round;stroke-linejoin:round" />
<circle
style="fill:#57c78f;fill-opacity:1;stroke:none;stroke-width:0.38708;stroke-linecap:round;stroke-linejoin:round"
id="circle841"
cx="8.2715864"
cy="7.9545956"
r="0.46234927" />
<circle
r="0.46234927"
cy="9.3416424"
cx="8.2715864"
id="circle843"
style="fill:#43c082;fill-opacity:1;stroke:none;stroke-width:0.38708;stroke-linecap:round;stroke-linejoin:round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -0,0 +1,321 @@
const FIELD_WIDTH = [11, 11, 15]; // for each phase
const FIELD_HEIGHT = FIELD_WIDTH;
const FIELD_LINE_WIDTH = 2;
const FIELD_MARGIN = 2;
const FIELD_COUNT_X = 10;
const FIELD_COUNT_Y = FIELD_COUNT_X;
const MARGIN_LEFT = 16;
const MARGIN_TOP = 42;
const HEADING_COLOR = ['#FF7070', '#7070FF']; // for each player
const FIELD_LINE_COLOR = '#FFFFFF';
const FIELD_BG_COLOR_REGULAR = '#808080';
const FIELD_BG_COLOR_SELECTED = '#FFFFFF';
const SHIP_COLOR_PLACED = '#507090';
const SHIP_COLOR_AVAIL = '#204070';
const STATE_HIT_COLOR = ['#B00000', '#0000B0']; // for each player
const STATE_MISS_COLOR = '#404040';
const SHIP_CAPS = [
1, // Carrier (type 0, size 5)
2, // Battleship (type 1, size 4)
3, // Destroyer (type 2, size 3)
4 // Patrol Boat (type 3, size 2)
];
const FULL_HITS = SHIP_CAPS.reduce((a, c, i) => a + c*(5 -i), 0);
const INDICATOR_LAYOUT = [
[0, 1, 1, 3],
[2, 2, 2, 3, 3, 3]
];
const INDICATORS = INDICATOR_LAYOUT.reduce((a, c, i) => {
let y = FIELD_COUNT_Y + 1 + i;
let x1 = 0;
c.forEach(type => {
let size = 5 - type;
let x2 = x1 + size - 1;
a.push({ "type": type, "position": [x1, y, x2, y] });
x1 += size;
});
return a;
}, []).sort((l, r) => (l.type - r.type)*FIELD_COUNT_X*FIELD_COUNT_Y
+ (l.position[0] + l.position[1]*FIELD_COUNT_X
- (r.position[0] + r.position[1]*FIELD_COUNT_X)));
let phase = 0;
let player = 0;
let selected = [-10, -10];
let to_add = null;
let to_rem = null;
let placements = [[],[]];
let field_states = [new Array(100).fill(0), new Array(FIELD_COUNT_X*FIELD_COUNT_Y).fill(0)];
let current = [[0, 0],[0, 0]];
let behaviours = []; // depending on phase
function getLeftOffset(x) {
return MARGIN_LEFT + x*(FIELD_WIDTH[phase] + FIELD_MARGIN + 1);
}
function getTopOffset(y) {
return MARGIN_TOP + y*(FIELD_HEIGHT[phase] + FIELD_MARGIN + 1);
}
function getFieldState(x, y) {
return field_states[player][x + FIELD_COUNT_X*y];
}
function setFieldState(x, y, value) {
field_states[player][x + FIELD_COUNT_X*y] = value;
}
function updateFieldStates() {
placements.forEach((ps, i) => {
ps.forEach(p => {
let pos = p.position;
for (let x = pos[0]; x <= pos[2]; x++)
for (let y = pos[1]; y <= pos[3]; y++) {
field_states[i][x + FIELD_COUNT_X*y] = 1;
}
});
});
}
function getHitCount() {
return field_states[player].reduce(
(v, state) => state == 3 ? v + 1 : v,
0);
}
function drawField(x, y, selected) {
let x1 = getLeftOffset(x);
let y1 = getTopOffset(y);
let x2 = x1 + FIELD_WIDTH[phase];
let y2 = y1 + FIELD_HEIGHT[phase];
let field_state = getFieldState(x, y);
g.setColor(selected ? FIELD_BG_COLOR_SELECTED : FIELD_BG_COLOR_REGULAR);
g.fillRect(x1, y1, x2, y2);
g.setColor(FIELD_LINE_COLOR);
g.drawRect(x1, y1, x2, y2);
switch (field_state) {
case 2:
g.setColor(STATE_MISS_COLOR);
g.fillCircle(x1 + FIELD_WIDTH[phase]/2 + 1, y1 + FIELD_HEIGHT[phase]/2 + 1, FIELD_WIDTH[phase]/2 - 3);
break;
case 3:
g.setColor(STATE_HIT_COLOR[player]);
g.fillCircle(x1 + FIELD_WIDTH[phase]/2 + 1, y1 + FIELD_HEIGHT[phase]/2 + 1, FIELD_WIDTH[phase]/2 - 1);
break;
default:
break;
}
}
function drawFields(x1, y1, x2, y2) {
let l = getLeftOffset(x1);
let t = getTopOffset(y1);
let r = getLeftOffset(x2) + FIELD_WIDTH[phase] + FIELD_MARGIN;
let b = getTopOffset(y2) + FIELD_HEIGHT[phase] + FIELD_MARGIN;
g.clearRect(l, t, r, b);
for (let x = x1; x <= x2; x++)
for (let y = y1; y <= y2; y++) {
drawField(x, y, x == current[player][0] && y == current[player][1]);
}
}
function drawShip(x1, y1, x2, y2, color) {
g.setColor(color);
let diam = Math.min(FIELD_HEIGHT[phase], FIELD_WIDTH[phase]) - 3;
let rad = diam/2;
let cx1 = getLeftOffset(x1) + FIELD_WIDTH[phase]/2 + 1;
let cy1 = getTopOffset(y1) + FIELD_HEIGHT[phase]/2 + 1;
let cx2 = getLeftOffset(x2) + FIELD_WIDTH[phase]/2 + 1;
let cy2 = getTopOffset(y2) + FIELD_HEIGHT[phase]/2 + 1;
if (x1 == x2) {
g.fillRect(cx1 - rad, cy1, cx1 + rad, cy2);
} else {
g.fillRect(cx1, cy1 - rad, cx2, cy1 + rad);
}
g.fillCircle(cx1, cy1, rad);
g.fillCircle(cx2, cy2, rad);
}
function hasCollision(pos) {
return placements[player].some(
p => pos[0] <= p.position[2]
&& pos[2] >= p.position[0]
&& pos[1] <= p.position[3]
&& pos[3] >= p.position[1]);
}
function isAvailable(type) {
let count = placements[player].reduce(
(v, p) => p.type == type ? v + 1 : v,
0);
return count < SHIP_CAPS[type];
}
function determineChanges() {
to_rem = to_add;
to_add = null;
if (selected[0] == current[player][0] && selected[1] == current[player][1]) return;
if (selected[0] == current[player][0]) {
let size = Math.abs(selected[1] - current[player][1]) + 1;
if (size < 2 || size > 5 ) return;
let y1 = Math.min(selected[1], current[player][1]);
let y2 = Math.max(selected[1], current[player][1]);
let pos = [current[player][0], y1, current[player][0], y2];
let type = 5 - size;
if (!hasCollision(pos) && isAvailable(type)) {
to_add = { "type": type, "position": pos };
}
}
if (selected[1] == current[player][1]) {
let size = Math.abs(selected[0] - current[player][0]) + 1;
if (size < 2 || size > 5 ) return;
let x1 = Math.min(selected[0], current[player][0]);
let x2 = Math.max(selected[0], current[player][0]);
let pos = [x1, current[player][1], x2, current[player][1]];
let type = 5 - size;
if (!hasCollision(pos) && isAvailable(type)) {
to_add = { "type": type, "position": pos };
}
}
}
function addPlacement(descriptor) {
placements[player].push(descriptor);
placements[player].sort((l, r) => l.type - r.type);
}
function drawShipPlacements() {
if (to_rem) {
drawFields.apply(null, to_rem.position);
}
placements[player].forEach(
p => drawShip.apply(null, p.position.concat([SHIP_COLOR_PLACED])));
if (to_add) {
drawShip.apply(null, to_add.position.concat([SHIP_COLOR_PLACED]));
}
}
function drawShipIndicator() {
let p = to_add
? placements[player].concat(to_add).sort((l, r) => l.type - r.type)
: placements[player];
let pi = 0;
INDICATORS.forEach(indicator => {
let color = SHIP_COLOR_AVAIL;
if (pi < p.length && p[pi].type == indicator.type) {
pi += 1;
color = SHIP_COLOR_PLACED;
}
drawShip.apply(null, indicator.position.concat(color));
});
}
function drawHeading(text) {
g.clearRect(0, 20, 100, 32);
g.setColor(HEADING_COLOR[player]);
g.setFont('4x6', 2.8);
g.drawString(text, MARGIN_LEFT, 20);
}
function reset() {
g.clear();
drawHeading('Player ' + (player + 1));
drawFields(0, 0, 9, 9);
}
function showResults() {
let text1 = 'Player ' + (player + 1) + ' won!';
let text2 = 'Congratulations!';
g.clear();
g.clearRect(0, 20, 100, 32);
g.setColor(HEADING_COLOR[player]);
g.setFont('Vector', 20);
g.drawString(text1, MARGIN_LEFT, 80);
g.drawString(text2, MARGIN_LEFT, 120);
}
function moveSelection(dx, dy) {
let x = current[player][0];
let y = current[player][1];
drawField(x, y, false);
current[player][0] = x = (x + dx + FIELD_COUNT_X)%FIELD_COUNT_X;
current[player][1] = y = (y + dy + FIELD_COUNT_Y)%FIELD_COUNT_Y;
drawField(x, y, true);
}
behaviours.push({
"move": (dx, dy) => {
moveSelection(dx, dy);
determineChanges();
drawShipPlacements();
drawShipIndicator();
},
"action": _ => {
if (to_add) {
addPlacement(to_add);
to_add = null;
selected = [-10, -10];
if (placements[player].length == 10) {
behaviours[phase].transition();
}
} else {
selected = [current[player][0], current[player][1]];
}
},
"transition": _ => {
current[0] = [0, 0];
player = 1;
phase = 1;
reset();
drawShipIndicator();
}
});
behaviours.push({
"move": behaviours[0].move,
"action": behaviours[0].action,
"transition": _ => {
current[1] = [0, 0];
player = 0;
phase = 2;
updateFieldStates();
reset();
}
});
behaviours.push({
"move": (dx, dy) => moveSelection(dx, dy),
"action": _ => {
let x = current[player][0];
let y = current[player][1];
let field_state = getFieldState(x, y);
if (field_state > 1) return;
setFieldState(x, y, field_state + 2);
drawField(x, y, true);
Bangle.buzz(200 + field_state*800, 0.5 + field_state*0.5);
if (getHitCount() < FULL_HITS) {
player = (player + 1)%2;
setTimeout(reset, 1000);
} else {
setTimeout(behaviours[phase].transition, 1000);
}
},
"transition": _ => {
phase = 3;
showResults();
}
});
behaviours.push({
"move": _ => {},
"action": _ => {}
});
reset();
drawShipIndicator();
setWatch(_ => behaviours[phase].move(0, -1), BTN1, {repeat: true, debounce: 100});
setWatch(_ => behaviours[phase].move(0, 1), BTN3, {repeat: true, debounce: 100});
setWatch(_ => behaviours[phase].move(-1, 0), BTN4, {repeat: true, debounce: 100});
setWatch(_ => behaviours[phase].move(1, 0), BTN5, {repeat: true, debounce: 100});
setWatch(_ => behaviours[phase].action(), BTN2, {repeat: true, debounce: 100});

1
apps/gbmusic/ChangeLog Normal file
View File

@ -0,0 +1 @@
0.01: Initial version

38
apps/gbmusic/README.md Normal file
View File

@ -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 <rigrig+banglejs@tubul.net>

691
apps/gbmusic/app.js Normal file
View File

@ -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()
}
}
}

1
apps/gbmusic/icon.js Normal file
View File

@ -0,0 +1 @@
require("heatshrink").decompress(atob("mEwwhC/AH4AihvQCynd7oXThoWBC6YVCC6QVEC6BCDC6QVHC5wWJC/4VHC6oJCC6QSDC6QJFC54JHC5oNIC/4X/BpkNA4IXTCwL0GC5z1EC8JVHIwgXJKpAXOBpAXlBpQJELxgXdBQaONBwyxCaZQ9LdZYXWKpgYNCygA/AGYA=="))

BIN
apps/gbmusic/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

BIN
apps/gbmusic/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

38
apps/gbmusic/settings.js Normal file
View File

@ -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)
})

38
apps/gbmusic/widget.js Normal file
View File

@ -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)
}
}
}
})()