From c5a239238c0f3f2907e59434d54d09d66f5b60fa Mon Sep 17 00:00:00 2001 From: phantomptr Date: Sun, 18 Oct 2026 07:38:10 -0700 Subject: [PATCH] Payloads: HearBridge status and controls HearBridge (Bluetooth headset audio) gets an integration card on On your PS5. The engine talks to it for the app (its page sends no CORS headers): new /api/ps5/hearbridge/{status,saved,devices,action} routes find it on 8090..8095, read its status with the token stripped, and pass only volume, mute, night mode, latency, reconnect, scan, select and stop, each checked against the ranges HearBridge takes. The token is read fresh per change and never leaves the engine. The card shows the stream state, headset, battery, link quality, delay, codec, volume and game profile; Manage has the volume and latency sliders, mute, night mode, reconnect, scan and connect, saved headsets, recent events and a link to HearBridge's own page for pairing, EQ and backups. Stop uses HearBridge's clean stop. An absent or older HearBridge shows "HearBridge didn't answer" with the page link. A card opened by a link now scrolls into view. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS --- client/src/api/hearbridge.test.ts | 104 ++++ client/src/api/hearbridge.ts | 168 ++++++ client/src/i18n/locales/ar.ts | 36 ++ client/src/i18n/locales/bn.ts | 36 ++ client/src/i18n/locales/de.ts | 36 ++ client/src/i18n/locales/en.ts | 36 ++ client/src/i18n/locales/es.ts | 36 ++ client/src/i18n/locales/fa.ts | 36 ++ client/src/i18n/locales/fr.ts | 36 ++ client/src/i18n/locales/hi.ts | 36 ++ client/src/i18n/locales/hu.ts | 36 ++ client/src/i18n/locales/id.ts | 36 ++ client/src/i18n/locales/it.ts | 36 ++ client/src/i18n/locales/ja.ts | 36 ++ client/src/i18n/locales/ko.ts | 36 ++ client/src/i18n/locales/pl.ts | 36 ++ client/src/i18n/locales/pt-BR.ts | 36 ++ client/src/i18n/locales/ru.ts | 36 ++ client/src/i18n/locales/th.ts | 36 ++ client/src/i18n/locales/tr.ts | 36 ++ client/src/i18n/locales/vi.ts | 36 ++ client/src/i18n/locales/zh-CN.ts | 36 ++ client/src/i18n/locales/zh-TW.ts | 36 ++ .../src/screens/Payloads/hub/OnPs5Panel.tsx | 81 ++- .../screens/Payloads/hub/hearbridge.test.tsx | 88 +++ .../src/screens/Payloads/hub/hearbridge.tsx | 389 ++++++++++++ .../src/screens/Payloads/hub/registry.test.ts | 12 + .../crates/ps5upload-core/src/hearbridge.rs | 570 ++++++++++++++++++ engine/crates/ps5upload-core/src/lib.rs | 1 + .../ps5upload-engine/src/hearbridge_api.rs | 105 ++++ engine/crates/ps5upload-engine/src/lib.rs | 5 + 31 files changed, 2271 insertions(+), 8 deletions(-) create mode 100644 client/src/api/hearbridge.test.ts create mode 100644 client/src/api/hearbridge.ts create mode 100644 client/src/screens/Payloads/hub/hearbridge.test.tsx create mode 100644 client/src/screens/Payloads/hub/hearbridge.tsx create mode 100644 engine/crates/ps5upload-core/src/hearbridge.rs create mode 100644 engine/crates/ps5upload-engine/src/hearbridge_api.rs diff --git a/client/src/api/hearbridge.test.ts b/client/src/api/hearbridge.test.ts new file mode 100644 index 00000000..81c95e4f --- /dev/null +++ b/client/src/api/hearbridge.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { hbAct, hbOverview, parseHbStatus, parseSaved, parseScanned } from "./hearbridge"; + +// A /api/status body as HearBridge's status_json writes it (token already removed by the +// engine), trimmed to the fields the app reads plus a few it ignores. +const FULL = { + version: "1.9.0", + connected: 1, + state: "streaming", + device: "WH-1000XM4", + gain_pct: 140, + muted: 0, + codec: "SBC", + latency: { target_ms: 120, estimate_ms: 185, capture_ms: 10 }, + lat_extra: { backoff_ms: 0, normal_ms: 120, auto: 1, auto_ms: 110 }, + battery: { status: "ok", level: 3, pct: 70, src: "hf", none: 0 }, + link: { score: 88, rssi: -54, lq: 255, drops_min: 0 }, + night: { on: 1, db10: 60 }, + game: { avail: 1, id: "PPSA01234", name: "Astro Bot", profile: 1, active: 1 }, + events: ["Connected", "Streaming"], + chip: { vid: "0e8d", vendor: "MediaTek" }, +}; + +describe("HearBridge status", () => { + it("reads every field the card and panel show", () => { + expect(parseHbStatus(FULL)).toEqual({ + version: "1.9.0", + state: "streaming", + device: "WH-1000XM4", + gainPct: 140, + muted: false, + codec: "SBC", + latencyTargetMs: 120, + latencyEstimateMs: 185, + latencyAuto: true, + batteryPct: 70, + linkScore: 88, + night: true, + gameName: "Astro Bot", + gameProfile: true, + events: ["Connected", "Streaming"], + }); + }); + + it("leaves out what an older HearBridge doesn't send, never guessing", () => { + const s = parseHbStatus({ version: "0.3", state: "disconnected" }); + expect(s.gainPct).toBeNull(); + expect(s.muted).toBeNull(); + expect(s.latencyAuto).toBeNull(); + expect(s.batteryPct).toBeNull(); + expect(s.events).toEqual([]); + // -1 is HearBridge's "unknown". + expect(parseHbStatus({ battery: { pct: -1 }, link: { score: -1 } }).batteryPct).toBeNull(); + expect(parseHbStatus(null).version).toBe(""); + expect(parseHbStatus({ events: [1, "x", null] }).events).toEqual(["x"]); + }); + + it("reads saved and scanned headsets, dropping malformed rows", () => { + expect( + parseSaved({ devices: [{ addr: "AA:BB:CC:DD:EE:FF", name: "Buds", kind: "audio", current: 1 }, { name: "no addr" }] }), + ).toEqual([{ addr: "AA:BB:CC:DD:EE:FF", name: "Buds", kind: "audio", current: true }]); + expect( + parseScanned({ version: 1, devices: [{ index: 2, addr: "11:22:33:44:55:66", name: "Pods", rssi: null }, { addr: "x" }] }), + ).toEqual([{ index: 2, addr: "11:22:33:44:55:66", name: "Pods", kind: "", rssi: null }]); + expect(parseSaved("garbage")).toEqual([]); + }); +}); + +describe("HearBridge through the engine", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("reports a console without HearBridge as absent, not an error", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true, present: false, error: "no answer" }))); + vi.stubGlobal("fetch", fetchMock); + await expect(hbOverview("192.168.1.5")).resolves.toEqual({ present: false, error: "no answer" }); + expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toContain("/api/ps5/hearbridge/status?host=192.168.1.5"); + }); + + it("posts one change with the console and returns the new status", async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ ok: true, result: { ...FULL, gain_pct: 200 } })), + ); + vi.stubGlobal("fetch", fetchMock); + const s = await hbAct("192.168.1.5:9120", { action: "volume", pct: 200 }); + expect(s?.gainPct).toBe(200); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toContain("/api/ps5/hearbridge/action"); + expect(JSON.parse(String(init.body))).toEqual({ host: "192.168.1.5", action: "volume", pct: 200 }); + }); + + it("returns no status for an action HearBridge answers with {ok:1}", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ ok: true, result: { ok: 1 } })))); + await expect(hbAct("10.0.0.2", { action: "reconnect" })).resolves.toBeNull(); + }); + + it("surfaces the engine's error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ error: "HearBridge refused" }), { status: 502 })), + ); + await expect(hbAct("10.0.0.2", { action: "stop" })).rejects.toThrow("HearBridge refused"); + }); +}); diff --git a/client/src/api/hearbridge.ts b/client/src/api/hearbridge.ts new file mode 100644 index 00000000..fbe04075 --- /dev/null +++ b/client/src/api/hearbridge.ts @@ -0,0 +1,168 @@ +// The console's HearBridge (Bluetooth headset audio; its own web page on :8090..8095) +// through the engine's /api/ps5/hearbridge routes. HearBridge sends no CORS headers, so +// the app never calls it directly; the engine finds its port, holds its token and passes +// only the changes below. Plain fetches against the engine origin, so the desktop app, the +// browser build and the Docker build take the same path. + +import { getEngineUrl } from "../state/engine"; +import { hostOf } from "../lib/addr"; + +/** HearBridge's status, read tolerantly: an older or newer HearBridge may leave any field + * out, and a missing field is null rather than a guess. */ +export interface HbStatus { + version: string; + /** "streaming", "connecting", "scanning", "reconnecting", "disconnected", "error", … */ + state: string; + /** The headset's name, when one is connected or being connected. */ + device: string; + /** Audio volume boost, 0..500 %. */ + gainPct: number | null; + muted: boolean | null; + codec: string; + latencyTargetMs: number | null; + latencyEstimateMs: number | null; + latencyAuto: boolean | null; + batteryPct: number | null; + /** Link quality 0..100. */ + linkScore: number | null; + night: boolean | null; + /** The running game HearBridge has a profile for, if any. */ + gameName: string; + gameProfile: boolean; + events: string[]; +} + +export interface HbSavedDevice { + addr: string; + name: string; + kind: string; + current: boolean; +} + +export interface HbScannedDevice { + index: number; + addr: string; + name: string; + kind: string; + rssi: number | null; +} + +export type HbOverview = + | { present: true; port: number; status: HbStatus } + | { present: false; error: string }; + +type Obj = Record; +const obj = (v: unknown): Obj => (v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {}); +const str = (v: unknown): string => (typeof v === "string" ? v : ""); +const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); +const flag = (v: unknown): boolean | null => + typeof v === "boolean" ? v : typeof v === "number" ? v !== 0 : null; + +export function parseHbStatus(raw: unknown): HbStatus { + const s = obj(raw); + const latency = obj(s.latency); + const battery = obj(s.battery); + const link = obj(s.link); + const game = obj(s.game); + const pct = num(battery.pct); + const score = num(link.score); + return { + version: str(s.version), + state: str(s.state), + device: str(s.device), + gainPct: num(s.gain_pct), + muted: flag(s.muted), + codec: str(s.codec), + latencyTargetMs: num(latency.target_ms), + latencyEstimateMs: num(latency.estimate_ms), + latencyAuto: flag(obj(s.lat_extra).auto), + // -1 is HearBridge's "not known". + batteryPct: pct !== null && pct >= 0 ? pct : null, + linkScore: score !== null && score >= 0 ? score : null, + night: flag(obj(s.night).on), + gameName: str(game.name), + gameProfile: flag(game.profile) === true, + events: Array.isArray(s.events) ? s.events.filter((e): e is string => typeof e === "string") : [], + }; +} + +export function parseSaved(raw: unknown): HbSavedDevice[] { + const list = obj(raw).devices; + if (!Array.isArray(list)) return []; + return list + .map(obj) + .filter((d) => str(d.addr)) + .map((d) => ({ addr: str(d.addr), name: str(d.name), kind: str(d.kind), current: flag(d.current) === true })); +} + +export function parseScanned(raw: unknown): HbScannedDevice[] { + const list = obj(raw).devices; + if (!Array.isArray(list)) return []; + return list + .map(obj) + .filter((d) => num(d.index) !== null) + .map((d) => ({ + index: num(d.index) as number, + addr: str(d.addr), + name: str(d.name), + kind: str(d.kind), + rssi: num(d.rssi), + })); +} + +async function read(res: Response): Promise { + const body = obj(await res.json().catch(() => ({}))); + if (!res.ok) throw new Error(str(body.error) || `request failed (${res.status})`); + return body; +} + +const q = (host: string) => `host=${encodeURIComponent(hostOf(host))}`; + +/** Whether HearBridge answers on `host`, on which port, and its status. */ +export async function hbOverview(host: string): Promise { + const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/status?${q(host)}`, { + signal: AbortSignal.timeout(20_000), + }); + const w = await read(res); + if (!w.present) return { present: false, error: str(w.error) || "not running" }; + return { present: true, port: num(w.port) ?? 8090, status: parseHbStatus(w.status) }; +} + +export async function hbSaved(host: string): Promise { + const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/saved?${q(host)}`, { + signal: AbortSignal.timeout(20_000), + }); + return parseSaved(await read(res)); +} + +export async function hbScanned(host: string): Promise { + const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/devices?${q(host)}`, { + signal: AbortSignal.timeout(20_000), + }); + return parseScanned(await read(res)); +} + +export type HbAction = + | { action: "volume"; pct: number } + | { action: "mute"; on: boolean } + | { action: "night"; on: boolean } + | { action: "latency"; auto: boolean } + | { action: "latency"; ms: number } + | { action: "select"; addr: string } + | { action: "select"; index: number } + | { action: "reconnect" } + | { action: "scan" } + | { action: "stop" }; + +/** Make one change. Returns HearBridge's new status when its answer carries one. */ +export async function hbAct(host: string, a: HbAction): Promise { + const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/action`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ host: hostOf(host), ...a }), + signal: AbortSignal.timeout(20_000), + }); + const w = await read(res); + const result = obj(w.result); + return typeof result.version === "string" ? parseHbStatus(result) : null; +} diff --git a/client/src/i18n/locales/ar.ts b/client/src/i18n/locales/ar.ts index dd7e9e8d..6685fc03 100644 --- a/client/src/i18n/locales/ar.ts +++ b/client/src/i18n/locales/ar.ts @@ -3959,6 +3959,42 @@ hub_stop: "إيقاف", hub_manage: "إدارة", hub_pm_summary: "الحمولات المحفوظة: {n}", hub_smp_summary: "صور الألعاب المركّبة: {n}", +hb_no_answer: "لم يستجب HearBridge", +hb_state_streaming: "يبث الصوت", +hb_state_connecting: "جارٍ الاتصال", +hb_state_reconnecting: "جارٍ إعادة الاتصال", +hb_state_scanning: "جارٍ البحث", +hb_state_waiting: "في انتظار سماعة", +hb_state_error: "خطأ", +hb_battery: "البطارية {pct}%", +hb_link: "جودة الاتصال {score}%", +hb_latency: "تأخير {ms} مللي ثانية", +hb_muted: "مكتوم", +hb_volume: "مستوى الصوت {pct}%", +hb_game: "الملف الشخصي: {name}", +hb_scan_confirm_title: "البحث عن سماعات؟", +hb_scan_confirm_body: "يوقف البحث صوت السماعة أثناء تشغيله (نحو 12 ثانية).", +hb_scan: "البحث عن سماعات", +hb_open_page: "فتح صفحة HearBridge", +hb_action_failed: "لم يقبل HearBridge هذا التغيير", +hb_volume_label: "مستوى الصوت", +hb_mute: "كتم الصوت", +hb_latency_label: "زمن التأخير", +hb_latency_auto: "تلقائي", +hb_night: "الوضع الليلي", +hb_night_hint: "انفجارات أهدأ وأصوات أوضح.", +hb_headsets: "السماعات", +hb_reconnect: "إعادة الاتصال", +hb_current: "قيد الاستخدام", +hb_switch: "التبديل إلى هذه", +hb_saved_empty: "لا توجد سماعات محفوظة بعد. ابحث عن واحدة أو أقرنها من صفحة HearBridge.", +hb_scan_found: "عُثر عليها في آخر بحث:", +hb_scan_empty: "لم يُعثر على شيء. ضع السماعة في وضع الإقران وابحث مرة أخرى.", +hb_connect: "اتصال", +hb_events: "الأحداث الأخيرة", +hb_page_hint: "إقران سماعة جديدة والمعادل والنسخ الاحتياطية وكل الإعدادات الأخرى موجودة في صفحة HearBridge نفسها.", +hb_stop_title: "إيقاف HearBridge؟", +hb_stop_body: "يُغلق HearBridge وتنقطع السماعة. أرسله مرة أخرى لتشغيله.", payloads_description_ports: "المنافذ المستخدمة على PS5 وما يستمع عادةً على كل منها، لتعرف أي حمولة تعمل على أي منفذ. افتح التي لها صفحة ويب في متصفحك.", "ports.scan": "افحص مجددًا", "ports.scanning": "جارٍ فحص كل المنافذ على PS5…", diff --git a/client/src/i18n/locales/bn.ts b/client/src/i18n/locales/bn.ts index fb629ea2..a2880f26 100644 --- a/client/src/i18n/locales/bn.ts +++ b/client/src/i18n/locales/bn.ts @@ -3959,6 +3959,42 @@ hub_stop: "বন্ধ করুন", hub_manage: "পরিচালনা", hub_pm_summary: "সংরক্ষিত পেলোড: {n}", hub_smp_summary: "মাউন্ট করা গেম ইমেজ: {n}", +hb_no_answer: "HearBridge সাড়া দেয়নি", +hb_state_streaming: "স্ট্রিম হচ্ছে", +hb_state_connecting: "সংযোগ হচ্ছে", +hb_state_reconnecting: "আবার সংযোগ হচ্ছে", +hb_state_scanning: "স্ক্যান হচ্ছে", +hb_state_waiting: "হেডসেটের অপেক্ষায়", +hb_state_error: "ত্রুটি", +hb_battery: "ব্যাটারি {pct}%", +hb_link: "সংযোগ {score}%", +hb_latency: "{ms} ms বিলম্ব", +hb_muted: "মিউট", +hb_volume: "ভলিউম {pct}%", +hb_game: "প্রোফাইল: {name}", +hb_scan_confirm_title: "হেডসেট খুঁজবেন?", +hb_scan_confirm_body: "স্ক্যান চলার সময় হেডসেটের অডিও বন্ধ থাকে (প্রায় ১২ সেকেন্ড)।", +hb_scan: "হেডসেট খুঁজুন", +hb_open_page: "HearBridge পেজ খুলুন", +hb_action_failed: "HearBridge এই পরিবর্তনটি নেয়নি", +hb_volume_label: "ভলিউম", +hb_mute: "মিউট", +hb_latency_label: "লেটেন্সি", +hb_latency_auto: "স্বয়ংক্রিয়", +hb_night: "নাইট মোড", +hb_night_hint: "বিস্ফোরণ কম জোরে, কণ্ঠস্বর আরও স্পষ্ট।", +hb_headsets: "হেডসেট", +hb_reconnect: "আবার সংযোগ", +hb_current: "ব্যবহৃত হচ্ছে", +hb_switch: "এটিতে বদলান", +hb_saved_empty: "এখনও কোনো সংরক্ষিত হেডসেট নেই। একটি খুঁজুন, অথবা HearBridge-এর পেজে পেয়ার করুন।", +hb_scan_found: "শেষ স্ক্যানে পাওয়া গেছে:", +hb_scan_empty: "কিছু পাওয়া যায়নি। হেডসেটটি পেয়ারিং মোডে রেখে আবার স্ক্যান করুন।", +hb_connect: "সংযোগ করুন", +hb_events: "সাম্প্রতিক ঘটনা", +hb_page_hint: "নতুন হেডসেট পেয়ার করা, ইকুয়ালাইজার, ব্যাকআপ ও অন্য সব সেটিং HearBridge-এর নিজস্ব পেজে আছে।", +hb_stop_title: "HearBridge বন্ধ করবেন?", +hb_stop_body: "HearBridge বন্ধ হবে এবং হেডসেটের সংযোগ বিচ্ছিন্ন হবে। চালু করতে আবার পাঠান।", payloads_description_ports: "PS5-এ ব্যবহৃত পোর্ট এবং প্রতিটিতে সাধারণত কী শোনে, যাতে বোঝা যায় কোন পেলোড কোথায়। যেগুলোর ওয়েব পেজ আছে সেগুলো ব্রাউজারে খুলুন।", "ports.scan": "আবার স্ক্যান করুন", "ports.scanning": "PS5-এর প্রতিটি পোর্ট পরীক্ষা করা হচ্ছে…", diff --git a/client/src/i18n/locales/de.ts b/client/src/i18n/locales/de.ts index 7329f6eb..aa0f8e9a 100644 --- a/client/src/i18n/locales/de.ts +++ b/client/src/i18n/locales/de.ts @@ -3967,6 +3967,42 @@ hub_stop: "Stoppen", hub_manage: "Verwalten", hub_pm_summary: "Gespeicherte Payloads: {n}", hub_smp_summary: "Eingehängte Spiel-Images: {n}", +hb_no_answer: "HearBridge hat nicht geantwortet", +hb_state_streaming: "Überträgt", +hb_state_connecting: "Verbindet", +hb_state_reconnecting: "Verbindet erneut", +hb_state_scanning: "Sucht", +hb_state_waiting: "Wartet auf ein Headset", +hb_state_error: "Fehler", +hb_battery: "Akku {pct} %", +hb_link: "Verbindung {score} %", +hb_latency: "{ms} ms Verzögerung", +hb_muted: "Stumm", +hb_volume: "Lautstärke {pct} %", +hb_game: "Profil: {name}", +hb_scan_confirm_title: "Nach Headsets suchen?", +hb_scan_confirm_body: "Während der Suche (etwa 12 Sekunden) ist der Ton zum Headset unterbrochen.", +hb_scan: "Nach Headsets suchen", +hb_open_page: "HearBridge-Seite öffnen", +hb_action_failed: "HearBridge hat die Änderung nicht übernommen", +hb_volume_label: "Lautstärke", +hb_mute: "Stumm", +hb_latency_label: "Latenz", +hb_latency_auto: "Automatisch", +hb_night: "Nachtmodus", +hb_night_hint: "Leisere Explosionen, klarere Stimmen.", +hb_headsets: "Headsets", +hb_reconnect: "Neu verbinden", +hb_current: "In Verwendung", +hb_switch: "Zu diesem wechseln", +hb_saved_empty: "Noch keine gespeicherten Headsets. Such nach einem oder kopple es auf der HearBridge-Seite.", +hb_scan_found: "Bei der letzten Suche gefunden:", +hb_scan_empty: "Nichts gefunden. Versetz das Headset in den Kopplungsmodus und such erneut.", +hb_connect: "Verbinden", +hb_events: "Letzte Ereignisse", +hb_page_hint: "Ein neues Headset koppeln, der Equalizer, Sicherungen und alle anderen Einstellungen sind auf der eigenen Seite von HearBridge.", +hb_stop_title: "HearBridge beenden?", +hb_stop_body: "HearBridge wird beendet und das Headset getrennt. Sende es erneut, um es zu starten.", payloads_description_ports: "Die belegten Ports auf der PS5 und was dort üblicherweise lauscht, damit du siehst, welche Payload wo läuft. Ports mit Webseite kannst du im Browser öffnen.", "ports.scan": "Erneut prüfen", "ports.scanning": "Alle Ports der PS5 werden geprüft…", diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts index 8502521c..aeae4a65 100644 --- a/client/src/i18n/locales/en.ts +++ b/client/src/i18n/locales/en.ts @@ -4282,6 +4282,42 @@ hub_stop: "Stop", hub_manage: "Manage", hub_pm_summary: "Payloads stored: {n}", hub_smp_summary: "Game images mounted: {n}", +hb_no_answer: "HearBridge didn't answer", +hb_state_streaming: "Streaming", +hb_state_connecting: "Connecting", +hb_state_reconnecting: "Reconnecting", +hb_state_scanning: "Scanning", +hb_state_waiting: "Waiting for a headset", +hb_state_error: "Error", +hb_battery: "Battery {pct}%", +hb_link: "Link {score}%", +hb_latency: "{ms} ms delay", +hb_muted: "Muted", +hb_volume: "Volume {pct}%", +hb_game: "Profile: {name}", +hb_scan_confirm_title: "Scan for headsets?", +hb_scan_confirm_body: "Scanning stops the headset's audio while it runs (about 12 seconds).", +hb_scan: "Scan for headsets", +hb_open_page: "Open HearBridge page", +hb_action_failed: "HearBridge didn't take that change", +hb_volume_label: "Volume", +hb_mute: "Mute", +hb_latency_label: "Latency", +hb_latency_auto: "Automatic", +hb_night: "Night mode", +hb_night_hint: "Quieter explosions, clearer voices.", +hb_headsets: "Headsets", +hb_reconnect: "Reconnect", +hb_current: "In use", +hb_switch: "Switch to this one", +hb_saved_empty: "No saved headsets yet. Scan for one, or pair it on HearBridge's page.", +hb_scan_found: "Found in the last scan:", +hb_scan_empty: "Nothing found. Put the headset in pairing mode and scan again.", +hb_connect: "Connect", +hb_events: "Recent events", +hb_page_hint: "Pairing a new headset, the equalizer, backups and every other setting are on HearBridge's own page.", +hb_stop_title: "Stop HearBridge?", +hb_stop_body: "HearBridge closes and the headset disconnects. Send it again to start it.", payloads_description_ports: "The ports in use on the PS5 and what usually listens on each, so you can tell which payload is where. Open the ones with a web page in your browser.", "ports.scan": "Scan again", "ports.scanning": "Checking every port on the PS5…", diff --git a/client/src/i18n/locales/es.ts b/client/src/i18n/locales/es.ts index 6aa0b636..c7f48c35 100644 --- a/client/src/i18n/locales/es.ts +++ b/client/src/i18n/locales/es.ts @@ -3909,6 +3909,42 @@ hub_stop: "Detener", hub_manage: "Gestionar", hub_pm_summary: "Payloads guardados: {n}", hub_smp_summary: "Imágenes de juego montadas: {n}", +hb_no_answer: "HearBridge no respondió", +hb_state_streaming: "Transmitiendo", +hb_state_connecting: "Conectando", +hb_state_reconnecting: "Reconectando", +hb_state_scanning: "Buscando", +hb_state_waiting: "Esperando unos auriculares", +hb_state_error: "Error", +hb_battery: "Batería {pct} %", +hb_link: "Enlace {score} %", +hb_latency: "{ms} ms de retardo", +hb_muted: "Silenciado", +hb_volume: "Volumen {pct} %", +hb_game: "Perfil: {name}", +hb_scan_confirm_title: "¿Buscar auriculares?", +hb_scan_confirm_body: "La búsqueda corta el audio de los auriculares mientras dura (unos 12 segundos).", +hb_scan: "Buscar auriculares", +hb_open_page: "Abrir la página de HearBridge", +hb_action_failed: "HearBridge no aceptó ese cambio", +hb_volume_label: "Volumen", +hb_mute: "Silenciar", +hb_latency_label: "Latencia", +hb_latency_auto: "Automática", +hb_night: "Modo nocturno", +hb_night_hint: "Explosiones más suaves, voces más claras.", +hb_headsets: "Auriculares", +hb_reconnect: "Reconectar", +hb_current: "En uso", +hb_switch: "Cambiar a estos", +hb_saved_empty: "Aún no hay auriculares guardados. Busca unos o emparéjalos en la página de HearBridge.", +hb_scan_found: "Encontrados en la última búsqueda:", +hb_scan_empty: "No se encontró nada. Pon los auriculares en modo de emparejamiento y busca de nuevo.", +hb_connect: "Conectar", +hb_events: "Eventos recientes", +hb_page_hint: "Emparejar unos auriculares nuevos, el ecualizador, las copias de seguridad y el resto de ajustes están en la propia página de HearBridge.", +hb_stop_title: "¿Detener HearBridge?", +hb_stop_body: "HearBridge se cierra y los auriculares se desconectan. Envíalo de nuevo para iniciarlo.", payloads_description_ports: "Los puertos en uso en la PS5 y lo que suele escuchar en cada uno, para saber qué payload está en cada sitio. Abre en el navegador los que tienen página web.", "ports.scan": "Volver a comprobar", "ports.scanning": "Comprobando todos los puertos de la PS5…", diff --git a/client/src/i18n/locales/fa.ts b/client/src/i18n/locales/fa.ts index f60f1a12..5e15f7a8 100644 --- a/client/src/i18n/locales/fa.ts +++ b/client/src/i18n/locales/fa.ts @@ -4260,6 +4260,42 @@ hub_stop: "توقف", hub_manage: "مدیریت", hub_pm_summary: "پیلودهای ذخیره‌شده: {n}", hub_smp_summary: "ایمیج‌های بازی مونت‌شده: {n}", +hb_no_answer: "HearBridge پاسخ نداد", +hb_state_streaming: "در حال پخش", +hb_state_connecting: "در حال اتصال", +hb_state_reconnecting: "در حال اتصال دوباره", +hb_state_scanning: "در حال جست‌وجو", +hb_state_waiting: "در انتظار هدست", +hb_state_error: "خطا", +hb_battery: "باتری {pct}٪", +hb_link: "کیفیت اتصال {score}٪", +hb_latency: "تأخیر {ms} میلی‌ثانیه", +hb_muted: "بی‌صدا", +hb_volume: "صدا {pct}٪", +hb_game: "نمایه: {name}", +hb_scan_confirm_title: "هدست‌ها جست‌وجو شوند؟", +hb_scan_confirm_body: "در طول جست‌وجو (حدود ۱۲ ثانیه) صدای هدست قطع می‌شود.", +hb_scan: "جست‌وجوی هدست", +hb_open_page: "باز کردن صفحهٔ HearBridge", +hb_action_failed: "HearBridge این تغییر را نپذیرفت", +hb_volume_label: "صدا", +hb_mute: "بی‌صدا", +hb_latency_label: "تأخیر", +hb_latency_auto: "خودکار", +hb_night: "حالت شب", +hb_night_hint: "انفجارهای آرام‌تر، صداهای واضح‌تر.", +hb_headsets: "هدست‌ها", +hb_reconnect: "اتصال دوباره", +hb_current: "در حال استفاده", +hb_switch: "رفتن به این", +hb_saved_empty: "هنوز هدستی ذخیره نشده است. یکی را جست‌وجو کنید یا در صفحهٔ HearBridge جفت کنید.", +hb_scan_found: "در آخرین جست‌وجو پیدا شد:", +hb_scan_empty: "چیزی پیدا نشد. هدست را در حالت جفت‌سازی بگذارید و دوباره جست‌وجو کنید.", +hb_connect: "اتصال", +hb_events: "رویدادهای اخیر", +hb_page_hint: "جفت کردن هدست جدید، اکولایزر، پشتیبان‌ها و همهٔ تنظیمات دیگر در صفحهٔ خود HearBridge هستند.", +hb_stop_title: "HearBridge متوقف شود؟", +hb_stop_body: "HearBridge بسته می‌شود و اتصال هدست قطع می‌شود. برای اجرا دوباره آن را ارسال کنید.", payloads_description_ports: "پورت‌های در حال استفاده روی PS5 و آنچه معمولاً روی هرکدام گوش می‌دهد، تا بدانید کدام payload کجاست. آن‌هایی که صفحهٔ وب دارند را در مرورگر باز کنید.", "ports.scan": "بررسی دوباره", "ports.scanning": "در حال بررسی همهٔ پورت‌های PS5…", diff --git a/client/src/i18n/locales/fr.ts b/client/src/i18n/locales/fr.ts index b59b3c61..93351746 100644 --- a/client/src/i18n/locales/fr.ts +++ b/client/src/i18n/locales/fr.ts @@ -3977,6 +3977,42 @@ hub_stop: "Arrêter", hub_manage: "Gérer", hub_pm_summary: "Payloads stockés : {n}", hub_smp_summary: "Images de jeu montées : {n}", +hb_no_answer: "HearBridge n'a pas répondu", +hb_state_streaming: "Diffusion", +hb_state_connecting: "Connexion", +hb_state_reconnecting: "Reconnexion", +hb_state_scanning: "Recherche", +hb_state_waiting: "En attente d'un casque", +hb_state_error: "Erreur", +hb_battery: "Batterie {pct} %", +hb_link: "Liaison {score} %", +hb_latency: "{ms} ms de délai", +hb_muted: "Muet", +hb_volume: "Volume {pct} %", +hb_game: "Profil : {name}", +hb_scan_confirm_title: "Rechercher des casques ?", +hb_scan_confirm_body: "La recherche coupe le son du casque pendant qu'elle dure (environ 12 secondes).", +hb_scan: "Rechercher des casques", +hb_open_page: "Ouvrir la page HearBridge", +hb_action_failed: "HearBridge n'a pas accepté ce changement", +hb_volume_label: "Volume", +hb_mute: "Couper le son", +hb_latency_label: "Latence", +hb_latency_auto: "Automatique", +hb_night: "Mode nuit", +hb_night_hint: "Explosions plus douces, voix plus claires.", +hb_headsets: "Casques", +hb_reconnect: "Reconnecter", +hb_current: "Utilisé", +hb_switch: "Passer à celui-ci", +hb_saved_empty: "Aucun casque enregistré pour l'instant. Lance une recherche ou associe-le sur la page HearBridge.", +hb_scan_found: "Trouvés lors de la dernière recherche :", +hb_scan_empty: "Rien trouvé. Mets le casque en mode d'association et relance la recherche.", +hb_connect: "Connecter", +hb_events: "Événements récents", +hb_page_hint: "L'association d'un nouveau casque, l'égaliseur, les sauvegardes et tous les autres réglages sont sur la page de HearBridge.", +hb_stop_title: "Arrêter HearBridge ?", +hb_stop_body: "HearBridge se ferme et le casque se déconnecte. Renvoie-le pour le relancer.", payloads_description_ports: "Les ports utilisés sur la PS5 et ce qui y écoute habituellement, pour savoir quel payload est où. Ouvrez dans le navigateur ceux qui ont une page web.", "ports.scan": "Analyser à nouveau", "ports.scanning": "Vérification de tous les ports de la PS5…", diff --git a/client/src/i18n/locales/hi.ts b/client/src/i18n/locales/hi.ts index eaf1d6d0..1940ab26 100644 --- a/client/src/i18n/locales/hi.ts +++ b/client/src/i18n/locales/hi.ts @@ -3975,6 +3975,42 @@ hub_stop: "रोकें", hub_manage: "प्रबंधित करें", hub_pm_summary: "सहेजे गए पेलोड: {n}", hub_smp_summary: "माउंट की गई गेम इमेज: {n}", +hb_no_answer: "HearBridge ने जवाब नहीं दिया", +hb_state_streaming: "स्ट्रीम हो रहा है", +hb_state_connecting: "कनेक्ट हो रहा है", +hb_state_reconnecting: "फिर से कनेक्ट हो रहा है", +hb_state_scanning: "स्कैन हो रहा है", +hb_state_waiting: "हेडसेट का इंतज़ार", +hb_state_error: "त्रुटि", +hb_battery: "बैटरी {pct}%", +hb_link: "कनेक्शन {score}%", +hb_latency: "{ms} ms देरी", +hb_muted: "म्यूट", +hb_volume: "वॉल्यूम {pct}%", +hb_game: "प्रोफ़ाइल: {name}", +hb_scan_confirm_title: "हेडसेट खोजें?", +hb_scan_confirm_body: "स्कैन चलने तक (लगभग 12 सेकंड) हेडसेट की आवाज़ रुकी रहती है।", +hb_scan: "हेडसेट खोजें", +hb_open_page: "HearBridge पेज खोलें", +hb_action_failed: "HearBridge ने यह बदलाव नहीं लिया", +hb_volume_label: "वॉल्यूम", +hb_mute: "म्यूट", +hb_latency_label: "लेटेंसी", +hb_latency_auto: "स्वचालित", +hb_night: "नाइट मोड", +hb_night_hint: "धीमे धमाके, साफ़ आवाज़ें।", +hb_headsets: "हेडसेट", +hb_reconnect: "फिर से कनेक्ट करें", +hb_current: "उपयोग में", +hb_switch: "इस पर स्विच करें", +hb_saved_empty: "अभी कोई सहेजा हुआ हेडसेट नहीं है। कोई खोजें, या HearBridge के पेज पर पेयर करें।", +hb_scan_found: "पिछले स्कैन में मिले:", +hb_scan_empty: "कुछ नहीं मिला। हेडसेट को पेयरिंग मोड में रखकर फिर से स्कैन करें।", +hb_connect: "कनेक्ट करें", +hb_events: "हाल की घटनाएँ", +hb_page_hint: "नया हेडसेट पेयर करना, इक्वलाइज़र, बैकअप और बाकी सभी सेटिंग HearBridge के अपने पेज पर हैं।", +hb_stop_title: "HearBridge रोकें?", +hb_stop_body: "HearBridge बंद हो जाएगा और हेडसेट डिस्कनेक्ट हो जाएगा। शुरू करने के लिए इसे फिर भेजें।", payloads_description_ports: "PS5 पर उपयोग में पोर्ट और हर एक पर आमतौर पर क्या सुनता है, ताकि पता चले कौन-सा पेलोड कहाँ है। जिनका वेब पेज है उन्हें ब्राउज़र में खोलें।", "ports.scan": "फिर से स्कैन करें", "ports.scanning": "PS5 के हर पोर्ट की जाँच हो रही है…", diff --git a/client/src/i18n/locales/hu.ts b/client/src/i18n/locales/hu.ts index 33685d31..29c887f0 100644 --- a/client/src/i18n/locales/hu.ts +++ b/client/src/i18n/locales/hu.ts @@ -4274,6 +4274,42 @@ hub_stop: "Leállítás", hub_manage: "Kezelés", hub_pm_summary: "Tárolt payloadok: {n}", hub_smp_summary: "Csatolt játéklemezképek: {n}", +hb_no_answer: "A HearBridge nem válaszolt", +hb_state_streaming: "Közvetít", +hb_state_connecting: "Csatlakozik", +hb_state_reconnecting: "Újracsatlakozik", +hb_state_scanning: "Keres", +hb_state_waiting: "Fejhallgatóra vár", +hb_state_error: "Hiba", +hb_battery: "Akkumulátor {pct}%", +hb_link: "Kapcsolat {score}%", +hb_latency: "{ms} ms késleltetés", +hb_muted: "Némítva", +hb_volume: "Hangerő {pct}%", +hb_game: "Profil: {name}", +hb_scan_confirm_title: "Fejhallgatók keresése?", +hb_scan_confirm_body: "A keresés idejére (kb. 12 másodperc) a fejhallgató hangja leáll.", +hb_scan: "Fejhallgatók keresése", +hb_open_page: "HearBridge oldal megnyitása", +hb_action_failed: "A HearBridge nem fogadta el a módosítást", +hb_volume_label: "Hangerő", +hb_mute: "Némítás", +hb_latency_label: "Késleltetés", +hb_latency_auto: "Automatikus", +hb_night: "Éjszakai mód", +hb_night_hint: "Halkabb robbanások, tisztább beszéd.", +hb_headsets: "Fejhallgatók", +hb_reconnect: "Újracsatlakozás", +hb_current: "Használatban", +hb_switch: "Váltás erre", +hb_saved_empty: "Még nincs mentett fejhallgató. Keress egyet, vagy párosítsd a HearBridge oldalán.", +hb_scan_found: "A legutóbbi keresés találatai:", +hb_scan_empty: "Nincs találat. Tedd a fejhallgatót párosítási módba, és keress újra.", +hb_connect: "Csatlakozás", +hb_events: "Legutóbbi események", +hb_page_hint: "Új fejhallgató párosítása, a hangszínszabályzó, a mentések és minden más beállítás a HearBridge saját oldalán van.", +hb_stop_title: "Leállítod a HearBridge-et?", +hb_stop_body: "A HearBridge bezárul, a fejhallgató lecsatlakozik. Az indításhoz küldd el újra.", payloads_description_ports: "A PS5-ön használt portok és hogy mi szokott rajtuk figyelni, így látod, melyik payload hol fut. A weboldallal rendelkezőket megnyithatod a böngészőben.", "ports.scan": "Újraellenőrzés", "ports.scanning": "A PS5 összes portjának ellenőrzése…", diff --git a/client/src/i18n/locales/id.ts b/client/src/i18n/locales/id.ts index bc8f7560..7027578a 100644 --- a/client/src/i18n/locales/id.ts +++ b/client/src/i18n/locales/id.ts @@ -3968,6 +3968,42 @@ hub_stop: "Hentikan", hub_manage: "Kelola", hub_pm_summary: "Payload tersimpan: {n}", hub_smp_summary: "Image game terpasang: {n}", +hb_no_answer: "HearBridge tidak menjawab", +hb_state_streaming: "Mengalirkan", +hb_state_connecting: "Menghubungkan", +hb_state_reconnecting: "Menghubungkan ulang", +hb_state_scanning: "Memindai", +hb_state_waiting: "Menunggu headset", +hb_state_error: "Galat", +hb_battery: "Baterai {pct}%", +hb_link: "Koneksi {score}%", +hb_latency: "jeda {ms} ms", +hb_muted: "Dibisukan", +hb_volume: "Volume suara {pct}%", +hb_game: "Profil: {name}", +hb_scan_confirm_title: "Pindai headset?", +hb_scan_confirm_body: "Pemindaian menghentikan audio headset selama berjalan (sekitar 12 detik).", +hb_scan: "Pindai headset", +hb_open_page: "Buka halaman HearBridge", +hb_action_failed: "HearBridge tidak menerima perubahan itu", +hb_volume_label: "Volume", +hb_mute: "Bisukan", +hb_latency_label: "Latensi", +hb_latency_auto: "Otomatis", +hb_night: "Mode malam", +hb_night_hint: "Ledakan lebih pelan, suara lebih jelas.", +hb_headsets: "Headset", +hb_reconnect: "Hubungkan ulang", +hb_current: "Digunakan", +hb_switch: "Beralih ke ini", +hb_saved_empty: "Belum ada headset tersimpan. Pindai satu, atau pasangkan di halaman HearBridge.", +hb_scan_found: "Ditemukan pada pemindaian terakhir:", +hb_scan_empty: "Tidak ada yang ditemukan. Masukkan headset ke mode pemasangan lalu pindai lagi.", +hb_connect: "Hubungkan", +hb_events: "Peristiwa terbaru", +hb_page_hint: "Memasangkan headset baru, ekualiser, cadangan, dan semua pengaturan lainnya ada di halaman HearBridge sendiri.", +hb_stop_title: "Hentikan HearBridge?", +hb_stop_body: "HearBridge ditutup dan headset terputus. Kirim lagi untuk menjalankannya.", payloads_description_ports: "Port yang dipakai di PS5 dan apa yang biasanya mendengarkan di tiap port, agar tahu payload mana di mana. Buka yang punya halaman web di browser.", "ports.scan": "Pindai lagi", "ports.scanning": "Memeriksa setiap port di PS5…", diff --git a/client/src/i18n/locales/it.ts b/client/src/i18n/locales/it.ts index 233a85c0..a86c8b90 100644 --- a/client/src/i18n/locales/it.ts +++ b/client/src/i18n/locales/it.ts @@ -3909,6 +3909,42 @@ hub_stop: "Ferma", hub_manage: "Gestisci", hub_pm_summary: "Payload memorizzati: {n}", hub_smp_summary: "Immagini di gioco montate: {n}", +hb_no_answer: "HearBridge non ha risposto", +hb_state_streaming: "In streaming", +hb_state_connecting: "Connessione", +hb_state_reconnecting: "Riconnessione", +hb_state_scanning: "Ricerca", +hb_state_waiting: "In attesa di cuffie", +hb_state_error: "Errore", +hb_battery: "Batteria {pct}%", +hb_link: "Collegamento {score}%", +hb_latency: "{ms} ms di ritardo", +hb_muted: "Disattivato", +hb_volume: "Volume al {pct}%", +hb_game: "Profilo: {name}", +hb_scan_confirm_title: "Cercare cuffie?", +hb_scan_confirm_body: "La ricerca interrompe l'audio delle cuffie mentre è in corso (circa 12 secondi).", +hb_scan: "Cerca cuffie", +hb_open_page: "Apri la pagina di HearBridge", +hb_action_failed: "HearBridge non ha accettato la modifica", +hb_volume_label: "Volume", +hb_mute: "Disattiva audio", +hb_latency_label: "Latenza", +hb_latency_auto: "Automatica", +hb_night: "Modalità notte", +hb_night_hint: "Esplosioni più attenuate, voci più chiare.", +hb_headsets: "Cuffie", +hb_reconnect: "Riconnetti", +hb_current: "In uso", +hb_switch: "Passa a queste", +hb_saved_empty: "Nessuna cuffia salvata. Cercane una o associala dalla pagina di HearBridge.", +hb_scan_found: "Trovate nell'ultima ricerca:", +hb_scan_empty: "Nessun risultato. Metti le cuffie in modalità di associazione e cerca di nuovo.", +hb_connect: "Connetti", +hb_events: "Eventi recenti", +hb_page_hint: "L'associazione di nuove cuffie, l'equalizzatore, i backup e tutte le altre impostazioni sono nella pagina di HearBridge.", +hb_stop_title: "Fermare HearBridge?", +hb_stop_body: "HearBridge si chiude e le cuffie si disconnettono. Invialo di nuovo per avviarlo.", payloads_description_ports: "Le porte in uso sulla PS5 e cosa di solito vi ascolta, per capire quale payload è dove. Apri nel browser quelle con una pagina web.", "ports.scan": "Controlla di nuovo", "ports.scanning": "Controllo di tutte le porte della PS5…", diff --git a/client/src/i18n/locales/ja.ts b/client/src/i18n/locales/ja.ts index d7bb7775..a87f83f4 100644 --- a/client/src/i18n/locales/ja.ts +++ b/client/src/i18n/locales/ja.ts @@ -3980,6 +3980,42 @@ hub_stop: "停止", hub_manage: "管理", hub_pm_summary: "保存済みペイロード: {n}", hub_smp_summary: "マウント済みゲームイメージ: {n}", +hb_no_answer: "HearBridge が応答しませんでした", +hb_state_streaming: "ストリーミング中", +hb_state_connecting: "接続中", +hb_state_reconnecting: "再接続中", +hb_state_scanning: "スキャン中", +hb_state_waiting: "ヘッドセット待ち", +hb_state_error: "エラー", +hb_battery: "バッテリー {pct}%", +hb_link: "接続品質 {score}%", +hb_latency: "遅延 {ms} ms", +hb_muted: "ミュート中", +hb_volume: "音量 {pct}%", +hb_game: "プロファイル: {name}", +hb_scan_confirm_title: "ヘッドセットを探しますか?", +hb_scan_confirm_body: "スキャン中(約 12 秒)はヘッドセットの音声が止まります。", +hb_scan: "ヘッドセットを探す", +hb_open_page: "HearBridge のページを開く", +hb_action_failed: "HearBridge が変更を受け付けませんでした", +hb_volume_label: "音量", +hb_mute: "ミュート", +hb_latency_label: "遅延", +hb_latency_auto: "自動", +hb_night: "ナイトモード", +hb_night_hint: "爆発音を抑え、声を聞き取りやすくします。", +hb_headsets: "ヘッドセット", +hb_reconnect: "再接続", +hb_current: "使用中", +hb_switch: "これに切り替える", +hb_saved_empty: "保存済みのヘッドセットはまだありません。スキャンするか、HearBridge のページでペアリングしてください。", +hb_scan_found: "前回のスキャンで見つかったもの:", +hb_scan_empty: "見つかりませんでした。ヘッドセットをペアリングモードにして、もう一度スキャンしてください。", +hb_connect: "接続", +hb_events: "最近のイベント", +hb_page_hint: "新しいヘッドセットのペアリング、イコライザー、バックアップなどの設定は HearBridge 自身のページにあります。", +hb_stop_title: "HearBridge を停止しますか?", +hb_stop_body: "HearBridge が終了し、ヘッドセットの接続が切れます。起動するには再送信してください。", payloads_description_ports: "PS5 で使用中のポートと、それぞれで通常待ち受けているもの。どのペイロードがどこにあるか分かります。Web ページがあるものはブラウザで開けます。", "ports.scan": "再スキャン", "ports.scanning": "PS5 の全ポートを確認中…", diff --git a/client/src/i18n/locales/ko.ts b/client/src/i18n/locales/ko.ts index 0e75328c..b5d47925 100644 --- a/client/src/i18n/locales/ko.ts +++ b/client/src/i18n/locales/ko.ts @@ -3978,6 +3978,42 @@ hub_stop: "중지", hub_manage: "관리", hub_pm_summary: "저장된 페이로드: {n}", hub_smp_summary: "마운트된 게임 이미지: {n}", +hb_no_answer: "HearBridge가 응답하지 않았습니다", +hb_state_streaming: "스트리밍 중", +hb_state_connecting: "연결 중", +hb_state_reconnecting: "다시 연결 중", +hb_state_scanning: "검색 중", +hb_state_waiting: "헤드셋 대기 중", +hb_state_error: "오류", +hb_battery: "배터리 {pct}%", +hb_link: "연결 품질 {score}%", +hb_latency: "지연 {ms}ms", +hb_muted: "음소거됨", +hb_volume: "볼륨 {pct}%", +hb_game: "프로필: {name}", +hb_scan_confirm_title: "헤드셋을 검색할까요?", +hb_scan_confirm_body: "검색하는 동안(약 12초) 헤드셋 오디오가 멈춥니다.", +hb_scan: "헤드셋 검색", +hb_open_page: "HearBridge 페이지 열기", +hb_action_failed: "HearBridge가 변경을 받아들이지 않았습니다", +hb_volume_label: "볼륨", +hb_mute: "음소거", +hb_latency_label: "지연 시간", +hb_latency_auto: "자동", +hb_night: "야간 모드", +hb_night_hint: "폭발음은 작게, 목소리는 더 또렷하게.", +hb_headsets: "헤드셋", +hb_reconnect: "다시 연결", +hb_current: "사용 중", +hb_switch: "이것으로 전환", +hb_saved_empty: "저장된 헤드셋이 아직 없습니다. 검색하거나 HearBridge 페이지에서 페어링하세요.", +hb_scan_found: "마지막 검색에서 찾은 항목:", +hb_scan_empty: "찾은 항목이 없습니다. 헤드셋을 페어링 모드로 두고 다시 검색하세요.", +hb_connect: "연결", +hb_events: "최근 이벤트", +hb_page_hint: "새 헤드셋 페어링, 이퀄라이저, 백업 등 다른 모든 설정은 HearBridge 자체 페이지에 있습니다.", +hb_stop_title: "HearBridge를 중지할까요?", +hb_stop_body: "HearBridge가 종료되고 헤드셋 연결이 끊어집니다. 시작하려면 다시 보내세요.", payloads_description_ports: "PS5에서 사용 중인 포트와 각 포트에서 보통 대기하는 것을 보여 줘, 어떤 페이로드가 어디 있는지 알 수 있습니다. 웹 페이지가 있는 포트는 브라우저에서 엽니다.", "ports.scan": "다시 검사", "ports.scanning": "PS5의 모든 포트를 확인하는 중…", diff --git a/client/src/i18n/locales/pl.ts b/client/src/i18n/locales/pl.ts index be27d80b..d5c9f622 100644 --- a/client/src/i18n/locales/pl.ts +++ b/client/src/i18n/locales/pl.ts @@ -4214,6 +4214,42 @@ hub_stop: "Zatrzymaj", hub_manage: "Zarządzaj", hub_pm_summary: "Zapisane payloady: {n}", hub_smp_summary: "Zamontowane obrazy gier: {n}", +hb_no_answer: "HearBridge nie odpowiedział", +hb_state_streaming: "Przesyła dźwięk", +hb_state_connecting: "Łączenie", +hb_state_reconnecting: "Ponowne łączenie", +hb_state_scanning: "Wyszukiwanie", +hb_state_waiting: "Czeka na słuchawki", +hb_state_error: "Błąd", +hb_battery: "Bateria {pct}%", +hb_link: "Łącze {score}%", +hb_latency: "opóźnienie {ms} ms", +hb_muted: "Wyciszony", +hb_volume: "Głośność {pct}%", +hb_game: "Profil: {name}", +hb_scan_confirm_title: "Wyszukać słuchawki?", +hb_scan_confirm_body: "Wyszukiwanie przerywa dźwięk w słuchawkach na czas jego trwania (około 12 sekund).", +hb_scan: "Wyszukaj słuchawki", +hb_open_page: "Otwórz stronę HearBridge", +hb_action_failed: "HearBridge nie przyjął tej zmiany", +hb_volume_label: "Głośność", +hb_mute: "Wycisz", +hb_latency_label: "Opóźnienie", +hb_latency_auto: "Automatyczne", +hb_night: "Tryb nocny", +hb_night_hint: "Cichsze wybuchy, wyraźniejsze głosy.", +hb_headsets: "Słuchawki", +hb_reconnect: "Połącz ponownie", +hb_current: "W użyciu", +hb_switch: "Przełącz na te", +hb_saved_empty: "Brak zapisanych słuchawek. Wyszukaj je albo sparuj na stronie HearBridge.", +hb_scan_found: "Znalezione podczas ostatniego wyszukiwania:", +hb_scan_empty: "Nic nie znaleziono. Przełącz słuchawki w tryb parowania i wyszukaj ponownie.", +hb_connect: "Połącz", +hb_events: "Ostatnie zdarzenia", +hb_page_hint: "Parowanie nowych słuchawek, korektor, kopie zapasowe i wszystkie inne ustawienia są na stronie HearBridge.", +hb_stop_title: "Zatrzymać HearBridge?", +hb_stop_body: "HearBridge się zamknie, a słuchawki rozłączą. Wyślij go ponownie, aby uruchomić.", payloads_description_ports: "Porty używane na PS5 i to, co zwykle na nich nasłuchuje, żeby wiedzieć, który payload jest gdzie. Te ze stroną WWW otwórz w przeglądarce.", "ports.scan": "Skanuj ponownie", "ports.scanning": "Sprawdzanie wszystkich portów PS5…", diff --git a/client/src/i18n/locales/pt-BR.ts b/client/src/i18n/locales/pt-BR.ts index 3711402d..5932ca46 100644 --- a/client/src/i18n/locales/pt-BR.ts +++ b/client/src/i18n/locales/pt-BR.ts @@ -3976,6 +3976,42 @@ hub_stop: "Parar", hub_manage: "Gerenciar", hub_pm_summary: "Payloads guardados: {n}", hub_smp_summary: "Imagens de jogo montadas: {n}", +hb_no_answer: "O HearBridge não respondeu", +hb_state_streaming: "Transmitindo", +hb_state_connecting: "Conectando", +hb_state_reconnecting: "Reconectando", +hb_state_scanning: "Procurando", +hb_state_waiting: "Aguardando um fone", +hb_state_error: "Erro", +hb_battery: "Bateria {pct}%", +hb_link: "Conexão {score}%", +hb_latency: "{ms} ms de atraso", +hb_muted: "Mudo", +hb_volume: "Volume em {pct}%", +hb_game: "Perfil: {name}", +hb_scan_confirm_title: "Procurar fones?", +hb_scan_confirm_body: "A busca interrompe o áudio do fone enquanto dura (cerca de 12 segundos).", +hb_scan: "Procurar fones", +hb_open_page: "Abrir a página do HearBridge", +hb_action_failed: "O HearBridge não aceitou a alteração", +hb_volume_label: "Volume", +hb_mute: "Silenciar", +hb_latency_label: "Latência", +hb_latency_auto: "Automática", +hb_night: "Modo noturno", +hb_night_hint: "Explosões mais baixas, vozes mais claras.", +hb_headsets: "Fones", +hb_reconnect: "Reconectar", +hb_current: "Em uso", +hb_switch: "Trocar para este", +hb_saved_empty: "Ainda não há fones salvos. Procure um ou pareie na página do HearBridge.", +hb_scan_found: "Encontrados na última busca:", +hb_scan_empty: "Nada encontrado. Coloque o fone em modo de pareamento e procure de novo.", +hb_connect: "Conectar", +hb_events: "Eventos recentes", +hb_page_hint: "Parear um fone novo, o equalizador, os backups e todas as outras opções ficam na própria página do HearBridge.", +hb_stop_title: "Parar o HearBridge?", +hb_stop_body: "O HearBridge fecha e o fone desconecta. Envie-o de novo para iniciar.", payloads_description_ports: "As portas em uso no PS5 e o que costuma escutar em cada uma, para saber qual payload está onde. Abra no navegador as que têm página web.", "ports.scan": "Verificar de novo", "ports.scanning": "Verificando todas as portas do PS5…", diff --git a/client/src/i18n/locales/ru.ts b/client/src/i18n/locales/ru.ts index 67a74a8b..3a495903 100644 --- a/client/src/i18n/locales/ru.ts +++ b/client/src/i18n/locales/ru.ts @@ -3909,6 +3909,42 @@ hub_stop: "Остановить", hub_manage: "Управление", hub_pm_summary: "Сохранено нагрузок: {n}", hub_smp_summary: "Смонтировано образов игр: {n}", +hb_no_answer: "HearBridge не ответил", +hb_state_streaming: "Передаёт звук", +hb_state_connecting: "Подключение", +hb_state_reconnecting: "Переподключение", +hb_state_scanning: "Поиск", +hb_state_waiting: "Ожидание гарнитуры", +hb_state_error: "Ошибка", +hb_battery: "Батарея {pct}%", +hb_link: "Связь {score}%", +hb_latency: "задержка {ms} мс", +hb_muted: "Без звука", +hb_volume: "Громкость {pct}%", +hb_game: "Профиль: {name}", +hb_scan_confirm_title: "Искать гарнитуры?", +hb_scan_confirm_body: "Во время поиска (около 12 секунд) звук на гарнитуре прерывается.", +hb_scan: "Искать гарнитуры", +hb_open_page: "Открыть страницу HearBridge", +hb_action_failed: "HearBridge не принял изменение", +hb_volume_label: "Громкость", +hb_mute: "Без звука", +hb_latency_label: "Задержка", +hb_latency_auto: "Автоматически", +hb_night: "Ночной режим", +hb_night_hint: "Тише взрывы, чётче голоса.", +hb_headsets: "Гарнитуры", +hb_reconnect: "Переподключить", +hb_current: "Используется", +hb_switch: "Переключиться на эту", +hb_saved_empty: "Сохранённых гарнитур пока нет. Найдите гарнитуру поиском или сопрягите её на странице HearBridge.", +hb_scan_found: "Найдено при последнем поиске:", +hb_scan_empty: "Ничего не найдено. Переведите гарнитуру в режим сопряжения и повторите поиск.", +hb_connect: "Подключить", +hb_events: "Последние события", +hb_page_hint: "Сопряжение новой гарнитуры, эквалайзер, резервные копии и все прочие настройки — на собственной странице HearBridge.", +hb_stop_title: "Остановить HearBridge?", +hb_stop_body: "HearBridge закроется, гарнитура отключится. Чтобы запустить его, отправьте его снова.", payloads_description_ports: "Занятые порты на PS5 и что обычно на них слушает, чтобы понять, какой payload где. Порты с веб-страницей можно открыть в браузере.", "ports.scan": "Проверить снова", "ports.scanning": "Проверка всех портов PS5…", diff --git a/client/src/i18n/locales/th.ts b/client/src/i18n/locales/th.ts index 98725738..2292f072 100644 --- a/client/src/i18n/locales/th.ts +++ b/client/src/i18n/locales/th.ts @@ -3968,6 +3968,42 @@ hub_stop: "หยุด", hub_manage: "จัดการ", hub_pm_summary: "เพย์โหลดที่เก็บไว้: {n}", hub_smp_summary: "อิมเมจเกมที่เมานต์: {n}", +hb_no_answer: "HearBridge ไม่ตอบสนอง", +hb_state_streaming: "กำลังสตรีม", +hb_state_connecting: "กำลังเชื่อมต่อ", +hb_state_reconnecting: "กำลังเชื่อมต่อใหม่", +hb_state_scanning: "กำลังสแกน", +hb_state_waiting: "รอหูฟัง", +hb_state_error: "ข้อผิดพลาด", +hb_battery: "แบตเตอรี่ {pct}%", +hb_link: "การเชื่อมต่อ {score}%", +hb_latency: "ดีเลย์ {ms} ms", +hb_muted: "ปิดเสียง", +hb_volume: "ระดับเสียง {pct}%", +hb_game: "โปรไฟล์: {name}", +hb_scan_confirm_title: "สแกนหาหูฟังหรือไม่", +hb_scan_confirm_body: "ระหว่างสแกน (ประมาณ 12 วินาที) เสียงที่หูฟังจะหยุด", +hb_scan: "สแกนหาหูฟัง", +hb_open_page: "เปิดหน้า HearBridge", +hb_action_failed: "HearBridge ไม่รับการเปลี่ยนแปลงนั้น", +hb_volume_label: "ระดับเสียง", +hb_mute: "ปิดเสียง", +hb_latency_label: "ความหน่วง", +hb_latency_auto: "อัตโนมัติ", +hb_night: "โหมดกลางคืน", +hb_night_hint: "เสียงระเบิดเบาลง เสียงพูดชัดขึ้น", +hb_headsets: "หูฟัง", +hb_reconnect: "เชื่อมต่อใหม่", +hb_current: "กำลังใช้", +hb_switch: "สลับไปใช้อันนี้", +hb_saved_empty: "ยังไม่มีหูฟังที่บันทึกไว้ สแกนหา หรือจับคู่ในหน้า HearBridge", +hb_scan_found: "พบในการสแกนครั้งล่าสุด:", +hb_scan_empty: "ไม่พบสิ่งใด ตั้งหูฟังเป็นโหมดจับคู่แล้วสแกนอีกครั้ง", +hb_connect: "เชื่อมต่อ", +hb_events: "เหตุการณ์ล่าสุด", +hb_page_hint: "การจับคู่หูฟังใหม่ อีควอไลเซอร์ การสำรองข้อมูล และการตั้งค่าอื่นทั้งหมดอยู่ในหน้าของ HearBridge เอง", +hb_stop_title: "หยุด HearBridge หรือไม่", +hb_stop_body: "HearBridge จะปิดและหูฟังจะตัดการเชื่อมต่อ ส่งอีกครั้งเพื่อเริ่มใหม่", payloads_description_ports: "พอร์ตที่ใช้งานอยู่บน PS5 และสิ่งที่มักรอรับบนแต่ละพอร์ต เพื่อให้รู้ว่าเพย์โหลดไหนอยู่ที่ไหน เปิดพอร์ตที่มีหน้าเว็บในเบราว์เซอร์ได้", "ports.scan": "สแกนอีกครั้ง", "ports.scanning": "กำลังตรวจทุกพอร์ตบน PS5…", diff --git a/client/src/i18n/locales/tr.ts b/client/src/i18n/locales/tr.ts index bf03bce9..ad05fd46 100644 --- a/client/src/i18n/locales/tr.ts +++ b/client/src/i18n/locales/tr.ts @@ -3966,6 +3966,42 @@ hub_stop: "Durdur", hub_manage: "Yönet", hub_pm_summary: "Saklanan payload: {n}", hub_smp_summary: "Bağlı oyun imajları: {n}", +hb_no_answer: "HearBridge yanıt vermedi", +hb_state_streaming: "Aktarıyor", +hb_state_connecting: "Bağlanıyor", +hb_state_reconnecting: "Yeniden bağlanıyor", +hb_state_scanning: "Taranıyor", +hb_state_waiting: "Kulaklık bekleniyor", +hb_state_error: "Hata", +hb_battery: "Pil %{pct}", +hb_link: "Bağlantı %{score}", +hb_latency: "{ms} ms gecikme", +hb_muted: "Sessiz", +hb_volume: "Ses %{pct}", +hb_game: "Profil: {name}", +hb_scan_confirm_title: "Kulaklık aransın mı?", +hb_scan_confirm_body: "Tarama sürerken (yaklaşık 12 saniye) kulaklığın sesi kesilir.", +hb_scan: "Kulaklık ara", +hb_open_page: "HearBridge sayfasını aç", +hb_action_failed: "HearBridge bu değişikliği almadı", +hb_volume_label: "Ses", +hb_mute: "Sessize al", +hb_latency_label: "Gecikme", +hb_latency_auto: "Otomatik", +hb_night: "Gece modu", +hb_night_hint: "Daha sessiz patlamalar, daha net sesler.", +hb_headsets: "Kulaklıklar", +hb_reconnect: "Yeniden bağlan", +hb_current: "Kullanımda", +hb_switch: "Buna geç", +hb_saved_empty: "Henüz kayıtlı kulaklık yok. Birini arayın ya da HearBridge sayfasında eşleyin.", +hb_scan_found: "Son taramada bulunanlar:", +hb_scan_empty: "Hiçbir şey bulunamadı. Kulaklığı eşleme moduna alıp yeniden tarayın.", +hb_connect: "Bağlan", +hb_events: "Son olaylar", +hb_page_hint: "Yeni kulaklık eşleme, ekolayzer, yedekler ve diğer tüm ayarlar HearBridge'in kendi sayfasında.", +hb_stop_title: "HearBridge durdurulsun mu?", +hb_stop_body: "HearBridge kapanır ve kulaklığın bağlantısı kesilir. Başlatmak için yeniden gönderin.", payloads_description_ports: "PS5'te kullanılan portlar ve her birinde genellikle neyin dinlediği; hangi payload'un nerede olduğunu görürsün. Web sayfası olanları tarayıcıda aç.", "ports.scan": "Yeniden tara", "ports.scanning": "PS5'in tüm portları kontrol ediliyor…", diff --git a/client/src/i18n/locales/vi.ts b/client/src/i18n/locales/vi.ts index 28a306ab..710cf290 100644 --- a/client/src/i18n/locales/vi.ts +++ b/client/src/i18n/locales/vi.ts @@ -3961,6 +3961,42 @@ hub_stop: "Dừng", hub_manage: "Quản lý", hub_pm_summary: "Payload đã lưu: {n}", hub_smp_summary: "Ảnh game đã gắn: {n}", +hb_no_answer: "HearBridge không phản hồi", +hb_state_streaming: "Đang phát", +hb_state_connecting: "Đang kết nối", +hb_state_reconnecting: "Đang kết nối lại", +hb_state_scanning: "Đang quét", +hb_state_waiting: "Đang chờ tai nghe", +hb_state_error: "Lỗi", +hb_battery: "Pin {pct}%", +hb_link: "Kết nối {score}%", +hb_latency: "độ trễ {ms} ms", +hb_muted: "Đã tắt tiếng", +hb_volume: "Âm lượng {pct}%", +hb_game: "Hồ sơ: {name}", +hb_scan_confirm_title: "Quét tìm tai nghe?", +hb_scan_confirm_body: "Trong lúc quét (khoảng 12 giây) âm thanh tới tai nghe sẽ dừng.", +hb_scan: "Quét tìm tai nghe", +hb_open_page: "Mở trang HearBridge", +hb_action_failed: "HearBridge không nhận thay đổi đó", +hb_volume_label: "Âm lượng", +hb_mute: "Tắt tiếng", +hb_latency_label: "Độ trễ", +hb_latency_auto: "Tự động", +hb_night: "Chế độ ban đêm", +hb_night_hint: "Tiếng nổ nhỏ hơn, giọng nói rõ hơn.", +hb_headsets: "Tai nghe", +hb_reconnect: "Kết nối lại", +hb_current: "Đang dùng", +hb_switch: "Chuyển sang cái này", +hb_saved_empty: "Chưa có tai nghe đã lưu. Hãy quét tìm, hoặc ghép nối trên trang HearBridge.", +hb_scan_found: "Tìm thấy trong lần quét gần nhất:", +hb_scan_empty: "Không tìm thấy gì. Đặt tai nghe ở chế độ ghép nối rồi quét lại.", +hb_connect: "Kết nối", +hb_events: "Sự kiện gần đây", +hb_page_hint: "Ghép nối tai nghe mới, bộ chỉnh âm, sao lưu và mọi cài đặt khác nằm trên trang riêng của HearBridge.", +hb_stop_title: "Dừng HearBridge?", +hb_stop_body: "HearBridge sẽ đóng và tai nghe ngắt kết nối. Gửi lại để chạy nó.", payloads_description_ports: "Các cổng đang dùng trên PS5 và thứ thường lắng nghe ở mỗi cổng, để biết payload nào ở đâu. Mở các cổng có trang web trong trình duyệt.", "ports.scan": "Quét lại", "ports.scanning": "Đang kiểm tra mọi cổng trên PS5…", diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 5f91e199..6e6cf687 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -3915,6 +3915,42 @@ hub_stop: "停止", hub_manage: "管理", hub_pm_summary: "已存储的 Payload:{n}", hub_smp_summary: "已挂载的游戏镜像:{n}", +hb_no_answer: "HearBridge 没有响应", +hb_state_streaming: "正在传输", +hb_state_connecting: "正在连接", +hb_state_reconnecting: "正在重新连接", +hb_state_scanning: "正在扫描", +hb_state_waiting: "等待耳机", +hb_state_error: "错误", +hb_battery: "电量 {pct}%", +hb_link: "连接质量 {score}%", +hb_latency: "延迟 {ms} 毫秒", +hb_muted: "已静音", +hb_volume: "音量 {pct}%", +hb_game: "配置:{name}", +hb_scan_confirm_title: "扫描耳机?", +hb_scan_confirm_body: "扫描期间(约 12 秒)耳机会暂停出声。", +hb_scan: "扫描耳机", +hb_open_page: "打开 HearBridge 页面", +hb_action_failed: "HearBridge 没有接受这项更改", +hb_volume_label: "音量", +hb_mute: "静音", +hb_latency_label: "延迟", +hb_latency_auto: "自动", +hb_night: "夜间模式", +hb_night_hint: "爆炸声更柔和,人声更清晰。", +hb_headsets: "耳机", +hb_reconnect: "重新连接", +hb_current: "使用中", +hb_switch: "切换到这个", +hb_saved_empty: "还没有已保存的耳机。扫描一个,或在 HearBridge 页面上配对。", +hb_scan_found: "上次扫描找到:", +hb_scan_empty: "没有找到。请让耳机进入配对模式后再扫描。", +hb_connect: "连接", +hb_events: "最近事件", +hb_page_hint: "配对新耳机、均衡器、备份及其他所有设置都在 HearBridge 自己的页面上。", +hb_stop_title: "停止 HearBridge?", +hb_stop_body: "HearBridge 将关闭,耳机断开连接。重新发送即可启动。", payloads_description_ports: "PS5 上正在使用的端口以及通常在每个端口上监听的程序,方便你知道哪个 payload 在哪里。有网页的端口可以在浏览器中打开。", "ports.scan": "重新扫描", "ports.scanning": "正在检查 PS5 的所有端口…", diff --git a/client/src/i18n/locales/zh-TW.ts b/client/src/i18n/locales/zh-TW.ts index fad68c0a..302d6688 100644 --- a/client/src/i18n/locales/zh-TW.ts +++ b/client/src/i18n/locales/zh-TW.ts @@ -3909,6 +3909,42 @@ hub_stop: "停止", hub_manage: "管理", hub_pm_summary: "已儲存的 Payload:{n}", hub_smp_summary: "已掛載的遊戲映像:{n}", +hb_no_answer: "HearBridge 沒有回應", +hb_state_streaming: "正在串流", +hb_state_connecting: "正在連線", +hb_state_reconnecting: "正在重新連線", +hb_state_scanning: "正在掃描", +hb_state_waiting: "等待耳機", +hb_state_error: "錯誤", +hb_battery: "電量 {pct}%", +hb_link: "連線品質 {score}%", +hb_latency: "延遲 {ms} 毫秒", +hb_muted: "已靜音", +hb_volume: "音量 {pct}%", +hb_game: "設定檔:{name}", +hb_scan_confirm_title: "掃描耳機?", +hb_scan_confirm_body: "掃描期間(約 12 秒)耳機會暫停出聲。", +hb_scan: "掃描耳機", +hb_open_page: "開啟 HearBridge 頁面", +hb_action_failed: "HearBridge 沒有接受這項變更", +hb_volume_label: "音量", +hb_mute: "靜音", +hb_latency_label: "延遲", +hb_latency_auto: "自動", +hb_night: "夜間模式", +hb_night_hint: "爆炸聲更柔和,人聲更清晰。", +hb_headsets: "耳機", +hb_reconnect: "重新連線", +hb_current: "使用中", +hb_switch: "切換到這個", +hb_saved_empty: "還沒有已儲存的耳機。掃描一個,或在 HearBridge 頁面上配對。", +hb_scan_found: "上次掃描找到:", +hb_scan_empty: "沒有找到。請讓耳機進入配對模式後再掃描。", +hb_connect: "連線", +hb_events: "最近事件", +hb_page_hint: "配對新耳機、等化器、備份及其他所有設定都在 HearBridge 自己的頁面上。", +hb_stop_title: "停止 HearBridge?", +hb_stop_body: "HearBridge 將關閉,耳機中斷連線。重新傳送即可啟動。", payloads_description_ports: "PS5 上正在使用的連接埠,以及通常在各連接埠上監聽的程式,方便你知道哪個 payload 在哪裡。有網頁的連接埠可以在瀏覽器中開啟。", "ports.scan": "重新掃描", "ports.scanning": "正在檢查 PS5 的所有連接埠…", diff --git a/client/src/screens/Payloads/hub/OnPs5Panel.tsx b/client/src/screens/Payloads/hub/OnPs5Panel.tsx index 09c97252..0e0e0998 100644 --- a/client/src/screens/Payloads/hub/OnPs5Panel.tsx +++ b/client/src/screens/Payloads/hub/OnPs5Panel.tsx @@ -42,6 +42,8 @@ import NanoDnsScreen from "../../NanoDns"; import SmpPanel from "../../Library/SmpPanel"; import PayloadManagerPanel from "../PayloadManagerPanel"; import { PmSummary, SmpSummary } from "./summaries"; +import { HbSummary, HearBridgePanel, useHearBridge } from "./hearbridge"; +import { hbAct } from "../../../api/hearbridge"; import { detectHubCards, hubEntries, @@ -185,16 +187,26 @@ function OnPs5Body() { void refreshMarkers(); }, [refreshMarkers]); + // HearBridge's own API is read while its card is on screen (or a link opened it); an + // answer also counts as running, should its process go by another name. + const hbWanted = + openId === "hearbridge" || + detectHubCards({ entries, processes: procs, markers }).some((c) => c.integration === "hearbridge"); + const hb = useHearBridge(host, online && hbWanted); + const hbPresent = hb.view?.present === true; const cards = useMemo( () => detectHubCards({ entries, processes: procs, markers, + answering: hbPresent ? new Set(["hearbridge"]) : undefined, forceShow: openId, }), - [entries, procs, markers, openId], + [entries, procs, markers, openId, hbPresent], ); + const hbVersion = hb.view?.present ? hb.view.status.version || null : null; + const hbPort = hb.view?.present ? hb.view.port : null; const report = useCallback((id: string, r: IntegrationReport) => { setReports((prev) => { @@ -203,6 +215,9 @@ function OnPs5Body() { return { ...prev, [id]: r }; }); }, []); + useEffect(() => { + if (hbVersion || hbPort) report("hearbridge", { version: hbVersion, webPort: hbPort }); + }, [hbVersion, hbPort, report]); // The newest stable release of each card whose running version is known, so the card // can offer an update. Desktop only (the catalogue's GitHub lookups live there). @@ -228,6 +243,17 @@ function OnPs5Body() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [native, versionKey]); + // A link that opened a card (an old tab's URL, a notification) scrolls to it once. + const scrolledTo = useRef(null); + const openShown = !!openId && cards.some((c) => c.id === openId); + useEffect(() => { + if (!openId || !openShown || scrolledTo.current === openId) return; + scrolledTo.current = openId; + document + .querySelector(`[data-card="${CSS.escape(openId)}"]`) + ?.scrollIntoView({ block: "start", behavior: "smooth" }); + }, [openId, openShown]); + const setOpen = (id: string | null) => { const next = new URLSearchParams(params); if (id) next.set("open", id); @@ -236,6 +262,7 @@ function OnPs5Body() { }; const stop = async (card: HubCard) => { + if (card.integration === "hearbridge" && hbPresent) return stopHearBridge(); const p = card.processes[0]; if (!p) return; const kind = killPrompt(p); @@ -266,6 +293,32 @@ function OnPs5Body() { } }; + /** HearBridge has a clean stop of its own (it closes the headset link first). */ + const stopHearBridge = async () => { + const ok = await confirm({ + title: tr("hb_stop_title", undefined, "Stop HearBridge?"), + message: tr( + "hb_stop_body", + undefined, + "HearBridge closes and the headset disconnects. Send it again to start it.", + ), + confirmLabel: tr("hub_stop", undefined, "Stop"), + destructive: true, + }); + if (!ok) return; + setBusy("hearbridge"); + try { + await hbAct(host, { action: "stop" }); + log.info("payloads", "stopped HearBridge"); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(null); + await hb.refresh(); + await refreshProcs(); + } + }; + const update = async (card: HubCard, tag: string) => { const ok = await confirm({ title: tr("hub_update_confirm_title", { name: card.name, version: tag }, `Update ${card.name} to ${tag}?`), @@ -380,14 +433,23 @@ function OnPs5Body() { onToggle={() => setOpen(openId === c.id ? null : c.id)} onStop={() => void stop(c)} onUpdate={(t) => void update(c, t)} + canStop={c.integration === "hearbridge" && hbPresent ? true : undefined} summary={ - c.integration - ? (fallback) => ( - - ) - : null + c.integration === "hearbridge" + ? () => + : c.integration + ? (fallback) => ( + + ) + : null + } + panel={ + c.integration === "hearbridge" ? ( + + ) : c.integration ? ( + + ) : null } - panel={c.integration ? : null} /> ); })} @@ -432,6 +494,7 @@ function HubCardView({ webPort, newer, catalogued, + canStop: canStopOverride, busy, expanded, onToggle, @@ -447,6 +510,8 @@ function HubCardView({ newer: string | null; /** The catalogue was read, so "not in the catalogue" can be said (not in the web UI). */ catalogued: boolean; + /** Overrides the process-based Stop (an integration with its own clean stop). */ + canStop?: boolean; busy: boolean; expanded: boolean; onToggle: () => void; @@ -459,7 +524,7 @@ function HubCardView({ const running = card.state === "running"; const link = running && webPort ? `http://${withPort(host.trim(), webPort)}/` : null; const proc = card.processes[0]; - const canStop = running && !!proc && killPrompt(proc) !== null; + const canStop = canStopOverride ?? (running && !!proc && killPrompt(proc) !== null); const stateBadge = card.state === "running" ? ( diff --git a/client/src/screens/Payloads/hub/hearbridge.test.tsx b/client/src/screens/Payloads/hub/hearbridge.test.tsx new file mode 100644 index 00000000..501e43cc --- /dev/null +++ b/client/src/screens/Payloads/hub/hearbridge.test.tsx @@ -0,0 +1,88 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { parseHbStatus, type HbOverview } from "../../../api/hearbridge"; +import { HbSummary, HearBridgePanel, hbPageUrl, hbSummaryParts } from "./hearbridge"; + +// No DOM here (vitest runs in node): static markup, asserting on what is shown. +const tr = ((_k: string, vars: Record | undefined, fallback: string) => + fallback.replace(/\{(\w+)\}/g, (_, n) => String(vars?.[n] ?? ""))) as never; + +const streaming = parseHbStatus({ + version: "1.9.0", + state: "streaming", + device: "WH-1000XM4", + gain_pct: 140, + muted: 0, + codec: "SBC", + latency: { target_ms: 120, estimate_ms: 185 }, + lat_extra: { auto: 0 }, + battery: { pct: 70 }, + link: { score: 88 }, + night: { on: 0 }, + game: { name: "Astro Bot", profile: 1 }, + events: ["Connected", "Streaming"], +}); + +const present: HbOverview = { present: true, port: 8091, status: streaming }; +const noop = () => undefined; +const noopAsync = async () => undefined; + +describe("HearBridge card", () => { + it("sums up the headset in one line", () => { + expect(hbSummaryParts(streaming, tr)).toEqual([ + "Streaming", + "WH-1000XM4", + "Battery 70%", + "Link 88%", + "185 ms delay", + "SBC", + "Volume 140%", + "Profile: Astro Bot", + ]); + }); + + it("says less while waiting, and nothing it doesn't know", () => { + const waiting = parseHbStatus({ version: "0.3", state: "disconnected", muted: 1 }); + expect(hbSummaryParts(waiting, tr)).toEqual(["Waiting for a headset", "Muted"]); + }); + + it("says HearBridge didn't answer instead of failing", () => { + expect(renderToStaticMarkup()).toContain( + "HearBridge didn't answer", + ); + }); + + it("opens HearBridge's page on the port it answered on", () => { + expect(hbPageUrl("192.168.86.99", 8091)).toBe("http://192.168.86.99:8091/"); + expect(hbPageUrl("192.168.86.99", null)).toBe("http://192.168.86.99:8090/"); + }); +}); + +describe("HearBridge Manage panel", () => { + it("has the everyday controls and points to HearBridge's page for the rest", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('max="500"'); // volume, HearBridge's 0..500 % + expect(html).toContain('max="400"'); // fixed latency, 40..400 ms + expect(html).toContain("Night mode"); + expect(html).toContain("Reconnect"); + expect(html).toContain("Scan for headsets"); + expect(html).toContain("Recent events"); + expect(html).toContain("Open HearBridge page"); + }); + + it("falls back to the page link when HearBridge doesn't answer", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Open HearBridge page"); + expect(html).not.toContain('type="range"'); + }); +}); diff --git a/client/src/screens/Payloads/hub/hearbridge.tsx b/client/src/screens/Payloads/hub/hearbridge.tsx new file mode 100644 index 00000000..0e0982d7 --- /dev/null +++ b/client/src/screens/Payloads/hub/hearbridge.tsx @@ -0,0 +1,389 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Bluetooth, ExternalLink, RefreshCw, Search } from "lucide-react"; + +import { Badge, Button, Callout, Toggle } from "../../../components"; +import { useConfirm } from "../../../components/ConfirmDialog"; +import { + hbAct, + hbOverview, + hbSaved, + hbScanned, + type HbAction, + type HbOverview, + type HbSavedDevice, + type HbScannedDevice, + type HbStatus, +} from "../../../api/hearbridge"; +import { withPort } from "../../../lib/addr"; +import { openExternalUrl } from "../../../lib/openExternalUrl"; +import { usePoll } from "../../../lib/usePoll"; +import { useDocumentVisible } from "../../../lib/visibility"; +import { useTr } from "../../../state/lang"; +import { log } from "../../../state/logs"; + +/** HearBridge's status is re-read this often while its card is on screen. Each read + * counts as its page being open on the console side only for the volume re-query, so + * keep it unhurried. */ +const POLL_MS = 5000; +/** HearBridge's scan runs about 12 s (its own page says so). */ +const SCAN_MS = 13_000; + +export const HB_DEFAULT_PORT = 8090; + +/** HearBridge's status on `host`, kept fresh while `active`. `null` until first read. */ +export function useHearBridge(host: string, active: boolean) { + const visible = useDocumentVisible(); + const [view, setView] = useState(null); + const gen = useRef(0); + useEffect(() => { + gen.current++; + setView(null); + }, [host]); + const refresh = useCallback(async () => { + if (!host.trim()) return; + const g = gen.current; + try { + const v = await hbOverview(host); + if (g === gen.current) setView(v); + } catch (e) { + if (g === gen.current) setView({ present: false, error: e instanceof Error ? e.message : String(e) }); + } + }, [host]); + usePoll(refresh, POLL_MS, { host, enabled: active && visible && !!host.trim() }); + /** Use the status HearBridge sent back with a change, so the panel doesn't wait a poll. */ + const apply = useCallback((s: HbStatus | null) => { + if (!s) return; + setView((v) => (v && v.present ? { ...v, status: s } : v)); + }, []); + return { view, refresh, apply }; +} + +/** The card's one-line summary: state, headset, battery, link, latency, codec, volume, game. */ +export function HbSummary({ view }: { view: HbOverview | null }) { + const tr = useTr(); + if (!view) return <>{tr("hub_checking", undefined, "Checking what is on this PS5…")}; + if (!view.present) return <>{tr("hb_no_answer", undefined, "HearBridge didn't answer")}; + return <>{hbSummaryParts(view.status, tr).join(" · ")}; +} + +type Tr = ReturnType; + +export function hbStateLabel(state: string, tr: Tr): string { + switch (state) { + case "streaming": + return tr("hb_state_streaming", undefined, "Streaming"); + case "connecting": + return tr("hb_state_connecting", undefined, "Connecting"); + case "reconnecting": + return tr("hb_state_reconnecting", undefined, "Reconnecting"); + case "scanning": + return tr("hb_state_scanning", undefined, "Scanning"); + case "disconnected": + return tr("hb_state_waiting", undefined, "Waiting for a headset"); + case "error": + return tr("hb_state_error", undefined, "Error"); + default: + return state; + } +} + +export function hbSummaryParts(s: HbStatus, tr: Tr): string[] { + const parts: string[] = []; + if (s.state) parts.push(hbStateLabel(s.state, tr)); + if (s.device) parts.push(s.device); + if (s.batteryPct !== null) parts.push(tr("hb_battery", { pct: s.batteryPct }, `Battery ${s.batteryPct}%`)); + if (s.linkScore !== null && s.state === "streaming") { + parts.push(tr("hb_link", { score: s.linkScore }, `Link ${s.linkScore}%`)); + } + const ms = s.latencyEstimateMs ?? s.latencyTargetMs; + if (ms !== null && s.state === "streaming") parts.push(tr("hb_latency", { ms }, `${ms} ms delay`)); + if (s.codec && s.state === "streaming") parts.push(s.codec); + if (s.muted) parts.push(tr("hb_muted", undefined, "Muted")); + else if (s.gainPct !== null) parts.push(tr("hb_volume", { pct: s.gainPct }, `Volume ${s.gainPct}%`)); + if (s.gameProfile && s.gameName) parts.push(tr("hb_game", { name: s.gameName }, `Profile: ${s.gameName}`)); + return parts; +} + +/** HearBridge's page on the console (pairing, EQ, backups and the rest live there). */ +export function hbPageUrl(host: string, port: number | null | undefined): string { + return `http://${withPort(host.trim(), port ?? HB_DEFAULT_PORT)}/`; +} + +/** Manage: the everyday controls. Everything else stays on HearBridge's own page. */ +export function HearBridgePanel({ + host, + view, + apply, + refresh, +}: { + host: string; + view: HbOverview | null; + apply: (s: HbStatus | null) => void; + refresh: () => Promise; +}) { + const tr = useTr(); + const { confirm, dialog } = useConfirm(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(null); + const [scanned, setScanned] = useState(null); + const [scanning, setScanning] = useState(false); + // Slider values while dragging; sent on release so a drag is one change, not dozens. + const [gainDraft, setGainDraft] = useState(null); + const [latDraft, setLatDraft] = useState(null); + const present = view?.present === true; + const port = view && view.present ? view.port : HB_DEFAULT_PORT; + const s = view && view.present ? view.status : null; + + const loadSaved = useCallback(async () => { + try { + setSaved(await hbSaved(host)); + } catch { + setSaved(null); + } + }, [host]); + useEffect(() => { + if (present) void loadSaved(); + }, [present, loadSaved]); + + const run = async (a: HbAction) => { + setBusy(true); + setError(null); + try { + apply(await hbAct(host, a)); + log.info("hearbridge", `${a.action} on ${host}`); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const scan = async () => { + if (s?.state === "streaming") { + const ok = await confirm({ + title: tr("hb_scan_confirm_title", undefined, "Scan for headsets?"), + message: tr( + "hb_scan_confirm_body", + undefined, + "Scanning stops the headset's audio while it runs (about 12 seconds).", + ), + confirmLabel: tr("hb_scan", undefined, "Scan for headsets"), + }); + if (!ok) return; + } + setScanning(true); + setScanned(null); + await run({ action: "scan" }); + window.setTimeout(() => { + hbScanned(host) + .then(setScanned) + .catch(() => setScanned([])) + .finally(() => setScanning(false)); + }, SCAN_MS); + }; + + const openPage = ( + + ); + + if (!view) return null; + if (!present || !s) { + return ( +
+ + {view.present ? null : view.error} + +
{openPage}
+
+ ); + } + + const gain = gainDraft ?? s.gainPct ?? 100; + const lat = latDraft ?? s.latencyTargetMs ?? 120; + + return ( +
+ {error && ( + + {error} + + )} + +
+
+ + void run({ action: "mute", on })} + label={tr("hb_mute", undefined, "Mute")} + /> +
+
+ {tr("hb_latency_label", undefined, "Latency")} + void run({ action: "latency", auto })} + label={tr("hb_latency_auto", undefined, "Automatic")} + /> + {!s.latencyAuto && ( + + )} +
+ void run({ action: "night", on })} + label={tr("hb_night", undefined, "Night mode")} + hint={tr("hb_night_hint", undefined, "Quieter explosions, clearer voices.")} + /> +
+ +
+
+

