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
14 changes: 14 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

Device configuration changes. For Pi details, see [archive/NOMADPI-DETAILS.md](archive/NOMADPI-DETAILS.md). For mini PC setup, see [MINIPC-SETUP.md](MINIPC-SETUP.md).

## 2026-11-06 - Google/Apple sign-in: a fallback when it never comes back, and every step logged (v0.149.0)

Live 2026-11-05 a singer's Google and Apple taps on the make-it screen "did nothing" (likely a Safari private
tab), she had no way forward, and nothing reached the box to say why.

- **Fallback:** each Google/Apple tap starts a 6 s watch. If no sign-in has come back, "Nothing happening? Use your
email below instead." appears under the buttons (make-it screen and "Save tonight's songs"). A successful sign-in
or an error clears it. The watch state lives on the screen's holder, so it survives re-renders.
- **Logging:** new `POST /sing/make/social-event {provider, event, detail, purpose}` (always 204, INFO log
`make social: <provider> <event> device=<8> purpose=... detail=...`, and in the action log). Events: tap, token,
no_token, closed, error (Apple's error code), script_failed, stall (with page visibility). Google's button is a
cross-origin iframe, so its tap is detected when the window blurs with that iframe focused.
- New string `make.socialStall`, hand-translated into 32 locales (re-run `translate.py --full` later).

## 2026-11-05 - KJ "Link phone" by code + an obvious "Switch singer" (v0.148.0)

Andrew, same show: "we'll also need to make sure there's some way in the KJ UI for me to link phone user sessions
Expand Down
2 changes: 1 addition & 1 deletion kj-controller/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "kj-controller"
version = "0.148.0"
version = "0.149.0"
description = "Web-based karaoke show management with mpv + VLC playback"
requires-python = ">=3.11"

Expand Down
27 changes: 27 additions & 0 deletions kj-controller/sing_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,33 @@ def make_verify_code():
return jsonify({"email": email})


_SOCIAL_EVENTS = frozenset({"tap", "token", "no_token", "closed", "error", "script_failed", "stall"})


@sing_bp.route("/make/social-event", methods=["POST"])
@require_token
def make_social_event():
"""What happened on the phone after a Google / Apple tap, for the action log.

Live 2026-11-05 a singer's taps did nothing and left no trace: the popup
never returned and nothing reached the box. The page now reports each step
(tap, token, closed, error, script_failed, stall) so the next one is
debuggable. Body ``{device_id, provider, event, detail?, purpose?}``;
always 204 (a log line, never a user-facing error).
"""
data, device_id = _body()
if _rate_limited(device_id, bucket="social_event", window=60, per_ip=120, per_device=30):
return ("", 204)
provider = str(data.get("provider") or "")
event = str(data.get("event") or "")
if provider in ("google", "apple") and event in _SOCIAL_EVENTS:
current_app.logger.info(
"make social: %s %s device=%s purpose=%s detail=%s", provider, event,
device_id[:8], str(data.get("purpose") or "make")[:8],
str(data.get("detail") or "")[:120])
return ("", 204)


@sing_bp.route("/make/social", methods=["POST"])
@require_token
def make_social():
Expand Down
100 changes: 88 additions & 12 deletions kj-controller/static-sing/make.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,32 +104,62 @@ const onApplePlatform = () => /iPhone|iPad|iPod|Macintosh/.test(navigator.userAg
// node (an iframe) across re-renders so it isn't rebuilt on every render.
// onToken(provider, idToken) signs in; onError() reports a failed popup.

async function signInWithApple(clientId, onToken, onError) {
async function signInWithApple(clientId, onToken, onError, w) {
w.tapped("apple");
try {
const stub = window.__SING_SOCIAL_STUB;
if (stub) return onToken("apple", stub.apple);
await loadScript(APPLE_SRC);
if (stub) {
if (stub.appleHang) return; // tests: a popup that never comes back
return onToken("apple", stub.apple);
}
try {
await loadScript(APPLE_SRC);
} catch {
w.report("apple", "script_failed");
w.settle();
return onError();
}
window.AppleID.auth.init({
clientId, scope: "email", usePopup: true,
redirectURI: `${window.location.origin}/`,
});
const r = await window.AppleID.auth.signIn();
await onToken("apple", r?.authorization?.id_token);
} catch (e) {
const code = (e && (e.error || e.message)) || "";
// Closing the popup is not an error worth shouting about.
if (e && e.error === "popup_closed_by_user") return;
if (code === "popup_closed_by_user") { w.report("apple", "closed"); w.settle(); return; }
w.report("apple", "error", String(code));
w.settle();
onError();
}
}

function googleButton({ el, social, holder, onToken, testPrefix }) {
// Google's button is a cross-origin iframe, so a tap on it never reaches our
// handlers. The window blurring with that iframe focused is the tell.
let _onGoogleIframeTap = null;
let _blurHooked = false;
function hookGoogleTap(fn) {
_onGoogleIframeTap = fn;
if (_blurHooked) return;
_blurHooked = true;
window.addEventListener("blur", () => {
setTimeout(() => {
const a = document.activeElement;
if (a && a.tagName === "IFRAME" && a.closest(".mk-google") && _onGoogleIframeTap) _onGoogleIframeTap();
}, 0);
});
}

function googleButton({ el, social, holder, onToken, testPrefix, w }) {
if (holder.googleNode) return holder.googleNode;
const node = el("div", { class: "mk-google", "data-testid": `${testPrefix}-google` });
holder.googleNode = node;
const stub = window.__SING_SOCIAL_STUB;
if (stub) {
node.appendChild(el("button", { class: "btn mk-social mk-social-google", type: "button",
onclick: () => onToken("google", stub.google) }, t("make.continueGoogle")));
onclick: () => { w.tapped("google"); if (!stub.googleHang) onToken("google", stub.google); } },
t("make.continueGoogle")));
return node;
}
loadScript(GSI_SRC).then(() => {
Expand All @@ -140,28 +170,73 @@ function googleButton({ el, social, holder, onToken, testPrefix }) {
gid.renderButton(node, { type: "standard", theme: "filled_black", size: "large", shape: "pill",
text: "continue_with", logo_alignment: "center", locale: getLocale(),
width: Math.min(400, node.clientWidth || 320) });
}).catch(() => { node.hidden = true; }); // blocked / offline: email still works
}).catch(() => { // blocked / offline: email still works
node.hidden = true;
w.report("google", "script_failed");
});
return node;
}

