feat: persönlichere Profilseite + iOS-App-Setup
Profil: Begrüßung in Zielsprache, Kategorie-Punkte-Übersicht, ruhigerer Header (kein rotierender Avatar/Online-Dot), Notch-Fix und kompaktere Aktivitäts-Heatmap. Außerdem Capacitor-iOS-Projekt und diverse Auth/Feed/Audio-Verbesserungen aus dem Premium-Redesign. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
48
src/utils/chipTimings.js
Normal file
48
src/utils/chipTimings.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Karaoke-Timing: aus dem Satz + ElevenLabs-`alignment` pro Wort-/Objekt-Chip
|
||||
// ein Zeitfenster (Medienzeit in Sekunden) berechnen, damit beim Vorlesen das
|
||||
// gerade gesprochene Chip markiert werden kann.
|
||||
//
|
||||
// alignment-Format (ElevenLabs /with-timestamps):
|
||||
// { characters: string[],
|
||||
// character_start_times_seconds: number[],
|
||||
// character_end_times_seconds: number[] }
|
||||
// `characters.join('')` entspricht dem vertonten Klartext (Platzhalter durch Labels ersetzt).
|
||||
|
||||
const CHIP_RE = /\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}/g
|
||||
|
||||
// Liefert [{ id, label, start, end }] – ein Eintrag pro Chip-Vorkommen im Satz.
|
||||
export function buildChipTimings(sentence, alignment) {
|
||||
if (!sentence || !alignment) return []
|
||||
const chars = alignment.characters
|
||||
const starts = alignment.character_start_times_seconds
|
||||
const ends = alignment.character_end_times_seconds
|
||||
if (!Array.isArray(chars) || !Array.isArray(starts) || !Array.isArray(ends) || !chars.length) return []
|
||||
|
||||
const plain = chars.join('')
|
||||
// Satz in derselben Reihenfolge nach Chip-Tokens absuchen; Labels sequenziell
|
||||
// im Klartext lokalisieren (toleriert kleine Whitespace-Unterschiede).
|
||||
const timings = []
|
||||
let cursor = 0
|
||||
for (const m of sentence.matchAll(CHIP_RE)) {
|
||||
const label = m[1]
|
||||
const id = m[3]
|
||||
const startIdx = plain.indexOf(label, cursor)
|
||||
if (startIdx === -1) continue
|
||||
const endIdx = startIdx + label.length - 1
|
||||
cursor = endIdx + 1
|
||||
const start = starts[startIdx]
|
||||
const end = ends[Math.min(endIdx, ends.length - 1)]
|
||||
if (typeof start !== 'number' || typeof end !== 'number') continue
|
||||
timings.push({ id, label, start, end })
|
||||
}
|
||||
return timings
|
||||
}
|
||||
|
||||
// Id des Chips, dessen Zeitfenster t (Sekunden) enthält – sonst null.
|
||||
export function activeChipIdAt(timings, t) {
|
||||
if (!timings?.length || typeof t !== 'number') return null
|
||||
for (const c of timings) {
|
||||
if (t >= c.start && t < c.end) return c.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
40
src/utils/secureToken.js
Normal file
40
src/utils/secureToken.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// Sicherer Token-Speicher: iOS-Keychain (nativ, nicht aus dem WebView lesbar),
|
||||
// im Browser-Dev fällt das Plugin auf localStorage zurück.
|
||||
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin'
|
||||
|
||||
const KEY = 'snakkimo_token'
|
||||
const LEGACY_LS_KEY = 'hejyou_token' // Alt-Token aus der localStorage-Ära
|
||||
|
||||
// get() wirft, wenn der Key fehlt → als "kein Token" behandeln.
|
||||
async function rawGet() {
|
||||
try {
|
||||
const { value } = await SecureStoragePlugin.get({ key: KEY })
|
||||
return value || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStoredToken(token) {
|
||||
await SecureStoragePlugin.set({ key: KEY, value: token })
|
||||
}
|
||||
|
||||
export async function clearStoredToken() {
|
||||
try { await SecureStoragePlugin.remove({ key: KEY }) } catch {}
|
||||
}
|
||||
|
||||
// Token aus dem sicheren Speicher lesen. Migriert dabei einmalig einen evtl. noch
|
||||
// in localStorage liegenden Alt-Token, damit eingeloggte Nutzer eingeloggt bleiben.
|
||||
export async function getStoredToken() {
|
||||
const existing = await rawGet()
|
||||
if (existing) return existing
|
||||
try {
|
||||
const legacy = localStorage.getItem(LEGACY_LS_KEY)
|
||||
if (legacy) {
|
||||
await setStoredToken(legacy)
|
||||
localStorage.removeItem(LEGACY_LS_KEY)
|
||||
return legacy
|
||||
}
|
||||
} catch { /* localStorage nicht verfügbar – ignorieren */ }
|
||||
return null
|
||||
}
|
||||
23
src/utils/speak.js
Normal file
23
src/utils/speak.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// Browser-TTS-Fallback fürs Vorlesen, wenn kein (ladbares) Audio-File vorhanden ist.
|
||||
|
||||
const LANG_TTS = { sv: 'sv-SE', en: 'en-US', de: 'de-DE' }
|
||||
|
||||
// {{label.w/o:uuid}} und ⟦PHn:wort⟧-Tokens auf reinen Text reduzieren.
|
||||
export function toPlainText(sentence) {
|
||||
if (!sentence) return ''
|
||||
return sentence
|
||||
.replace(/\{\{([^.]+)\.[wo]:[0-9a-f-]{36}\}\}/g, '$1')
|
||||
.replace(/[⟦〚]PH\d+:([^⟧〛]*)[⟧〛]/g, '$1')
|
||||
}
|
||||
|
||||
// Liest den Satz per SpeechSynthesis vor. Gibt true zurück, wenn tatsächlich
|
||||
// vorgelesen wurde (für Karten, die daran ein Unlock koppeln).
|
||||
export function speak(sentence, lang) {
|
||||
if (!window.speechSynthesis || !sentence) return false
|
||||
window.speechSynthesis.cancel()
|
||||
const utt = new SpeechSynthesisUtterance(toPlainText(sentence))
|
||||
utt.lang = LANG_TTS[lang] || 'de-DE'
|
||||
utt.rate = 0.7
|
||||
window.speechSynthesis.speak(utt)
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user