mirror of https://github.com/espruino/BangleApps
Merge branch 'master' of github.com:espruino/BangleApps
commit
6b39352894
42
apps.json
42
apps.json
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
0.01: New App!
|
|
@ -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.
|
|
@ -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 |
|
@ -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 |
|
@ -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});
|
|
@ -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