function appleButton({ el, social, onToken, onError, testPrefix }) {
function appleButton({ el, social, onToken, onError, testPrefix, w }) {
const btn = el("button", { class: "btn mk-social mk-social-apple", type: "button",
"data-testid": `${testPrefix}-apple`,
onclick: () => signInWithApple(social.apple, onToken, onError) });
onclick: () => signInWithApple(social.apple, onToken, onError, w) });
// Apple's logo as inline SVG (the glyph only exists in Apple's own fonts).
btn.innerHTML = APPLE_LOGO_SVG;
btn.appendChild(document.createTextNode(` ${t("make.continueApple")}`));
return btn;
}

export function socialButtons({ el, social, holder, onToken, onError, testPrefix = "make" }) {
// A tap whose sign-in hasn't come back after this long shows "Nothing
// happening? Use your email below". Live 2026-11-05 a singer's taps did
// nothing at all (likely a Safari private tab) and she had no way forward.
const SOCIAL_STALL_MS = 6000;
const socialStallMs = () => (typeof window.__SING_SOCIAL_STALL_MS === "number"
? window.__SING_SOCIAL_STALL_MS : SOCIAL_STALL_MS);

// `report(provider, event, detail)` sends each step to the box's action log
// (tap, token, no_token, closed, error, script_failed, stall).
export function socialButtons({ el, social, holder, onToken, onError, report = () => {}, testPrefix = "make" }) {
if (!socialHostOk() || !social) return null;
const opts = { el, social, holder, onToken, onError, testPrefix };
// Watchdog state lives on `holder`, so a re-render (the singer typing their
// email) keeps the timer and the hint.
const hint = el("p", { class: "hint mk-social-stall", role: "status",
"data-testid": `${testPrefix}-social-stall` }, t("make.socialStall"));
hint.hidden = !holder.socialStall;
holder.stallHint = hint;
const settle = () => {
clearTimeout(holder.stallTimer);
holder.stallTimer = null;
holder.socialStall = false;
if (holder.stallHint) holder.stallHint.hidden = true;
};
const w = {
report: (provider, event, detail) => { try { report(provider, event, detail || ""); } catch { /* best-effort */ } },
settle,
tapped: (provider) => {
w.report(provider, "tap");
clearTimeout(holder.stallTimer);
holder.stallTimer = setTimeout(() => {
holder.stallTimer = null;
holder.socialStall = true;
if (holder.stallHint) holder.stallHint.hidden = false;
w.report(provider, "stall", document.visibilityState);
}, socialStallMs());
},
};
const token = (provider, idToken) => {
settle();
w.report(provider, idToken ? "token" : "no_token");
return onToken(provider, idToken);
};
const opts = { el, social, holder, onToken: token, onError, testPrefix, w };
const google = social.google ? googleButton(opts) : null;
const apple = social.apple ? appleButton(opts) : null;
if (!google && !apple) return null;
if (google) hookGoogleTap(() => w.tapped("google"));
if (apple && !window.__SING_SOCIAL_STUB) loadScript(APPLE_SRC).catch(() => {}); // so the tap opens the popup at once
return el("div", { class: "mk-socials" }, ...(onApplePlatform() ? [apple, google] : [google, apple]));
return el("div", { class: "mk-socials" },
...(onApplePlatform() ? [apple, google] : [google, apple]), hint);
}

// --- Wizard ------------------------------------------------------------------
Expand Down Expand Up @@ -782,6 +857,7 @@ export function createMakeFlow(deps) {
const socials = socialButtons({
el, social: s.social, holder: s, onToken: socialSignIn,
onError: () => { s.err = t("make.socialFailed"); rerender(); },
report: (provider, event, detail) => api("social-event", { provider, event, detail, purpose: "make" }).catch(() => {}),
});
if (socials) {
out.push(socials);
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "سجّل دخولك بالأعلى للمتابعة",
"findingAudio": "جارٍ البحث عن الصوت…",
"needAudio": "اختر تسجيلاً من الأعلى",
"socialFailed": "تعذر تسجيل الدخول. حاول مجدداً، أو استخدم بريدك الإلكتروني."
"socialFailed": "تعذر تسجيل الدخول. حاول مجدداً، أو استخدم بريدك الإلكتروني.",
"socialStall": "لا يحدث شيء؟ استخدم بريدك الإلكتروني أدناه بدلاً من ذلك."
},
"confirm": {
"partnersTitle": "هل ستغني مع أي شخص آخر؟ (اختياري)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/ca.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Inicia sessió a dalt per continuar",
"findingAudio": "Buscant l'àudio…",
"needAudio": "Tria un enregistrament de dalt",
"socialFailed": "No s'ha pogut iniciar sessió. Torna-ho a provar o fes servir el teu correu electrònic."
"socialFailed": "No s'ha pogut iniciar sessió. Torna-ho a provar o fes servir el teu correu electrònic.",
"socialStall": "No passa res? Fes servir el teu correu a sota."
},
"confirm": {
"partnersTitle": "Cantes amb algú més? (opcional)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Pro pokračování se výše přihlas",
"findingAudio": "Hledáme zvuk…",
"needAudio": "Vyber nahrávku výše",
"socialFailed": "Přihlášení se nepovedlo. Zkus to znovu, nebo použij e-mail."
"socialFailed": "Přihlášení se nepovedlo. Zkus to znovu, nebo použij e-mail.",
"socialStall": "Nic se neděje? Použijte raději svůj e-mail níže."
},
"confirm": {
"partnersTitle": "Zpíváš ještě s někým? (volitelné)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Log ind ovenfor for at fortsætte",
"findingAudio": "Finder lyden…",
"needAudio": "Vælg en optagelse ovenfor",
"socialFailed": "Login mislykkedes. Prøv igen, eller brug din e-mail."
"socialFailed": "Login mislykkedes. Prøv igen, eller brug din e-mail.",
"socialStall": "Sker der ikke noget? Brug din e-mail herunder i stedet."
},
"confirm": {
"partnersTitle": "Synger du med andre? (valgfrit)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Melde dich oben an, um fortzufahren",
"findingAudio": "Audio wird gesucht…",
"needAudio": "Wähle oben eine Aufnahme aus",
"socialFailed": "Die Anmeldung hat leider nicht geklappt. Versuch es nochmal oder nutze deine E-Mail-Adresse."
"socialFailed": "Die Anmeldung hat leider nicht geklappt. Versuch es nochmal oder nutze deine E-Mail-Adresse.",
"socialStall": "Passiert nichts? Nutze stattdessen unten deine E-Mail."
},
"confirm": {
"partnersTitle": "Singst du noch mit jemandem? (optional)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/el.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Συνδέσου παραπάνω για να συνεχίσεις",
"findingAudio": "Αναζήτηση ήχου…",
"needAudio": "Διάλεξε μια ηχογράφηση παραπάνω",
"socialFailed": "Η σύνδεση δεν πέτυχε. Δοκίμασε ξανά ή χρησιμοποίησε το email σου."
"socialFailed": "Η σύνδεση δεν πέτυχε. Δοκίμασε ξανά ή χρησιμοποίησε το email σου.",
"socialStall": "Δεν συμβαίνει τίποτα; Χρησιμοποίησε το email σου παρακάτω."
},
"confirm": {
"partnersTitle": "Τραγουδάς με κάποιον άλλο; (προαιρετικό)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Sign in above to continue",
"findingAudio": "Finding the audio…",
"needAudio": "Pick a recording above",
"socialFailed": "That sign-in didn't work. Try again, or use your email."
"socialFailed": "That sign-in didn't work. Try again, or use your email.",
"socialStall": "Nothing happening? Use your email below instead."
},
"confirm": {
"partnersTitle": "Singing with anyone else? (optional)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Inicia sesión arriba para continuar",
"findingAudio": "Buscando el audio…",
"needAudio": "Elige una grabación arriba",
"socialFailed": "No se pudo iniciar sesión. Inténtalo de nuevo o usa tu correo electrónico."
"socialFailed": "No se pudo iniciar sesión. Inténtalo de nuevo o usa tu correo electrónico.",
"socialStall": "¿No pasa nada? Usa tu correo aquí abajo."
},
"confirm": {
"partnersTitle": "¿Cantas con alguien más? (opcional)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Kirjaudu sisään yltä jatkaaksesi",
"findingAudio": "Etsitään ääniraitaa…",
"needAudio": "Valitse versio yltä",
"socialFailed": "Kirjautuminen ei onnistunut. Yritä uudelleen tai käytä sähköpostia."
"socialFailed": "Kirjautuminen ei onnistunut. Yritä uudelleen tai käytä sähköpostia.",
"socialStall": "Eikö mitään tapahdu? Käytä sen sijaan sähköpostiasi alla."
},
"confirm": {
"partnersTitle": "Laulatko jonkun muun kanssa? (valinnainen)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Connecte-toi ci-dessus pour continuer",
"findingAudio": "Recherche de l'audio…",
"needAudio": "Choisis un enregistrement ci-dessus",
"socialFailed": "La connexion n'a pas fonctionné. Réessaie ou utilise ton e-mail."
"socialFailed": "La connexion n'a pas fonctionné. Réessaie ou utilise ton e-mail.",
"socialStall": "Rien ne se passe ? Utilisez plutôt votre e-mail ci-dessous."
},
"confirm": {
"partnersTitle": "Tu chantes avec quelqu'un d'autre ? (facultatif)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/he.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "יש להתחבר למעלה כדי להמשיך",
"findingAudio": "מחפשים את האודיו…",
"needAudio": "יש לבחור הקלטה למעלה",
"socialFailed": "ההתחברות לא הצליחה. כדאי לנסות שוב, או להשתמש באימייל."
"socialFailed": "ההתחברות לא הצליחה. כדאי לנסות שוב, או להשתמש באימייל.",
"socialStall": "שום דבר לא קורה? השתמשו במקום זאת באימייל שלכם למטה."
},
"confirm": {
"partnersTitle": "שרים עם עוד מישהו? (אופציונלי)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "आगे बढ़ने के लिए ऊपर साइन इन करो",
"findingAudio": "ऑडियो ढूंढा जा रहा है…",
"needAudio": "ऊपर दी गई रिकॉर्डिंग्स में से एक चुनो",
"socialFailed": "साइन-इन नहीं हो पाया। दोबारा कोशिश करो, या अपना ईमेल इस्तेमाल करो।"
"socialFailed": "साइन-इन नहीं हो पाया। दोबारा कोशिश करो, या अपना ईमेल इस्तेमाल करो।",
"socialStall": "कुछ नहीं हो रहा? इसके बजाय नीचे अपना ईमेल इस्तेमाल करें।"
},
"confirm": {
"partnersTitle": "क्या किसी और के साथ गा रहे हो? (ऑप्शनल)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/hr.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Prijavi se iznad za nastavak",
"findingAudio": "Tražimo snimku…",
"needAudio": "Odaberi snimku iznad",
"socialFailed": "Prijava nije uspjela. Pokušaj ponovno ili unesi e-poštu."
"socialFailed": "Prijava nije uspjela. Pokušaj ponovno ili unesi e-poštu.",
"socialStall": "Ništa se ne događa? Umjesto toga upotrijebite svoju e-poštu ispod."
},
"confirm": {
"partnersTitle": "Pjevaš li s još nekim? (opcionalno)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "A folytatáshoz jelentkezz be fent",
"findingAudio": "Zenei alap keresése…",
"needAudio": "Válassz ki fent egy verziót",
"socialFailed": "A bejelentkezés nem sikerült. Próbáld újra, vagy használd az e-mail-címedet."
"socialFailed": "A bejelentkezés nem sikerült. Próbáld újra, vagy használd az e-mail-címedet.",
"socialStall": "Nem történik semmi? Használd inkább lent az e-mail-címedet."
},
"confirm": {
"partnersTitle": "Énekelsz még valakivel? (opcionális)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/id.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Masuk di atas untuk lanjut",
"findingAudio": "Mencari audio…",
"needAudio": "Pilih rekaman di atas",
"socialFailed": "Gagal masuk. Coba lagi, atau gunakan emailmu."
"socialFailed": "Gagal masuk. Coba lagi, atau gunakan emailmu.",
"socialStall": "Tidak ada yang terjadi? Pakai email kamu di bawah saja."
},
"confirm": {
"partnersTitle": "Nyanyi bareng orang lain? (opsional)",
Expand Down
3 changes: 2 additions & 1 deletion kj-controller/static-sing/messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@
"needSignIn": "Accedi qui sopra per continuare",
"findingAudio": "Ricerca dell'audio in corso…",
"needAudio": "Scegli una registrazione qui sopra",
"socialFailed": "Accesso non riuscito. Riprova o usa la tua email."
"socialFailed": "Accesso non riuscito. Riprova o usa la tua email.",
"socialStall": "Non succede niente? Usa invece la tua email qui sotto."
},
"confirm": {
"partnersTitle": "Canti con qualcun altro? (opzionale)",
Expand Down
Loading
Loading