diff --git a/apps/widhid/README.md b/apps/widhid/README.md new file mode 100644 index 000000000..7651d74eb --- /dev/null +++ b/apps/widhid/README.md @@ -0,0 +1,21 @@ +# Description + +A music control widget based on [Swipe Bluetooth Music Controls] (based on [Bluetooth Music Controls]). +By operating as a widget, you can control music without leaving your current app (e.g. on a run, bike ride or just watching the clock). + + +[Swipe Bluetooth Music Controls]: https://github.com/espruino/BangleApps/tree/master/apps/hidmsicswipe +[Bluetooth Music Controls]: https://github.com/espruino/BangleApps/tree/master/apps/hidmsic + +# Usage + +Swipe down to enable - note the icon changes from blue to orange, indicating it's listening for your instruction. Then drag up/down for volume, left/right for previous and next and tap for play/pause. + +All other watch interaction is disabled for 3 seconds, to prevent clashing taps/drags - this period is extended as you continue to alter the volume, play/pause and jump between tracks. + + +# Setup / Technical details + +Note that HID must be enabled in settings. Then provided you're paired with your phone/computer, the widget icon will appear and you can control music from your clock face! + +The app disables all other drag and tap handlers while this widget is "active" (in a similar manner to [`backswipe`](https://github.com/espruino/BangleApps/pull/2524#issuecomment-1406230564) and [`lightswitch`](https://github.com/espruino/Espruino/issues/2151#issuecomment-1042423211)). diff --git a/apps/widhid/icon.js b/apps/widhid/icon.js new file mode 100644 index 000000000..a8f9cbdb4 --- /dev/null +++ b/apps/widhid/icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwkBiIA/AEhtDNSSHGCyIbFGJ0QFAowIA48QE4oGBBAomBHAxXHA4IJED5IXJCAcQIAxGGC4YKEI44HCBAxAGO4wXBB4JYGNRBfHC/6HFB4wXHUA6YIC4oOCGA6YGU4quHJ5LXGdJIXNF65fIC5AQFQorHJXxwXJK5xGJC65GsgJG/Iw4uUfgIuUC4QWTIwIusLq4WBFy50tC1YXBCyoA/ADw=")) diff --git a/apps/widhid/icon.png b/apps/widhid/icon.png new file mode 100644 index 000000000..2ccc88883 Binary files /dev/null and b/apps/widhid/icon.png differ diff --git a/apps/widhid/metadata.json b/apps/widhid/metadata.json new file mode 100644 index 000000000..10e75fadc --- /dev/null +++ b/apps/widhid/metadata.json @@ -0,0 +1,16 @@ +{ + "id": "widhid", + "name": "Bluetooth Music Swipe Control Widget", + "shortName": "BLE Swipe Widget", + "version": "0.01", + "description": "Based on Swipe Bluetooth Music Controls (based on Bluetooth Music Controls). Swipe down to enable, then swipe up/down for volume, left/right for previous and next and tap for play/pause. Enable HID in settings, pair with your phone/computer, then use this widget to control music from your watch!", + "icon": "icon.png", + "readme": "README.md", + "type": "widget", + "tags": "widget,bluetooth,music", + "supports": ["BANGLEJS2"], + "storage": [ + {"name":"widhid.wid.js","url":"wid.js"}, + {"name":"widhid.img","url":"icon.js","evaluate":true} + ] +} diff --git a/apps/widhid/wid.js b/apps/widhid/wid.js new file mode 100644 index 000000000..ed1e78e76 --- /dev/null +++ b/apps/widhid/wid.js @@ -0,0 +1,176 @@ +(function () { + var settings = require("Storage").readJSON("setting.json", true) || { HID: false }; + if (settings.HID !== "kbmedia") { + console.log("widhid: can't enable, HID setting isn't \"kbmedia\""); + return; + } + delete settings; + var anchor = { x: 0, y: 0 }; + var start = { x: 0, y: 0 }; + var dragging = false; + var activeTimeout; + var waitForRelease = true; + var onSwipe = (function (_lr, ud) { + if (Bangle.CLKINFO_FOCUS) + return; + if (!activeTimeout && ud > 0) { + listen(); + Bangle.buzz(20); + } + }); + var onDrag = (function (e) { + if (Bangle.CLKINFO_FOCUS) + return; + if (e.b === 0) { + var wasDragging = dragging; + dragging = false; + if (waitForRelease) { + waitForRelease = false; + return; + } + if (!wasDragging + || (Math.abs(e.x - anchor.x) < 2 && Math.abs(e.y - anchor.y) < 2)) { + toggle(); + onEvent(); + return; + } + } + if (waitForRelease) + return; + if (e.b && !dragging) { + dragging = true; + setStart(e); + Object.assign(anchor, start); + return; + } + var dx = e.x - start.x; + var dy = e.y - start.y; + if (Math.abs(dy) > 25 && Math.abs(dx) > 25) { + setStart(e); + return; + } + if (dx > 40) { + next(); + onEvent(); + waitForRelease = true; + } + else if (dx < -40) { + prev(); + onEvent(); + waitForRelease = true; + } + else if (dy > 30) { + down(); + onEvent(); + setStart(e); + } + else if (dy < -30) { + up(); + onEvent(); + setStart(e); + } + }); + var setStart = function (_a) { + var x = _a.x, y = _a.y; + start.x = x; + start.y = y; + }; + var onEvent = function () { + Bangle.buzz(20); + listen(); + }; + var listen = function () { + var wasActive = !!activeTimeout; + if (!wasActive) { + suspendOthers(); + waitForRelease = true; + Bangle.on("drag", onDrag); + redraw(); + } + if (activeTimeout) + clearTimeout(activeTimeout); + activeTimeout = setTimeout(function () { + activeTimeout = undefined; + Bangle.removeListener("drag", onDrag); + resumeOthers(); + redraw(); + }, 3000); + }; + var redraw = function () { return setTimeout(Bangle.drawWidgets, 50); }; + var connected = NRF.getSecurityStatus().connected; + WIDGETS["hid"] = { + area: "tr", + sortorder: -20, + draw: function () { + if (this.width === 0) + return; + g.drawImage(activeTimeout + ? require("heatshrink").decompress(atob("jEYxH+AEfH44XXAAYXXDKIXZDYp3pC/6KHUMwWHC/4XvUy4YGdqoA/AFoA==")) + : require("heatshrink").decompress(atob("jEYxH+AEcdjoXXAAYXXDKIXZDYp3pC/6KHUMwWHC/4XvUy4YGdqoA/AFoA==")), this.x + 2, this.y + 2); + }, + width: connected ? 24 : 0, + }; + if (connected) + Bangle.on("swipe", onSwipe); + delete connected; + NRF.on("connect", function () { + WIDGETS["hid"].width = 24; + Bangle.on("swipe", onSwipe); + redraw(); + }); + NRF.on("disconnect", function () { + WIDGETS["hid"].width = 0; + Bangle.removeListener("swipe", onSwipe); + redraw(); + }); + var sendHid = function (code) { + NRF.sendHIDReport([1, code], function () { return NRF.sendHIDReport([1, 0]); }); + }; + var next = function () { return sendHid(0x01); }; + var prev = function () { return sendHid(0x02); }; + var toggle = function () { return sendHid(0x10); }; + var up = function () { return sendHid(0x40); }; + var down = function () { return sendHid(0x80); }; + var touchEvents = { + tap: null, + gesture: null, + aiGesture: null, + swipe: null, + touch: null, + drag: null, + stroke: null, + }; + var suspendOthers = function () { + for (var event in touchEvents) { + var handlers = Bangle["#on".concat(event)]; + if (!handlers) + continue; + var newEvents = void 0; + if (handlers instanceof Array) + newEvents = handlers.slice(); + else + newEvents = [handlers]; + for (var _i = 0, newEvents_1 = newEvents; _i < newEvents_1.length; _i++) { + var handler = newEvents_1[_i]; + Bangle.removeListener(event, handler); + } + touchEvents[event] = newEvents; + } + }; + var resumeOthers = function () { + for (var event in touchEvents) { + var handlers = touchEvents[event]; + touchEvents[event] = null; + if (handlers) + for (var _i = 0, handlers_1 = handlers; _i < handlers_1.length; _i++) { + var handler = handlers_1[_i]; + try { + Bangle.on(event, handler); + } + catch (e) { + console.log("couldn't restore \"".concat(event, "\" handler:"), e); + } + } + } + }; +})(); diff --git a/apps/widhid/wid.ts b/apps/widhid/wid.ts new file mode 100644 index 000000000..6b5e38855 --- /dev/null +++ b/apps/widhid/wid.ts @@ -0,0 +1,199 @@ +(() => { + const settings: Settings = require("Storage").readJSON("setting.json", true) || { HID: false } as Settings; + if (settings.HID !== "kbmedia") { + console.log("widhid: can't enable, HID setting isn't \"kbmedia\""); + return; + } + // @ts-ignore + delete settings; + + let anchor = {x:0,y:0}; + let start = {x:0,y:0}; + let dragging = false; + let activeTimeout: number | undefined; + let waitForRelease = true; + + const onSwipe = ((_lr, ud) => { + if((Bangle as BangleExt).CLKINFO_FOCUS) return; + + if(!activeTimeout && ud! > 0){ + listen(); + Bangle.buzz(20); + } + }) satisfies SwipeCallback; + + const onDrag = (e => { + if((Bangle as BangleExt).CLKINFO_FOCUS) return; + + if(e.b === 0){ + // released + const wasDragging = dragging; + dragging = false; + + if(waitForRelease){ + waitForRelease = false; + return; + } + + if(!wasDragging // i.e. tap + || (Math.abs(e.x - anchor.x) < 2 && Math.abs(e.y - anchor.y) < 2)) + { + toggle(); + onEvent(); + return; + } + } + if(waitForRelease) return; + + if(e.b && !dragging){ + dragging = true; + setStart(e); + Object.assign(anchor, start); + return; + } + + const dx = e.x - start.x; + const dy = e.y - start.y; + + if(Math.abs(dy) > 25 && Math.abs(dx) > 25){ + // diagonal, ignore + setStart(e); + return; + } + + // had a drag in a single axis + if(dx > 40){ next(); onEvent(); waitForRelease = true; } + else if(dx < -40){ prev(); onEvent(); waitForRelease = true; } + else if(dy > 30){ down(); onEvent(); setStart(e); } + else if(dy < -30){ up(); onEvent(); setStart(e); } + }) satisfies DragCallback; + + const setStart = ({ x, y }: { x: number, y: number }) => { + start.x = x; + start.y = y; + }; + + const onEvent = () => { + Bangle.buzz(20); // feedback event sent + listen(); // had an event, keep listening for more + }; + + const listen = () => { + const wasActive = !!activeTimeout; + if(!wasActive){ + suspendOthers(); + waitForRelease = true; // wait for first touch up before accepting gestures + Bangle.on("drag", onDrag); + redraw(); + } + + if(activeTimeout) clearTimeout(activeTimeout); + activeTimeout = setTimeout(() => { + activeTimeout = undefined; + + Bangle.removeListener("drag", onDrag); + resumeOthers(); + + redraw(); + }, 3000); + }; + + const redraw = () => setTimeout(Bangle.drawWidgets, 50); + + const connected = NRF.getSecurityStatus().connected; + WIDGETS["hid"] = { + area: "tr", + sortorder: -20, + draw: function() { + if(this.width === 0) return; + g.drawImage( + activeTimeout + ? require("heatshrink").decompress(atob("jEYxH+AEfH44XXAAYXXDKIXZDYp3pC/6KHUMwWHC/4XvUy4YGdqoA/AFoA==")) + : require("heatshrink").decompress(atob("jEYxH+AEcdjoXXAAYXXDKIXZDYp3pC/6KHUMwWHC/4XvUy4YGdqoA/AFoA==")), + this.x! + 2, + this.y! + 2 + ); + }, + width: connected ? 24 : 0, + }; + + if(connected) + Bangle.on("swipe", onSwipe); + // @ts-ignore + delete connected; + + NRF.on("connect", () => { + WIDGETS["hid"]!.width = 24; + Bangle.on("swipe", onSwipe); + redraw(); + }); + NRF.on("disconnect", () => { + WIDGETS["hid"]!.width = 0; + Bangle.removeListener("swipe", onSwipe); + redraw(); + }); + + //const DEBUG = true; + const sendHid = (code: number) => { + //if(DEBUG) return; + NRF.sendHIDReport( + [1, code], + () => NRF.sendHIDReport([1, 0]), + ); + }; + + const next = () => /*DEBUG ? console.log("next") : */ sendHid(0x01); + const prev = () => /*DEBUG ? console.log("prev") : */ sendHid(0x02); + const toggle = () => /*DEBUG ? console.log("toggle") : */ sendHid(0x10); + const up = () => /*DEBUG ? console.log("up") : */ sendHid(0x40); + const down = () => /*DEBUG ? console.log("down") : */ sendHid(0x80); + + // similarly to the lightswitch app, we tangle with the listener arrays to + // disable event handlers + type Handler = () => void; + const touchEvents: { + [key: string]: null | Handler[] + } = { + tap: null, + gesture: null, + aiGesture: null, + swipe: null, + touch: null, + drag: null, + stroke: null, + }; + + const suspendOthers = () => { + for(const event in touchEvents){ + const handlers: Handler[] | Handler | undefined + = (Bangle as any)[`#on${event}`]; + + if(!handlers) continue; + + let newEvents; + if(handlers instanceof Array) + newEvents = handlers.slice(); + else + newEvents = [handlers /* single fn */]; + + for(const handler of newEvents) + Bangle.removeListener(event, handler); + + touchEvents[event] = newEvents; + } + }; + const resumeOthers = () => { + for(const event in touchEvents){ + const handlers = touchEvents[event]; + touchEvents[event] = null; + + if(handlers) + for(const handler of handlers) + try{ + Bangle.on(event as any, handler); + }catch(e){ + console.log(`couldn't restore "${event}" handler:`, e); + } + } + }; +})() diff --git a/apps/widhid/widget-active.png b/apps/widhid/widget-active.png new file mode 100644 index 000000000..74ce4b2a0 Binary files /dev/null and b/apps/widhid/widget-active.png differ diff --git a/apps/widhid/widget.png b/apps/widhid/widget.png new file mode 100644 index 000000000..dfb108060 Binary files /dev/null and b/apps/widhid/widget.png differ diff --git a/typescript/types/clock_info.d.ts b/typescript/types/clock_info.d.ts index 06a2d400b..b12732683 100644 --- a/typescript/types/clock_info.d.ts +++ b/typescript/types/clock_info.d.ts @@ -57,3 +57,7 @@ declare module ClockInfo { focus: boolean, }; } + +interface BangleExt { + CLKINFO_FOCUS?: true; +} diff --git a/typescript/types/main.d.ts b/typescript/types/main.d.ts index 29118b7c6..4e4dd224e 100644 --- a/typescript/types/main.d.ts +++ b/typescript/types/main.d.ts @@ -1584,7 +1584,7 @@ declare class NRF { * @param {any} callback - A callback function to be called when the data is sent * @url http://www.espruino.com/Reference#l_NRF_sendHIDReport */ - static sendHIDReport(data: any, callback: any): void; + static sendHIDReport(data: number[], callback?: () => void): void /** * Check if Apple Notification Center Service (ANCS) is currently active on the BLE @@ -3188,8 +3188,10 @@ declare class Puck { * Check out [the Puck.js page on the * accelerometer](http://www.espruino.com/Puck.js#on-board-peripherals) for more * information. + * **Note:** Puck.js cannot currently read every sample from the + * accelerometer at sample rates above 208Hz. * - * @param {number} samplerate - The sample rate in Hz, or undefined + * @param {number} samplerate - The sample rate in Hz, or `undefined` (default is 12.5 Hz) * @url http://www.espruino.com/Reference#l_Puck_accelOn */ static accelOn(samplerate: number): void; @@ -3819,6 +3821,9 @@ declare class Bangle { * and polling rate may not be exact. The algorithm's filtering is tuned for * 20-40ms poll intervals, so higher/lower intervals may effect the reliability * of the BPM reading. + * * `hrmSportMode` - on the newest Bangle.js 2 builds with with the proprietary + * heart rate algorithm, this is the sport mode passed to the algorithm. See `libs/misc/vc31_binary/algo.h` + * for more info. 0 = normal (default), 1 = running, 2 = ... * * `seaLevelPressure` (Bangle.js 2) Normally 1013.25 millibars - this is used for * calculating altitude with the pressure sensor * Where accelerations are used they are in internal units, where `8192 = 1g` @@ -13222,7 +13227,7 @@ declare module "Storage" { * @returns {any} An object containing parsed JSON from the file, or undefined * @url http://www.espruino.com/Reference#l_Storage_readJSON */ - function readJSON(name: string, noExceptions: boolean): any; + function readJSON(name: string, noExceptions: ShortBoolean): any; /** * Read a file from the flash storage area that has been written with diff --git a/typescript/types/settings.d.ts b/typescript/types/settings.d.ts new file mode 100644 index 000000000..a46b6ace7 --- /dev/null +++ b/typescript/types/settings.d.ts @@ -0,0 +1,51 @@ +type Settings = { + beep: boolean, + vibrate: boolean, + quiet: number, + + ble: boolean, + blerepl: boolean, + HID?: false | "kbmedia" | "kb" | "com" | "joy", + + passkey?: string, + whitelist_disabled?: boolean, + whitelist: string[], + + theme: Theme, + + brightness: number, + timeout: number, + rotate: number, + + options: SettingsOptions, + + timezone: number, + log: number, + + clock: string, + clockHasWidgets: boolean, + launcher: string, +}; + +type SettingsTheme = { + fg: string, + bg: string, + fg2: string, + bg2: string, + fgH: string, + bgH: string, + dark: boolean, +}; + +type SettingsOptions = { + wakeOnBTN1: boolean, + wakeOnBTN2: boolean, + wakeOnBTN3: boolean, + wakeOnFaceUp: boolean, + wakeOnTouch: boolean, + + wakeOnTwist: boolean, + twistThreshold: number, + twistMaxY: number, + twistTimeout: number, +};