{tr("hb_headsets", undefined, "Headsets")}

+ + +
+ {saved && saved.length > 0 && ( +
    + {saved.map((d) => ( +
  • + + {d.name || d.addr} + {d.current ? ( + {tr("hb_current", undefined, "In use")} + ) : ( + + )} +
  • + ))} +
+ )} + {saved && saved.length === 0 && ( +

+ {tr("hb_saved_empty", undefined, "No saved headsets yet. Scan for one, or pair it on HearBridge's page.")} +

+ )} + {scanned && ( +
+ + {scanned.length + ? tr("hb_scan_found", undefined, "Found in the last scan:") + : tr("hb_scan_empty", undefined, "Nothing found. Put the headset in pairing mode and scan again.")} + +
    + {scanned.map((d) => ( +
  • + + {d.name || d.addr} + +
  • + ))} +
+
+ )} +
+ + {s.events.length > 0 && ( +
+

{tr("hb_events", undefined, "Recent events")}

+
    + {s.events.slice(-8).reverse().map((e, i) => ( +
  • {e}
  • + ))} +
+
+ )} + +
+ {openPage} + + {tr( + "hb_page_hint", + undefined, + "Pairing a new headset, the equalizer, backups and every other setting are on HearBridge's own page.", + )} + +
+ {dialog} +
+ ); +} diff --git a/client/src/screens/Payloads/hub/registry.test.ts b/client/src/screens/Payloads/hub/registry.test.ts index 7e6835c4..a768f85c 100644 --- a/client/src/screens/Payloads/hub/registry.test.ts +++ b/client/src/screens/Payloads/hub/registry.test.ts @@ -99,6 +99,18 @@ describe("On your PS5 detection", () => { expect(forced.map((c) => [c.id, c.state])).toEqual([["hearbridge", "unknown"]]); }); + it("counts an integration whose own API answered as running", () => { + const cards = detectHubCards({ + entries: hubEntries(CATALOG), + processes: [], + markers: new Set(["/data/hearbridge/hearbridge.log"]), + answering: new Set(["hearbridge"]), + }); + expect(cards.map((c) => [c.id, c.state, c.integration, c.webPort])).toEqual([ + ["hearbridge", "running", "hearbridge", 8090], + ]); + }); + it("works without a catalogue (the browser build) for the integrations", () => { const cards = detectHubCards({ entries: hubEntries(null), processes: PHAT, markers: new Set() }); expect(cards.map((c) => c.id)).toEqual([ diff --git a/engine/crates/ps5upload-core/src/hearbridge.rs b/engine/crates/ps5upload-core/src/hearbridge.rs new file mode 100644 index 00000000..a8919dae --- /dev/null +++ b/engine/crates/ps5upload-core/src/hearbridge.rs @@ -0,0 +1,570 @@ +//! HearBridge (X-F1REBALL-X/HearBridge-PS5): Bluetooth headset audio for the PS5, with its +//! own small HTTP API. The app reads and drives it through the engine because the page +//! sends no CORS headers, so a browser page on another origin can't call it directly. +//! +//! Its API, read from its source (src/http.c): +//! HTTP/1.1, `Connection: close`, on TCP 8090; when that port is taken it tries the next +//! five (8091..8095). +//! GET /api/status one JSON object: version, token, state, device, gain_pct, muted, +//! codec, latency{target_ms,estimate_ms}, battery{pct}, link{score, +//! rssi}, night{on}, lat_extra{auto}, game{id,name,profile}, events[]… +//! GET /api/saved {"devices":[{addr,name,kind,current}]} paired headsets +//! GET /api/devices {"version":1,"devices":[{index,addr,name,kind,rssi}]} last scan +//! POST /api/volume?pct=0..500 /api/mute?on=0|1 /api/night?on=0|1 +//! /api/latency?auto=0|1 or ?ms=40..400 /api/reconnect /api/stop +//! /api/select?index=N or ?addr=XX:XX:.. /api/scan?user=1 (without user=1 a +//! scan is ignored: an inquiry holds the radio the DualSense shares) +//! Every POST needs header `X-HB-Token` with the token from /api/status (403 otherwise). +//! +//! Nothing is cached on disk. The port a console's HearBridge answered on is remembered in +//! memory so the next call tries it first; the token is read fresh for every change. +//! +//! Plain HTTP over a TcpStream, like `payload_manager`: the Android engine has no HTTP +//! client crate. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use serde::Deserialize; +use serde_json::Value; + +/// HearBridge's port, then the five it falls back to. +pub const HEARBRIDGE_PORTS: [u16; 6] = [8090, 8091, 8092, 8093, 8094, 8095]; + +const CONNECT_TIMEOUT: Duration = Duration::from_millis(1500); +const IO_TIMEOUT: Duration = Duration::from_secs(5); +/// /api/status is at most 32 KiB (its own buffer); saved/devices at most 8 KiB. +const MAX_RESPONSE: usize = 256 * 1024; + +fn port_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// A HearBridge that answered: its port, its status (without the token) and the token. +#[derive(Debug, Clone)] +pub struct Found { + pub port: u16, + pub status: Value, + token: String, +} + +/// Find HearBridge on `host` and read its status. +pub fn status(host: &str) -> Result { + find_on(host, &HEARBRIDGE_PORTS) +} + +fn find_on(host: &str, ports: &[u16]) -> Result { + let cached = port_cache().lock().ok().and_then(|c| c.get(host).copied()); + let mut order: Vec = Vec::with_capacity(ports.len()); + if let Some(p) = cached.filter(|p| ports.contains(p)) { + order.push(p); + } + order.extend(ports.iter().copied().filter(|p| Some(*p) != cached)); + let mut last = String::new(); + for port in order { + match status_at(host, port) { + Ok(found) => { + if let Ok(mut c) = port_cache().lock() { + c.insert(host.to_string(), port); + } + return Ok(found); + } + Err(e) => last = e, + } + } + if let Ok(mut c) = port_cache().lock() { + c.remove(host); + } + let range = match (ports.first(), ports.last()) { + (Some(a), Some(b)) if a != b => format!("{a}-{b}"), + (Some(a), _) => a.to_string(), + _ => String::new(), + }; + Err(format!( + "HearBridge didn't answer on {host} port {range} ({last})" + )) +} + +fn status_at(host: &str, port: u16) -> Result { + let (code, body) = request(host, port, "GET", "/api/status", None)?; + if code != 200 { + return Err(format!(":{port} answered HTTP {code}")); + } + parse_status(&body).map(|(status, token)| Found { + port, + status, + token, + }) +} + +/// The status object without its token, and the token. Anything that isn't a JSON object +/// with a `version` string is not HearBridge (another service on that port). +fn parse_status(body: &[u8]) -> Result<(Value, String), String> { + let mut v: Value = + serde_json::from_slice(body).map_err(|_| "not HearBridge (no JSON status)".to_string())?; + let obj = v + .as_object_mut() + .ok_or_else(|| "not HearBridge (status is not an object)".to_string())?; + if !obj.get("version").is_some_and(Value::is_string) { + return Err("not HearBridge (no version in its status)".into()); + } + let token = match obj.remove("token") { + Some(Value::String(t)) => t, + _ => String::new(), + }; + Ok((v, token)) +} + +/// Which list to read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceList { + /// Paired headsets (`/api/saved`). + Saved, + /// The last scan's results (`/api/devices`). + Scanned, +} + +/// `{"devices":[…]}` from HearBridge; an empty list when it has none or sent something else. +pub fn devices(host: &str, which: DeviceList) -> Result { + devices_on(host, which, &HEARBRIDGE_PORTS) +} + +fn devices_on(host: &str, which: DeviceList, ports: &[u16]) -> Result { + let found = find_on(host, ports)?; + let path = match which { + DeviceList::Saved => "/api/saved", + DeviceList::Scanned => "/api/devices", + }; + let (code, body) = request(host, found.port, "GET", path, None)?; + if code != 200 { + return Err(format!("HearBridge could not list devices (HTTP {code})")); + } + Ok(parse_devices(&body)) +} + +fn parse_devices(body: &[u8]) -> Value { + let list = serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("devices").filter(|d| d.is_array()).cloned()) + .unwrap_or_else(|| Value::Array(Vec::new())); + serde_json::json!({ "devices": list }) +} + +/// An action's parameters, as the app sends them. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ActionParams { + pub pct: Option, + pub on: Option, + pub index: Option, + pub addr: Option, + pub auto: Option, + pub ms: Option, +} + +/// The changes the app makes. Pairing, EQ, backups and the rest stay on HearBridge's page. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Volume(u16), + Mute(bool), + Reconnect, + SelectIndex(u8), + SelectAddr(String), + Scan, + Night(bool), + LatencyAuto(bool), + LatencyMs(u16), + Stop, +} + +fn is_mac(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 17 + && b.iter().enumerate().all(|(i, c)| { + if i % 3 == 2 { + *c == b':' + } else { + c.is_ascii_hexdigit() + } + }) +} + +impl Action { + /// The action `name` with its parameters checked against the ranges HearBridge takes. + /// Anything else is refused here, so only these requests ever reach the console. + pub fn parse(name: &str, p: &ActionParams) -> Result { + let need_on = || p.on.ok_or_else(|| format!("{name} needs on")); + Ok(match name { + "volume" => match p.pct { + Some(v) if (0..=500).contains(&v) => Action::Volume(v as u16), + _ => return Err("volume needs pct 0-500".into()), + }, + "mute" => Action::Mute(need_on()?), + "night" => Action::Night(need_on()?), + "reconnect" => Action::Reconnect, + "scan" => Action::Scan, + "stop" => Action::Stop, + "select" => match (&p.addr, p.index) { + (Some(a), _) if is_mac(a) => Action::SelectAddr(a.to_ascii_uppercase()), + (Some(_), _) => return Err("select needs a Bluetooth address".into()), + (None, Some(i)) if (0..64).contains(&i) => Action::SelectIndex(i as u8), + _ => return Err("select needs index 0-63 or addr".into()), + }, + "latency" => match (p.auto, p.ms) { + (Some(a), _) => Action::LatencyAuto(a), + (None, Some(ms)) if (40..=400).contains(&ms) => Action::LatencyMs(ms as u16), + _ => return Err("latency needs auto or ms 40-400".into()), + }, + other => return Err(format!("unknown HearBridge action {other:?}")), + }) + } + + fn path(&self) -> String { + let b = |on: bool| if on { 1 } else { 0 }; + match self { + Action::Volume(v) => format!("/api/volume?pct={v}"), + Action::Mute(on) => format!("/api/mute?on={}", b(*on)), + Action::Night(on) => format!("/api/night?on={}", b(*on)), + Action::Reconnect => "/api/reconnect".into(), + Action::Scan => "/api/scan?user=1".into(), + Action::Stop => "/api/stop".into(), + Action::SelectIndex(i) => format!("/api/select?index={i}"), + Action::SelectAddr(a) => format!("/api/select?addr={a}"), + Action::LatencyAuto(a) => format!("/api/latency?auto={}", b(*a)), + Action::LatencyMs(ms) => format!("/api/latency?ms={ms}"), + } + } +} + +/// Send `action` to HearBridge on `host`. Returns its answer: the new status (token removed) +/// for most actions, `{"ok":1}` for select/scan/reconnect. +pub fn act(host: &str, action: &Action) -> Result { + act_on(host, action, &HEARBRIDGE_PORTS) +} + +fn act_on(host: &str, action: &Action, ports: &[u16]) -> Result { + let path = action.path(); + let mut found = find_on(host, ports)?; + for attempt in 0..2 { + if found.token.is_empty() { + return Err("HearBridge gave no token; it may be too old for changes from here".into()); + } + let (code, body) = request(host, found.port, "POST", &path, Some(&found.token))?; + // 403: the token changed (HearBridge restarted between the read and the change). + if code == 403 && attempt == 0 { + found = status_at(host, found.port).or_else(|_| find_on(host, ports))?; + continue; + } + if code != 200 { + let msg = String::from_utf8_lossy(&body); + return Err(format!( + "HearBridge refused {path} (HTTP {code}: {})", + msg.trim() + )); + } + let mut v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + if let Some(o) = v.as_object_mut() { + o.remove("token"); + } + return Ok(v); + } + Err("HearBridge refused the token".into()) +} + +fn request( + host: &str, + port: u16, + method: &str, + path: &str, + token: Option<&str>, +) -> Result<(u16, Vec), String> { + let addr = crate::payload_lifecycle::join_host_port(host, port); + let sock = std::net::ToSocketAddrs::to_socket_addrs(&addr) + .map_err(|e| format!("resolve {addr}: {e}"))? + .next() + .ok_or_else(|| format!("resolve {addr}: no address"))?; + let mut stream = TcpStream::connect_timeout(&sock, CONNECT_TIMEOUT) + .map_err(|e| format!("connect {addr}: {e}"))?; + stream.set_read_timeout(Some(IO_TIMEOUT)).ok(); + stream.set_write_timeout(Some(IO_TIMEOUT)).ok(); + stream + .write_all(request_head(method, path, &addr, token).as_bytes()) + .map_err(|e| format!("send to {addr}: {e}"))?; + let mut resp = Vec::new(); + stream + .take(MAX_RESPONSE as u64 + 1) + .read_to_end(&mut resp) + .map_err(|e| format!("read from {addr}: {e}"))?; + if resp.len() > MAX_RESPONSE { + return Err(format!("{addr} sent more than {MAX_RESPONSE} bytes")); + } + parse_response(&resp).ok_or_else(|| format!("{addr} sent a response that is not HTTP")) +} + +/// The request line and headers. The User-Agent starts with "curl" on purpose: HearBridge +/// treats a status read from anything else as its page being open in a browser, which +/// starts extra Bluetooth traffic (a volume re-query and a link wake) meant for someone +/// looking at that page (http.c `/api/status`, main.c `lw_tick`). +fn request_head(method: &str, path: &str, addr: &str, token: Option<&str>) -> String { + let mut head = format!( + "{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\nUser-Agent: curl-compatible ps5upload\r\n" + ); + if let Some(t) = token { + head.push_str(&format!("X-HB-Token: {t}\r\n")); + } + if method == "POST" { + head.push_str("Content-Length: 0\r\n"); + } + head.push_str("\r\n"); + head +} + +fn parse_response(resp: &[u8]) -> Option<(u16, Vec)> { + let split = resp.windows(4).position(|w| w == b"\r\n\r\n")?; + let head = std::str::from_utf8(&resp[..split]).ok()?; + let status: u16 = head + .lines() + .next()? + .split_whitespace() + .nth(1)? + .parse() + .ok()?; + Some((status, resp[split + 4..].to_vec())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + use std::sync::Arc; + use std::thread; + + /// One request as the fake server saw it. + #[derive(Debug, Clone)] + struct Seen { + line: String, + token: Option, + ua: Option, + } + + /// A fake HearBridge on an ephemeral port: answers each request with `reply(line)`. + fn fake( + reply: impl Fn(&str, Option<&str>) -> (u16, String) + Send + Sync + 'static, + ) -> (u16, Arc>>) { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Vec::new())); + let seen2 = seen.clone(); + let reply = Arc::new(reply); + thread::spawn(move || { + for s in l.incoming() { + let Ok(mut s) = s else { continue }; + let mut buf = [0u8; 4096]; + let mut got = Vec::new(); + while !got.windows(4).any(|w| w == b"\r\n\r\n") { + match s.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => got.extend_from_slice(&buf[..n]), + } + } + let text = String::from_utf8_lossy(&got).to_string(); + let line = text.lines().next().unwrap_or("").to_string(); + let header = |name: &str| { + text.lines().find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case(name).then(|| v.trim().to_string()) + }) + }; + let token = header("X-HB-Token"); + seen2.lock().unwrap().push(Seen { + line: line.clone(), + token: token.clone(), + ua: header("User-Agent"), + }); + let (code, body) = reply(&line, token.as_deref()); + let _ = write!( + s, + "HTTP/1.1 {code} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + } + }); + (port, seen) + } + + fn closed_port() -> u16 { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() + } + + const STATUS: &str = r#"{"version":"1.9.0","token":"abc123","connected":1,"state":"streaming","device":"WH-1000XM4","gain_pct":120,"muted":0,"codec":"SBC","latency":{"target_ms":120,"estimate_ms":180},"battery":{"status":"ok","level":3,"pct":80},"link":{"score":92,"rssi":-50},"events":["Connected"]}"#; + + #[test] + fn reads_the_status_without_handing_out_the_token() { + let (port, seen) = fake(|_, _| (200, STATUS.into())); + let f = find_on("127.0.0.1", &[port]).unwrap(); + assert_eq!(f.port, port); + assert_eq!(f.status["device"], "WH-1000XM4"); + assert!(f.status.get("token").is_none()); + assert_eq!(f.token, "abc123"); + let s = seen.lock().unwrap(); + assert_eq!(s[0].line, "GET /api/status HTTP/1.1"); + // Not counted as HearBridge's page being open (see request_head). + assert!(s[0].ua.as_deref().unwrap().starts_with("curl")); + } + + #[test] + fn tolerates_an_older_status_with_few_fields() { + let (status, token) = parse_status(br#"{"version":"0.3"}"#).unwrap(); + assert_eq!(status["version"], "0.3"); + assert_eq!(token, ""); + assert!(parse_status(b"").is_err()); + assert!(parse_status(br#"{"ok":1}"#).is_err()); + assert!(parse_status(br#"[1]"#).is_err()); + } + + #[test] + fn falls_back_to_the_next_port_like_hearbridge_does() { + let dead = closed_port(); + // Something else on the second port: answers, but isn't HearBridge. + let (other, _) = fake(|_, _| (200, "hi".into())); + let (hb, _) = fake(|_, _| (200, STATUS.into())); + let host = "127.0.0.1"; + let f = find_on(host, &[dead, other, hb]).unwrap(); + assert_eq!(f.port, hb); + let err = find_on(host, &[dead]).unwrap_err(); + assert!(err.contains("HearBridge didn't answer"), "{err}"); + } + + #[test] + fn sends_the_token_with_every_change() { + let (port, seen) = fake(|line, token| { + if line.starts_with("GET /api/status") { + (200, STATUS.into()) + } else if token == Some("abc123") { + ( + 200, + r#"{"version":"1.9.0","token":"abc123","gain_pct":200}"#.into(), + ) + } else { + (403, r#"{"error":"token"}"#.into()) + } + }); + let v = act_on("127.0.0.1", &Action::Volume(200), &[port]).unwrap(); + assert_eq!(v["gain_pct"], 200); + assert!(v.get("token").is_none()); + let s = seen.lock().unwrap(); + let post = s.iter().find(|r| r.line.starts_with("POST")).unwrap(); + assert_eq!(post.line, "POST /api/volume?pct=200 HTTP/1.1"); + assert_eq!(post.token.as_deref(), Some("abc123")); + } + + #[test] + fn rereads_the_token_once_when_hearbridge_restarted() { + let calls = Arc::new(Mutex::new(0)); + let c2 = calls.clone(); + let (port, _) = fake(move |line, token| { + if line.starts_with("GET /api/status") { + let mut n = c2.lock().unwrap(); + *n += 1; + // First read hands out a token that is stale by the time it is used. + let t = if *n == 1 { "old" } else { "new" }; + (200, format!(r#"{{"version":"1.9.0","token":"{t}"}}"#)) + } else if token == Some("new") { + (200, r#"{"ok":1}"#.into()) + } else { + (403, r#"{"error":"token"}"#.into()) + } + }); + let v = act_on("127.0.0.1", &Action::Reconnect, &[port]).unwrap(); + assert_eq!(v["ok"], 1); + } + + #[test] + fn a_missing_token_is_a_clear_error() { + let (port, _) = fake(|_, _| (200, r#"{"version":"0.3"}"#.into())); + let e = act_on("127.0.0.1", &Action::Stop, &[port]).unwrap_err(); + assert!(e.contains("no token"), "{e}"); + } + + #[test] + fn lists_devices_and_survives_garbage() { + let (port, _) = fake(|line, _| { + if line.starts_with("GET /api/status") { + (200, STATUS.into()) + } else if line.starts_with("GET /api/saved") { + ( + 200, + r#"{"devices":[{"addr":"AA:BB:CC:DD:EE:FF","name":"Buds","kind":"audio","current":1}]}"# + .into(), + ) + } else { + (200, "not json".into()) + } + }); + let saved = devices_on("127.0.0.1", DeviceList::Saved, &[port]).unwrap(); + assert_eq!(saved["devices"][0]["name"], "Buds"); + let scanned = devices_on("127.0.0.1", DeviceList::Scanned, &[port]).unwrap(); + assert_eq!(scanned, serde_json::json!({ "devices": [] })); + } + + #[test] + fn only_allowlisted_actions_with_sane_values_pass() { + let p = |f: fn(&mut ActionParams)| { + let mut a = ActionParams::default(); + f(&mut a); + a + }; + assert_eq!( + Action::parse("volume", &p(|a| a.pct = Some(500))) + .unwrap() + .path(), + "/api/volume?pct=500" + ); + assert!(Action::parse("volume", &p(|a| a.pct = Some(501))).is_err()); + assert!(Action::parse("volume", &p(|_| {})).is_err()); + assert_eq!( + Action::parse("mute", &p(|a| a.on = Some(true))) + .unwrap() + .path(), + "/api/mute?on=1" + ); + assert_eq!( + Action::parse("scan", &p(|_| {})).unwrap().path(), + "/api/scan?user=1" + ); + assert_eq!( + Action::parse("select", &p(|a| a.index = Some(3))) + .unwrap() + .path(), + "/api/select?index=3" + ); + assert_eq!( + Action::parse("select", &p(|a| a.addr = Some("aa:bb:cc:dd:ee:ff".into()))) + .unwrap() + .path(), + "/api/select?addr=AA:BB:CC:DD:EE:FF" + ); + assert!(Action::parse("select", &p(|a| a.addr = Some("x&y=1".into()))).is_err()); + assert!(Action::parse("select", &p(|a| a.index = Some(64))).is_err()); + assert_eq!( + Action::parse("latency", &p(|a| a.auto = Some(false))) + .unwrap() + .path(), + "/api/latency?auto=0" + ); + assert_eq!( + Action::parse("latency", &p(|a| a.ms = Some(40))) + .unwrap() + .path(), + "/api/latency?ms=40" + ); + assert!(Action::parse("latency", &p(|a| a.ms = Some(39))).is_err()); + for refused in ["forget", "eq", "restore", "backup", "hci", "../stop", ""] { + assert!(Action::parse(refused, &p(|_| {})).is_err(), "{refused}"); + } + } +} diff --git a/engine/crates/ps5upload-core/src/lib.rs b/engine/crates/ps5upload-core/src/lib.rs index 0b6040e7..9262ee8c 100644 --- a/engine/crates/ps5upload-core/src/lib.rs +++ b/engine/crates/ps5upload-core/src/lib.rs @@ -17,6 +17,7 @@ pub mod fs_ops; pub mod fw_spoof; pub mod game_meta; pub mod health; +pub mod hearbridge; pub mod hw; pub mod installer_client; pub mod local_image; diff --git a/engine/crates/ps5upload-engine/src/hearbridge_api.rs b/engine/crates/ps5upload-engine/src/hearbridge_api.rs new file mode 100644 index 00000000..777b4678 --- /dev/null +++ b/engine/crates/ps5upload-engine/src/hearbridge_api.rs @@ -0,0 +1,105 @@ +//! `/api/ps5/hearbridge/*` — the console's HearBridge (Bluetooth headset audio, HTTP on +//! :8090..8095) as the app sees it: its status, its saved and scanned headsets, and the +//! few changes the app makes. Every call is a plain HTTP request to the console +//! (`ps5upload_core::hearbridge`); its token never leaves the engine. + +use axum::{ + extract::{Query, State}, + response::{IntoResponse, Response}, + Json, +}; +use serde::Deserialize; + +use ps5upload_core::hearbridge as hb; + +#[derive(Deserialize)] +pub(crate) struct HostParams { + #[serde(alias = "addr")] + host: String, +} + +fn bad(e: String) -> Response { + crate::json_err(axum::http::StatusCode::BAD_GATEWAY, e).into_response() +} + +fn host(h: &str) -> String { + ps5upload_core::events::console_host(h.trim()) +} + +/// GET /api/ps5/hearbridge/status?host= — `{ok, present:true, port, status}`, or +/// `{ok, present:false, error}` when nothing answers on 8090..8095. +pub(crate) async fn status( + State(_s): State, + Query(q): Query, +) -> Response { + let h = host(&q.host); + match tokio::task::spawn_blocking(move || hb::status(&h)).await { + Ok(Ok(f)) => Json(serde_json::json!({ + "ok": true, + "present": true, + "port": f.port, + "status": f.status, + })) + .into_response(), + Ok(Err(e)) => { + Json(serde_json::json!({ "ok": true, "present": false, "error": e })).into_response() + } + Err(e) => bad(format!("{e}")), + } +} + +async fn list(q: HostParams, which: hb::DeviceList) -> Response { + let h = host(&q.host); + match tokio::task::spawn_blocking(move || hb::devices(&h, which)).await { + Ok(Ok(v)) => { + Json(serde_json::json!({ "ok": true, "devices": v["devices"] })).into_response() + } + Ok(Err(e)) => bad(e), + Err(e) => bad(format!("{e}")), + } +} + +/// GET /api/ps5/hearbridge/saved?host= — the paired headsets `{ok, devices}`. +pub(crate) async fn saved( + State(_s): State, + Query(q): Query, +) -> Response { + list(q, hb::DeviceList::Saved).await +} + +/// GET /api/ps5/hearbridge/devices?host= — the last scan's results `{ok, devices}`. +pub(crate) async fn devices( + State(_s): State, + Query(q): Query, +) -> Response { + list(q, hb::DeviceList::Scanned).await +} + +#[derive(Deserialize)] +pub(crate) struct ActionReq { + /// The console (`addr` here is the headset's Bluetooth address, see `ActionParams`). + host: String, + action: String, + #[serde(flatten)] + params: hb::ActionParams, +} + +/// POST /api/ps5/hearbridge/action `{host, action, pct?|on?|index?|addr?|auto?|ms?}` — +/// one of volume, mute, reconnect, select, scan, night, latency, stop. Answers +/// `{ok, result}` with HearBridge's reply (its new status, token removed). +pub(crate) async fn action( + State(_s): State, + Json(req): Json, +) -> Response { + let a = match hb::Action::parse(&req.action, &req.params) { + Ok(a) => a, + Err(e) => return crate::json_err(axum::http::StatusCode::BAD_REQUEST, e).into_response(), + }; + let h = host(&req.host); + crate::log_info!("hearbridge {}: host={h}", req.action); + match tokio::task::spawn_blocking(move || hb::act(&h, &a)).await { + Ok(Ok(v)) => Json(serde_json::json!({ "ok": true, "result": v })).into_response(), + Ok(Err(e)) => bad(e), + Err(e) => bad(format!("{e}")), + } +} diff --git a/engine/crates/ps5upload-engine/src/lib.rs b/engine/crates/ps5upload-engine/src/lib.rs index 8094948a..bf798275 100644 --- a/engine/crates/ps5upload-engine/src/lib.rs +++ b/engine/crates/ps5upload-engine/src/lib.rs @@ -51,6 +51,7 @@ mod folder_build; mod fpkg_api; mod fpkg_firmware; mod games_api; +mod hearbridge_api; mod helper_mirror; mod helper_stop; mod icon_cache; @@ -10701,6 +10702,10 @@ async fn run(cfg: EngineConfig) -> anyhow::Result<()> { .route("/api/ps5/pldmgr/delete", post(pldmgr_api::delete)) .route("/api/ps5/pldmgr/autoload", post(pldmgr_api::autoload)) .route("/api/ps5/pldmgr/store", post(pldmgr_api::store)) + .route("/api/ps5/hearbridge/status", get(hearbridge_api::status)) + .route("/api/ps5/hearbridge/saved", get(hearbridge_api::saved)) + .route("/api/ps5/hearbridge/devices", get(hearbridge_api::devices)) + .route("/api/ps5/hearbridge/action", post(hearbridge_api::action)) .route("/api/ps5/elfldr/ensure", post(ps5_elfldr_ensure)) .route("/api/ps5/process/kill", post(ps5_process_kill)) .route("/api/ps5/power/control", post(ps5_power_control))