Skip to content

Commit 5f840b9

Browse files
authored
Merge pull request #4316 from bensynapse/add-tennisscores
tennisscores: new app - live tennis scores (Bangle.js 2)
2 parents dc0303d + 07b0872 commit 5f840b9

10 files changed

Lines changed: 469 additions & 0 deletions

File tree

apps/tennisscores/ChangeLog

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0.01: New App!

apps/tennisscores/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Tennis Scores
2+
3+
Live tennis scores on your wrist, from the [Live Tennis API](https://livetennisapi.com).
4+
5+
When matches are live it shows one match per page: tournament, round, both
6+
players, games per set, current points and a dot next to the serving player.
7+
When nothing is live it shows the next scheduled fixtures instead.
8+
9+
## Usage
10+
11+
Just install and configure the app. This needs an internet-enabled Gadgetbridge version.
12+
13+
You need a (free) API key from [livetennisapi.com](https://livetennisapi.com).
14+
The easiest way to enter it is the app's web interface in the App Loader,
15+
which lets you paste the key and pick a tour from the browser. Alternatively
16+
install one of the text input libraries and set the key on the watch in the
17+
app settings.
18+
19+
## Controls
20+
21+
* Swipe up/down (or press the button) to page through matches/fixtures
22+
* Tap the screen to refresh
23+
24+
## Settings
25+
26+
* **Tour** - only show matches for one tour (ATP, WTA, Challenger, ITF, Juniors) or all
27+
* **Auto refresh** - refresh automatically while the app is open (off by default)
28+
* **Refresh every** - auto refresh interval, default 15 minutes
29+
* **API key** - your Live Tennis API key (needs a text input library installed)
30+
31+
## API usage and the free tier
32+
33+
The free tier allows 30 requests/minute and 100 requests/day. Live scores,
34+
players and fixtures are free; historical data is paid.
35+
36+
Each refresh is one request (two when nothing is live, because the app then
37+
also fetches the fixtures list). At the default 15 minute auto-refresh a full
38+
day of continuous refreshing is about 96 requests, which fits the free tier's
39+
100/day - and since the app only refreshes while it is open, real usage is
40+
normally far below that. If you want a faster refresh interval you'll likely
41+
need a paid key.
42+
43+
## Creator
44+
45+
[bensynapse](https://github.com/bensynapse)
46+
47+
Disclosure: I maintain the Live Tennis API this app talks to.

apps/tennisscores/app-icon.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/tennisscores/app.js

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
const FILE = "tennisscores.json";
2+
const BASE = "https://api.livetennisapi.com/api/public/v1";
3+
4+
let settings = Object.assign({
5+
apikey: "",
6+
tour: "", // "" = all tours, or atp/wta/challenger/itf/juniors
7+
auto: false, // periodic refresh while the app is open
8+
refresh: 15 // minutes
9+
}, require("Storage").readJSON(FILE, true) || {});
10+
11+
let state = "loading"; // loading | live | fixtures | nokey | error
12+
let matches = []; // slimmed live matches
13+
let fixtures = []; // slimmed upcoming fixtures
14+
let index = 0; // current page
15+
let lastError = "";
16+
let updatedAt = null;
17+
let autoTimer = null;
18+
let loading = false;
19+
20+
function tourQuery() {
21+
return settings.tour ? "&tour=" + settings.tour : "";
22+
}
23+
24+
function apiGet(path) {
25+
if (!Bangle.http)
26+
return Promise.reject(/*LANG*/"Gadgetbridge required");
27+
return Bangle.http(BASE + path, {
28+
timeout: 15000,
29+
headers: {Authorization: "Bearer " + settings.apikey}
30+
}).then(ev => JSON.parse(ev.resp));
31+
}
32+
33+
function trunc(s, n) {
34+
if (!s) return "";
35+
return s.length > n ? s.substr(0, n - 1) + "." : s;
36+
}
37+
38+
function pad2(n) {
39+
return (n < 10 ? "0" : "") + n;
40+
}
41+
42+
function fmtTime(iso) {
43+
if (!iso) return "";
44+
let d = new Date(iso);
45+
return pad2(d.getDate()) + "/" + pad2(d.getMonth() + 1) + " " +
46+
pad2(d.getHours()) + ":" + pad2(d.getMinutes());
47+
}
48+
49+
// keep only what we draw - the full API objects are too big to hold many of
50+
function slimMatch(m) {
51+
let s = m.score;
52+
let p = m.players || {};
53+
return {
54+
tournament: m.tournament || "",
55+
round: m.round || "",
56+
doubles: !!m.is_doubles,
57+
n1: (p.p1 && p.p1.name) || "?",
58+
n2: (p.p2 && p.p2.name) || "?",
59+
// score.games is [games_p1, games_p2], each a per-set list
60+
g1: (s && s.games && s.games[0]) || [],
61+
g2: (s && s.games && s.games[1]) || [],
62+
// in-game points as tennis strings; entries can be null
63+
pt1: (s && s.points && s.points[0]) || null,
64+
pt2: (s && s.points && s.points[1]) || null,
65+
server: (s && s.server) || null, // 1, 2 or null
66+
tiebreak: !!(s && s.is_tiebreak)
67+
};
68+
}
69+
70+
function slimFixture(f) {
71+
return {
72+
tournament: f.tournament || "",
73+
round: f.round || "",
74+
n1: f.player1_name || "?",
75+
n2: f.player2_name || "?",
76+
date: f.event_date || null,
77+
time: f.start_time || null // null until the order of play assigns one
78+
};
79+
}
80+
81+
function scheduleAuto() {
82+
if (autoTimer) clearTimeout(autoTimer);
83+
autoTimer = null;
84+
if (settings.auto)
85+
autoTimer = setTimeout(() => refresh(), settings.refresh * 60000);
86+
}
87+
88+
function refresh() {
89+
if (loading) return;
90+
if (!settings.apikey) {
91+
state = "nokey";
92+
draw();
93+
return;
94+
}
95+
loading = true;
96+
drawUpdating();
97+
apiGet("/matches?status=live" + tourQuery() + "&limit=10").then(r => {
98+
if (!r || !Array.isArray(r.data)) throw new Error(/*LANG*/"Bad response");
99+
if (r.data.length) {
100+
matches = r.data.map(slimMatch);
101+
state = "live";
102+
return;
103+
}
104+
// nothing live - show the next scheduled fixtures instead
105+
return apiGet("/fixtures?limit=8" + tourQuery()).then(r2 => {
106+
if (!r2 || !Array.isArray(r2.data)) throw new Error(/*LANG*/"Bad response");
107+
fixtures = r2.data.map(slimFixture);
108+
state = "fixtures";
109+
});
110+
}).then(() => {
111+
loading = false;
112+
index = 0;
113+
updatedAt = new Date();
114+
scheduleAuto();
115+
draw();
116+
}).catch(e => {
117+
loading = false;
118+
lastError = ("" + ((e && e.message) || e)).substr(0, 60);
119+
state = "error";
120+
scheduleAuto();
121+
draw();
122+
});
123+
}
124+
125+
function pageCount() {
126+
if (state === "live") return matches.length;
127+
if (state === "fixtures") return Math.ceil(fixtures.length / 2);
128+
return 1;
129+
}
130+
131+
function drawUpdating() {
132+
let R = Bangle.appRect;
133+
g.reset().clearRect(R.x, R.y2 - 10, R.x2, R.y2);
134+
g.setFont("6x8").setFontAlign(0, 1);
135+
g.drawString(/*LANG*/"Updating...", (R.x + R.x2) / 2, R.y2);
136+
}
137+
138+
function drawFooter(R) {
139+
g.setFont("6x8").setFontAlign(0, 1);
140+
let s = (pageCount() > 1 ? (index + 1) + "/" + pageCount() + " " : "") +
141+
(updatedAt ? /*LANG*/"upd " + pad2(updatedAt.getHours()) + ":" + pad2(updatedAt.getMinutes()) : "") +
142+
" " + /*LANG*/"tap=reload";
143+
g.drawString(s, (R.x + R.x2) / 2, R.y2);
144+
}
145+
146+
function drawScoreRow(games, points, serving, y, R) {
147+
g.setFont("6x8", 2).setFontAlign(-1, -1);
148+
// show at most the last 3 sets so BO5 still fits on screen
149+
let shown = games.length > 3 ? games.slice(games.length - 3) : games;
150+
let s = shown.join(" ");
151+
if (points !== null && points !== undefined) s += " " + points;
152+
g.drawString(s, R.x + 24, y);
153+
if (serving) g.fillCircle(R.x + 12, y + 7, 4);
154+
}
155+
156+
function drawLive(R) {
157+
let m = matches[index];
158+
if (!m) return;
159+
let y = R.y + 2;
160+
g.setFont("6x8").setFontAlign(-1, -1);
161+
g.drawString(trunc(m.tournament, 29), R.x + 2, y);
162+
y += 10;
163+
g.drawString(trunc(m.round + (m.doubles ? /*LANG*/" (doubles)" : ""), 29), R.x + 2, y);
164+
y += 14;
165+
g.setFont("12x20").setFontAlign(-1, -1);
166+
g.drawString(trunc(m.n1, 14), R.x + 2, y);
167+
y += 22;
168+
drawScoreRow(m.g1, m.pt1, m.server === 1, y, R);
169+
y += 20;
170+
g.setFont("12x20").setFontAlign(-1, -1);
171+
g.drawString(trunc(m.n2, 14), R.x + 2, y);
172+
y += 22;
173+
drawScoreRow(m.g2, m.pt2, m.server === 2, y, R);
174+
y += 20;
175+
if (m.tiebreak) {
176+
g.setFont("6x8").setFontAlign(-1, -1);
177+
g.drawString(/*LANG*/"Tiebreak", R.x + 24, y);
178+
}
179+
drawFooter(R);
180+
}
181+
182+
function drawFixtures(R) {
183+
let y = R.y + 2;
184+
g.setFont("6x8", 2).setFontAlign(0, -1);
185+
g.drawString(/*LANG*/"Nothing live", (R.x + R.x2) / 2, y);
186+
y += 20;
187+
g.setFont("6x8").setFontAlign(0, -1);
188+
g.drawString(settings.tour ? settings.tour.toUpperCase() + /*LANG*/" - next up:" : /*LANG*/"Next up:", (R.x + R.x2) / 2, y);
189+
y += 12;
190+
let shown = fixtures.slice(index * 2, index * 2 + 2);
191+
if (!shown.length) {
192+
g.setFont("6x8", 2).setFontAlign(0, -1);
193+
g.drawString(/*LANG*/"No fixtures", (R.x + R.x2) / 2, y + 10);
194+
}
195+
shown.forEach(f => {
196+
g.setFont("6x8").setFontAlign(-1, -1);
197+
let when = f.time ? fmtTime(f.time) :
198+
(f.date ? f.date.substr(8, 2) + "/" + f.date.substr(5, 2) + " " : "") + /*LANG*/"TBA";
199+
g.drawString(trunc(when + " " + f.tournament, 29), R.x + 2, y);
200+
y += 10;
201+
g.setFont("6x8", 2).setFontAlign(-1, -1);
202+
g.drawString(trunc(f.n1, 14), R.x + 2, y);
203+
y += 16;
204+
g.drawString(trunc(f.n2, 14), R.x + 2, y);
205+
y += 20;
206+
});
207+
drawFooter(R);
208+
}
209+
210+
function drawCentered(lines, R) {
211+
let y = (R.y + R.y2) / 2 - lines.length * 8;
212+
g.setFont("6x8", 2).setFontAlign(0, -1);
213+
lines.forEach(l => {
214+
g.drawString(l, (R.x + R.x2) / 2, y);
215+
y += 18;
216+
});
217+
}
218+
219+
function draw() {
220+
let R = Bangle.appRect;
221+
g.reset().clearRect(R);
222+
if (state === "live") drawLive(R);
223+
else if (state === "fixtures") drawFixtures(R);
224+
else if (state === "nokey") {
225+
drawCentered([/*LANG*/"No API key", /*LANG*/"Set one in", /*LANG*/"Settings"], R);
226+
g.setFont("6x8").setFontAlign(0, 1);
227+
g.drawString("livetennisapi.com", (R.x + R.x2) / 2, R.y2);
228+
} else if (state === "error") {
229+
drawCentered([/*LANG*/"Error"], R);
230+
g.setFont("6x8").setFontAlign(0, -1);
231+
g.drawString(g.wrapString(lastError, R.w - 8).slice(0, 3).join("\n"), (R.x + R.x2) / 2, (R.y + R.y2) / 2 + 4);
232+
drawFooter(R);
233+
} else {
234+
drawCentered([/*LANG*/"Loading..."], R);
235+
}
236+
}
237+
238+
Bangle.setUI({mode: "updown"}, dir => {
239+
if (!dir) {
240+
refresh(); // tap / button = manual refresh
241+
return;
242+
}
243+
let n = pageCount();
244+
if (n < 2) return;
245+
index = (index + dir + n) % n;
246+
draw();
247+
});
248+
249+
Bangle.loadWidgets();
250+
draw();
251+
Bangle.drawWidgets();
252+
refresh();

apps/tennisscores/app.png

291 Bytes
Loading

apps/tennisscores/interface.html

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<html>
2+
<head>
3+
<link rel="stylesheet" href="../../css/spectre.min.css">
4+
</head>
5+
<body>
6+
<h3>Tennis Scores settings</h3>
7+
<p>
8+
<label for="apikey">Live Tennis API key</label><br>
9+
<input id="apikey" onkeyup="checkInput()" style="width:90%; margin: 3px">
10+
</p>
11+
<p>
12+
<label for="tour">Tour</label><br>
13+
<select id="tour" style="margin: 3px">
14+
<option value="">All</option>
15+
<option value="atp">ATP</option>
16+
<option value="wta">WTA</option>
17+
<option value="challenger">Challenger</option>
18+
<option value="itf">ITF</option>
19+
<option value="juniors">Juniors</option>
20+
</select>
21+
</p>
22+
<p><button id="upload" class="btn btn-primary">Save</button></p>
23+
24+
<h4>Where to get your personal API key?</h4>
25+
<p>Go to <a href="https://livetennisapi.com" target="_blank">https://livetennisapi.com</a> and sign up for a free account.<br>
26+
After registration you can log in and obtain your personal API key.</p>
27+
28+
<script src="../../core/lib/interface.js"></script>
29+
30+
<script>
31+
32+
function checkInput() {
33+
document.getElementById('upload').disabled =
34+
document.getElementById("apikey").value === "";
35+
}
36+
checkInput();
37+
38+
var settings = {};
39+
function onInit() {
40+
console.log("Loading settings from BangleJs...");
41+
try {
42+
Util.readStorageJSON("tennisscores.json", data => {
43+
if (data) {
44+
settings = data;
45+
console.log("Got settings", settings);
46+
if (settings.apikey) document.getElementById("apikey").value = settings.apikey;
47+
document.getElementById("tour").value = settings.tour || "";
48+
checkInput();
49+
}
50+
});
51+
} catch (ex) {
52+
console.log("(Warning) Could not load settings from BangleJs.");
53+
console.log(ex);
54+
}
55+
}
56+
57+
document.getElementById("upload").addEventListener("click", function() {
58+
try {
59+
settings.apikey = document.getElementById("apikey").value.trim();
60+
settings.tour = document.getElementById("tour").value;
61+
Util.showModal("Saving...");
62+
Util.writeStorage("tennisscores.json", JSON.stringify(settings), () => {
63+
Util.hideModal();
64+
});
65+
console.log("Sent settings!");
66+
} catch (ex) {
67+
console.log("(Warning) Could not write settings to BangleJs.");
68+
console.log(ex);
69+
}
70+
});
71+
72+
</script>
73+
</body>
74+
</html>

apps/tennisscores/metadata.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{ "id": "tennisscores",
2+
"name": "Tennis Scores",
3+
"shortName": "Tennis",
4+
"version": "0.01",
5+
"author": "bensynapse",
6+
"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)",
7+
"icon": "app.png",
8+
"tags": "sport,http",
9+
"supports" : ["BANGLEJS2"],
10+
"readme": "README.md",
11+
"interface": "interface.html",
12+
"screenshots" : [ {"url":"screenshot.png"}, {"url":"screenshot2.png"} ],
13+
"storage": [
14+
{"name":"tennisscores.app.js","url":"app.js"},
15+
{"name":"tennisscores.settings.js","url":"settings.js"},
16+
{"name":"tennisscores.img","url":"app-icon.js","evaluate":true}
17+
],
18+
"data": [{"name":"tennisscores.json"}]
19+
}

apps/tennisscores/screenshot.png

1.64 KB
Loading

apps/tennisscores/screenshot2.png

1.82 KB
Loading

0 commit comments

Comments
 (0)