Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/tennisscores/ChangeLog
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.01: New App!
47 changes: 47 additions & 0 deletions apps/tennisscores/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Tennis Scores

Live tennis scores on your wrist, from the [Live Tennis API](https://livetennisapi.com).

When matches are live it shows one match per page: tournament, round, both
players, games per set, current points and a dot next to the serving player.
When nothing is live it shows the next scheduled fixtures instead.

## Usage

Just install and configure the app. This needs an internet-enabled Gadgetbridge version.

You need a (free) API key from [livetennisapi.com](https://livetennisapi.com).
The easiest way to enter it is the app's web interface in the App Loader,
which lets you paste the key and pick a tour from the browser. Alternatively
install one of the text input libraries and set the key on the watch in the
app settings.

## Controls

* Swipe up/down (or press the button) to page through matches/fixtures
* Tap the screen to refresh

## Settings

* **Tour** - only show matches for one tour (ATP, WTA, Challenger, ITF, Juniors) or all
* **Auto refresh** - refresh automatically while the app is open (off by default)
* **Refresh every** - auto refresh interval, default 15 minutes
* **API key** - your Live Tennis API key (needs a text input library installed)

## API usage and the free tier

The free tier allows 30 requests/minute and 100 requests/day. Live scores,
players and fixtures are free; historical data is paid.

Each refresh is one request (two when nothing is live, because the app then
also fetches the fixtures list). At the default 15 minute auto-refresh a full
day of continuous refreshing is about 96 requests, which fits the free tier's
100/day - and since the app only refreshes while it is open, real usage is
normally far below that. If you want a faster refresh interval you'll likely
need a paid key.

## Creator

[bensynapse](https://github.com/bensynapse)

Disclosure: I maintain the Live Tennis API this app talks to.
1 change: 1 addition & 0 deletions apps/tennisscores/app-icon.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

252 changes: 252 additions & 0 deletions apps/tennisscores/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
const FILE = "tennisscores.json";
const BASE = "https://api.livetennisapi.com/api/public/v1";

let settings = Object.assign({
apikey: "",
tour: "", // "" = all tours, or atp/wta/challenger/itf/juniors
auto: false, // periodic refresh while the app is open
refresh: 15 // minutes
}, require("Storage").readJSON(FILE, true) || {});

let state = "loading"; // loading | live | fixtures | nokey | error
let matches = []; // slimmed live matches
let fixtures = []; // slimmed upcoming fixtures
let index = 0; // current page
let lastError = "";
let updatedAt = null;
let autoTimer = null;
let loading = false;

function tourQuery() {
return settings.tour ? "&tour=" + settings.tour : "";
}

function apiGet(path) {
if (!Bangle.http)
return Promise.reject(/*LANG*/"Gadgetbridge required");
return Bangle.http(BASE + path, {
timeout: 15000,
headers: {Authorization: "Bearer " + settings.apikey}
}).then(ev => JSON.parse(ev.resp));
}

function trunc(s, n) {
if (!s) return "";
return s.length > n ? s.substr(0, n - 1) + "." : s;
}

function pad2(n) {
return (n < 10 ? "0" : "") + n;
}

function fmtTime(iso) {
if (!iso) return "";
let d = new Date(iso);
return pad2(d.getDate()) + "/" + pad2(d.getMonth() + 1) + " " +
pad2(d.getHours()) + ":" + pad2(d.getMinutes());
}

// keep only what we draw - the full API objects are too big to hold many of
function slimMatch(m) {
let s = m.score;
let p = m.players || {};
return {
tournament: m.tournament || "",
round: m.round || "",
doubles: !!m.is_doubles,
n1: (p.p1 && p.p1.name) || "?",
n2: (p.p2 && p.p2.name) || "?",
// score.games is [games_p1, games_p2], each a per-set list
g1: (s && s.games && s.games[0]) || [],
g2: (s && s.games && s.games[1]) || [],
// in-game points as tennis strings; entries can be null
pt1: (s && s.points && s.points[0]) || null,
pt2: (s && s.points && s.points[1]) || null,
server: (s && s.server) || null, // 1, 2 or null
tiebreak: !!(s && s.is_tiebreak)
};
}

function slimFixture(f) {
return {
tournament: f.tournament || "",
round: f.round || "",
n1: f.player1_name || "?",
n2: f.player2_name || "?",
date: f.event_date || null,
time: f.start_time || null // null until the order of play assigns one
};
}

function scheduleAuto() {
if (autoTimer) clearTimeout(autoTimer);
autoTimer = null;
if (settings.auto)
autoTimer = setTimeout(() => refresh(), settings.refresh * 60000);
}

function refresh() {
if (loading) return;
if (!settings.apikey) {
state = "nokey";
draw();
return;
}
loading = true;
drawUpdating();
apiGet("/matches?status=live" + tourQuery() + "&limit=10").then(r => {
if (!r || !Array.isArray(r.data)) throw new Error(/*LANG*/"Bad response");
if (r.data.length) {
matches = r.data.map(slimMatch);
state = "live";
return;
}
// nothing live - show the next scheduled fixtures instead
return apiGet("/fixtures?limit=8" + tourQuery()).then(r2 => {
if (!r2 || !Array.isArray(r2.data)) throw new Error(/*LANG*/"Bad response");
fixtures = r2.data.map(slimFixture);
state = "fixtures";
});
}).then(() => {
loading = false;
index = 0;
updatedAt = new Date();
scheduleAuto();
draw();
}).catch(e => {
loading = false;
lastError = ("" + ((e && e.message) || e)).substr(0, 60);
state = "error";
scheduleAuto();
draw();
});
}

function pageCount() {
if (state === "live") return matches.length;
if (state === "fixtures") return Math.ceil(fixtures.length / 2);
return 1;
}

function drawUpdating() {
let R = Bangle.appRect;
g.reset().clearRect(R.x, R.y2 - 10, R.x2, R.y2);
g.setFont("6x8").setFontAlign(0, 1);
g.drawString(/*LANG*/"Updating...", (R.x + R.x2) / 2, R.y2);
}

function drawFooter(R) {
g.setFont("6x8").setFontAlign(0, 1);
let s = (pageCount() > 1 ? (index + 1) + "/" + pageCount() + " " : "") +
(updatedAt ? /*LANG*/"upd " + pad2(updatedAt.getHours()) + ":" + pad2(updatedAt.getMinutes()) : "") +
" " + /*LANG*/"tap=reload";
g.drawString(s, (R.x + R.x2) / 2, R.y2);
}

function drawScoreRow(games, points, serving, y, R) {
g.setFont("6x8", 2).setFontAlign(-1, -1);
// show at most the last 3 sets so BO5 still fits on screen
let shown = games.length > 3 ? games.slice(games.length - 3) : games;
let s = shown.join(" ");
if (points !== null && points !== undefined) s += " " + points;
g.drawString(s, R.x + 24, y);
if (serving) g.fillCircle(R.x + 12, y + 7, 4);
}

function drawLive(R) {
let m = matches[index];
if (!m) return;
let y = R.y + 2;
g.setFont("6x8").setFontAlign(-1, -1);
g.drawString(trunc(m.tournament, 29), R.x + 2, y);
y += 10;
g.drawString(trunc(m.round + (m.doubles ? /*LANG*/" (doubles)" : ""), 29), R.x + 2, y);
y += 14;
g.setFont("12x20").setFontAlign(-1, -1);
g.drawString(trunc(m.n1, 14), R.x + 2, y);
y += 22;
drawScoreRow(m.g1, m.pt1, m.server === 1, y, R);
y += 20;
g.setFont("12x20").setFontAlign(-1, -1);
g.drawString(trunc(m.n2, 14), R.x + 2, y);
y += 22;
drawScoreRow(m.g2, m.pt2, m.server === 2, y, R);
y += 20;
if (m.tiebreak) {
g.setFont("6x8").setFontAlign(-1, -1);
g.drawString(/*LANG*/"Tiebreak", R.x + 24, y);
}
drawFooter(R);
}

function drawFixtures(R) {
let y = R.y + 2;
g.setFont("6x8", 2).setFontAlign(0, -1);
g.drawString(/*LANG*/"Nothing live", (R.x + R.x2) / 2, y);
y += 20;
g.setFont("6x8").setFontAlign(0, -1);
g.drawString(settings.tour ? settings.tour.toUpperCase() + /*LANG*/" - next up:" : /*LANG*/"Next up:", (R.x + R.x2) / 2, y);
y += 12;
let shown = fixtures.slice(index * 2, index * 2 + 2);
if (!shown.length) {
g.setFont("6x8", 2).setFontAlign(0, -1);
g.drawString(/*LANG*/"No fixtures", (R.x + R.x2) / 2, y + 10);
}
shown.forEach(f => {
g.setFont("6x8").setFontAlign(-1, -1);
let when = f.time ? fmtTime(f.time) :
(f.date ? f.date.substr(8, 2) + "/" + f.date.substr(5, 2) + " " : "") + /*LANG*/"TBA";
g.drawString(trunc(when + " " + f.tournament, 29), R.x + 2, y);
y += 10;
g.setFont("6x8", 2).setFontAlign(-1, -1);
g.drawString(trunc(f.n1, 14), R.x + 2, y);
y += 16;
g.drawString(trunc(f.n2, 14), R.x + 2, y);
y += 20;
});
drawFooter(R);
}

function drawCentered(lines, R) {
let y = (R.y + R.y2) / 2 - lines.length * 8;
g.setFont("6x8", 2).setFontAlign(0, -1);
lines.forEach(l => {
g.drawString(l, (R.x + R.x2) / 2, y);
y += 18;
});
}

function draw() {
let R = Bangle.appRect;
g.reset().clearRect(R);
if (state === "live") drawLive(R);
else if (state === "fixtures") drawFixtures(R);
else if (state === "nokey") {
drawCentered([/*LANG*/"No API key", /*LANG*/"Set one in", /*LANG*/"Settings"], R);
g.setFont("6x8").setFontAlign(0, 1);
g.drawString("livetennisapi.com", (R.x + R.x2) / 2, R.y2);
} else if (state === "error") {
drawCentered([/*LANG*/"Error"], R);
g.setFont("6x8").setFontAlign(0, -1);
g.drawString(g.wrapString(lastError, R.w - 8).slice(0, 3).join("\n"), (R.x + R.x2) / 2, (R.y + R.y2) / 2 + 4);
drawFooter(R);
} else {
drawCentered([/*LANG*/"Loading..."], R);
}
}

Bangle.setUI({mode: "updown"}, dir => {
if (!dir) {
refresh(); // tap / button = manual refresh
return;
}
let n = pageCount();
if (n < 2) return;
index = (index + dir + n) % n;
draw();
});

Bangle.loadWidgets();
draw();
Bangle.drawWidgets();
refresh();
Binary file added apps/tennisscores/app.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 74 additions & 0 deletions apps/tennisscores/interface.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<html>
<head>
<link rel="stylesheet" href="../../css/spectre.min.css">
</head>
<body>
<h3>Tennis Scores settings</h3>
<p>
<label for="apikey">Live Tennis API key</label><br>
<input id="apikey" onkeyup="checkInput()" style="width:90%; margin: 3px">
</p>
<p>
<label for="tour">Tour</label><br>
<select id="tour" style="margin: 3px">
<option value="">All</option>
<option value="atp">ATP</option>
<option value="wta">WTA</option>
<option value="challenger">Challenger</option>
<option value="itf">ITF</option>
<option value="juniors">Juniors</option>
</select>
</p>
<p><button id="upload" class="btn btn-primary">Save</button></p>

<h4>Where to get your personal API key?</h4>
<p>Go to <a href="https://livetennisapi.com" target="_blank">https://livetennisapi.com</a> and sign up for a free account.<br>
After registration you can log in and obtain your personal API key.</p>

<script src="../../core/lib/interface.js"></script>

<script>

function checkInput() {
document.getElementById('upload').disabled =
document.getElementById("apikey").value === "";
}
checkInput();

var settings = {};
function onInit() {
console.log("Loading settings from BangleJs...");
try {
Util.readStorageJSON("tennisscores.json", data => {
if (data) {
settings = data;
console.log("Got settings", settings);
if (settings.apikey) document.getElementById("apikey").value = settings.apikey;
document.getElementById("tour").value = settings.tour || "";
checkInput();
}
});
} catch (ex) {
console.log("(Warning) Could not load settings from BangleJs.");
console.log(ex);
}
}

document.getElementById("upload").addEventListener("click", function() {
try {
settings.apikey = document.getElementById("apikey").value.trim();
settings.tour = document.getElementById("tour").value;
Util.showModal("Saving...");
Util.writeStorage("tennisscores.json", JSON.stringify(settings), () => {
Util.hideModal();
});
console.log("Sent settings!");
} catch (ex) {
console.log("(Warning) Could not write settings to BangleJs.");
console.log(ex);
}
});

</script>
</body>
</html>
19 changes: 19 additions & 0 deletions apps/tennisscores/metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{ "id": "tennisscores",
"name": "Tennis Scores",
"shortName": "Tennis",
"version": "0.01",
"author": "bensynapse",
"description": "Live tennis scores and upcoming fixtures from the Live Tennis API. Shows score, serving player and next matches for a configurable tour (requires Gadgetbridge and an API key)",
"icon": "app.png",
"tags": "sport,http",
"supports" : ["BANGLEJS2"],
"readme": "README.md",
"interface": "interface.html",
"screenshots" : [ {"url":"screenshot.png"}, {"url":"screenshot2.png"} ],
"storage": [
{"name":"tennisscores.app.js","url":"app.js"},
{"name":"tennisscores.settings.js","url":"settings.js"},
{"name":"tennisscores.img","url":"app-icon.js","evaluate":true}
],
"data": [{"name":"tennisscores.json"}]
}
Binary file added apps/tennisscores/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/tennisscores/screenshot2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading