Browse Source

client: 设备内核抽象、Smartcar 车载香薰接入与语音纪要等一批改动

工作区里累积的 Flutter 客户端改动,随本轮一并入库。主要涉及:

- lib/devices/ 设备内核:DeviceHub / DevicePlugin / DeviceSession 抽象,
  恒玄(bes)、EaiRec 支架(holder)、Smartcar 车载香薰(smartcar) 各自的插件实现,
  业务层不再直接依赖任何厂商类;分层边界由 test/devices/ 下的测试守着
- local_plugins/device_plugin_interface、device_smartcar、recorder_holder:
  纯 Dart 契约与原生侧实现
- lib/modules/ 能力模块注册表、语音纪要、OTA 升级等模块
- test/ 新增设备协议、Ogg Opus 时长、模块注册表、路由等金测试
- ios/sync_spm_platform.sh:修 Xcode 直接构建时 SPM 平台版本被打回 13.0 的问题

验证:dart analyze lib 无 error(264 条 warning 均为 HEAD 上既有的 unused_import /
avoid_print 等 lint,非本次引入)。未跑真机与打包。

注:这批不是本次会话中由 Claude 编写的,此处仅做入库。
main
Rodger-Wang 4 weeks ago
parent
commit
27cabe1bde
  1. 3
      .vscode/settings.json
  2. 2
      apps/client/.env.example
  3. 3
      apps/client/.gitignore
  4. 46
      apps/client/GOOGLE_PLAY_RELEASE_CHECKLIST.md
  5. 22
      apps/client/analysis_options.yaml
  6. 9
      apps/client/android/app/build.gradle.kts
  7. 8
      apps/client/android/app/src/main/AndroidManifest.xml
  8. 4
      apps/client/android/settings.gradle.kts
  9. BIN
      apps/client/assets/images/devices/Echomeet.png
  10. BIN
      apps/client/assets/images/devices/Echomeet_icon.png
  11. 6
      apps/client/build_channels.sh
  12. 106
      apps/client/check_16kb.py
  13. 6
      apps/client/ios/Podfile.lock
  14. 7
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  15. 146
      apps/client/ios/sync_spm_platform.sh
  16. 60
      apps/client/lib/core/bindings/initial_binding.dart
  17. 1
      apps/client/lib/core/services/ai_orb_controller.dart
  18. 8
      apps/client/lib/core/translations/app_translations.dart
  19. 204
      apps/client/lib/core/translations/language/ar_sa.dart
  20. 204
      apps/client/lib/core/translations/language/bg_bg.dart
  21. 204
      apps/client/lib/core/translations/language/cs_cz.dart
  22. 204
      apps/client/lib/core/translations/language/da_dk.dart
  23. 30
      apps/client/lib/core/translations/language/de_de.dart
  24. 204
      apps/client/lib/core/translations/language/el_gr.dart
  25. 16
      apps/client/lib/core/translations/language/en_us.dart
  26. 30
      apps/client/lib/core/translations/language/es_es.dart
  27. 204
      apps/client/lib/core/translations/language/et_ee.dart
  28. 204
      apps/client/lib/core/translations/language/fa_ir.dart
  29. 204
      apps/client/lib/core/translations/language/fi_fi.dart
  30. 30
      apps/client/lib/core/translations/language/fr_fr.dart
  31. 204
      apps/client/lib/core/translations/language/hi_in.dart
  32. 204
      apps/client/lib/core/translations/language/hr_hr.dart
  33. 204
      apps/client/lib/core/translations/language/hu_hu.dart
  34. 204
      apps/client/lib/core/translations/language/id_id.dart
  35. 204
      apps/client/lib/core/translations/language/is_is.dart
  36. 204
      apps/client/lib/core/translations/language/it_it.dart
  37. 30
      apps/client/lib/core/translations/language/ja_jp.dart
  38. 30
      apps/client/lib/core/translations/language/ko_kr.dart
  39. 204
      apps/client/lib/core/translations/language/lt_lt.dart
  40. 204
      apps/client/lib/core/translations/language/lv_lv.dart
  41. 204
      apps/client/lib/core/translations/language/ms_my.dart
  42. 204
      apps/client/lib/core/translations/language/nb_no.dart
  43. 359
      apps/client/lib/core/translations/language/nl_nl.dart
  44. 204
      apps/client/lib/core/translations/language/pl_pl.dart
  45. 30
      apps/client/lib/core/translations/language/pt_pt.dart
  46. 204
      apps/client/lib/core/translations/language/ro_ro.dart
  47. 30
      apps/client/lib/core/translations/language/ru_ru.dart
  48. 204
      apps/client/lib/core/translations/language/sk_sk.dart
  49. 204
      apps/client/lib/core/translations/language/sl_si.dart
  50. 204
      apps/client/lib/core/translations/language/sr_rs.dart
  51. 204
      apps/client/lib/core/translations/language/sv_se.dart
  52. 204
      apps/client/lib/core/translations/language/th_th.dart
  53. 204
      apps/client/lib/core/translations/language/tl_ph.dart
  54. 206
      apps/client/lib/core/translations/language/tr_tr.dart
  55. 204
      apps/client/lib/core/translations/language/uk_ua.dart
  56. 204
      apps/client/lib/core/translations/language/vi_vn.dart
  57. 16
      apps/client/lib/core/translations/language/zh_cn.dart
  58. 30
      apps/client/lib/core/translations/language/zh_hk.dart
  59. 30
      apps/client/lib/core/translations/language/zh_tw.dart
  60. 88
      apps/client/lib/core/utils/device_connection.dart
  61. 34
      apps/client/lib/core/utils/version_compare.dart
  62. 145
      apps/client/lib/data/models/agent_module.dart
  63. 5
      apps/client/lib/data/models/appconfig.dart
  64. 29
      apps/client/lib/data/models/appconfig_model.dart
  65. 59
      apps/client/lib/data/models/appconfig_model.g.dart
  66. 87
      apps/client/lib/data/models/assistant_directive.dart
  67. 73
      apps/client/lib/data/models/device_category.dart
  68. 28
      apps/client/lib/data/models/goods.dart
  69. 18
      apps/client/lib/data/models/user_Info.dart
  70. 6
      apps/client/lib/data/services/account_service.dart
  71. 67
      apps/client/lib/data/services/agent_module_defaults.dart
  72. 58
      apps/client/lib/data/services/agent_module_service.dart
  73. 19
      apps/client/lib/data/services/assistant_directive_service.dart
  74. 23
      apps/client/lib/data/services/bailian_multimodal_service.dart
  75. 666
      apps/client/lib/data/services/device_ai_session_service.dart
  76. 296
      apps/client/lib/data/services/device_call_recording_service.dart
  77. 489
      apps/client/lib/data/services/device_file_import_service.dart
  78. 284
      apps/client/lib/data/services/device_recording_service.dart
  79. 103
      apps/client/lib/data/services/device_stereo_recorder.dart
  80. 22
      apps/client/lib/data/services/network/api.dart
  81. 48
      apps/client/lib/data/services/network/auth_interceptor.dart
  82. 183
      apps/client/lib/data/services/ota_check_service.dart
  83. 14
      apps/client/lib/data/services/recording_archive.dart
  84. 136
      apps/client/lib/data/services/wav_writer.dart
  85. 27
      apps/client/lib/data/utils/device_file_match.dart
  86. 93
      apps/client/lib/data/utils/ogg_opus_duration.dart
  87. 28
      apps/client/lib/data/utils/recording_time.dart
  88. 158
      apps/client/lib/devices/bes/bes_bluetooth_service.dart
  89. 23
      apps/client/lib/devices/bes/bes_device_auth.dart
  90. 637
      apps/client/lib/devices/bes/bes_device_plugin.dart
  91. 203
      apps/client/lib/devices/bes/bes_ota_port.dart
  92. 278
      apps/client/lib/devices/bes/bes_protocol.dart
  93. 71
      apps/client/lib/devices/device_auth.dart
  94. 41
      apps/client/lib/devices/device_bootstrap.dart
  95. 265
      apps/client/lib/devices/device_hub.dart
  96. 14
      apps/client/lib/devices/device_vendors.dart
  97. 437
      apps/client/lib/devices/holder/holder_device_plugin.dart
  98. 293
      apps/client/lib/devices/holder/holder_device_service.dart
  99. 120
      apps/client/lib/devices/holder/holder_file_store.dart
  100. 222
      apps/client/lib/devices/holder/holder_ota_port.dart

3
.vscode/settings.json

@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "automatic"
}

2
apps/client/.env.example

@ -72,3 +72,5 @@ VOLCANO_TRANSLATION_REGION=
APP_IOS_IAP_PREFIX=
APP_IOS_IAP_SUFFIX=
APP_IOS_IAP_ID_MAP=
# 请求签名密钥(可选,不配用内置默认值;须与服务端一致)
API_SIGN_KEY=

3
apps/client/.gitignore

@ -69,3 +69,6 @@ app.*.map.json
# ---- 本机私密配置 ----
# .env 含火山/OpenAI/COS 等第三方密钥,只提交 .env.example
.env
# 多渠道打包产物(build_channels.sh 的默认输出目录)
build_outputs/

46
apps/client/GOOGLE_PLAY_RELEASE_CHECKLIST.md

@ -5,6 +5,52 @@
---
## 进度(2026-09-12 更新)
基线产物换成 `build_outputs/EAIMAR_0912_1.0.1_google.aab`(1.0.1+7,Channel=GLOBAL_GOOGLE),
下面每条都是在**这个 aab 上复验**过的,不是改完就算数。
| 项 | 状态 | 复验方式 |
|---|---|---|
| 1.1 targetSdk 36 | ✅ 已改 | 合并 manifest `targetSdkVersion="36"` |
| 1.2 Play Billing 8 | ✅ 已改 | aab 的 `dependencies.pb` 里 `com.android.billingclient:billing 8.0.0` |
| 1.3 16KB 页对齐 | ✅ 已改 | `python3 check_16kb.py <aab>` → 64 位 .so 44/44 合规 |
| 2. 支付回退 | ✅ 已改 | Google 渠道硬闸门,见下 |
| 3. 权限瘦身 | 🟡 部分 | `GET_TASKS`、`MEDIA_CONTENT_CONTROL` 已移除(44→40 条),其余待定 |
| 4. 构建脚本 | ✅ 已改 | `./build_channels.sh google` 实测跑通 |
| 5. Play Console 事项 | ⬜ 未做 | 外部系统 |
⚠️ **Billing 8 的迁移成本远低于本文当初的估计**:0.5.0 唯一的破坏性变更是移除
`queryPurchaseHistory`,本项目零引用。实际改动就是 `in_app_purchase: ^3.3.0` +
`in_app_purchase_android: ^0.5.0` 两行,没碰一行业务代码。
⚠️ **支付回退的修法与本文第 2 节的建议不同,刻意的**:本文建议「安卓端兜底只留 google」,
但那会连带改掉国内五家渠道的兜底行为(后端没下发 paychannels 时国内用户就付不了款)。
实际实现是**只对 Google 渠道加硬闸门**:`_isGooglePlayBuild` 为真时直接返回
`[google]`,既不信后端下发的渠道列表、也不走任何回退分支;其它渠道逻辑原封不动。
渠道判定读的是**编译期烧进包里的 `.env` 的 `Channel`**,⚠️ 不要换成
`VersionUpdateService.cachedChannel`——那个值在版本检查跑完前会回退成 `GLOBAL_GOOGLE`,
用它会在国内包启动初期把微信/支付宝误杀。
⚠️ **`check_16kb.py` 当初并没有入库**(本文 1.3 节说「仓库里已有」是错的),
2026-09-12 才补写并提交。
### 仍然待办
- **`ACCESS_BACKGROUND_LOCATION` + `WRITE_SETTINGS` 主要由 `amap_service` 插件拖进来**
(`ACCESS_BACKGROUND_LOCATION` 另有 `location_service` 和主工程两个来源)。
而 `NavigationManager` 全项目只在 `initial_binding.dart` 用 `Get.lazyPut` 注册过,
**没有任何一处 `Get.find`**——高德导航在当前 App 里是走不到的死代码。
所以「Google 渠道不引入 `amap_service`」能一次干掉这两条最难过审的权限,
且不会有功能损失(也顺带证明了排除高德 `.so` 不会崩:没有代码路径去加载它)。
- `QUERY_ALL_PACKAGES`(主工程)、`SYSTEM_ALERT_WINDOW`(主工程 + `floating_ui_plugin`)、
`SCHEDULE_EXACT_ALARM`(主工程)仍需声明表或删除。
- **targetSdk 36 的真机回归没做**(edge-to-edge / 前台服务类型校验),见 1.1 节的警告。
- **iOS 侧有同类的支付政策风险**:本次的闸门只加在 Google 渠道,App Store 包在
`paychannels` 缺失时仍会回退成展示微信/支付宝/PayPal。
---
## 0. 先决定:要不要申请延期
有两条硬期限是 **2026-08-31**,也就是排查次日。两条都可以在 Play Console 申请延期到 **2026-11-01**。

22
apps/client/analysis_options.yaml

@ -1 +1,23 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "local_plugins/**"
- "build/**"
errors:
# 这几类几乎总是「写了没接上」或「删了没删干净」,提级成 warning 让 CI 拦住
unused_import: warning
unused_element: warning
unused_field: warning
unused_local_variable: warning
dead_code: warning
dead_null_aware_expression: warning
avoid_print: warning
# 老代码里的大写常量不在本轮整改范围,先降噪
constant_identifier_names: ignore
linter:
rules:
avoid_print: true
prefer_const_constructors: false

9
apps/client/android/app/build.gradle.kts

@ -5,7 +5,6 @@ repositories {
mavenCentral()
flatDir {
dirs(project(":qq_music").projectDir.resolve("libs")) // 添加这行
dirs(project(":device_jieli").projectDir.resolve("libs")) // 杰理 SDK 运行时 .aar
}
}
@ -81,7 +80,7 @@ android {
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 26 // 更新为26以满足ble_service库的要求
targetSdk = 35 // 设置为 Android 15 (API 35),以满足应用市场要求
targetSdk = 36 // Android 16 (API 36),Google Play 2026-08-31 起的最低要求
versionCode = flutter.versionCode // 对应构建号,自动同步flutter构建号
versionName = flutter.versionName // 对应版本号,自动同步flutter版本号
}
@ -151,12 +150,6 @@ dependencies {
// 添加 CardView 依赖
implementation("androidx.cardview:cardview:1.0.0")
// device_jieli 杰理 SDK 运行时依赖(.aar 在 device_jieli 模块只能 compileOnly,
// host app 必须自己引入,否则 RCSPController 等类在运行时找不到 → scanFeature 不会被初始化)
implementation(":jl_bluetooth_rcsp_V4.2.0_beta1_40255_20260324@aar")
implementation(":jl_audio_v2_V1.0.0_9-release@aar")
implementation(":jldecryption_v0.4-release@aar")
// jl_audio_decode 已经被 ble_service 通过 com.jieli.audio:jl_audio_decode:2.1.0 引入,无需重复
}
flutter {

8
apps/client/android/app/src/main/AndroidManifest.xml

@ -2,7 +2,13 @@
xmlns:tools="http://schemas.android.com/tools">
<!-- 添加必要的权限 -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.GET_TASKS" />
<!-- GET_TASKS 自 API 21 起已废弃并被系统忽略,声明了也无效,反而在 Play 审核时显眼,已移除 -->
<!-- MEDIA_CONTENT_CONTROL 是系统签名权限,普通应用根本拿不到;由 ble_service /
recorder_holder 两个本地插件的 manifest 合并进来,必须在这里显式 remove 掉,
否则会出现在最终产物的权限清单里。 -->
<uses-permission android:name="android.permission.MEDIA_CONTENT_CONTROL"
tools:node="remove" />
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<!-- Android 13+ 前台服务特定类型权限 -->

4
apps/client/android/settings.gradle.kts

@ -34,7 +34,6 @@ plugins {
include(":app")
include(":azure_speech")
include(":chat_storage")
include(":jieli")
include(":ble_service")
include(":classic_bluetooth")
include(":agent_service")
@ -42,13 +41,11 @@ include(":speech")
include(":chat_api")
include(":bytedance_speech")
include(":qq_music") // 添加这行
include(":jl_opus")
include(":bluetooth_manager") // 恒玄(BES)经典蓝牙连接 + G722/mSBC 音频编解码
// 设置项目路径
project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android")
project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android")
project(":jieli").projectDir = file("../local_plugins/jieli/android")
project(":ble_service").projectDir = file("../local_plugins/ble_service/android")
project(":classic_bluetooth").projectDir = file("../local_plugins/classic_bluetooth/android")
project(":agent_service").projectDir = file("../local_plugins/agent_service/android")
@ -56,5 +53,4 @@ project(":speech").projectDir = file("../local_plugins/speech/android")
project(":chat_api").projectDir = file("../local_plugins/chat_api/android")
project(":bytedance_speech").projectDir = file("../local_plugins/bytedance_speech/android")
project(":qq_music").projectDir = file("../local_plugins/qq_music/android") // 添加这行
project(":jl_opus").projectDir = file("../local_plugins/jl_opus/android")
project(":bluetooth_manager").projectDir = file("../local_plugins/bluetooth_manager/android")

BIN
apps/client/assets/images/devices/Echomeet.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

BIN
apps/client/assets/images/devices/Echomeet_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

6
apps/client/build_channels.sh

@ -14,7 +14,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="${SCRIPT_DIR}/.env"
PUBSPEC_FILE="${SCRIPT_DIR}/pubspec.yaml"
OUTPUT_DIR="/Users/liuxiamin/work/build_outputs"
# 产物输出目录。默认落在仓库内的 build_outputs/(已在 .gitignore 里),
# 需要放别处时用环境变量覆盖:BUILD_OUTPUT_DIR=/path/to/out ./build_channels.sh google
OUTPUT_DIR="${BUILD_OUTPUT_DIR:-${SCRIPT_DIR}/build_outputs}"
APP_NAME="EAIMAR"
BUILD_DATE=$(date +"%m%d")
@ -314,7 +316,7 @@ build_channel() {
2>&1 | tee "${OUTPUT_DIR}/build_${channel_name}.log" || build_result=$?
source_file="${SCRIPT_DIR}/build/app/outputs/flutter-apk/app-release.apk"
else
flutter build aab \
flutter build appbundle \
--obfuscate \
--split-debug-info=./build/app/outputs/symbols \
2>&1 | tee "${OUTPUT_DIR}/build_${channel_name}.log" || build_result=$?

106
apps/client/check_16kb.py

@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""校验 APK/AAB 里 64 位 .so 是否满足 Google Play 的 16KB 页对齐要求。
Google Play 自 2025-11-01 起要求 64 位原生库(arm64-v8a / x86_64)的每个
PT_LOAD 段 p_align >= 0x4000(16KB),不合规直接拒收,且**无延期**。
32 位(armeabi-v7a / x86)不受约束,脚本会列出但不判失败。
用法:
python3 check_16kb.py build/app/outputs/bundle/release/app-release.aab
python3 check_16kb.py build/app/outputs/flutter-apk/app-release.apk
退出码: 0=全部合规, 1=有不合规的 64 位库, 2=用法/文件错误
"""
import struct
import sys
import zipfile
REQUIRED_ALIGN = 0x4000
ABI64 = ("arm64-v8a", "x86_64")
PT_LOAD = 1
def max_load_align(data: bytes):
"""返回 ELF 所有 PT_LOAD 段里最小的 p_align;不是 64 位 ELF 返回 None。"""
if len(data) < 64 or data[:4] != b"\x7fELF":
return None
if data[4] != 2: # EI_CLASS: 2 = ELFCLASS64,32 位不参与判定
return None
little = data[5] == 1
end = "<" if little else ">"
# Elf64_Ehdr: e_phoff@0x20, e_phentsize@0x36, e_phnum@0x38
(e_phoff,) = struct.unpack_from(end + "Q", data, 0x20)
(e_phentsize,) = struct.unpack_from(end + "H", data, 0x36)
(e_phnum,) = struct.unpack_from(end + "H", data, 0x38)
aligns = []
for i in range(e_phnum):
off = e_phoff + i * e_phentsize
if off + 56 > len(data):
break
(p_type,) = struct.unpack_from(end + "I", data, off)
if p_type != PT_LOAD:
continue
# Elf64_Phdr: p_align 是最后一个字段,偏移 0x30
(p_align,) = struct.unpack_from(end + "Q", data, off + 0x30)
aligns.append(p_align)
return min(aligns) if aligns else None
def abi_of(name: str):
for abi in ("arm64-v8a", "armeabi-v7a", "x86_64", "x86"):
if "/%s/" % abi in name:
return abi
return "?"
def main(argv):
if len(argv) != 2:
print(__doc__)
return 2
path = argv[1]
try:
zf = zipfile.ZipFile(path)
except Exception as exc: # noqa: BLE001
print("无法打开 %s: %s" % (path, exc))
return 2
bad, ok, skipped = [], [], []
with zf:
for info in zf.infolist():
if not info.filename.endswith(".so"):
continue
data = zf.read(info.filename)
align = max_load_align(data)
abi = abi_of(info.filename)
if align is None:
# 32 位或非 ELF:不参与 16KB 判定
skipped.append((info.filename, abi))
continue
if align < REQUIRED_ALIGN:
bad.append((info.filename, abi, align))
else:
ok.append((info.filename, abi, align))
print("包: %s" % path)
print("64 位 .so 合规 %d 个,不合规 %d 个;32 位/非 ELF 跳过 %d 个\n"
% (len(ok), len(bad), len(skipped)))
if bad:
print("❌ 不合规(p_align < 0x4000,Play 会拒收):")
for name, abi, align in sorted(bad):
print(" %-60s %-12s p_align=0x%x" % (name, abi, align))
print("")
print("修法:给产出该库的模块声明 NDK r28+(`ndkVersion`),或在 CMake 里加")
print(" target_link_options(<target> PRIVATE \"-Wl,-z,max-page-size=16384\")")
print(" 改完记得清掉该模块的 android/.cxx 缓存,否则会复用旧产物。")
return 1
print("✅ 全部 64 位 .so 均满足 16KB 页对齐")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

6
apps/client/ios/Podfile.lock

@ -11,8 +11,6 @@ PODS:
- Flutter
- bluetooth_manager (0.0.1):
- Flutter
- device_plugin_interface (0.1.0):
- Flutter
- floating_ui_plugin (0.0.1):
- Flutter
- Flutter (1.0.0)
@ -60,7 +58,6 @@ DEPENDENCIES:
- audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`)
- bes_ota_manager (from `.symlinks/plugins/bes_ota_manager/ios`)
- bluetooth_manager (from `.symlinks/plugins/bluetooth_manager/ios`)
- device_plugin_interface (from `.symlinks/plugins/device_plugin_interface/ios`)
- floating_ui_plugin (from `.symlinks/plugins/floating_ui_plugin/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
@ -92,8 +89,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/bes_ota_manager/ios"
bluetooth_manager:
:path: ".symlinks/plugins/bluetooth_manager/ios"
device_plugin_interface:
:path: ".symlinks/plugins/device_plugin_interface/ios"
floating_ui_plugin:
:path: ".symlinks/plugins/floating_ui_plugin/ios"
Flutter:
@ -126,7 +121,6 @@ SPEC CHECKSUMS:
audio_waveforms: cd736909ebc6b6a164eb74701d8c705ce3241e1c
bes_ota_manager: 4fcdbe6962548a1ac69421f0c3212837e76c0af4
bluetooth_manager: f09915bb90e61cc30cbf93f1f5b127b86d957e43
device_plugin_interface: ee316d62288f942bd08a05acdf417ec4671639a7
floating_ui_plugin: d218b25dcd922d3a66a536dd2190ecd47451e5a8
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: df98d66e515e1ca797af436137b4459b160ad8c9

7
apps/client/ios/Runner.xcodeproj/project.pbxproj

@ -144,7 +144,6 @@
75F9C6FC394EA5D10CEE9FE1 /* BroadcastExtension.entitlements */,
6C2968D7A4F3A0D478C6EF09 /* Extension.xcconfig */,
);
name = BroadcastExtension;
path = BroadcastExtension;
sourceTree = "<group>";
};
@ -642,7 +641,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -836,7 +835,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -870,7 +869,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

146
apps/client/ios/sync_spm_platform.sh

@ -0,0 +1,146 @@
#!/usr/bin/env bash
#
# 把 Flutter 生成的 SPM 中间包的 iOS 平台版本,对齐到本工程的 deployment target。
#
# ── 解决什么问题 ────────────────────────────────────────────────────────────
# Flutter 3.29+ 的 Swift Package Manager 集成会生成
# ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift
# 所有插件都挂在这个包下面。**它生成时的平台版本写死是 Flutter 自己的最低版本
# (iOS 13.0)**,而本项目有十几个本地插件声明了 `.iOS("16.0")`。SPM 不允许依赖的
# 平台版本高于上层包,于是 Xcode 报:
#
# The package product 'xxx' requires minimum platform version 16.0
# for the iOS platform, but this target supports 13.0
#
# Flutter 自己有补救(SwiftPackageManager.updateMinimumDeployment:读 Runner 的
# IPHONEOS_DEPLOYMENT_TARGET 把生成包改写上去),但**只在 `flutter build/run` 里跑**。
# 而 Xcode 直接构建时,SPM 解析发生在所有 Flutter 步骤之前,看到的还是 13.0 —— 所以
# `flutter build ios` 能过、Xcode 里点构建就炸。
#
# ⚠️ 任何重新生成插件清单的操作都会把它打回 13.0:flutter pub get / clean / install、
# 改完 pubspec 的自动 pub get。也就是说这不是一次性问题,会反复出现。
#
# ── 怎么用 ──────────────────────────────────────────────────────────────────
# ./ios/sync_spm_platform.sh # 在 apps/client 下随时手动跑,幂等
# ./ios/sync_spm_platform.sh --check # 只检查不改写,不一致时退出码 1(给 CI 用)
#
# 挂进 Xcode(推荐,省得每次记着手动跑):
# Product → Scheme → Edit Scheme → Build → Pre-actions → + New Run Script Action
# Provide build settings from: Runner
# 脚本内容: "$SRCROOT/sync_spm_platform.sh"
#
# ⚠️ 不依赖 flutter 命令:Xcode 的 pre-action 环境 PATH 很干净,调 flutter 经常找不到。
# 这里只做纯文本改写,几毫秒完成。代价是生成包必须已经存在(跑过一次
# flutter pub get 即可),不存在时脚本会明确告诉你先跑什么。
#
# ⚠️ 别改 Package.swift 本身来"修好"这个问题——它头上写着 Generated file. Do not edit.,
# 下次 pub get 就被覆盖,属于假修好。本脚本做的是同一件事,但可重复执行。
# ⚠️ macOS 自带 bash 3.2 不认多字节字符边界:`$VAR,` 会被当成变量名 `VAR,`,
# 在 set -u 下报 unbound variable。所以本文件里凡是变量后面紧跟中文标点的,
# 一律写成 ${VAR}。
set -euo pipefail
IOS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLIENT_DIR="$(cd "$IOS_DIR/.." && pwd)"
PBXPROJ="$IOS_DIR/Runner.xcodeproj/project.pbxproj"
MANIFEST="$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift"
CHECK_ONLY=0
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1
red() { printf "\033[31m%s\033[0m\n" "$*"; }
green() { printf "\033[32m%s\033[0m\n" "$*"; }
blue() { printf "\033[34m%s\033[0m\n" "$*"; }
# 把 .iOS(.v16) / .iOS("16.0") / 16 统一成可比较的 x.y 形式
normalize() {
local v="$1"
v="${v#.v}" # .v16 → 16
v="${v//\"/}" # 去引号
[[ "$v" == *.* ]] || v="$v.0" # 16 → 16.0
printf '%s' "$v"
}
# 版本比较:$1 <= $2 ?
le() { [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$1" ]]; }
[[ -f "$PBXPROJ" ]] || { red "✗ 找不到 $PBXPROJ"; exit 1; }
# ── 1) 工程能接受的上限 ─────────────────────────────────────────────────────
# 生成包的平台版本必须 ≤ 每个链接它的 target,所以取全工程最低的那个 deployment target。
CEILING="$(grep -o 'IPHONEOS_DEPLOYMENT_TARGET = [0-9.]*' "$PBXPROJ" \
| awk '{print $3}' | sed 's/;$//' | sort -V | head -1)"
[[ -n "$CEILING" ]] || { red "✗ 没能从 project.pbxproj 读出 IPHONEOS_DEPLOYMENT_TARGET"; exit 1; }
CEILING="$(normalize "$CEILING")"
# ── 2) 插件要求的下限 ───────────────────────────────────────────────────────
# 扫本地插件的 Package.swift,取声明得最高的那个。
FLOOR="0.0"; FLOOR_OWNER=""
while IFS= read -r f; do
raw="$(grep -o '\.iOS([^)]*)' "$f" | head -1 | sed 's/^\.iOS(//; s/)$//')" || true
[[ -n "$raw" ]] || continue
v="$(normalize "$raw")"
if ! le "$v" "$FLOOR"; then FLOOR="$v"; FLOOR_OWNER="${f#"$CLIENT_DIR"/}"; fi
done < <(find "$CLIENT_DIR/local_plugins" -name Package.swift -not -path '*/.*' 2>/dev/null | sort)
blue "工程 deployment target(取最低) = ${CEILING} 本地插件最高要求 = ${FLOOR}${FLOOR_OWNER:+ (${FLOOR_OWNER})}"
# ── 3) 插件要求超过工程上限 = 真问题,脚本救不了,直接点名 ───────────────────
if ! le "$FLOOR" "$CEILING"; then
red "✗ 插件要求 iOS ${FLOOR},但工程最低 target 只有 ${CEILING} —— 这个脚本改不了。"
red " 要么把工程的 IPHONEOS_DEPLOYMENT_TARGET 抬到 ${FLOOR}(Runner + Podfile 都要),"
red " 要么把 ${FLOOR_OWNER} 的平台声明降下来。"
exit 1
fi
# ── 4) 对齐生成包 ───────────────────────────────────────────────────────────
if [[ ! -f "$MANIFEST" ]]; then
red "✗ 生成包还不存在:${MANIFEST#"$CLIENT_DIR"/}"
red " 先在 apps/client 下跑一次 \`flutter pub get\`(或 \`flutter build ios --config-only\`)再执行本脚本。"
exit 1
fi
# ── 本地插件之间的相对路径依赖 ────────────────────────────────────────────
# 若干本地插件的 Package.swift 用 `../../../<plugin>/ios/<plugin>` 引用兄弟插件
# (agent_service→test、azure_speech→classic_bluetooth、recorder_holder→device_jieli、
# chat_api→swift_sdk_local)。这些路径是按 `local_plugins/<name>/ios/<name>` 的真实
# 目录写的,但 Flutter 把插件以符号链接挂在 `ephemeral/Packages/.packages/<name>` 下,
# SPM 不解析符号链接,`../../..` 就落到了 `ephemeral/`——于是报
# "the package at 'ephemeral/<plugin>/ios/<plugin>' cannot be accessed"。
# 这里在 ephemeral/ 下给每个本地插件目录补一个同名链接,让那些相对路径能落地。
# 幂等;`flutter pub get` 不会清掉它们。
ensure_local_plugin_links() {
local eph="$IOS_DIR/Flutter/ephemeral"
local plugins_dir="$CLIENT_DIR/local_plugins"
[ -d "$eph" ] || return 0
for d in "$plugins_dir"/*/; do
local name
name="$(basename "$d")"
[ -e "$eph/$name" ] && continue
ln -s "../../../local_plugins/$name" "$eph/$name"
done
}
ensure_local_plugin_links
CURRENT_RAW="$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')"
CURRENT="$(normalize "$CURRENT_RAW")"
if [[ "$CURRENT" == "$CEILING" ]]; then
green "✓ 生成包已是 iOS ${CURRENT},无需改动"
exit 0
fi
if [[ $CHECK_ONLY -eq 1 ]]; then
red "✗ 生成包是 iOS ${CURRENT},应为 ${CEILING}(跑 ./ios/sync_spm_platform.sh 修)"
exit 1
fi
# 只替换 platforms 段里的第一个 .iOS(...),不碰文件其余内容
/usr/bin/sed -i '' "s/\.iOS($(printf '%s' "$CURRENT_RAW" | sed 's/[.[\*^$\/]/\\&/g'))/.iOS(\"$CEILING\")/" "$MANIFEST"
NEW="$(normalize "$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')")"
if [[ "$NEW" != "$CEILING" ]]; then
red "✗ 改写失败,现在是 ${NEW}"; exit 1
fi
green "✓ 生成包平台版本 ${CURRENT} → ${CEILING},可以回 Xcode 构建了"

60
apps/client/lib/core/bindings/initial_binding.dart

@ -16,21 +16,20 @@ import '../../data/services/speech_factory.dart';
import '../../data/services/alibaba_image_translation_service.dart';
import '../../data/services/assistant_directive_service.dart';
import '../../data/services/bailian_multimodal_service.dart';
import '../../data/services/bes_ai_session_service.dart';
import '../../data/services/emai_chat_store.dart';
import '../../data/services/id_verify_service.dart';
import '../../data/services/microsoft_translation_service.dart';
import '../../data/services/server_translation_service.dart';
import '../../data/services/language_manager.dart';
import '../../core/utils/logger.dart';
import '../../data/services/bluetooth_manager.dart';
import '../../data/services/ble_manager.dart';
import '../../data/services/bes_bluetooth_service.dart';
import '../../data/services/holder_device_service.dart';
import '../../data/services/holder_recording_service.dart';
import '/data/services/bes_recording_service.dart';
import '/data/services/bes_call_recording_service.dart';
import '/data/services/face_to_face_ble_service.dart';
import '../../data/services/agent_module_defaults.dart';
import '../../data/services/device_ai_session_service.dart';
import '../../data/services/device_call_recording_service.dart';
import '../../data/services/device_file_import_service.dart';
import '../../data/services/device_recording_service.dart';
import '../../devices/device_bootstrap.dart';
import '../../devices/device_hub.dart';
import '../../modules/module_registry.dart';
import '../services/ai_orb_controller.dart';
import '../../data/services/account_service.dart';
import '../../data/services/memory_reminder_scheduler.dart';
@ -83,31 +82,30 @@ class InitialBinding extends Bindings {
// 会话都往它送指令,代办页又要读它,任何一方被回收都会丢记录。
Get.put<AssistantDirectiveService>(AssistantDirectiveService(),
permanent: true);
// Agent 能力模块注册表:路由/跳转/指令/文案/常驻服务都由模块自带
ModuleRegistry.install();
ModuleRegistry.registerServices();
AssistantDirectiveService.to
.registerHandlers(ModuleRegistry.directiveHandlers);
// 阿里通义千问 VL:拍照翻译(识别 + 翻译一次完成)
Get.lazyPut<AlibabaImageTranslationService>(
() => AlibabaImageTranslationService(),
fenix: true);
// BleManager —— 提前 put(不用 lazyPut),让原生杰理 SDK 在 onInit 里立即开始 initialize
Get.put<BleManager>(BleManager(), permanent: true);
// 蓝牙服务(经典蓝牙系统状态观测,仅用于通话音频路由判断)
Get.lazyPut<BluetoothManager>(() => BluetoothManager(), fenix: true);
// 恒玄(BES)蓝牙服务:经典蓝牙 SPP 主动连接 + 音频编解码,独立于杰理 BLE 链路
// 常驻:状态监听与冷启动自动回连都要在任意页面生效
Get.put<BesBluetoothService>(BesBluetoothService(), permanent: true);
// 蓝讯方案设备(AI手机支架 / AI Pencil):BLE 连接 + Opus 录音流,
// 协议实现来自 echomeet 的 recorder_holder 插件
Get.put<HolderDeviceService>(HolderDeviceService(), permanent: true);
// 支架录音的全局采集/归档:设备端按键在任意页面(含页面未打开)都生效,
// 所以必须常驻,不能挂在录音页控制器上
Get.put<HolderRecordingService>(HolderRecordingService(), permanent: true);
// 恒玄耳机录音的全局采集/归档,与支架那条完全独立
Get.put<BesRecordingService>(BesRecordingService(), permanent: true);
// 恒玄耳机通话录音(双声道:左本端 / 右对端),跟随通话状态自动起停
Get.put<BesCallRecordingService>(BesCallRecordingService(),
// 设备中枢:注册所有厂商插件(恒玄耳机 / EaiRec 支架 / …),业务层只认它。
// 常驻:状态监听与冷启动自动回连都要在任意页面生效。
installDeviceHub();
// 设备品类首次连上时把默认功能铺到 AI 页
AgentDefaults.bind(DeviceHub.to);
// 设备主麦录音(现场录音)的全局采集/归档:设备端按键在任意页面都生效
Get.put<DeviceRecordingService>(DeviceRecordingService(), permanent: true);
// 通话录音(双声道:左本端 / 右对端),跟随通话状态自动起停
Get.put<DeviceCallRecordingService>(DeviceCallRecordingService(),
permanent: true);
// 把「设备自己录在 TF 卡 / flash 里的音频」同步进语音纪要。
// 常驻:支架连上(含后台自动回连)就该开始同步,不能等用户进某个页面。
Get.put<DeviceFileImportService>(DeviceFileImportService(),
permanent: true);
// 面对面翻译的左右耳独立 BLE(底层待固件命令,先占好位)
Get.put<FaceToFaceBleService>(FaceToFaceBleService(), permanent: true);
// EMAI 对话记录:手机页面与耳机会话共用一份,必须先于 BesAiSessionService
// 注册(后者一起来就可能收到耳机唤醒并往里写)
Get.put<EmaiChatStore>(EmaiChatStore(), permanent: true);
@ -115,9 +113,9 @@ class InitialBinding extends Bindings {
// 实名认证状态:AI 页闸门与「我的」页都读它,全局常驻。
// onInit 里会自动拉一次服务端状态(游客跳过)。
Get.put<IdVerifyService>(IdVerifyService(), permanent: true);
// 恒玄耳机按键唤醒的 AI 实时对话(接百炼多模态)。必须常驻:
// 耳机按键在任意页面、甚至 App 在后台时都要能起会话
Get.put<BesAiSessionService>(BesAiSessionService(), permanent: true);
// 设备按键唤醒的 AI 实时对话(接百炼多模态)。必须常驻:
// 按键在任意页面、甚至 App 在后台时都要能起会话
Get.put<DeviceAiSessionService>(DeviceAiSessionService(), permanent: true);
// MusicManager
Get.lazyPut<MusicManager>(() => MusicManager(), fenix: true);

1
apps/client/lib/core/services/ai_orb_controller.dart

@ -21,7 +21,6 @@ class AiOrbController extends GetxController {
static const Set<String> _hiddenRoutes = {
Routes.splash,
Routes.login,
Routes.agent,
Routes.emai,
Routes.mobileElf,
};

8
apps/client/lib/core/translations/app_translations.dart

@ -3,6 +3,8 @@ import 'dart:ui';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../modules/module_registry.dart';
import 'language/en_us.dart'; // 英语
import 'language/zh_cn.dart'; // 简体中文
import 'language/es_es.dart'; // 西班牙语
@ -302,8 +304,12 @@ class AppTranslations extends Translations {
return fallbackLocale;
}
/// 全局语言表 + 各能力模块自带的文案(见 ModuleRegistry.mergeTranslations)
@override
Map<String, Map<String, String>> get keys => {
Map<String, Map<String, String>> get keys =>
ModuleRegistry.mergeTranslations(_base);
static final Map<String, Map<String, String>> _base = {
'en_US': enUS,
'zh_CN': zhCN,
'zh_HK': zhHK, // 添加繁体中文映射

204
apps/client/lib/core/translations/language/ar_sa.dart

@ -1868,4 +1868,208 @@ const Map<String, String> arSA = {
'unmarkFavorite': 'إزالة من المفضلة',
'unnamedDevice': 'جهاز بلا اسم',
'userDataParseFailed': 'تعذّر تحليل بيانات المستخدم',
'catEarphone': 'سماعات ذكية',
'catEarphoneDesc': 'سماعات بلوتوث كلاسيكي / BLE',
'catPhoneHolder': 'حامل هاتف ذكي',
'catPhoneHolderDesc': 'أجهزة الحوامل',
'catCarAroma': 'معطر سيارة ذكي',
'catCarAromaDesc': 'موزع عطر للسيارة',
'agreeAndContinue': 'موافقة ومتابعة',
'memberServiceAgreement': 'اتفاقية خدمة العضوية',
'memberServiceAgreementTitle': 'اتفاقية خدمة العضوية',
'memberServiceAgreementIntro': 'لتقديم خدمات عضوية أفضل لك، يرجى القراءة بعناية قبل الاشتراك: ',
'memberServiceAgreementOutro': '. بالنقر على «موافقة ومتابعة» فإنك تؤكد أنك قرأت الاتفاقية أعلاه بالكامل ووافقت عليها.',
'memberAgreementRejectedTip': 'يجب الموافقة على اتفاقية خدمة العضوية للمتابعة.',
'translationCallExitTitle': 'الخروج من ترجمة المكالمة',
'translationCallExitMessage': 'اختر ما تريد فعله قبل المغادرة',
'translationCallEnd': 'إنهاء ترجمة المكالمة',
'translationContinueInBackground': 'متابعة الترجمة في الخلفية',
'backgroundRecordingIndicatorBody': 'التسجيل مستمر في الخلفية. اضغط للعودة.',
'backgroundTranslationIndicatorBody': 'ترجمة المكالمة مستمرة في الخلفية. اضغط للعودة.',
'meetingRecordScreenCapturePermissionDenied': 'إذن تسجيل الشاشة مطلوب لالتقاط صوت النظام. يرجى منحه ثم إعادة المحاولة.',
'pleaseGrantBluetoothPermission': 'يرجى منح إذن البلوتوث للمسح',
'startScanFailed': 'فشل بدء المسح',
'pleaseLoginFirst': 'يرجى تسجيل الدخول أولاً',
'deviceUnauthorized': 'الجهاز غير مصرح به، يرجى إعادة المحاولة',
'boundDevices': 'الأجهزة المرتبطة',
'currentConnection': 'الاتصال الحالي',
'binding': 'جارٍ الربط...',
'permissionDisabled': 'معطّل',
'confirmDisablePermissionTitle': 'إيقاف الإذن',
'confirmDisablePermissionContent': 'هل تريد إيقاف إذن {permission}؟ ستحتاج إلى الذهاب إلى إعدادات النظام لإعادة تفعيله.',
'confirmPermanentlyDisallowTitle': 'التوقف عن السؤال؟',
'confirmPermanentlyDisallowContent': 'لقد رفضت إذن {permission}. هل تريد التوقف عن السؤال من الآن فصاعداً؟ إذا اخترت «عدم السماح»، يمكنك تفعيله لاحقاً من إعدادات النظام.',
'notNow': 'ليس الآن',
'permanentlyDisallow': 'عدم السماح',
'otaBesNotSupported': 'هذه السماعة لا تدعم ترقية البرنامج الثابت داخل التطبيق بعد. يرجى التواصل مع الدعم.',
'tabDevice': 'الأجهزة',
'tabPlaza': 'الساحة',
'sloganHome': 'تقنية لحياة أذكى',
'sloganAi': 'ذكاء بلا حدود',
'sloganDevice': 'أجهزة ذكية جاهزة للمستقبل',
'sloganPlaza': 'عالم ذكي يفهمك',
'aiEditHint': 'اضغط مطولاً على بطاقة لسحبها وإعادة ترتيب الوحدات',
'aiEmptyTitle': 'لم تتم إضافة أي وحدات بعد',
'aiEmptyDesc': 'اختر الوكلاء الذين تحتاجهم من الساحة',
'goToPlaza': 'الذهاب إلى الساحة',
'plazaOnline': 'متاح',
'plazaPlanned': 'قريباً',
'plazaSearchHint': 'ابحث عن الوكلاء، مثل «ترجمة» أو «تسجيل»',
'moduleRemove': 'إزالة',
'moduleAdd': 'إضافة',
'moduleRemoved': 'تمت الإزالة',
'moduleAdded': 'تمت الإضافة',
'goSee': 'عرض',
'comingSoonTag': 'قريباً',
'needDeviceTag': 'يتطلب جهازاً',
'featureInDev': 'هذه الميزة قيد التطوير',
'featureNotWired': 'غير متاح بعد',
'todayLabel': 'اليوم',
'deviceConnected': 'متصل',
'licenseUnknown': 'الترخيص غير معروف',
'selectDevice': 'اختر جهازاً',
'catNoteCard': 'بطاقة تسجيل AI',
'catNoteCardDesc': 'مسجّل على شكل بطاقة',
'catParkerPen': 'قلم AI',
'catParkerPenDesc': 'أقلام تسجيل صوتي',
'catStylusPen': 'قلم لمس AI',
'catStylusPenDesc': 'أجهزة الكتابة اليدوية / أقلام اللمس',
'catStudyMachine': 'لوح تعلّم AI',
'catStudyMachineDesc': 'أجهزة التعلّم',
'besAddTitle': 'إضافة سماعات AI',
'besConnecting': 'جارٍ الاتصال بالسماعات…',
'besNotFound': 'لم يتم العثور على السماعات',
'besNotFoundIosDesc': 'قم بتوصيل السماعات أولاً من الإعدادات ← البلوتوث، ثم عد إلى هنا',
'besNotFoundAndroidDesc': 'قم بإقران السماعات أولاً من الإعدادات ← البلوتوث، ثم اتصل من هنا',
'besReadFailed': 'فشلت القراءة',
'besConnectFailIos': 'تأكد من ظهور السماعات بحالة «متصل» في بلوتوث النظام، ثم أعد المحاولة',
'besConnectFailAndroid': 'تأكد من أن السماعات قيد التشغيل وقريبة وغير متصلة بجهاز آخر. ستعيد الاتصال تلقائياً بمجرد الارتباط.',
'holderAddTitle': 'إضافة حامل هاتف AI',
'holderConnecting': 'جارٍ الاتصال بالجهاز…',
'holderSearching': 'جارٍ البحث عن الأجهزة…',
'holderNotFound': 'لم يتم العثور على أجهزة',
'holderPairHint': 'تأكد من أن الحامل قيد التشغيل وفي وضع الإقران',
'holderRetryHint': 'تأكد من أن الجهاز قيد التشغيل وقريب منك، ثم أعد المحاولة',
'autoConnectTitle': 'الاتصال التلقائي بالأجهزة',
'autoConnectDesc': 'إيقاظ التطبيق لإعادة الاتصال واستقبال التسجيلات عند تشغيل جهازك أو اقترابه',
'autoConnectConsentTitle': 'تفعيل الاتصال التلقائي',
'autoConnectConsentIntro': 'للسماح لجهازك (سماعات AI / حامل هاتف AI) بالاتصال تلقائياً وحفظ التسجيلات عند الضغط على زر التسجيل فيه، نحتاج إلى موافقتك:',
'autoConnectConsentB1': 'تسجيل مسح بلوتوث في الخلفية بحيث يتم إيقاظ التطبيق (بدء تلقائي) عند اكتشاف جهازك المرتبط، لإعادة الاتصال واستقبال التسجيلات',
'autoConnectConsentB2': 'بعد الإيقاظ، يُستخدم فقط لاتصال الجهاز والتسجيل والرفع — لا شيء آخر، ولا يتم تشغيل أي تطبيقات أخرى',
'autoConnectConsentB3': 'يمكنك الاستمرار في استخدام التطبيق بدونه — فقط اتصل بالجهاز يدوياً في كل مرة',
'autoConnectConsentB4': 'يمكنك إيقافه في أي وقت من الإعدادات ← الاتصال التلقائي بالأجهزة',
'autoConnectAgree': 'موافقة وتفعيل',
'autoConnectLater': 'ليس الآن',
'autoConnectRequired': 'الاتصال التلقائي متوقف',
'autoConnectRequiredDesc': 'فعّل الاتصال التلقائي لربط هذا الجهاز',
'recordTapToStart': 'اضغط على الزر أدناه لبدء التسجيل',
'recordPaused': 'متوقف مؤقتاً',
'recordMark': 'علامة',
'recordDeleteTitle': 'حذف التسجيل',
'accountAndProfile': 'الحساب والملف الشخصي',
'nicknameUnset': 'لم يتم تعيين لقب',
'nicknameInput': 'أدخل لقباً',
'loginMethod': 'طريقة تسجيل الدخول',
'manageMyLogin': 'إدارة تسجيل الدخول',
'logoutAction': 'تسجيل الخروج',
'warningLabel': 'تحذير:',
'deleteAccountWarn': 'سيتم حذف حسابك وجميع ملفاتك نهائياً.',
'icpFiling': 'تسجيل ICP',
'logExport': 'تصدير السجلات',
'logExportDesc': 'عرض سجلات التطبيق وتصديرها',
'permLoadFailed': 'فشل تحميل مجموعات الأذونات',
'permInsufficient': 'تم رفض الإذن',
'voiceCloneCreating': 'جارٍ إنشاء صوتك…',
'voiceCloneCreate': 'إنشاء صوتي',
'voiceCloneAgree': 'الموافقة وقراءة البيان بصوت عالٍ',
'voiceCloneReadPrompt': 'يرجى قراءة:',
'voiceCloneFirstMonthFree': 'الشهر الأول مجاناً',
'voiceCloneEnable': 'تفعيل استنساخ الصوت',
'noMusicPlaying': 'لا يوجد شيء قيد التشغيل',
'qqMusicNoSeek': 'التقديم غير مدعوم في QQ Music',
'bleScanTitle': 'مسح BLE',
'bluetoothOn': 'البلوتوث مفعّل',
'bleScanning': 'جارٍ المسح…',
'bleConnectedDevices': 'الأجهزة المتصلة',
'bleUnknownDevice': 'جهاز غير معروف',
'bleTapToScan': 'اضغط على مسح للبحث عن أجهزة BLE',
'weekMon': 'الاثنين',
'weekTue': 'الثلاثاء',
'weekWed': 'الأربعاء',
'weekThu': 'الخميس',
'weekFri': 'الجمعة',
'weekSat': 'السبت',
'weekSun': 'الأحد',
'agentMeeting': 'محاضر صوتية',
'agentMeetingDesc': 'تلخيص التسجيلات ومركز الذاكرة',
'agentEmai': 'مساعد EMAI',
'agentEmaiDesc': 'تحدث واسأل أي شيء بصوتك',
'agentSimultaneous': 'الترجمة الفورية',
'agentSimultaneousDesc': 'ترجمة أثناء حديثك',
'agentFaceToFace': 'وجهاً لوجه',
'agentFaceToFaceDesc': 'ترجمة محادثة ثنائية الاتجاه',
'agentCallRecording': 'تسجيل المكالمات',
'agentCallRecordingDesc': 'حفظ المكالمات تلقائياً',
'agentFieldRecording': 'التسجيل الميداني',
'agentFieldRecordingDesc': 'ميكروفون الجهاز أو الهاتف',
'agentCall': 'ترجمة المكالمات',
'agentCallDesc': 'ترجمة فورية أثناء المكالمات',
'agentAudioVideo': 'ترجمة الوسائط',
'agentAudioVideoDesc': 'تأطير الكاميرا / ترجمة الصوت والفيديو',
'agentEnglishChat': 'محادثة بالإنجليزية',
'agentEnglishChatDesc': 'تدريب غامر على التحدث',
'agentEnglishStudy': 'دراسة الإنجليزية',
'agentEnglishStudyDesc': 'دروس المفردات والقواعد',
'agentKidsStory': 'قصص الأطفال',
'agentKidsStoryDesc': 'قصص ما قبل النوم وأسئلة وأجوبة',
'moduleRemovedDesc': 'تمت إزالته من صفحة AI',
'moduleAddedDesc': 'تمت إضافته إلى أسفل صفحة AI',
'calNoRecords': 'لا توجد إدخالات',
'calNoRecordsDay': 'لا يوجد شيء مجدول',
'calTodos': 'المهام',
'calExpenses': 'المصروفات',
'calTodoCount': '@count مهام',
'calExpenseCount': '@count مصروفات',
'calEntryCount': '@count إدخالات',
'calItemsUnit': 'عناصر',
'calCountUnit': 'إدخالات',
'calTotalAmount': 'الإجمالي',
'recordPause': 'إيقاف مؤقت',
'recordResume': 'استئناف',
'recordDeleteConfirm': 'حذف هذا التسجيل؟',
'scopeDay': 'يوم',
'scopeWeek': 'أسبوع',
'scopeMonth': 'شهر',
'allDay': 'طوال اليوم',
'guestModeTitle': 'وضع الضيف',
'guestModeDesc': 'يمكن للضيوف التصفح فقط. سجّل الدخول لاستخدام الملاحظات الصوتية والترجمة والتسجيل — وسيتم حفظ بياناتك في حسابك.',
'guestKeepBrowsing': 'متابعة التصفح',
'guestGoLogin': 'تسجيل الدخول',
'guestViewOnlyTag': 'للعرض فقط',
'autoConnectReenableHint': 'الاتصال التلقائي متوقف. أعد تفعيله من الإعدادات ← الاتصال التلقائي بالأجهزة.',
'plazaEntryDesc': 'تصفح وأضف المزيد من الوكلاء',
'agentTextTranslation': 'ترجمة النصوص',
'agentTextTranslationDesc': 'ترجمة النص المكتوب أو الملصق',
'agentPictureTranslation': 'ترجمة الصور',
'agentPictureTranslationDesc': 'التقط صورة أو اختر واحدة لترجمتها',
'callRequiredTitle': 'يتطلب مكالمة',
'callRequiredDesc': 'تعمل هذه الميزة أثناء المكالمة فقط',
'inCallBusyTitle': 'غير متاح أثناء المكالمة',
'inCallBusyDesc': 'الميكروفون ومسار الصوت مشغولان بالمكالمة. حاول مرة أخرى بعد إنهاء المكالمة.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'مجاني',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/bg_bg.dart

@ -1931,4 +1931,208 @@ const Map<String, String> bgBG = {
'unmarkFavorite': 'Премахни от любими',
'unnamedDevice': 'Устройство без име',
'userDataParseFailed': 'Данните на потребителя не бяха разчетени',
'catEarphone': 'AI слушалки',
'catEarphoneDesc': 'Класически Bluetooth / BLE слушалки',
'catPhoneHolder': 'AI стойка за телефон',
'catPhoneHolderDesc': 'Устройства тип стойка',
'catCarAroma': 'AI ароматизатор за кола',
'catCarAromaDesc': 'Ароматизатор за автомобил',
'agreeAndContinue': 'Приемам и продължавам',
'memberServiceAgreement': 'Споразумение за членски услуги',
'memberServiceAgreementTitle': 'Споразумение за членски услуги',
'memberServiceAgreementIntro': 'За да ви предоставим по-добри членски услуги, моля, прочетете внимателно, преди да се абонирате: ',
'memberServiceAgreementOutro': '. С натискане на „Приемам и продължавам“ потвърждавате, че сте прочели изцяло и приемате горното споразумение.',
'memberAgreementRejectedTip': 'Трябва да приемете Споразумението за членски услуги, за да продължите.',
'translationCallExitTitle': 'Изход от превода на разговор',
'translationCallExitMessage': 'Изберете какво да направите, преди да излезете',
'translationCallEnd': 'Край на превода на разговор',
'translationContinueInBackground': 'Продължи превода във фонов режим',
'backgroundRecordingIndicatorBody': 'Записът продължава във фонов режим. Натиснете, за да се върнете.',
'backgroundTranslationIndicatorBody': 'Преводът на разговора продължава във фонов режим. Натиснете, за да се върнете.',
'meetingRecordScreenCapturePermissionDenied': 'Необходимо е разрешение за запис на екрана, за да се записва системното аудио. Моля, разрешете го и опитайте отново.',
'pleaseGrantBluetoothPermission': 'Моля, разрешете достъп до Bluetooth за сканиране',
'startScanFailed': 'Неуспешно стартиране на сканирането',
'pleaseLoginFirst': 'Моля, първо влезте',
'deviceUnauthorized': 'Устройството не е оторизирано, опитайте отново',
'boundDevices': 'Обвързани устройства',
'currentConnection': 'Текуща връзка',
'binding': 'Обвързване...',
'permissionDisabled': 'Изключено',
'confirmDisablePermissionTitle': 'Изключване на разрешение',
'confirmDisablePermissionContent': 'Да се изключи ли разрешението {permission}? Ще трябва да го включите отново от системните настройки.',
'confirmPermanentlyDisallowTitle': 'Спиране на запитванията?',
'confirmPermanentlyDisallowContent': 'Отказахте разрешението {permission}. Да спрем ли да питаме занапред? Ако изберете „Не разрешавай“, пак можете да го включите по-късно от системните настройки.',
'notNow': 'Не сега',
'permanentlyDisallow': 'Не разрешавай',
'otaBesNotSupported': 'Тези слушалки все още не поддържат обновяване на фърмуера от приложението. Моля, свържете се с поддръжката.',
'tabDevice': 'Устройства',
'tabPlaza': 'Площад',
'sloganHome': 'Технология за по-умен живот',
'sloganAi': 'Безграничен интелект',
'sloganDevice': 'Умен хардуер, готов за бъдещето',
'sloganPlaza': 'Умен свят, който те разбира',
'aiEditHint': 'Задръжте карта, за да плъзнете и пренаредите модулите',
'aiEmptyTitle': 'Все още няма добавени модули',
'aiEmptyDesc': 'Изберете нужните агенти в Площад',
'goToPlaza': 'Към Площад',
'plazaOnline': 'Наличен',
'plazaPlanned': 'Очаквайте скоро',
'plazaSearchHint': 'Търсене на агенти, напр. „превод“, „запис“',
'moduleRemove': 'Премахни',
'moduleAdd': 'Добави',
'moduleRemoved': 'Премахнато',
'moduleAdded': 'Добавено',
'goSee': 'Виж',
'comingSoonTag': 'Очаквайте скоро',
'needDeviceTag': 'Изисква устройство',
'featureInDev': 'Тази функция е в разработка',
'featureNotWired': 'Все още не е налична',
'todayLabel': 'Днес',
'deviceConnected': 'Свързано',
'licenseUnknown': 'Неизвестен лиценз',
'selectDevice': 'Изберете устройство',
'catNoteCard': 'AI карта за запис',
'catNoteCardDesc': 'Рекордер с форма на карта',
'catParkerPen': 'AI писалка',
'catParkerPenDesc': 'Писалки с диктофон',
'catStylusPen': 'AI стилус',
'catStylusPenDesc': 'Устройства за ръкопис / стилус',
'catStudyMachine': 'AI таблет за учене',
'catStudyMachineDesc': 'Устройства за обучение',
'besAddTitle': 'Добавяне на AI слушалки',
'besConnecting': 'Свързване със слушалките…',
'besNotFound': 'Слушалките не са намерени',
'besNotFoundIosDesc': 'Първо свържете слушалките в Настройки → Bluetooth, след което се върнете тук',
'besNotFoundAndroidDesc': 'Първо сдвоете слушалките в Настройки → Bluetooth, след което ги свържете тук',
'besReadFailed': 'Неуспешно четене',
'besConnectFailIos': 'Уверете се, че слушалките са показани като „Свързани“ в системния Bluetooth, след което опитайте отново',
'besConnectFailAndroid': 'Уверете се, че слушалките са включени, наблизо и не са свързани с друго устройство. След свързване ще се свързват отново автоматично.',
'holderAddTitle': 'Добавяне на AI стойка за телефон',
'holderConnecting': 'Свързване с устройството…',
'holderSearching': 'Търсене на устройства…',
'holderNotFound': 'Не са намерени устройства',
'holderPairHint': 'Уверете се, че стойката е включена и в режим на сдвояване',
'holderRetryHint': 'Уверете се, че устройството е включено и наблизо, след което опитайте отново',
'autoConnectTitle': 'Автоматично свързване на устройства',
'autoConnectDesc': 'Събужда приложението, за да се свърже отново и да получи записите, когато устройството ви се включи или е наблизо',
'autoConnectConsentTitle': 'Включване на автоматичното свързване',
'autoConnectConsentIntro': 'За да може устройството ви (AI слушалки / AI стойка за телефон) да се свързва автоматично и да запазва записи при натискане на бутона му за запис, ни е нужно вашето съгласие:',
'autoConnectConsentB1': 'Регистриране на фоново Bluetooth сканиране, за да се събуди (стартира автоматично) приложението при откриване на обвързаното ви устройство, да се свърже отново и да получи записите',
'autoConnectConsentB2': 'След събуждане се използва само за свързване с устройството, запис и качване — нищо друго, и не се стартират други приложения',
'autoConnectConsentB3': 'Можете да използвате приложението и без това — просто свързвайте устройството ръчно всеки път',
'autoConnectConsentB4': 'Можете да го изключите по всяко време в Настройки → Автоматично свързване на устройства',
'autoConnectAgree': 'Приемам и включвам',
'autoConnectLater': 'Не сега',
'autoConnectRequired': 'Автоматичното свързване е изключено',
'autoConnectRequiredDesc': 'Включете автоматичното свързване, за да обвържете това устройство',
'recordTapToStart': 'Натиснете бутона по-долу, за да започнете запис',
'recordPaused': 'На пауза',
'recordMark': 'Маркер',
'recordDeleteTitle': 'Изтриване на запис',
'accountAndProfile': 'Акаунт и профил',
'nicknameUnset': 'Няма зададен прякор',
'nicknameInput': 'Въведете прякор',
'loginMethod': 'Начин на вход',
'manageMyLogin': 'Управление на входа',
'logoutAction': 'Излизане',
'warningLabel': 'Предупреждение:',
'deleteAccountWarn': 'Вашият акаунт и всички файлове ще бъдат изтрити завинаги.',
'icpFiling': 'ICP регистрация',
'logExport': 'Експорт на логове',
'logExportDesc': 'Преглед и експорт на логовете на приложението',
'permLoadFailed': 'Неуспешно зареждане на групите разрешения',
'permInsufficient': 'Отказано разрешение',
'voiceCloneCreating': 'Създаване на вашия глас…',
'voiceCloneCreate': 'Създай моя глас',
'voiceCloneAgree': 'Приемам и прочитам декларацията на глас',
'voiceCloneReadPrompt': 'Моля, прочетете:',
'voiceCloneFirstMonthFree': 'Първият месец безплатно',
'voiceCloneEnable': 'Включване на клониране на глас',
'noMusicPlaying': 'Нищо не се възпроизвежда',
'qqMusicNoSeek': 'Превъртането не се поддържа за QQ Music',
'bleScanTitle': 'BLE сканиране',
'bluetoothOn': 'Bluetooth е включен',
'bleScanning': 'Сканиране…',
'bleConnectedDevices': 'Свързани устройства',
'bleUnknownDevice': 'Неизвестно устройство',
'bleTapToScan': 'Натиснете „Сканирай“, за да търсите BLE устройства',
'weekMon': 'Пн',
'weekTue': 'Вт',
'weekWed': 'Ср',
'weekThu': 'Чт',
'weekFri': 'Пт',
'weekSat': 'Сб',
'weekSun': 'Нд',
'agentMeeting': 'Гласови бележки',
'agentMeetingDesc': 'Резюме на записи, център за памет',
'agentEmai': 'EMAI асистент',
'agentEmaiDesc': 'Разговаряйте и питайте всичко с глас',
'agentSimultaneous': 'Превод на живо',
'agentSimultaneousDesc': 'Превод, докато говорите',
'agentFaceToFace': 'Лице в лице',
'agentFaceToFaceDesc': 'Двупосочен превод на разговор',
'agentCallRecording': 'Запис на разговор',
'agentCallRecordingDesc': 'Разговорите се записват автоматично',
'agentFieldRecording': 'Запис на място',
'agentFieldRecordingDesc': 'Микрофон на устройството или телефона',
'agentCall': 'Превод на разговори',
'agentCallDesc': 'Превод в реално време по време на разговор',
'agentAudioVideo': 'Превод на медия',
'agentAudioVideoDesc': 'Кадриране с камера / превод на аудио и видео',
'agentEnglishChat': 'Разговор на английски',
'agentEnglishChatDesc': 'Потапяща практика на говорене',
'agentEnglishStudy': 'Учене на английски',
'agentEnglishStudyDesc': 'Уроци по лексика и граматика',
'agentKidsStory': 'Детски приказки',
'agentKidsStoryDesc': 'Приказки за лека нощ и въпроси и отговори',
'moduleRemovedDesc': 'премахнато от страницата AI',
'moduleAddedDesc': 'добавено в края на страницата AI',
'calNoRecords': 'Няма записи',
'calNoRecordsDay': 'Нищо планирано',
'calTodos': 'Задачи',
'calExpenses': 'Разходи',
'calTodoCount': '@count задачи',
'calExpenseCount': '@count разходи',
'calEntryCount': '@count записи',
'calItemsUnit': 'елемента',
'calCountUnit': 'записа',
'calTotalAmount': 'Общо',
'recordPause': 'Пауза',
'recordResume': 'Продължи',
'recordDeleteConfirm': 'Да се изтрие ли този запис?',
'scopeDay': 'Ден',
'scopeWeek': 'Седмица',
'scopeMonth': 'Месец',
'allDay': 'Цял ден',
'guestModeTitle': 'Режим гост',
'guestModeDesc': 'Гостите могат само да разглеждат. Влезте, за да използвате гласови бележки, превод и запис — данните ви ще се запазват в акаунта ви.',
'guestKeepBrowsing': 'Продължи разглеждането',
'guestGoLogin': 'Вход',
'guestViewOnlyTag': 'Само преглед',
'autoConnectReenableHint': 'Автоматичното свързване е изключено. Включете го отново в Настройки → Автоматично свързване на устройства.',
'plazaEntryDesc': 'Разгледайте и добавете още агенти',
'agentTextTranslation': 'Текстов превод',
'agentTextTranslationDesc': 'Превод на въведен или поставен текст',
'agentPictureTranslation': 'Превод на снимки',
'agentPictureTranslationDesc': 'Снимайте или изберете снимка за превод',
'callRequiredTitle': 'Необходим е разговор',
'callRequiredDesc': 'Тази функция работи само по време на разговор',
'inCallBusyTitle': 'Недостъпно по време на разговор',
'inCallBusyDesc': 'Микрофонът и аудио каналът се използват от разговора. Опитайте отново след затваряне на разговора.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Безплатно',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/cs_cz.dart

@ -1899,4 +1899,208 @@ const Map<String, String> csCZ = {
'unmarkFavorite': 'Odebrat z oblíbených',
'unnamedDevice': 'Nepojmenované zařízení',
'userDataParseFailed': 'Data uživatele se nepodařilo načíst',
'catEarphone': 'AI sluchátka',
'catEarphoneDesc': 'Klasická Bluetooth / BLE sluchátka',
'catPhoneHolder': 'AI držák telefonu',
'catPhoneHolderDesc': 'Zařízení typu držák',
'catCarAroma': 'AI vůně do auta',
'catCarAromaDesc': 'Difuzér vůně do auta',
'agreeAndContinue': 'Souhlasím a pokračovat',
'memberServiceAgreement': 'Smlouva o členských službách',
'memberServiceAgreementTitle': 'Smlouva o členských službách',
'memberServiceAgreementIntro': 'Abychom vám mohli poskytovat lepší členské služby, před předplacením si prosím pečlivě přečtěte: ',
'memberServiceAgreementOutro': '. Klepnutím na „Souhlasím a pokračovat“ potvrzujete, že jste si výše uvedenou smlouvu celou přečetli a přijímáte ji.',
'memberAgreementRejectedTip': 'Pro pokračování musíte souhlasit se Smlouvou o členských službách.',
'translationCallExitTitle': 'Opustit překlad hovoru',
'translationCallExitMessage': 'Vyberte, co udělat před odchodem',
'translationCallEnd': 'Ukončit překlad hovoru',
'translationContinueInBackground': 'Pokračovat v překladu na pozadí',
'backgroundRecordingIndicatorBody': 'Nahrávání pokračuje na pozadí. Klepnutím se vrátíte.',
'backgroundTranslationIndicatorBody': 'Překlad hovoru pokračuje na pozadí. Klepnutím se vrátíte.',
'meetingRecordScreenCapturePermissionDenied': 'K nahrávání systémového zvuku je potřeba oprávnění k nahrávání obrazovky. Udělte ho prosím a zkuste to znovu.',
'pleaseGrantBluetoothPermission': 'Pro skenování udělte prosím oprávnění Bluetooth',
'startScanFailed': 'Spuštění skenování selhalo',
'pleaseLoginFirst': 'Nejprve se prosím přihlaste',
'deviceUnauthorized': 'Zařízení není autorizováno, zkuste to znovu',
'boundDevices': 'Propojená zařízení',
'currentConnection': 'Aktuální připojení',
'binding': 'Propojování…',
'permissionDisabled': 'Vypnuto',
'confirmDisablePermissionTitle': 'Vypnout oprávnění',
'confirmDisablePermissionContent': 'Vypnout oprávnění {permission}? Pro opětovné zapnutí bude nutné přejít do nastavení systému.',
'confirmPermanentlyDisallowTitle': 'Přestat se ptát?',
'confirmPermanentlyDisallowContent': 'Odepřeli jste oprávnění {permission}. Přestat se od nynějška ptát? Pokud zvolíte „Nepovolit“, můžete ho později zapnout v nastavení systému.',
'notNow': 'Teď ne',
'permanentlyDisallow': 'Nepovolit',
'otaBesNotSupported': 'Tato sluchátka zatím nepodporují aktualizaci firmwaru v aplikaci. Kontaktujte prosím podporu.',
'tabDevice': 'Zařízení',
'tabPlaza': 'Plaza',
'sloganHome': 'Technologie pro chytřejší život',
'sloganAi': 'Inteligence bez hranic',
'sloganDevice': 'Chytrý hardware připravený na budoucnost',
'sloganPlaza': 'Chytrý svět, který vám rozumí',
'aiEditHint': 'Dlouhým stisknutím karty přetáhnete a přeuspořádáte moduly',
'aiEmptyTitle': 'Zatím žádné moduly',
'aiEmptyDesc': 'Vyberte si potřebné agenty v Plaza',
'goToPlaza': 'Přejít do Plaza',
'plazaOnline': 'K dispozici',
'plazaPlanned': 'Již brzy',
'plazaSearchHint': 'Hledat agenty, např. „překlad“, „nahrávání“',
'moduleRemove': 'Odebrat',
'moduleAdd': 'Přidat',
'moduleRemoved': 'Odebráno',
'moduleAdded': 'Přidáno',
'goSee': 'Zobrazit',
'comingSoonTag': 'Již brzy',
'needDeviceTag': 'Vyžaduje zařízení',
'featureInDev': 'Tato funkce se připravuje',
'featureNotWired': 'Zatím není k dispozici',
'todayLabel': 'Dnes',
'deviceConnected': 'Připojeno',
'licenseUnknown': 'Licence neznámá',
'selectDevice': 'Vyberte zařízení',
'catNoteCard': 'AI záznamová karta',
'catNoteCardDesc': 'Záznamník ve tvaru karty',
'catParkerPen': 'AI pero',
'catParkerPenDesc': 'Pera s hlasovým záznamníkem',
'catStylusPen': 'AI stylus',
'catStylusPenDesc': 'Zařízení pro ruční psaní / stylusy',
'catStudyMachine': 'AI výukový tablet',
'catStudyMachineDesc': 'Výuková zařízení',
'besAddTitle': 'Přidat AI sluchátka',
'besConnecting': 'Připojování ke sluchátkům…',
'besNotFound': 'Sluchátka nenalezena',
'besNotFoundIosDesc': 'Nejprve připojte sluchátka v Nastavení → Bluetooth a poté se vraťte',
'besNotFoundAndroidDesc': 'Nejprve spárujte sluchátka v Nastavení → Bluetooth a poté je zde připojte',
'besReadFailed': 'Čtení selhalo',
'besConnectFailIos': 'Ujistěte se, že jsou sluchátka v systémovém Bluetooth zobrazena jako Připojeno, a zkuste to znovu',
'besConnectFailAndroid': 'Ujistěte se, že jsou sluchátka zapnutá, poblíž a nejsou připojena k jinému zařízení. Po propojení se budou připojovat automaticky.',
'holderAddTitle': 'Přidat AI stojánek na telefon',
'holderConnecting': 'Připojování k zařízení…',
'holderSearching': 'Hledání zařízení…',
'holderNotFound': 'Žádná zařízení nenalezena',
'holderPairHint': 'Ujistěte se, že je stojánek zapnutý a v režimu párování',
'holderRetryHint': 'Ujistěte se, že je zařízení zapnuté a poblíž, a zkuste to znovu',
'autoConnectTitle': 'Automatické připojení zařízení',
'autoConnectDesc': 'Probudí aplikaci, aby se znovu připojila a přijala nahrávky, když se vaše zařízení zapne nebo přiblíží',
'autoConnectConsentTitle': 'Zapnout automatické připojení',
'autoConnectConsentIntro': 'Aby se vaše zařízení (AI sluchátka / AI stojánek na telefon) mohlo připojovat automaticky a ukládat nahrávky po stisknutí tlačítka nahrávání, potřebujeme váš souhlas:',
'autoConnectConsentB1': 'Zaregistrovat skenování Bluetooth na pozadí, aby se aplikace při zjištění propojeného zařízení probudila (automaticky spustila), znovu připojila a přijala nahrávky',
'autoConnectConsentB2': 'Po probuzení se používá pouze pro připojení zařízení, nahrávání a odesílání — nic jiného, žádné další aplikace se nespouštějí',
'autoConnectConsentB3': 'Aplikaci můžete používat i bez toho — stačí zařízení pokaždé připojit ručně',
'autoConnectConsentB4': 'Kdykoli to můžete vypnout v Nastavení → Automatické připojení zařízení',
'autoConnectAgree': 'Souhlasím a zapnout',
'autoConnectLater': 'Teď ne',
'autoConnectRequired': 'Automatické připojení je vypnuté',
'autoConnectRequiredDesc': 'Pro propojení tohoto zařízení zapněte automatické připojení',
'recordTapToStart': 'Klepnutím na tlačítko níže spustíte nahrávání',
'recordPaused': 'Pozastaveno',
'recordMark': 'Značka',
'recordDeleteTitle': 'Smazat nahrávku',
'accountAndProfile': 'Účet a profil',
'nicknameUnset': 'Přezdívka nenastavena',
'nicknameInput': 'Zadejte přezdívku',
'loginMethod': 'Způsob přihlášení',
'manageMyLogin': 'Spravovat přihlášení',
'logoutAction': 'Odhlásit se',
'warningLabel': 'Upozornění:',
'deleteAccountWarn': 'Váš účet a všechny soubory budou trvale smazány.',
'icpFiling': 'Registrace ICP',
'logExport': 'Export protokolů',
'logExportDesc': 'Zobrazit a exportovat protokoly aplikace',
'permLoadFailed': 'Načtení skupin oprávnění selhalo',
'permInsufficient': 'Oprávnění odepřeno',
'voiceCloneCreating': 'Vytváření vašeho hlasu…',
'voiceCloneCreate': 'Vytvořit můj hlas',
'voiceCloneAgree': 'Souhlasím a přečtu prohlášení nahlas',
'voiceCloneReadPrompt': 'Přečtěte prosím:',
'voiceCloneFirstMonthFree': 'První měsíc zdarma',
'voiceCloneEnable': 'Zapnout klonování hlasu',
'noMusicPlaying': 'Nic se nepřehrává',
'qqMusicNoSeek': 'QQ Music nepodporuje přetáčení',
'bleScanTitle': 'Skenování BLE',
'bluetoothOn': 'Bluetooth zapnutý',
'bleScanning': 'Skenování…',
'bleConnectedDevices': 'Připojená zařízení',
'bleUnknownDevice': 'Neznámé zařízení',
'bleTapToScan': 'Klepnutím na Skenovat vyhledáte BLE zařízení',
'weekMon': 'Po',
'weekTue': 'Út',
'weekWed': 'St',
'weekThu': 'Čt',
'weekFri': 'Pá',
'weekSat': 'So',
'weekSun': 'Ne',
'agentMeeting': 'Hlasové zápisy',
'agentMeetingDesc': 'Shrnutí nahrávek, centrum paměti',
'agentEmai': 'Asistent EMAI',
'agentEmaiDesc': 'Povídejte si a ptejte se hlasem na cokoli',
'agentSimultaneous': 'Živý překlad',
'agentSimultaneousDesc': 'Překlad během řeči',
'agentFaceToFace': 'Tváří v tvář',
'agentFaceToFaceDesc': 'Obousměrný překlad konverzace',
'agentCallRecording': 'Nahrávání hovorů',
'agentCallRecordingDesc': 'Hovory se ukládají automaticky',
'agentFieldRecording': 'Terénní nahrávání',
'agentFieldRecordingDesc': 'Mikrofon zařízení nebo telefonu',
'agentCall': 'Překlad hovoru',
'agentCallDesc': 'Překlad v reálném čase během hovorů',
'agentAudioVideo': 'Překlad médií',
'agentAudioVideoDesc': 'Záběr kamery / překlad audia a videa',
'agentEnglishChat': 'Konverzace v angličtině',
'agentEnglishChatDesc': 'Imerzivní procvičování mluvení',
'agentEnglishStudy': 'Studium angličtiny',
'agentEnglishStudyDesc': 'Lekce slovní zásoby a gramatiky',
'agentKidsStory': 'Dětské pohádky',
'agentKidsStoryDesc': 'Pohádky na dobrou noc a otázky s odpověďmi',
'moduleRemovedDesc': 'odebráno ze stránky AI',
'moduleAddedDesc': 'přidáno na konec stránky AI',
'calNoRecords': 'Žádné záznamy',
'calNoRecordsDay': 'Nic naplánováno',
'calTodos': 'Úkoly',
'calExpenses': 'Výdaje',
'calTodoCount': 'Úkoly: @count',
'calExpenseCount': 'Výdaje: @count',
'calEntryCount': 'Záznamy: @count',
'calItemsUnit': 'položek',
'calCountUnit': 'záznamů',
'calTotalAmount': 'Celkem',
'recordPause': 'Pozastavit',
'recordResume': 'Pokračovat',
'recordDeleteConfirm': 'Smazat tuto nahrávku?',
'scopeDay': 'Den',
'scopeWeek': 'Týden',
'scopeMonth': 'Měsíc',
'allDay': 'Celý den',
'guestModeTitle': 'Režim hosta',
'guestModeDesc': 'Hosté mohou pouze prohlížet. Přihlaste se a používejte hlasové poznámky, překlad a nahrávání — vaše data se uloží k vašemu účtu.',
'guestKeepBrowsing': 'Pokračovat v prohlížení',
'guestGoLogin': 'Přihlásit se',
'guestViewOnlyTag': 'Pouze prohlížení',
'autoConnectReenableHint': 'Automatické připojení je vypnuté. Znovu ho zapněte v Nastavení → Automatické připojení zařízení.',
'plazaEntryDesc': 'Procházejte a přidávejte další agenty',
'agentTextTranslation': 'Překlad textu',
'agentTextTranslationDesc': 'Překlad napsaného nebo vloženého textu',
'agentPictureTranslation': 'Překlad fotek',
'agentPictureTranslationDesc': 'Vyfoťte nebo vyberte fotku k překladu',
'callRequiredTitle': 'Vyžadován hovor',
'callRequiredDesc': 'Tato funkce funguje pouze během hovoru',
'inCallBusyTitle': 'Během hovoru nedostupné',
'inCallBusyDesc': 'Mikrofon a zvukovou cestu používá hovor. Zkuste to znovu po zavěšení.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Zdarma',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/da_dk.dart

@ -1901,4 +1901,208 @@ const Map<String, String> daDK = {
'unmarkFavorite': 'Fjern fra favoritter',
'unnamedDevice': 'Enhed uden navn',
'userDataParseFailed': 'Brugerdata kunne ikke læses',
'catEarphone': 'AI-ørepropper',
'catEarphoneDesc': 'Klassiske Bluetooth- / BLE-ørepropper',
'catPhoneHolder': 'AI-mobilholder',
'catPhoneHolderDesc': 'Holderenheder',
'catCarAroma': 'AI-bilduft',
'catCarAromaDesc': 'Aromadiffuser til bil',
'agreeAndContinue': 'Accepter og fortsæt',
'memberServiceAgreement': 'Medlemsserviceaftale',
'memberServiceAgreementTitle': 'Medlemsserviceaftale',
'memberServiceAgreementIntro': 'For at give dig bedre medlemsservice bedes du læse følgende omhyggeligt, før du abonnerer: ',
'memberServiceAgreementOutro': '. Ved at trykke på "Accepter og fortsæt" bekræfter du, at du har læst og accepterer ovenstående aftale.',
'memberAgreementRejectedTip': 'Du skal acceptere medlemsserviceaftalen for at fortsætte.',
'translationCallExitTitle': 'Forlad opkaldsoversættelse',
'translationCallExitMessage': 'Vælg, hvad der skal ske, før du forlader',
'translationCallEnd': 'Afslut opkaldsoversættelse',
'translationContinueInBackground': 'Fortsæt oversættelse i baggrunden',
'backgroundRecordingIndicatorBody': 'Optagelsen fortsætter i baggrunden. Tryk for at vende tilbage.',
'backgroundTranslationIndicatorBody': 'Opkaldsoversættelsen fortsætter i baggrunden. Tryk for at vende tilbage.',
'meetingRecordScreenCapturePermissionDenied': 'Skærmoptagelsestilladelse kræves for at optage systemlyd. Giv tilladelse, og prøv igen.',
'pleaseGrantBluetoothPermission': 'Giv Bluetooth-tilladelse for at scanne',
'startScanFailed': 'Kunne ikke starte scanning',
'pleaseLoginFirst': 'Log venligst ind først',
'deviceUnauthorized': 'Enheden er ikke autoriseret, prøv igen',
'boundDevices': 'Tilknyttede enheder',
'currentConnection': 'Aktuel forbindelse',
'binding': 'Tilknytter...',
'permissionDisabled': 'Deaktiveret',
'confirmDisablePermissionTitle': 'Slå tilladelse fra',
'confirmDisablePermissionContent': 'Slå {permission}-tilladelse fra? Du skal gå til systemindstillinger for at aktivere den igen.',
'confirmPermanentlyDisallowTitle': 'Stop med at spørge?',
'confirmPermanentlyDisallowContent': 'Du afviste {permission}-tilladelse. Stop med at spørge fremover? Hvis du vælger "Tillad ikke", kan du stadig aktivere den senere i systemindstillinger.',
'notNow': 'Ikke nu',
'permanentlyDisallow': 'Tillad ikke',
'otaBesNotSupported': 'Dette headset understøtter endnu ikke firmwareopgradering i appen. Kontakt venligst support.',
'tabDevice': 'Enheder',
'tabPlaza': 'Plaza',
'sloganHome': 'Teknologi til et smartere liv',
'sloganAi': 'Grænseløs intelligens',
'sloganDevice': 'Smart hardware, klar til fremtiden',
'sloganPlaza': 'En smart verden, der forstår dig',
'aiEditHint': 'Hold et kort nede for at trække og omarrangere moduler',
'aiEmptyTitle': 'Ingen moduler tilføjet endnu',
'aiEmptyDesc': 'Vælg de agenter, du har brug for, i Plaza',
'goToPlaza': 'Gå til Plaza',
'plazaOnline': 'Tilgængelig',
'plazaPlanned': 'Kommer snart',
'plazaSearchHint': 'Søg efter agenter, f.eks. “oversæt”, “optag”',
'moduleRemove': 'Fjern',
'moduleAdd': 'Tilføj',
'moduleRemoved': 'Fjernet',
'moduleAdded': 'Tilføjet',
'goSee': 'Se',
'comingSoonTag': 'Kommer snart',
'needDeviceTag': 'Kræver enhed',
'featureInDev': 'Denne funktion er under udvikling',
'featureNotWired': 'Ikke tilgængelig endnu',
'todayLabel': 'I dag',
'deviceConnected': 'Tilsluttet',
'licenseUnknown': 'Licens ukendt',
'selectDevice': 'Vælg en enhed',
'catNoteCard': 'AI-optagerkort',
'catNoteCardDesc': 'Kortformet optager',
'catParkerPen': 'AI-pen',
'catParkerPenDesc': 'Stemmeoptagerpenne',
'catStylusPen': 'AI-stylus',
'catStylusPenDesc': 'Håndskrifts-/stylusenheder',
'catStudyMachine': 'AI-læringstablet',
'catStudyMachineDesc': 'Læringsenheder',
'besAddTitle': 'Tilføj AI-øretelefoner',
'besConnecting': 'Tilslutter øretelefoner…',
'besNotFound': 'Øretelefoner ikke fundet',
'besNotFoundIosDesc': 'Tilslut først øretelefonerne under Indstillinger → Bluetooth, og kom så tilbage',
'besNotFoundAndroidDesc': 'Par først øretelefonerne under Indstillinger → Bluetooth, og tilslut dem så her',
'besReadFailed': 'Læsning mislykkedes',
'besConnectFailIos': 'Sørg for, at øretelefonerne vises som Tilsluttet i systemets Bluetooth, og prøv igen',
'besConnectFailAndroid': 'Sørg for, at øretelefonerne er tændt, i nærheden og ikke tilsluttet en anden enhed. De genopretter forbindelsen automatisk, når de er parret.',
'holderAddTitle': 'Tilføj AI-telefonholder',
'holderConnecting': 'Tilslutter enhed…',
'holderSearching': 'Søger efter enheder…',
'holderNotFound': 'Ingen enheder fundet',
'holderPairHint': 'Sørg for, at holderen er tændt og i parringstilstand',
'holderRetryHint': 'Sørg for, at enheden er tændt og i nærheden, og prøv igen',
'autoConnectTitle': 'Tilslut enheder automatisk',
'autoConnectDesc': 'Væk appen for at genoprette forbindelsen og modtage optagelser, når din enhed tændes eller kommer i nærheden',
'autoConnectConsentTitle': 'Aktivér automatisk tilslutning',
'autoConnectConsentIntro': 'For at din enhed (AI-øretelefoner / AI-telefonholder) kan tilslutte automatisk og gemme optagelser, når du trykker på dens optageknap, har vi brug for dit samtykke:',
'autoConnectConsentB1': 'Registrere en Bluetooth-scanning i baggrunden, så appen vækkes (startes automatisk), når din tilknyttede enhed registreres, for at genoprette forbindelsen og modtage optagelser',
'autoConnectConsentB2': 'Når appen er vækket, bruges den kun til enhedstilslutning, optagelse og upload — intet andet, og ingen andre apps startes',
'autoConnectConsentB3': 'Du kan stadig bruge appen uden — tilslut blot enheden manuelt hver gang',
'autoConnectConsentB4': 'Du kan slå det fra når som helst under Indstillinger → Tilslut enheder automatisk',
'autoConnectAgree': 'Accepter og aktivér',
'autoConnectLater': 'Ikke nu',
'autoConnectRequired': 'Automatisk tilslutning er slået fra',
'autoConnectRequiredDesc': 'Aktivér automatisk tilslutning for at tilknytte denne enhed',
'recordTapToStart': 'Tryk på knappen nedenfor for at starte optagelsen',
'recordPaused': 'Sat på pause',
'recordMark': 'Markér',
'recordDeleteTitle': 'Slet optagelse',
'accountAndProfile': 'Konto og profil',
'nicknameUnset': 'Intet kaldenavn angivet',
'nicknameInput': 'Indtast et kaldenavn',
'loginMethod': 'Loginmetode',
'manageMyLogin': 'Administrer login',
'logoutAction': 'Log ud',
'warningLabel': 'Advarsel:',
'deleteAccountWarn': 'Din konto og alle filer slettes permanent.',
'icpFiling': 'ICP-registrering',
'logExport': 'Eksportér logfiler',
'logExportDesc': 'Se og eksportér app-logfiler',
'permLoadFailed': 'Kunne ikke indlæse tilladelsesgrupper',
'permInsufficient': 'Tilladelse nægtet',
'voiceCloneCreating': 'Opretter din stemme…',
'voiceCloneCreate': 'Opret min stemme',
'voiceCloneAgree': 'Accepter og læs erklæringen højt',
'voiceCloneReadPrompt': 'Læs venligst:',
'voiceCloneFirstMonthFree': 'Første måned gratis',
'voiceCloneEnable': 'Aktivér stemmekloning',
'noMusicPlaying': 'Intet afspilles',
'qqMusicNoSeek': 'Spoling understøttes ikke for QQ Music',
'bleScanTitle': 'BLE-scanning',
'bluetoothOn': 'Bluetooth er tændt',
'bleScanning': 'Scanner…',
'bleConnectedDevices': 'Tilsluttede enheder',
'bleUnknownDevice': 'Ukendt enhed',
'bleTapToScan': 'Tryk på scan for at søge efter BLE-enheder',
'weekMon': 'Man',
'weekTue': 'Tir',
'weekWed': 'Ons',
'weekThu': 'Tor',
'weekFri': 'Fre',
'weekSat': 'Lør',
'weekSun': 'Søn',
'agentMeeting': 'Talereferat',
'agentMeetingDesc': 'Opsummer optagelser, hukommelsescenter',
'agentEmai': 'EMAI-assistent',
'agentEmaiDesc': 'Chat og spørg om alt med stemmen',
'agentSimultaneous': 'Realtidsoversættelse',
'agentSimultaneousDesc': 'Oversæt, mens du taler',
'agentFaceToFace': 'Ansigt-til-ansigt',
'agentFaceToFaceDesc': 'Tovejs samtaleoversættelse',
'agentCallRecording': 'Opkaldsoptagelse',
'agentCallRecordingDesc': 'Opkald gemmes automatisk',
'agentFieldRecording': 'Feltoptagelse',
'agentFieldRecordingDesc': 'Enhedens eller telefonens mikrofon',
'agentCall': 'Opkaldsoversættelse',
'agentCallDesc': 'Realtidsoversættelse under opkald',
'agentAudioVideo': 'Medieoversættelse',
'agentAudioVideoDesc': 'Kameraindramning / lyd- og videooversættelse',
'agentEnglishChat': 'Engelsk samtale',
'agentEnglishChatDesc': 'Fordybende taletræning',
'agentEnglishStudy': 'Engelskstudie',
'agentEnglishStudyDesc': 'Ordforråds- og grammatiklektioner',
'agentKidsStory': 'Børnehistorier',
'agentKidsStoryDesc': 'Godnathistorier og spørgsmål og svar',
'moduleRemovedDesc': 'fjernet fra AI-siden',
'moduleAddedDesc': 'tilføjet nederst på AI-siden',
'calNoRecords': 'Ingen poster',
'calNoRecordsDay': 'Intet planlagt',
'calTodos': 'To-do',
'calExpenses': 'Udgifter',
'calTodoCount': '@count to-dos',
'calExpenseCount': '@count udgifter',
'calEntryCount': '@count poster',
'calItemsUnit': 'stk.',
'calCountUnit': 'poster',
'calTotalAmount': 'I alt',
'recordPause': 'Pause',
'recordResume': 'Fortsæt',
'recordDeleteConfirm': 'Slet denne optagelse?',
'scopeDay': 'Dag',
'scopeWeek': 'Uge',
'scopeMonth': 'Måned',
'allDay': 'Hele dagen',
'guestModeTitle': 'Gæstetilstand',
'guestModeDesc': 'Gæster kan kun kigge. Log ind for at bruge talereferater, oversættelse og optagelse — dine data gemmes på din konto.',
'guestKeepBrowsing': 'Fortsæt med at kigge',
'guestGoLogin': 'Log ind',
'guestViewOnlyTag': 'Kun visning',
'autoConnectReenableHint': 'Automatisk tilslutning er slået fra. Slå det til igen under Indstillinger → Tilslut enheder automatisk.',
'plazaEntryDesc': 'Gennemse og tilføj flere agenter',
'agentTextTranslation': 'Tekstoversættelse',
'agentTextTranslationDesc': 'Oversæt indtastet eller indsat tekst',
'agentPictureTranslation': 'Billedoversættelse',
'agentPictureTranslationDesc': 'Tag eller vælg et billede for at oversætte',
'callRequiredTitle': 'Opkald påkrævet',
'callRequiredDesc': 'Denne funktion virker kun under et opkald',
'inCallBusyTitle': 'Ikke tilgængelig under et opkald',
'inCallBusyDesc': 'Mikrofonen og lydvejen bruges af opkaldet. Prøv igen, når du har lagt på.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

30
apps/client/lib/core/translations/language/de_de.dart

@ -1887,6 +1887,8 @@ const Map<String, String> deDE = {
'catNoteCardDesc': 'Rekorder im Kartenformat',
'catPhoneHolder': 'KI-Handyhalterung',
'catPhoneHolderDesc': 'Halterungsgeräte',
'catCarAroma': 'KI-Autoduft',
'catCarAromaDesc': 'Auto-Aromadiffusor',
'catParkerPen': 'KI-Stift',
'catParkerPenDesc': 'Aufnahmestifte',
'catStylusPen': 'KI-Stylus',
@ -2142,4 +2144,32 @@ const Map<String, String> deDE = {
'unmarkFavorite': 'Aus Favoriten entfernen',
'unnamedDevice': 'Unbenanntes Gerät',
'userDataParseFailed': 'Benutzerdaten konnten nicht gelesen werden',
'translationCallExitTitle': 'Anruf-Übersetzung verlassen',
'translationCallExitMessage': 'Wählen Sie, was beim Verlassen geschehen soll',
'translationCallEnd': 'Anruf-Übersetzung beenden',
'translationContinueInBackground': 'Im Hintergrund weiter übersetzen',
'backgroundRecordingIndicatorBody': 'Die Aufnahme läuft im Hintergrund weiter. Zum Zurückkehren tippen.',
'backgroundTranslationIndicatorBody': 'Die Anruf-Übersetzung läuft im Hintergrund weiter. Zum Zurückkehren tippen.',
'permissionDisabled': 'Deaktiviert',
'confirmDisablePermissionTitle': 'Berechtigung deaktivieren',
'confirmDisablePermissionContent': 'Berechtigung {permission} deaktivieren? Sie müssen sie danach in den Systemeinstellungen erneut aktivieren.',
'confirmPermanentlyDisallowTitle': 'Nicht mehr fragen?',
'confirmPermanentlyDisallowContent': 'Sie haben die Berechtigung {permission} abgelehnt. Ab jetzt nicht mehr fragen? Wenn Sie „Nicht erlauben“ wählen, können Sie sie später in den Systemeinstellungen aktivieren.',
'notNow': 'Nicht jetzt',
'permanentlyDisallow': 'Nicht erlauben',
'otaBesNotSupported': 'Dieses Headset unterstützt das Firmware-Upgrade in der App noch nicht. Bitte wenden Sie sich an den Support.',
'smartcarConnected': 'Verbunden',
'smartcarConnectFailed': 'Verbindung fehlgeschlagen',
'smartcarConnecting': 'Verbinde…',
'smartcarSearching': 'Suche nach Auto-Diffusoren in der Nähe…',
'smartcarNotFound': 'Kein Gerät gefunden',
'smartcarScanHint': 'Stellen Sie sicher, dass das Gerät eingeschaltet und in der Nähe ist',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/el_gr.dart

@ -1935,4 +1935,208 @@ const Map<String, String> elGR = {
'unmarkFavorite': 'Αφαίρεση από τα αγαπημένα',
'unnamedDevice': 'Συσκευή χωρίς όνομα',
'userDataParseFailed': 'Αποτυχία ανάγνωσης δεδομένων χρήστη',
'catEarphone': 'Ακουστικά AI',
'catEarphoneDesc': 'Ακουστικά κλασικού Bluetooth / BLE',
'catPhoneHolder': 'Βάση κινητού AI',
'catPhoneHolderDesc': 'Συσκευές βάσης',
'catCarAroma': 'Αρωματικό αυτοκινήτου AI',
'catCarAromaDesc': 'Διαχύτης αρώματος αυτοκινήτου',
'agreeAndContinue': 'Συμφωνώ και συνέχεια',
'memberServiceAgreement': 'Συμφωνία υπηρεσιών μέλους',
'memberServiceAgreementTitle': 'Συμφωνία υπηρεσιών μέλους',
'memberServiceAgreementIntro': 'Για να σας παρέχουμε καλύτερες υπηρεσίες μέλους, διαβάστε προσεκτικά πριν από τη συνδρομή: ',
'memberServiceAgreementOutro': '. Πατώντας «Συμφωνώ και συνέχεια», επιβεβαιώνετε ότι έχετε διαβάσει πλήρως και αποδέχεστε την παραπάνω συμφωνία.',
'memberAgreementRejectedTip': 'Πρέπει να αποδεχτείτε τη Συμφωνία υπηρεσιών μέλους για να συνεχίσετε.',
'translationCallExitTitle': 'Έξοδος από τη μετάφραση κλήσης',
'translationCallExitMessage': 'Επιλέξτε τι θα κάνετε πριν φύγετε',
'translationCallEnd': 'Τερματισμός μετάφρασης κλήσης',
'translationContinueInBackground': 'Συνέχιση μετάφρασης στο παρασκήνιο',
'backgroundRecordingIndicatorBody': 'Η εγγραφή συνεχίζεται στο παρασκήνιο. Πατήστε για επιστροφή.',
'backgroundTranslationIndicatorBody': 'Η μετάφραση κλήσης συνεχίζεται στο παρασκήνιο. Πατήστε για επιστροφή.',
'meetingRecordScreenCapturePermissionDenied': 'Απαιτείται δικαίωμα εγγραφής οθόνης για την καταγραφή ήχου συστήματος. Παραχωρήστε το και δοκιμάστε ξανά.',
'pleaseGrantBluetoothPermission': 'Παραχωρήστε δικαίωμα Bluetooth για σάρωση',
'startScanFailed': 'Αποτυχία έναρξης σάρωσης',
'pleaseLoginFirst': 'Συνδεθείτε πρώτα',
'deviceUnauthorized': 'Η συσκευή δεν είναι εξουσιοδοτημένη, δοκιμάστε ξανά',
'boundDevices': 'Συνδεδεμένες συσκευές',
'currentConnection': 'Τρέχουσα σύνδεση',
'binding': 'Σύνδεση...',
'permissionDisabled': 'Απενεργοποιημένο',
'confirmDisablePermissionTitle': 'Απενεργοποίηση δικαιώματος',
'confirmDisablePermissionContent': 'Απενεργοποίηση δικαιώματος {permission}; Θα χρειαστεί να μεταβείτε στις ρυθμίσεις συστήματος για να το ενεργοποιήσετε ξανά.',
'confirmPermanentlyDisallowTitle': 'Να μην ξαναρωτηθείτε;',
'confirmPermanentlyDisallowContent': 'Απορρίψατε το δικαίωμα {permission}. Να μην ξαναρωτηθείτε από εδώ και πέρα; Αν επιλέξετε «Να μην επιτραπεί», μπορείτε να το ενεργοποιήσετε αργότερα από τις ρυθμίσεις συστήματος.',
'notNow': 'Όχι τώρα',
'permanentlyDisallow': 'Να μην επιτραπεί',
'otaBesNotSupported': 'Αυτά τα ακουστικά δεν υποστηρίζουν ακόμα αναβάθμιση υλικολογισμικού μέσα από την εφαρμογή. Επικοινωνήστε με την υποστήριξη.',
'tabDevice': 'Συσκευές',
'tabPlaza': 'Plaza',
'sloganHome': 'Τεχνολογία για μια πιο έξυπνη ζωή',
'sloganAi': 'Απεριόριστη νοημοσύνη',
'sloganDevice': 'Έξυπνο hardware, έτοιμο για το μέλλον',
'sloganPlaza': 'Ένας έξυπνος κόσμος που σας καταλαβαίνει',
'aiEditHint': 'Πατήστε παρατεταμένα μια κάρτα για να σύρετε και να αναδιατάξετε τις ενότητες',
'aiEmptyTitle': 'Δεν έχουν προστεθεί ενότητες ακόμα',
'aiEmptyDesc': 'Επιλέξτε τους πράκτορες που χρειάζεστε στο Plaza',
'goToPlaza': 'Μετάβαση στο Plaza',
'plazaOnline': 'Διαθέσιμο',
'plazaPlanned': 'Σύντομα',
'plazaSearchHint': 'Αναζήτηση πρακτόρων, π.χ. «μετάφραση», «εγγραφή»',
'moduleRemove': 'Αφαίρεση',
'moduleAdd': 'Προσθήκη',
'moduleRemoved': 'Αφαιρέθηκε',
'moduleAdded': 'Προστέθηκε',
'goSee': 'Προβολή',
'comingSoonTag': 'Σύντομα',
'needDeviceTag': 'Απαιτείται συσκευή',
'featureInDev': 'Αυτή η λειτουργία βρίσκεται υπό ανάπτυξη',
'featureNotWired': 'Δεν είναι διαθέσιμο ακόμα',
'todayLabel': 'Σήμερα',
'deviceConnected': 'Συνδεδεμένο',
'licenseUnknown': 'Άγνωστη άδεια',
'selectDevice': 'Επιλέξτε συσκευή',
'catNoteCard': 'Κάρτα εγγραφής AI',
'catNoteCardDesc': 'Συσκευή εγγραφής σε σχήμα κάρτας',
'catParkerPen': 'Στυλό AI',
'catParkerPenDesc': 'Στυλό εγγραφής φωνής',
'catStylusPen': 'Γραφίδα AI',
'catStylusPenDesc': 'Συσκευές γραφής / γραφίδας',
'catStudyMachine': 'Tablet μάθησης AI',
'catStudyMachineDesc': 'Συσκευές μάθησης',
'besAddTitle': 'Προσθήκη ακουστικών AI',
'besConnecting': 'Σύνδεση με τα ακουστικά…',
'besNotFound': 'Δεν βρέθηκαν ακουστικά',
'besNotFoundIosDesc': 'Συνδέστε πρώτα τα ακουστικά από Ρυθμίσεις → Bluetooth και μετά επιστρέψτε εδώ',
'besNotFoundAndroidDesc': 'Κάντε πρώτα σύζευξη των ακουστικών από Ρυθμίσεις → Bluetooth και μετά συνδεθείτε εδώ',
'besReadFailed': 'Αποτυχία ανάγνωσης',
'besConnectFailIos': 'Βεβαιωθείτε ότι τα ακουστικά εμφανίζονται ως Συνδεδεμένα στο Bluetooth του συστήματος και δοκιμάστε ξανά',
'besConnectFailAndroid': 'Βεβαιωθείτε ότι τα ακουστικά είναι ενεργοποιημένα, κοντά σας και δεν είναι συνδεδεμένα σε άλλη συσκευή. Μόλις συνδεθούν, θα επανασυνδέονται αυτόματα.',
'holderAddTitle': 'Προσθήκη βάσης κινητού AI',
'holderConnecting': 'Σύνδεση με τη συσκευή…',
'holderSearching': 'Αναζήτηση συσκευών…',
'holderNotFound': 'Δεν βρέθηκαν συσκευές',
'holderPairHint': 'Βεβαιωθείτε ότι η βάση είναι ενεργοποιημένη και σε λειτουργία σύζευξης',
'holderRetryHint': 'Βεβαιωθείτε ότι η συσκευή είναι ενεργοποιημένη και κοντά σας και δοκιμάστε ξανά',
'autoConnectTitle': 'Αυτόματη σύνδεση συσκευών',
'autoConnectDesc': 'Αφύπνιση της εφαρμογής για επανασύνδεση και λήψη εγγραφών όταν η συσκευή σας ενεργοποιείται ή βρίσκεται κοντά',
'autoConnectConsentTitle': 'Ενεργοποίηση αυτόματης σύνδεσης',
'autoConnectConsentIntro': 'Για να συνδέεται αυτόματα η συσκευή σας (ακουστικά AI / βάση κινητού AI) και να αποθηκεύονται οι εγγραφές όταν πατάτε το κουμπί εγγραφής της, χρειαζόμαστε τη συγκατάθεσή σας:',
'autoConnectConsentB1': 'Καταχώριση σάρωσης Bluetooth στο παρασκήνιο, ώστε η εφαρμογή να αφυπνίζεται (να ξεκινά αυτόματα) όταν εντοπίζεται η συνδεδεμένη συσκευή σας, για επανασύνδεση και λήψη εγγραφών',
'autoConnectConsentB2': 'Μετά την αφύπνιση, χρησιμοποιείται μόνο για σύνδεση συσκευής, εγγραφή και μεταφόρτωση — τίποτα άλλο, και δεν εκκινούνται άλλες εφαρμογές',
'autoConnectConsentB3': 'Μπορείτε να χρησιμοποιείτε την εφαρμογή και χωρίς αυτό — απλώς συνδέετε τη συσκευή χειροκίνητα κάθε φορά',
'autoConnectConsentB4': 'Μπορείτε να το απενεργοποιήσετε ανά πάσα στιγμή από Ρυθμίσεις → Αυτόματη σύνδεση συσκευών',
'autoConnectAgree': 'Συμφωνώ και ενεργοποίηση',
'autoConnectLater': 'Όχι τώρα',
'autoConnectRequired': 'Η αυτόματη σύνδεση είναι απενεργοποιημένη',
'autoConnectRequiredDesc': 'Ενεργοποιήστε την αυτόματη σύνδεση για να συνδέσετε αυτή τη συσκευή',
'recordTapToStart': 'Πατήστε το παρακάτω κουμπί για να ξεκινήσει η εγγραφή',
'recordPaused': 'Σε παύση',
'recordMark': 'Σήμανση',
'recordDeleteTitle': 'Διαγραφή εγγραφής',
'accountAndProfile': 'Λογαριασμός & προφίλ',
'nicknameUnset': 'Δεν έχει οριστεί ψευδώνυμο',
'nicknameInput': 'Εισαγάγετε ψευδώνυμο',
'loginMethod': 'Τρόπος σύνδεσης',
'manageMyLogin': 'Διαχείριση σύνδεσης',
'logoutAction': 'Αποσύνδεση',
'warningLabel': 'Προειδοποίηση:',
'deleteAccountWarn': 'Ο λογαριασμός σας και όλα τα αρχεία θα διαγραφούν οριστικά.',
'icpFiling': 'Καταχώριση ICP',
'logExport': 'Εξαγωγή αρχείων καταγραφής',
'logExportDesc': 'Προβολή και εξαγωγή αρχείων καταγραφής της εφαρμογής',
'permLoadFailed': 'Αποτυχία φόρτωσης ομάδων δικαιωμάτων',
'permInsufficient': 'Δεν παραχωρήθηκε δικαίωμα',
'voiceCloneCreating': 'Δημιουργία της φωνής σας…',
'voiceCloneCreate': 'Δημιουργία της φωνής μου',
'voiceCloneAgree': 'Συμφωνώ και διαβάζω τη δήλωση δυνατά',
'voiceCloneReadPrompt': 'Διαβάστε:',
'voiceCloneFirstMonthFree': 'Ο πρώτος μήνας δωρεάν',
'voiceCloneEnable': 'Ενεργοποίηση κλωνοποίησης φωνής',
'noMusicPlaying': 'Δεν αναπαράγεται τίποτα',
'qqMusicNoSeek': 'Η αναζήτηση θέσης δεν υποστηρίζεται για το QQ Music',
'bleScanTitle': 'Σάρωση BLE',
'bluetoothOn': 'Bluetooth ενεργοποιημένο',
'bleScanning': 'Σάρωση…',
'bleConnectedDevices': 'Συνδεδεμένες συσκευές',
'bleUnknownDevice': 'Άγνωστη συσκευή',
'bleTapToScan': 'Πατήστε σάρωση για αναζήτηση συσκευών BLE',
'weekMon': 'Δευ',
'weekTue': 'Τρί',
'weekWed': 'Τετ',
'weekThu': 'Πέμ',
'weekFri': 'Παρ',
'weekSat': 'Σάβ',
'weekSun': 'Κυρ',
'agentMeeting': 'Φωνητικά πρακτικά',
'agentMeetingDesc': 'Σύνοψη εγγραφών, κέντρο μνήμης',
'agentEmai': 'Βοηθός EMAI',
'agentEmaiDesc': 'Συνομιλήστε και ρωτήστε οτιδήποτε με τη φωνή σας',
'agentSimultaneous': 'Ζωντανή μετάφραση',
'agentSimultaneousDesc': 'Μετάφραση καθώς μιλάτε',
'agentFaceToFace': 'Πρόσωπο με πρόσωπο',
'agentFaceToFaceDesc': 'Αμφίδρομη μετάφραση συνομιλίας',
'agentCallRecording': 'Εγγραφή κλήσεων',
'agentCallRecordingDesc': 'Οι κλήσεις αποθηκεύονται αυτόματα',
'agentFieldRecording': 'Εγγραφή πεδίου',
'agentFieldRecordingDesc': 'Μικρόφωνο συσκευής ή τηλεφώνου',
'agentCall': 'Μετάφραση κλήσης',
'agentCallDesc': 'Μετάφραση σε πραγματικό χρόνο κατά τις κλήσεις',
'agentAudioVideo': 'Μετάφραση πολυμέσων',
'agentAudioVideoDesc': 'Καδράρισμα κάμερας / μετάφραση ήχου & βίντεο',
'agentEnglishChat': 'Συνομιλία στα Αγγλικά',
'agentEnglishChatDesc': 'Εξάσκηση ομιλίας με πλήρη εμβύθιση',
'agentEnglishStudy': 'Μελέτη Αγγλικών',
'agentEnglishStudyDesc': 'Μαθήματα λεξιλογίου και γραμματικής',
'agentKidsStory': 'Παιδικές ιστορίες',
'agentKidsStoryDesc': 'Ιστορίες για ύπνο και ερωτήσεις-απαντήσεις',
'moduleRemovedDesc': 'αφαιρέθηκε από τη σελίδα AI',
'moduleAddedDesc': 'προστέθηκε στο τέλος της σελίδας AI',
'calNoRecords': 'Δεν υπάρχουν καταχωρίσεις',
'calNoRecordsDay': 'Τίποτα προγραμματισμένο',
'calTodos': 'Εργασίες',
'calExpenses': 'Έξοδα',
'calTodoCount': '@count εργασίες',
'calExpenseCount': '@count έξοδα',
'calEntryCount': '@count καταχωρίσεις',
'calItemsUnit': 'στοιχεία',
'calCountUnit': 'καταχωρίσεις',
'calTotalAmount': 'Σύνολο',
'recordPause': 'Παύση',
'recordResume': 'Συνέχιση',
'recordDeleteConfirm': 'Διαγραφή αυτής της εγγραφής;',
'scopeDay': 'Ημέρα',
'scopeWeek': 'Εβδομάδα',
'scopeMonth': 'Μήνας',
'allDay': 'Ολοήμερο',
'guestModeTitle': 'Λειτουργία επισκέπτη',
'guestModeDesc': 'Οι επισκέπτες μπορούν μόνο να περιηγηθούν. Συνδεθείτε για να χρησιμοποιήσετε φωνητικές σημειώσεις, μετάφραση και εγγραφή — τα δεδομένα σας θα αποθηκεύονται στον λογαριασμό σας.',
'guestKeepBrowsing': 'Συνέχιση περιήγησης',
'guestGoLogin': 'Σύνδεση',
'guestViewOnlyTag': 'Μόνο προβολή',
'autoConnectReenableHint': 'Η αυτόματη σύνδεση είναι απενεργοποιημένη. Ενεργοποιήστε την ξανά από Ρυθμίσεις → Αυτόματη σύνδεση συσκευών.',
'plazaEntryDesc': 'Περιηγηθείτε και προσθέστε περισσότερους πράκτορες',
'agentTextTranslation': 'Μετάφραση κειμένου',
'agentTextTranslationDesc': 'Μετάφραση πληκτρολογημένου ή επικολλημένου κειμένου',
'agentPictureTranslation': 'Μετάφραση φωτογραφίας',
'agentPictureTranslationDesc': 'Τραβήξτε ή επιλέξτε μια φωτογραφία για μετάφραση',
'callRequiredTitle': 'Απαιτείται κλήση',
'callRequiredDesc': 'Αυτή η λειτουργία λειτουργεί μόνο κατά τη διάρκεια κλήσης',
'inCallBusyTitle': 'Μη διαθέσιμο κατά τη διάρκεια κλήσης',
'inCallBusyDesc': 'Το μικρόφωνο και η διαδρομή ήχου χρησιμοποιούνται από την κλήση. Δοκιμάστε ξανά αφού τερματίσετε την κλήση.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Δωρεάν',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

16
apps/client/lib/core/translations/language/en_us.dart

@ -1857,6 +1857,8 @@ const Map<String, String> enUS = {
'catNoteCardDesc': 'Card-shaped recorder',
'catPhoneHolder': 'AI Phone Stand',
'catPhoneHolderDesc': 'Stand devices',
'catCarAroma': 'AI Car Diffuser',
'catCarAromaDesc': 'In-car aroma diffuser',
'catParkerPen': 'AI Pen',
'catParkerPenDesc': 'Voice recorder pens',
'catStylusPen': 'AI Stylus',
@ -2112,4 +2114,18 @@ const Map<String, String> enUS = {
'unmarkFavorite': 'Remove from Favorites',
'unnamedDevice': 'Unnamed Device',
'userDataParseFailed': 'Failed to parse user data',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

30
apps/client/lib/core/translations/language/es_es.dart

@ -1859,6 +1859,8 @@ const Map<String, String> esES = {
'catNoteCardDesc': 'Grabadora tipo tarjeta',
'catPhoneHolder': 'Soporte de móvil IA',
'catPhoneHolderDesc': 'Dispositivos tipo soporte',
'catCarAroma': 'Ambientador de coche IA',
'catCarAromaDesc': 'Difusor de aroma para coche',
'catParkerPen': 'Bolígrafo IA',
'catParkerPenDesc': 'Bolígrafos grabadores',
'catStylusPen': 'Lápiz óptico IA',
@ -2136,4 +2138,32 @@ const Map<String, String> esES = {
'unmarkFavorite': 'Quitar de favoritos',
'unnamedDevice': 'Dispositivo sin nombre',
'userDataParseFailed': 'Error al procesar los datos del usuario',
'translationCallExitTitle': 'Salir de la traducción de llamadas',
'translationCallExitMessage': 'Elige qué hacer antes de salir',
'translationCallEnd': 'Finalizar traducción de llamada',
'translationContinueInBackground': 'Seguir traduciendo en segundo plano',
'backgroundRecordingIndicatorBody': 'La grabación continúa en segundo plano. Toca para volver.',
'backgroundTranslationIndicatorBody': 'La traducción de llamada continúa en segundo plano. Toca para volver.',
'permissionDisabled': 'Desactivado',
'confirmDisablePermissionTitle': 'Desactivar permiso',
'confirmDisablePermissionContent': '¿Desactivar el permiso de {permission}? Tendrás que ir a los ajustes del sistema para volver a activarlo.',
'confirmPermanentlyDisallowTitle': '¿Dejar de preguntar?',
'confirmPermanentlyDisallowContent': 'Has denegado el permiso de {permission}. ¿No volver a preguntar? Si eliges «No permitir», podrás activarlo más tarde en los ajustes del sistema.',
'notNow': 'Ahora no',
'permanentlyDisallow': 'No permitir',
'otaBesNotSupported': 'Estos auriculares aún no admiten la actualización de firmware desde la app. Ponte en contacto con el soporte.',
'smartcarConnected': 'Conectado',
'smartcarConnectFailed': 'Error de conexión',
'smartcarConnecting': 'Conectando…',
'smartcarSearching': 'Buscando difusores de coche cercanos…',
'smartcarNotFound': 'No se encontró ningún dispositivo',
'smartcarScanHint': 'Asegúrate de que el dispositivo esté encendido y cerca del teléfono',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/et_ee.dart

@ -1895,4 +1895,208 @@ const Map<String, String> etEE = {
'unmarkFavorite': 'Eemalda lemmikutest',
'unnamedDevice': 'Nimeta seade',
'userDataParseFailed': 'Kasutajaandmete lugemine ebaõnnestus',
'catEarphone': 'AI kõrvaklapid',
'catEarphoneDesc': 'Klassikalised Bluetooth / BLE kõrvaklapid',
'catPhoneHolder': 'AI telefonihoidik',
'catPhoneHolderDesc': 'Hoidikuseadmed',
'catCarAroma': 'AI auto lõhnastaja',
'catCarAromaDesc': 'Auto aroomihajuti',
'agreeAndContinue': 'Nõustu ja jätka',
'memberServiceAgreement': 'Liikmeteenuse leping',
'memberServiceAgreementTitle': 'Liikmeteenuse leping',
'memberServiceAgreementIntro': 'Parema liikmeteenuse pakkumiseks loe enne tellimist hoolikalt läbi: ',
'memberServiceAgreementOutro': '. Klõpsates „Nõustu ja jätka“ kinnitad, et oled ülaltoodud lepingu täielikult läbi lugenud ja sellega nõustud.',
'memberAgreementRejectedTip': 'Jätkamiseks pead nõustuma liikmeteenuse lepinguga.',
'translationCallExitTitle': 'Välju kõnetõlkest',
'translationCallExitMessage': 'Vali, mida enne lahkumist teha',
'translationCallEnd': 'Lõpeta kõnetõlge',
'translationContinueInBackground': 'Jätka tõlkimist taustal',
'backgroundRecordingIndicatorBody': 'Salvestamine jätkub taustal. Naasmiseks puuduta.',
'backgroundTranslationIndicatorBody': 'Kõnetõlge jätkub taustal. Naasmiseks puuduta.',
'meetingRecordScreenCapturePermissionDenied': 'Süsteemiheli salvestamiseks on vajalik ekraanisalvestuse luba. Anna luba ja proovi uuesti.',
'pleaseGrantBluetoothPermission': 'Skannimiseks anna Bluetoothi luba',
'startScanFailed': 'Skannimise käivitamine ebaõnnestus',
'pleaseLoginFirst': 'Palun logi esmalt sisse',
'deviceUnauthorized': 'Seade pole autoriseeritud, proovi uuesti',
'boundDevices': 'Seotud seadmed',
'currentConnection': 'Praegune ühendus',
'binding': 'Sidumine...',
'permissionDisabled': 'Keelatud',
'confirmDisablePermissionTitle': 'Lülita luba välja',
'confirmDisablePermissionContent': 'Kas lülitada {permission} luba välja? Uuesti lubamiseks pead minema süsteemi seadetesse.',
'confirmPermanentlyDisallowTitle': 'Ei küsi enam?',
'confirmPermanentlyDisallowContent': 'Keeldusid {permission} loast. Kas edaspidi enam mitte küsida? Kui valid „Ära luba“, saad selle hiljem süsteemi seadetes siiski sisse lülitada.',
'notNow': 'Mitte praegu',
'permanentlyDisallow': 'Ära luba',
'otaBesNotSupported': 'See peakomplekt ei toeta veel rakendusesisest püsivara uuendamist. Palun võta ühendust klienditoega.',
'tabDevice': 'Seadmed',
'tabPlaza': 'Plaza',
'sloganHome': 'Tehnoloogia nutikama elu heaks',
'sloganAi': 'Piiritu intelligentsus',
'sloganDevice': 'Nutikas riistvara, tulevikuks valmis',
'sloganPlaza': 'Nutikas maailm, mis sind mõistab',
'aiEditHint': 'Moodulite ümberpaigutamiseks hoia kaarti all ja lohista',
'aiEmptyTitle': 'Mooduleid pole veel lisatud',
'aiEmptyDesc': 'Vali Plazast vajalikud agendid',
'goToPlaza': 'Mine Plazasse',
'plazaOnline': 'Saadaval',
'plazaPlanned': 'Peagi tulekul',
'plazaSearchHint': 'Otsi agente, nt „tõlgi“, „salvesta“',
'moduleRemove': 'Eemalda',
'moduleAdd': 'Lisa',
'moduleRemoved': 'Eemaldatud',
'moduleAdded': 'Lisatud',
'goSee': 'Vaata',
'comingSoonTag': 'Peagi tulekul',
'needDeviceTag': 'Vajab seadet',
'featureInDev': 'See funktsioon on arendamisel',
'featureNotWired': 'Pole veel saadaval',
'todayLabel': 'Täna',
'deviceConnected': 'Ühendatud',
'licenseUnknown': 'Litsents teadmata',
'selectDevice': 'Vali seade',
'catNoteCard': 'AI salvestuskaart',
'catNoteCardDesc': 'Kaardikujuline salvesti',
'catParkerPen': 'AI pastakas',
'catParkerPenDesc': 'Salvestavad pastakad',
'catStylusPen': 'AI pliiats',
'catStylusPenDesc': 'Käsitsi kirjutamise / pliiatsiga seadmed',
'catStudyMachine': 'AI õppetahvel',
'catStudyMachineDesc': 'Õppeseadmed',
'besAddTitle': 'Lisa AI kõrvaklapid',
'besConnecting': 'Ühendan kõrvaklappidega…',
'besNotFound': 'Kõrvaklappe ei leitud',
'besNotFoundIosDesc': 'Ühenda kõrvaklapid esmalt menüüs Seaded → Bluetooth ja tule siis tagasi',
'besNotFoundAndroidDesc': 'Seo kõrvaklapid esmalt menüüs Seaded → Bluetooth ja ühenda siis siin',
'besReadFailed': 'Lugemine ebaõnnestus',
'besConnectFailIos': 'Veendu, et kõrvaklapid on süsteemi Bluetoothis olekus „Ühendatud“, seejärel proovi uuesti',
'besConnectFailAndroid': 'Veendu, et kõrvaklapid on sisse lülitatud, lähedal ega ole ühendatud teise seadmega. Pärast sidumist ühenduvad need automaatselt uuesti.',
'holderAddTitle': 'Lisa AI telefonialus',
'holderConnecting': 'Ühendan seadmega…',
'holderSearching': 'Otsin seadmeid…',
'holderNotFound': 'Seadmeid ei leitud',
'holderPairHint': 'Veendu, et alus on sisse lülitatud ja sidumisrežiimis',
'holderRetryHint': 'Veendu, et seade on sisse lülitatud ja lähedal, seejärel proovi uuesti',
'autoConnectTitle': 'Seadmete automaatne ühendamine',
'autoConnectDesc': 'Äratab rakenduse, et taasühenduda ja salvestusi vastu võtta, kui seade sisse lülitub või lähedale tuleb',
'autoConnectConsentTitle': 'Luba automaatne ühendamine',
'autoConnectConsentIntro': 'Et sinu seade (AI kõrvaklapid / AI telefonialus) saaks automaatselt ühenduda ja salvestusnupu vajutamisel salvestusi salvestada, vajame sinu nõusolekut:',
'autoConnectConsentB1': 'Registreerime Bluetoothi taustaskannimise, et rakendus äratataks (käivitataks automaatselt) seotud seadme tuvastamisel, et taasühenduda ja salvestusi vastu võtta',
'autoConnectConsentB2': 'Pärast äratamist kasutatakse seda ainult seadme ühendamiseks, salvestamiseks ja üleslaadimiseks — mitte millekski muuks, ja teisi rakendusi ei käivitata',
'autoConnectConsentB3': 'Saad rakendust kasutada ka ilma selleta — ühenda seade lihtsalt iga kord käsitsi',
'autoConnectConsentB4': 'Saad selle igal ajal välja lülitada menüüs Seaded → Seadmete automaatne ühendamine',
'autoConnectAgree': 'Nõustu ja luba',
'autoConnectLater': 'Mitte praegu',
'autoConnectRequired': 'Automaatne ühendamine on välja lülitatud',
'autoConnectRequiredDesc': 'Selle seadme sidumiseks lülita sisse automaatne ühendamine',
'recordTapToStart': 'Salvestamise alustamiseks puuduta allolevat nuppu',
'recordPaused': 'Peatatud',
'recordMark': 'Märgi',
'recordDeleteTitle': 'Kustuta salvestus',
'accountAndProfile': 'Konto ja profiil',
'nicknameUnset': 'Hüüdnime pole määratud',
'nicknameInput': 'Sisesta hüüdnimi',
'loginMethod': 'Sisselogimisviis',
'manageMyLogin': 'Halda sisselogimist',
'logoutAction': 'Logi välja',
'warningLabel': 'Hoiatus:',
'deleteAccountWarn': 'Sinu konto ja kõik failid kustutatakse jäädavalt.',
'icpFiling': 'ICP registreering',
'logExport': 'Ekspordi logid',
'logExportDesc': 'Vaata ja ekspordi rakenduse logisid',
'permLoadFailed': 'Loagruppide laadimine ebaõnnestus',
'permInsufficient': 'Luba puudub',
'voiceCloneCreating': 'Loon sinu häält…',
'voiceCloneCreate': 'Loo minu hääl',
'voiceCloneAgree': 'Nõustu ja loe avaldus valjusti ette',
'voiceCloneReadPrompt': 'Palun loe:',
'voiceCloneFirstMonthFree': 'Esimene kuu tasuta',
'voiceCloneEnable': 'Luba hääle kloneerimine',
'noMusicPlaying': 'Midagi ei mängi',
'qqMusicNoSeek': 'QQ Music ei toeta kerimist',
'bleScanTitle': 'BLE skannimine',
'bluetoothOn': 'Bluetooth sees',
'bleScanning': 'Skannin…',
'bleConnectedDevices': 'Ühendatud seadmed',
'bleUnknownDevice': 'Tundmatu seade',
'bleTapToScan': 'BLE-seadmete otsimiseks puuduta „Skanni“',
'weekMon': 'E',
'weekTue': 'T',
'weekWed': 'K',
'weekThu': 'N',
'weekFri': 'R',
'weekSat': 'L',
'weekSun': 'P',
'agentMeeting': 'Häälmemod',
'agentMeetingDesc': 'Salvestuste kokkuvõtted, mälukeskus',
'agentEmai': 'EMAI assistent',
'agentEmaiDesc': 'Vestle ja küsi häälega mida iganes',
'agentSimultaneous': 'Sünkroontõlge',
'agentSimultaneousDesc': 'Tõlgib rääkimise ajal',
'agentFaceToFace': 'Näost näkku',
'agentFaceToFaceDesc': 'Kahesuunaline vestluse tõlge',
'agentCallRecording': 'Kõne salvestus',
'agentCallRecordingDesc': 'Kõned salvestatakse automaatselt',
'agentFieldRecording': 'Kohapealne salvestus',
'agentFieldRecordingDesc': 'Seadme või telefoni mikrofon',
'agentCall': 'Kõnetõlge',
'agentCallDesc': 'Reaalajas tõlge kõne ajal',
'agentAudioVideo': 'Meediatõlge',
'agentAudioVideoDesc': 'Kaamera kadreering / heli ja video tõlge',
'agentEnglishChat': 'Inglise vestlus',
'agentEnglishChatDesc': 'Süvenev kõnepraktika',
'agentEnglishStudy': 'Inglise keele õpe',
'agentEnglishStudyDesc': 'Sõnavara ja grammatika tunnid',
'agentKidsStory': 'Lastejutud',
'agentKidsStoryDesc': 'Unejutud ja küsimused-vastused',
'moduleRemovedDesc': 'eemaldatud AI lehelt',
'moduleAddedDesc': 'lisatud AI lehe lõppu',
'calNoRecords': 'Kirjeid pole',
'calNoRecordsDay': 'Midagi pole plaanis',
'calTodos': 'Ülesanded',
'calExpenses': 'Kulud',
'calTodoCount': '@count ülesannet',
'calExpenseCount': '@count kulu',
'calEntryCount': '@count kirjet',
'calItemsUnit': 'tk',
'calCountUnit': 'kirjet',
'calTotalAmount': 'Kokku',
'recordPause': 'Peata',
'recordResume': 'Jätka',
'recordDeleteConfirm': 'Kas kustutada see salvestus?',
'scopeDay': 'Päev',
'scopeWeek': 'Nädal',
'scopeMonth': 'Kuu',
'allDay': 'Terve päev',
'guestModeTitle': 'Külalisrežiim',
'guestModeDesc': 'Külalised saavad ainult sirvida. Logi sisse, et kasutada häälmärkmeid, tõlget ja salvestamist — sinu andmed salvestatakse sinu kontole.',
'guestKeepBrowsing': 'Jätka sirvimist',
'guestGoLogin': 'Logi sisse',
'guestViewOnlyTag': 'Ainult vaatamiseks',
'autoConnectReenableHint': 'Automaatne ühendamine on välja lülitatud. Lülita see uuesti sisse menüüs Seaded → Seadmete automaatne ühendamine.',
'plazaEntryDesc': 'Sirvi ja lisa rohkem agente',
'agentTextTranslation': 'Tekstitõlge',
'agentTextTranslationDesc': 'Tõlgi trükitud või kleebitud teksti',
'agentPictureTranslation': 'Fototõlge',
'agentPictureTranslationDesc': 'Pildista või vali foto tõlkimiseks',
'callRequiredTitle': 'Vajalik on kõne',
'callRequiredDesc': 'See funktsioon töötab ainult kõne ajal',
'inCallBusyTitle': 'Kõne ajal pole saadaval',
'inCallBusyDesc': 'Mikrofon ja heliliin on kõne kasutuses. Proovi uuesti pärast kõne lõpetamist.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Tasuta',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/fa_ir.dart

@ -1886,4 +1886,208 @@ const Map<String, String> faIR = {
'unmarkFavorite': 'حذف از علاقه‌مندی‌ها',
'unnamedDevice': 'دستگاه بدون نام',
'userDataParseFailed': 'تحلیل داده‌های کاربر ناموفق بود',
'catEarphone': 'هدفون هوشمند',
'catEarphoneDesc': 'هدفون بلوتوث کلاسیک / BLE',
'catPhoneHolder': 'پایه گوشی هوشمند',
'catPhoneHolderDesc': 'دستگاه‌های پایه',
'catCarAroma': 'خوشبوکننده خودرو هوشمند',
'catCarAromaDesc': 'دستگاه پخش رایحه خودرو',
'agreeAndContinue': 'موافقت و ادامه',
'memberServiceAgreement': 'توافق‌نامه خدمات عضویت',
'memberServiceAgreementTitle': 'توافق‌نامه خدمات عضویت',
'memberServiceAgreementIntro': 'برای ارائه خدمات عضویت بهتر، لطفاً پیش از اشتراک با دقت مطالعه کنید: ',
'memberServiceAgreementOutro': '. با کلیک بر روی «موافقت و ادامه»، تأیید می‌کنید که توافق‌نامه فوق را به طور کامل مطالعه کرده و می‌پذیرید.',
'memberAgreementRejectedTip': 'برای ادامه باید با توافق‌نامه خدمات عضویت موافقت کنید.',
'translationCallExitTitle': 'خروج از ترجمه تماس',
'translationCallExitMessage': 'پیش از خروج، انتخاب کنید چه کاری انجام شود',
'translationCallEnd': 'پایان ترجمه تماس',
'translationContinueInBackground': 'ادامهٔ ترجمه در پس‌زمینه',
'backgroundRecordingIndicatorBody': 'ضبط در پس‌زمینه ادامه دارد. برای بازگشت لمس کنید.',
'backgroundTranslationIndicatorBody': 'ترجمه تماس در پس‌زمینه ادامه دارد. برای بازگشت لمس کنید.',
'meetingRecordScreenCapturePermissionDenied': 'برای ضبط صدای سیستم، مجوز ضبط صفحه لازم است. لطفاً آن را بدهید و دوباره تلاش کنید.',
'pleaseGrantBluetoothPermission': 'برای اسکن، لطفاً مجوز بلوتوث را بدهید',
'startScanFailed': 'شروع اسکن ناموفق بود',
'pleaseLoginFirst': 'لطفاً ابتدا وارد شوید',
'deviceUnauthorized': 'دستگاه مجاز نیست، لطفاً دوباره تلاش کنید',
'boundDevices': 'دستگاه‌های پیوندشده',
'currentConnection': 'اتصال فعلی',
'binding': 'در حال پیوند...',
'permissionDisabled': 'غیرفعال',
'confirmDisablePermissionTitle': 'خاموش کردن مجوز',
'confirmDisablePermissionContent': 'مجوز {permission} خاموش شود؟ برای فعال‌سازی مجدد باید به تنظیمات سیستم بروید.',
'confirmPermanentlyDisallowTitle': 'دیگر پرسیده نشود؟',
'confirmPermanentlyDisallowContent': 'شما مجوز {permission} را رد کردید. از این پس دیگر پرسیده نشود؟ اگر «اجازه نده» را انتخاب کنید، بعداً می‌توانید آن را از تنظیمات سیستم فعال کنید.',
'notNow': 'حالا نه',
'permanentlyDisallow': 'اجازه نده',
'otaBesNotSupported': 'این هدست هنوز از ارتقای سفت‌افزار داخل برنامه پشتیبانی نمی‌کند. لطفاً با پشتیبانی تماس بگیرید.',
'tabDevice': 'دستگاه‌ها',
'tabPlaza': 'پلازا',
'sloganHome': 'فناوری برای زندگی هوشمندتر',
'sloganAi': 'هوشی بی‌پایان',
'sloganDevice': 'سخت‌افزار هوشمند، آمادهٔ آینده',
'sloganPlaza': 'دنیایی هوشمند که شما را می‌فهمد',
'aiEditHint': 'برای جابه‌جایی و مرتب‌سازی ماژول‌ها، کارت را لمس طولانی کنید',
'aiEmptyTitle': 'هنوز ماژولی اضافه نشده است',
'aiEmptyDesc': 'عامل‌های مورد نیاز خود را از پلازا انتخاب کنید',
'goToPlaza': 'رفتن به پلازا',
'plazaOnline': 'در دسترس',
'plazaPlanned': 'به زودی',
'plazaSearchHint': 'جستجوی عامل‌ها، مثلاً «ترجمه»، «ضبط»',
'moduleRemove': 'حذف',
'moduleAdd': 'افزودن',
'moduleRemoved': 'حذف شد',
'moduleAdded': 'اضافه شد',
'goSee': 'مشاهده',
'comingSoonTag': 'به زودی',
'needDeviceTag': 'نیاز به دستگاه',
'featureInDev': 'این قابلیت در حال توسعه است',
'featureNotWired': 'هنوز در دسترس نیست',
'todayLabel': 'امروز',
'deviceConnected': 'متصل',
'licenseUnknown': 'مجوز نامشخص',
'selectDevice': 'یک دستگاه انتخاب کنید',
'catNoteCard': 'کارت ضبط AI',
'catNoteCardDesc': 'ضبط‌کنندهٔ کارتی',
'catParkerPen': 'قلم AI',
'catParkerPenDesc': 'قلم‌های ضبط صدا',
'catStylusPen': 'قلم لمسی AI',
'catStylusPenDesc': 'دستگاه‌های دست‌نویس / قلم لمسی',
'catStudyMachine': 'تبلت آموزشی AI',
'catStudyMachineDesc': 'دستگاه‌های آموزشی',
'besAddTitle': 'افزودن هدست AI',
'besConnecting': 'در حال اتصال به هدست…',
'besNotFound': 'هدست پیدا نشد',
'besNotFoundIosDesc': 'ابتدا هدست را از تنظیمات ← بلوتوث متصل کنید، سپس به اینجا برگردید',
'besNotFoundAndroidDesc': 'ابتدا هدست را از تنظیمات ← بلوتوث جفت کنید، سپس از اینجا متصل شوید',
'besReadFailed': 'خواندن ناموفق بود',
'besConnectFailIos': 'مطمئن شوید هدست در بلوتوث سیستم به‌صورت «متصل» نمایش داده می‌شود، سپس دوباره تلاش کنید',
'besConnectFailAndroid': 'مطمئن شوید هدست روشن و نزدیک است و به دستگاه دیگری متصل نیست. پس از پیوند، به‌صورت خودکار دوباره متصل می‌شود.',
'holderAddTitle': 'افزودن پایه گوشی AI',
'holderConnecting': 'در حال اتصال به دستگاه…',
'holderSearching': 'در حال جستجوی دستگاه‌ها…',
'holderNotFound': 'دستگاهی پیدا نشد',
'holderPairHint': 'مطمئن شوید پایه روشن و در حالت جفت‌سازی است',
'holderRetryHint': 'مطمئن شوید دستگاه روشن و نزدیک است، سپس دوباره تلاش کنید',
'autoConnectTitle': 'اتصال خودکار دستگاه‌ها',
'autoConnectDesc': 'بیدار کردن برنامه برای اتصال مجدد و دریافت ضبط‌ها هنگام روشن شدن یا نزدیک شدن دستگاه',
'autoConnectConsentTitle': 'فعال‌سازی اتصال خودکار',
'autoConnectConsentIntro': 'برای اینکه دستگاه شما (هدست AI / پایه گوشی AI) به‌صورت خودکار متصل شود و با فشردن دکمه ضبط آن، ضبط‌ها ذخیره شوند، به رضایت شما نیاز داریم:',
'autoConnectConsentB1': 'ثبت اسکن بلوتوث در پس‌زمینه تا هنگام شناسایی دستگاه پیوندشده، برنامه بیدار (خودکار اجرا) شود و اتصال مجدد و دریافت ضبط‌ها انجام گیرد',
'autoConnectConsentB2': 'پس از بیدار شدن، فقط برای اتصال دستگاه، ضبط و بارگذاری استفاده می‌شود — نه چیز دیگری، و هیچ برنامه دیگری اجرا نمی‌شود',
'autoConnectConsentB3': 'بدون آن هم می‌توانید از برنامه استفاده کنید — فقط هر بار دستگاه را به‌صورت دستی متصل کنید',
'autoConnectConsentB4': 'هر زمان می‌توانید آن را از تنظیمات ← اتصال خودکار دستگاه‌ها خاموش کنید',
'autoConnectAgree': 'موافقت و فعال‌سازی',
'autoConnectLater': 'حالا نه',
'autoConnectRequired': 'اتصال خودکار خاموش است',
'autoConnectRequiredDesc': 'برای پیوند این دستگاه، اتصال خودکار را فعال کنید',
'recordTapToStart': 'برای شروع ضبط، دکمهٔ زیر را لمس کنید',
'recordPaused': 'متوقف شده',
'recordMark': 'نشانه',
'recordDeleteTitle': 'حذف ضبط',
'accountAndProfile': 'حساب و پروفایل',
'nicknameUnset': 'نام مستعار تنظیم نشده',
'nicknameInput': 'نام مستعار را وارد کنید',
'loginMethod': 'روش ورود',
'manageMyLogin': 'مدیریت ورود',
'logoutAction': 'خروج از حساب',
'warningLabel': 'هشدار:',
'deleteAccountWarn': 'حساب شما و همه فایل‌ها برای همیشه حذف خواهند شد.',
'icpFiling': 'ثبت ICP',
'logExport': 'خروجی گزارش‌ها',
'logExportDesc': 'مشاهده و خروجی گرفتن از گزارش‌های برنامه',
'permLoadFailed': 'بارگذاری گروه‌های مجوز ناموفق بود',
'permInsufficient': 'مجوز رد شد',
'voiceCloneCreating': 'در حال ساخت صدای شما…',
'voiceCloneCreate': 'ساخت صدای من',
'voiceCloneAgree': 'موافقت و خواندن بیانیه با صدای بلند',
'voiceCloneReadPrompt': 'لطفاً بخوانید:',
'voiceCloneFirstMonthFree': 'ماه اول رایگان',
'voiceCloneEnable': 'فعال‌سازی شبیه‌سازی صدا',
'noMusicPlaying': 'چیزی در حال پخش نیست',
'qqMusicNoSeek': 'جابه‌جایی در QQ Music پشتیبانی نمی‌شود',
'bleScanTitle': 'اسکن BLE',
'bluetoothOn': 'بلوتوث روشن',
'bleScanning': 'در حال اسکن…',
'bleConnectedDevices': 'دستگاه‌های متصل',
'bleUnknownDevice': 'دستگاه ناشناس',
'bleTapToScan': 'برای جستجوی دستگاه‌های BLE، اسکن را لمس کنید',
'weekMon': 'دوشنبه',
'weekTue': 'سه‌شنبه',
'weekWed': 'چهارشنبه',
'weekThu': 'پنجشنبه',
'weekFri': 'جمعه',
'weekSat': 'شنبه',
'weekSun': 'یکشنبه',
'agentMeeting': 'صورت‌جلسه صوتی',
'agentMeetingDesc': 'خلاصه‌سازی ضبط‌ها، مرکز حافظه',
'agentEmai': 'دستیار EMAI',
'agentEmaiDesc': 'با صدا گفتگو کنید و هر چیزی بپرسید',
'agentSimultaneous': 'ترجمه همزمان',
'agentSimultaneousDesc': 'ترجمه همزمان با صحبت شما',
'agentFaceToFace': 'رو در رو',
'agentFaceToFaceDesc': 'ترجمه گفتگوی دوطرفه',
'agentCallRecording': 'ضبط تماس',
'agentCallRecordingDesc': 'تماس‌ها به‌صورت خودکار ذخیره می‌شوند',
'agentFieldRecording': 'ضبط میدانی',
'agentFieldRecordingDesc': 'میکروفون دستگاه یا گوشی',
'agentCall': 'ترجمه تماس',
'agentCallDesc': 'ترجمه بلادرنگ در حین تماس',
'agentAudioVideo': 'ترجمه رسانه',
'agentAudioVideoDesc': 'قاب‌بندی دوربین / ترجمه صوت و ویدیو',
'agentEnglishChat': 'گفتگوی انگلیسی',
'agentEnglishChatDesc': 'تمرین مکالمه به‌صورت غوطه‌ور',
'agentEnglishStudy': 'یادگیری انگلیسی',
'agentEnglishStudyDesc': 'درس‌های واژگان و دستور زبان',
'agentKidsStory': 'قصه‌های کودکان',
'agentKidsStoryDesc': 'قصه‌های شب و پرسش و پاسخ',
'moduleRemovedDesc': 'از صفحه AI حذف شد',
'moduleAddedDesc': 'به انتهای صفحه AI اضافه شد',
'calNoRecords': 'موردی وجود ندارد',
'calNoRecordsDay': 'برنامه‌ای وجود ندارد',
'calTodos': 'کارها',
'calExpenses': 'هزینه‌ها',
'calTodoCount': '@count کار',
'calExpenseCount': '@count هزینه',
'calEntryCount': '@count مورد',
'calItemsUnit': 'مورد',
'calCountUnit': 'مورد',
'calTotalAmount': 'مجموع',
'recordPause': 'توقف',
'recordResume': 'ادامه',
'recordDeleteConfirm': 'این ضبط حذف شود؟',
'scopeDay': 'روز',
'scopeWeek': 'هفته',
'scopeMonth': 'ماه',
'allDay': 'تمام روز',
'guestModeTitle': 'حالت مهمان',
'guestModeDesc': 'مهمان‌ها فقط می‌توانند مرور کنند. برای استفاده از یادداشت صوتی، ترجمه و ضبط وارد شوید — داده‌های شما در حسابتان ذخیره می‌شود.',
'guestKeepBrowsing': 'ادامه مرور',
'guestGoLogin': 'ورود',
'guestViewOnlyTag': 'فقط مشاهده',
'autoConnectReenableHint': 'اتصال خودکار خاموش است. آن را از تنظیمات ← اتصال خودکار دستگاه‌ها دوباره روشن کنید.',
'plazaEntryDesc': 'عامل‌های بیشتری را مرور و اضافه کنید',
'agentTextTranslation': 'ترجمه متن',
'agentTextTranslationDesc': 'ترجمه متن تایپ‌شده یا چسبانده‌شده',
'agentPictureTranslation': 'ترجمه عکس',
'agentPictureTranslationDesc': 'عکس بگیرید یا انتخاب کنید تا ترجمه شود',
'callRequiredTitle': 'نیاز به تماس',
'callRequiredDesc': 'این قابلیت فقط در حین تماس کار می‌کند',
'inCallBusyTitle': 'در حین تماس در دسترس نیست',
'inCallBusyDesc': 'میکروفون و مسیر صدا در اختیار تماس است. پس از پایان تماس دوباره تلاش کنید.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'رایگان',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/fi_fi.dart

@ -1905,4 +1905,208 @@ const Map<String, String> fiFI = {
'unmarkFavorite': 'Poista suosikeista',
'unnamedDevice': 'Nimetön laite',
'userDataParseFailed': 'Käyttäjätietojen luku epäonnistui',
'catEarphone': 'AI-nappikuulokkeet',
'catEarphoneDesc': 'Klassiset Bluetooth- / BLE-kuulokkeet',
'catPhoneHolder': 'AI-puhelinteline',
'catPhoneHolderDesc': 'Telinelaitteet',
'catCarAroma': 'AI-autotuoksu',
'catCarAromaDesc': 'Auton tuoksudiffuusori',
'agreeAndContinue': 'Hyväksy ja jatka',
'memberServiceAgreement': 'Jäsenpalvelusopimus',
'memberServiceAgreementTitle': 'Jäsenpalvelusopimus',
'memberServiceAgreementIntro': 'Tarjotaksemme sinulle parempia jäsenpalveluita, lue huolellisesti ennen tilaamista: ',
'memberServiceAgreementOutro': '. Napsauttamalla ”Hyväksy ja jatka” vahvistat lukeneesi yllä olevan sopimuksen kokonaan ja hyväksyväsi sen.',
'memberAgreementRejectedTip': 'Sinun on hyväksyttävä jäsenpalvelusopimus jatkaaksesi.',
'translationCallExitTitle': 'Poistu puhelukäännöksestä',
'translationCallExitMessage': 'Valitse, mitä tehdään ennen poistumista',
'translationCallEnd': 'Lopeta puhelukäännös',
'translationContinueInBackground': 'Jatka kääntämistä taustalla',
'backgroundRecordingIndicatorBody': 'Tallennus jatkuu taustalla. Palaa napauttamalla.',
'backgroundTranslationIndicatorBody': 'Puhelukäännös jatkuu taustalla. Palaa napauttamalla.',
'meetingRecordScreenCapturePermissionDenied': 'Järjestelmän äänen tallentamiseen tarvitaan näytön tallennuslupa. Myönnä lupa ja yritä uudelleen.',
'pleaseGrantBluetoothPermission': 'Myönnä Bluetooth-lupa hakua varten',
'startScanFailed': 'Haun käynnistys epäonnistui',
'pleaseLoginFirst': 'Kirjaudu ensin sisään',
'deviceUnauthorized': 'Laitetta ei ole valtuutettu, yritä uudelleen',
'boundDevices': 'Sidotut laitteet',
'currentConnection': 'Nykyinen yhteys',
'binding': 'Sidotaan...',
'permissionDisabled': 'Pois käytöstä',
'confirmDisablePermissionTitle': 'Poista lupa käytöstä',
'confirmDisablePermissionContent': 'Poistetaanko {permission}-lupa käytöstä? Sinun on otettava se uudelleen käyttöön järjestelmän asetuksista.',
'confirmPermanentlyDisallowTitle': 'Lopetetaanko kysyminen?',
'confirmPermanentlyDisallowContent': 'Epäsit {permission}-luvan. Lopetetaanko kysyminen tästä lähtien? Jos valitset ”Älä salli”, voit silti ottaa sen käyttöön myöhemmin järjestelmän asetuksista.',
'notNow': 'Ei nyt',
'permanentlyDisallow': 'Älä salli',
'otaBesNotSupported': 'Nämä kuulokkeet eivät vielä tue laiteohjelmiston päivitystä sovelluksesta. Ota yhteyttä tukeen.',
'tabDevice': 'Laitteet',
'tabPlaza': 'Plaza',
'sloganHome': 'Teknologiaa älykkäämpään elämään',
'sloganAi': 'Rajaton älykkyys',
'sloganDevice': 'Älykäs laitteisto, valmis tulevaisuuteen',
'sloganPlaza': 'Älykäs maailma, joka ymmärtää sinua',
'aiEditHint': 'Paina korttia pitkään ja vedä järjestääksesi moduulit',
'aiEmptyTitle': 'Moduuleja ei ole vielä lisätty',
'aiEmptyDesc': 'Valitse tarvitsemasi agentit Plazasta',
'goToPlaza': 'Siirry Plazaan',
'plazaOnline': 'Saatavilla',
'plazaPlanned': 'Tulossa pian',
'plazaSearchHint': 'Hae agentteja, esim. ”käännä”, ”tallenna”',
'moduleRemove': 'Poista',
'moduleAdd': 'Lisää',
'moduleRemoved': 'Poistettu',
'moduleAdded': 'Lisätty',
'goSee': 'Katso',
'comingSoonTag': 'Tulossa pian',
'needDeviceTag': 'Vaatii laitteen',
'featureInDev': 'Tämä ominaisuus on kehitteillä',
'featureNotWired': 'Ei vielä saatavilla',
'todayLabel': 'Tänään',
'deviceConnected': 'Yhdistetty',
'licenseUnknown': 'Lisenssi tuntematon',
'selectDevice': 'Valitse laite',
'catNoteCard': 'AI-tallenninkortti',
'catNoteCardDesc': 'Kortin muotoinen tallennin',
'catParkerPen': 'AI-kynä',
'catParkerPenDesc': 'Tallentavat kynät',
'catStylusPen': 'AI-stylus',
'catStylusPenDesc': 'Käsinkirjoitus-/stylus-laitteet',
'catStudyMachine': 'AI-oppimistabletti',
'catStudyMachineDesc': 'Oppimislaitteet',
'besAddTitle': 'Lisää AI-kuulokkeet',
'besConnecting': 'Yhdistetään kuulokkeisiin…',
'besNotFound': 'Kuulokkeita ei löytynyt',
'besNotFoundIosDesc': 'Yhdistä kuulokkeet ensin kohdassa Asetukset → Bluetooth ja palaa sitten tänne',
'besNotFoundAndroidDesc': 'Parita kuulokkeet ensin kohdassa Asetukset → Bluetooth ja yhdistä sitten täällä',
'besReadFailed': 'Lukeminen epäonnistui',
'besConnectFailIos': 'Varmista, että kuulokkeet näkyvät yhdistettyinä järjestelmän Bluetooth-asetuksissa, ja yritä sitten uudelleen',
'besConnectFailAndroid': 'Varmista, että kuulokkeet ovat päällä, lähellä eivätkä yhdistettynä toiseen laitteeseen. Ne yhdistyvät automaattisesti uudelleen parituksen jälkeen.',
'holderAddTitle': 'Lisää AI-puhelinteline',
'holderConnecting': 'Yhdistetään laitteeseen…',
'holderSearching': 'Etsitään laitteita…',
'holderNotFound': 'Laitteita ei löytynyt',
'holderPairHint': 'Varmista, että teline on päällä ja paritustilassa',
'holderRetryHint': 'Varmista, että laite on päällä ja lähellä, ja yritä sitten uudelleen',
'autoConnectTitle': 'Laitteiden automaattinen yhdistäminen',
'autoConnectDesc': 'Herättää sovelluksen yhdistämään uudelleen ja vastaanottamaan tallenteita, kun laite käynnistyy tai tulee lähelle',
'autoConnectConsentTitle': 'Ota automaattinen yhdistäminen käyttöön',
'autoConnectConsentIntro': 'Jotta laitteesi (AI-kuulokkeet / AI-puhelinteline) voi yhdistää automaattisesti ja tallentaa tallenteet, kun painat sen tallennuspainiketta, tarvitsemme suostumuksesi:',
'autoConnectConsentB1': 'Rekisteröidään Bluetooth-taustahaku, jotta sovellus herätetään (käynnistetään automaattisesti), kun sidottu laite havaitaan, yhteyden palauttamiseksi ja tallenteiden vastaanottamiseksi',
'autoConnectConsentB2': 'Herättämisen jälkeen sitä käytetään vain laitteen yhdistämiseen, tallentamiseen ja lataamiseen — ei mihinkään muuhun, eikä muita sovelluksia käynnistetä',
'autoConnectConsentB3': 'Voit käyttää sovellusta myös ilman sitä — yhdistä laite vain manuaalisesti joka kerta',
'autoConnectConsentB4': 'Voit poistaa sen käytöstä milloin tahansa kohdassa Asetukset → Laitteiden automaattinen yhdistäminen',
'autoConnectAgree': 'Hyväksy ja ota käyttöön',
'autoConnectLater': 'Ei nyt',
'autoConnectRequired': 'Automaattinen yhdistäminen on pois päältä',
'autoConnectRequiredDesc': 'Ota automaattinen yhdistäminen käyttöön sitoaksesi tämän laitteen',
'recordTapToStart': 'Aloita tallennus napauttamalla alla olevaa painiketta',
'recordPaused': 'Keskeytetty',
'recordMark': 'Merkitse',
'recordDeleteTitle': 'Poista tallenne',
'accountAndProfile': 'Tili ja profiili',
'nicknameUnset': 'Nimimerkkiä ei ole asetettu',
'nicknameInput': 'Syötä nimimerkki',
'loginMethod': 'Kirjautumistapa',
'manageMyLogin': 'Hallitse kirjautumista',
'logoutAction': 'Kirjaudu ulos',
'warningLabel': 'Varoitus:',
'deleteAccountWarn': 'Tilisi ja kaikki tiedostot poistetaan pysyvästi.',
'icpFiling': 'ICP-rekisteröinti',
'logExport': 'Vie lokit',
'logExportDesc': 'Tarkastele ja vie sovelluksen lokeja',
'permLoadFailed': 'Luparyhmien lataus epäonnistui',
'permInsufficient': 'Lupa evätty',
'voiceCloneCreating': 'Luodaan ääntäsi…',
'voiceCloneCreate': 'Luo oma ääneni',
'voiceCloneAgree': 'Hyväksy ja lue lausunto ääneen',
'voiceCloneReadPrompt': 'Lue ääneen:',
'voiceCloneFirstMonthFree': 'Ensimmäinen kuukausi ilmaiseksi',
'voiceCloneEnable': 'Ota äänen kloonaus käyttöön',
'noMusicPlaying': 'Mitään ei toisteta',
'qqMusicNoSeek': 'QQ Music ei tue kelausta',
'bleScanTitle': 'BLE-haku',
'bluetoothOn': 'Bluetooth päällä',
'bleScanning': 'Haetaan…',
'bleConnectedDevices': 'Yhdistetyt laitteet',
'bleUnknownDevice': 'Tuntematon laite',
'bleTapToScan': 'Napauta Hae etsiäksesi BLE-laitteita',
'weekMon': 'Ma',
'weekTue': 'Ti',
'weekWed': 'Ke',
'weekThu': 'To',
'weekFri': 'Pe',
'weekSat': 'La',
'weekSun': 'Su',
'agentMeeting': 'Äänimuistiot',
'agentMeetingDesc': 'Tiivistä tallenteet, muistikeskus',
'agentEmai': 'EMAI-avustaja',
'agentEmaiDesc': 'Keskustele ja kysy mitä tahansa puhumalla',
'agentSimultaneous': 'Simultaanitulkkaus',
'agentSimultaneousDesc': 'Kääntää puhuessasi',
'agentFaceToFace': 'Kasvokkain',
'agentFaceToFaceDesc': 'Kaksisuuntainen keskustelun käännös',
'agentCallRecording': 'Puhelun tallennus',
'agentCallRecordingDesc': 'Puhelut tallennetaan automaattisesti',
'agentFieldRecording': 'Kenttätallennus',
'agentFieldRecordingDesc': 'Laitteen tai puhelimen mikrofoni',
'agentCall': 'Puhelukäännös',
'agentCallDesc': 'Reaaliaikainen käännös puhelun aikana',
'agentAudioVideo': 'Mediakäännös',
'agentAudioVideoDesc': 'Kameran rajaus / ääni- ja videokäännös',
'agentEnglishChat': 'Englannin keskustelu',
'agentEnglishChatDesc': 'Immersiivistä puheharjoittelua',
'agentEnglishStudy': 'Englannin opiskelu',
'agentEnglishStudyDesc': 'Sanasto- ja kielioppitunnit',
'agentKidsStory': 'Lastentarinat',
'agentKidsStoryDesc': 'Iltasatuja ja kysymyksiä',
'moduleRemovedDesc': 'poistettu AI-sivulta',
'moduleAddedDesc': 'lisätty AI-sivun alaosaan',
'calNoRecords': 'Ei merkintöjä',
'calNoRecordsDay': 'Ei mitään suunniteltua',
'calTodos': 'Tehtävät',
'calExpenses': 'Kulut',
'calTodoCount': '@count tehtävää',
'calExpenseCount': '@count kulua',
'calEntryCount': '@count merkintää',
'calItemsUnit': 'kpl',
'calCountUnit': 'merkintää',
'calTotalAmount': 'Yhteensä',
'recordPause': 'Keskeytä',
'recordResume': 'Jatka',
'recordDeleteConfirm': 'Poistetaanko tämä tallenne?',
'scopeDay': 'Päivä',
'scopeWeek': 'Viikko',
'scopeMonth': 'Kuukausi',
'allDay': 'Koko päivä',
'guestModeTitle': 'Vierastila',
'guestModeDesc': 'Vieraat voivat vain selata. Kirjaudu sisään käyttääksesi äänimuistiinpanoja, käännöstä ja tallennusta — tietosi tallennetaan tilillesi.',
'guestKeepBrowsing': 'Jatka selaamista',
'guestGoLogin': 'Kirjaudu sisään',
'guestViewOnlyTag': 'Vain katselu',
'autoConnectReenableHint': 'Automaattinen yhdistäminen on pois päältä. Ota se uudelleen käyttöön kohdassa Asetukset → Laitteiden automaattinen yhdistäminen.',
'plazaEntryDesc': 'Selaa ja lisää agentteja',
'agentTextTranslation': 'Tekstin käännös',
'agentTextTranslationDesc': 'Käännä kirjoitettua tai liitettyä tekstiä',
'agentPictureTranslation': 'Kuvan käännös',
'agentPictureTranslationDesc': 'Ota tai valitse kuva käännettäväksi',
'callRequiredTitle': 'Puhelu vaaditaan',
'callRequiredDesc': 'Tämä ominaisuus toimii vain puhelun aikana',
'inCallBusyTitle': 'Ei käytettävissä puhelun aikana',
'inCallBusyDesc': 'Mikrofoni ja äänireitti ovat puhelun käytössä. Yritä uudelleen puhelun päätyttyä.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Ilmainen',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

30
apps/client/lib/core/translations/language/fr_fr.dart

@ -1898,6 +1898,8 @@ const Map<String, String> frFR = {
'catNoteCardDesc': 'Enregistreur format carte',
'catPhoneHolder': 'Support téléphone IA',
'catPhoneHolderDesc': 'Appareils de type support',
'catCarAroma': 'Diffuseur voiture IA',
'catCarAromaDesc': 'Diffuseur d’arôme pour voiture',
'catParkerPen': 'Stylo IA',
'catParkerPenDesc': 'Stylos enregistreurs',
'catStylusPen': 'Stylet IA',
@ -2155,4 +2157,32 @@ const Map<String, String> frFR = {
'unmarkFavorite': 'Retirer des favoris',
'unnamedDevice': 'Appareil sans nom',
'userDataParseFailed': "Échec de l'analyse des données utilisateur",
'translationCallExitTitle': 'Quitter la traduction d\'appel',
'translationCallExitMessage': 'Choisissez ce que vous souhaitez faire avant de quitter',
'translationCallEnd': 'Terminer la traduction d\'appel',
'translationContinueInBackground': 'Continuer la traduction en arrière-plan',
'backgroundRecordingIndicatorBody': 'L\'enregistrement continue en arrière-plan. Appuyez pour revenir.',
'backgroundTranslationIndicatorBody': 'La traduction d\'appel continue en arrière-plan. Appuyez pour revenir.',
'permissionDisabled': 'Désactivé',
'confirmDisablePermissionTitle': 'Désactiver l\'autorisation',
'confirmDisablePermissionContent': 'Désactiver l\'autorisation {permission} ? Vous devrez aller dans les paramètres système pour la réactiver.',
'confirmPermanentlyDisallowTitle': 'Ne plus demander ?',
'confirmPermanentlyDisallowContent': 'Vous avez refusé l\'autorisation {permission}. Ne plus demander désormais ? Si vous choisissez « Ne pas autoriser », vous pourrez l\'activer plus tard dans les paramètres système.',
'notNow': 'Pas maintenant',
'permanentlyDisallow': 'Ne pas autoriser',
'otaBesNotSupported': 'Ce casque ne prend pas encore en charge la mise à jour du firmware depuis l\'application. Veuillez contacter le support.',
'smartcarConnected': 'Connecté',
'smartcarConnectFailed': 'Échec de la connexion',
'smartcarConnecting': 'Connexion…',
'smartcarSearching': 'Recherche de diffuseurs auto à proximité…',
'smartcarNotFound': 'Aucun appareil trouvé',
'smartcarScanHint': 'Vérifiez que l’appareil est allumé et proche du téléphone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/hi_in.dart

@ -1904,4 +1904,208 @@ const Map<String, String> hiIN = {
'unmarkFavorite': 'पसंदीदा से हटाएँ',
'unnamedDevice': 'बिना नाम का डिवाइस',
'userDataParseFailed': 'उपयोगकर्ता डेटा पढ़ने में विफल',
'catEarphone': 'AI ईयरबड्स',
'catEarphoneDesc': 'क्लासिक ब्लूटूथ / BLE ईयरबड्स',
'catPhoneHolder': 'AI फ़ोन स्टैंड',
'catPhoneHolderDesc': 'स्टैंड डिवाइस',
'catCarAroma': 'AI कार डिफ्यूज़र',
'catCarAromaDesc': 'कार सुगंध डिफ्यूज़र',
'agreeAndContinue': 'सहमत हों और जारी रखें',
'memberServiceAgreement': 'सदस्यता सेवा अनुबंध',
'memberServiceAgreementTitle': 'सदस्यता सेवा अनुबंध',
'memberServiceAgreementIntro': 'आपको बेहतर सदस्यता सेवाएँ देने के लिए, कृपया सदस्यता लेने से पहले ध्यान से पढ़ें: ',
'memberServiceAgreementOutro': '। "सहमत हों और जारी रखें" पर टैप करके आप पुष्टि करते हैं कि आपने उपरोक्त अनुबंध पूरी तरह पढ़ लिया है और स्वीकार करते हैं।',
'memberAgreementRejectedTip': 'जारी रखने के लिए आपको सदस्यता सेवा अनुबंध से सहमत होना होगा।',
'translationCallExitTitle': 'कॉल अनुवाद से बाहर निकलें',
'translationCallExitMessage': 'बाहर निकलने से पहले चुनें कि क्या करना है',
'translationCallEnd': 'कॉल अनुवाद समाप्त करें',
'translationContinueInBackground': 'बैकग्राउंड में अनुवाद जारी रखें',
'backgroundRecordingIndicatorBody': 'रिकॉर्डिंग बैकग्राउंड में जारी है। वापस जाने के लिए टैप करें।',
'backgroundTranslationIndicatorBody': 'कॉल अनुवाद बैकग्राउंड में जारी है। वापस जाने के लिए टैप करें।',
'meetingRecordScreenCapturePermissionDenied': 'सिस्टम ऑडियो कैप्चर करने के लिए स्क्रीन रिकॉर्डिंग अनुमति आवश्यक है। कृपया अनुमति दें और पुनः प्रयास करें।',
'pleaseGrantBluetoothPermission': 'स्कैन करने के लिए कृपया ब्लूटूथ अनुमति दें',
'startScanFailed': 'स्कैन शुरू करने में विफल',
'pleaseLoginFirst': 'कृपया पहले लॉग इन करें',
'deviceUnauthorized': 'डिवाइस अधिकृत नहीं है, कृपया पुनः प्रयास करें',
'boundDevices': 'बाउंड डिवाइस',
'currentConnection': 'वर्तमान कनेक्शन',
'binding': 'बाइंड हो रहा है...',
'permissionDisabled': 'बंद',
'confirmDisablePermissionTitle': 'अनुमति बंद करें',
'confirmDisablePermissionContent': '{permission} अनुमति बंद करें? इसे फिर से चालू करने के लिए आपको सिस्टम सेटिंग्स में जाना होगा।',
'confirmPermanentlyDisallowTitle': 'अब न पूछें?',
'confirmPermanentlyDisallowContent': 'आपने {permission} अनुमति अस्वीकार कर दी। क्या अब से न पूछा जाए? यदि आप "अनुमति न दें" चुनते हैं, तो बाद में सिस्टम सेटिंग्स में इसे चालू कर सकते हैं।',
'notNow': 'अभी नहीं',
'permanentlyDisallow': 'अनुमति न दें',
'otaBesNotSupported': 'यह हेडसेट अभी ऐप में फ़र्मवेयर अपग्रेड का समर्थन नहीं करता। कृपया सहायता से संपर्क करें।',
'tabDevice': 'डिवाइस',
'tabPlaza': 'प्लाज़ा',
'sloganHome': 'स्मार्ट जीवन के लिए तकनीक',
'sloganAi': 'असीमित बुद्धिमत्ता',
'sloganDevice': 'स्मार्ट हार्डवेयर, भविष्य के लिए तैयार',
'sloganPlaza': 'एक स्मार्ट दुनिया जो आपको समझती है',
'aiEditHint': 'मॉड्यूल खींचकर पुनर्व्यवस्थित करने के लिए कार्ड को देर तक दबाएँ',
'aiEmptyTitle': 'अभी तक कोई मॉड्यूल नहीं जोड़ा गया',
'aiEmptyDesc': 'प्लाज़ा में अपने ज़रूरी एजेंट चुनें',
'goToPlaza': 'प्लाज़ा पर जाएँ',
'plazaOnline': 'उपलब्ध',
'plazaPlanned': 'जल्द आ रहा है',
'plazaSearchHint': 'एजेंट खोजें, जैसे “अनुवाद”, “रिकॉर्ड”',
'moduleRemove': 'हटाएँ',
'moduleAdd': 'जोड़ें',
'moduleRemoved': 'हटाया गया',
'moduleAdded': 'जोड़ा गया',
'goSee': 'देखें',
'comingSoonTag': 'जल्द आ रहा है',
'needDeviceTag': 'डिवाइस आवश्यक',
'featureInDev': 'यह सुविधा विकासाधीन है',
'featureNotWired': 'अभी उपलब्ध नहीं',
'todayLabel': 'आज',
'deviceConnected': 'कनेक्टेड',
'licenseUnknown': 'लाइसेंस अज्ञात',
'selectDevice': 'एक डिवाइस चुनें',
'catNoteCard': 'AI रिकॉर्डर कार्ड',
'catNoteCardDesc': 'कार्ड के आकार का रिकॉर्डर',
'catParkerPen': 'AI पेन',
'catParkerPenDesc': 'वॉयस रिकॉर्डर पेन',
'catStylusPen': 'AI स्टाइलस',
'catStylusPenDesc': 'हस्तलेखन / स्टाइलस डिवाइस',
'catStudyMachine': 'AI लर्निंग पैड',
'catStudyMachineDesc': 'लर्निंग डिवाइस',
'besAddTitle': 'AI ईयरबड्स जोड़ें',
'besConnecting': 'ईयरबड्स से कनेक्ट हो रहा है…',
'besNotFound': 'ईयरबड्स नहीं मिले',
'besNotFoundIosDesc': 'पहले सेटिंग्स → ब्लूटूथ में ईयरबड्स कनेक्ट करें, फिर यहाँ वापस आएँ',
'besNotFoundAndroidDesc': 'पहले सेटिंग्स → ब्लूटूथ में ईयरबड्स पेयर करें, फिर यहाँ कनेक्ट करें',
'besReadFailed': 'पढ़ने में विफल',
'besConnectFailIos': 'सुनिश्चित करें कि सिस्टम ब्लूटूथ में ईयरबड्स "कनेक्टेड" दिख रहे हैं, फिर पुनः प्रयास करें',
'besConnectFailAndroid': 'सुनिश्चित करें कि ईयरबड्स चालू हैं, पास में हैं और किसी अन्य डिवाइस से कनेक्ट नहीं हैं। लिंक होते ही वे अपने आप फिर से कनेक्ट हो जाएँगे।',
'holderAddTitle': 'AI फ़ोन स्टैंड जोड़ें',
'holderConnecting': 'डिवाइस से कनेक्ट हो रहा है…',
'holderSearching': 'डिवाइस खोजे जा रहे हैं…',
'holderNotFound': 'कोई डिवाइस नहीं मिला',
'holderPairHint': 'सुनिश्चित करें कि स्टैंड चालू है और पेयरिंग मोड में है',
'holderRetryHint': 'सुनिश्चित करें कि डिवाइस चालू और पास में है, फिर पुनः प्रयास करें',
'autoConnectTitle': 'डिवाइस ऑटो-कनेक्ट',
'autoConnectDesc': 'डिवाइस चालू होने या पास आने पर फिर से कनेक्ट होने और रिकॉर्डिंग प्राप्त करने के लिए ऐप को जगाएँ',
'autoConnectConsentTitle': 'ऑटो-कनेक्ट सक्षम करें',
'autoConnectConsentIntro': 'आपके डिवाइस (AI ईयरबड्स / AI फ़ोन स्टैंड) को अपने आप कनेक्ट होने और उसका रिकॉर्ड बटन दबाने पर रिकॉर्डिंग सहेजने के लिए, हमें आपकी सहमति चाहिए:',
'autoConnectConsentB1': 'बैकग्राउंड ब्लूटूथ स्कैन पंजीकृत करें ताकि आपका बाउंड डिवाइस मिलने पर ऐप जागे (ऑटो-स्टार्ट), और फिर से कनेक्ट होकर रिकॉर्डिंग प्राप्त करे',
'autoConnectConsentB2': 'जागने के बाद इसका उपयोग केवल डिवाइस कनेक्शन, रिकॉर्डिंग और अपलोड के लिए होता है — और कुछ नहीं, न ही कोई अन्य ऐप खोला जाता है',
'autoConnectConsentB3': 'इसके बिना भी आप ऐप का उपयोग कर सकते हैं — बस हर बार डिवाइस को मैन्युअल रूप से कनेक्ट करें',
'autoConnectConsentB4': 'आप इसे कभी भी सेटिंग्स → डिवाइस ऑटो-कनेक्ट में बंद कर सकते हैं',
'autoConnectAgree': 'सहमत हों और सक्षम करें',
'autoConnectLater': 'अभी नहीं',
'autoConnectRequired': 'ऑटो-कनेक्ट बंद है',
'autoConnectRequiredDesc': 'इस डिवाइस को बाइंड करने के लिए ऑटो-कनेक्ट सक्षम करें',
'recordTapToStart': 'रिकॉर्डिंग शुरू करने के लिए नीचे दिए बटन पर टैप करें',
'recordPaused': 'रुका हुआ',
'recordMark': 'मार्क',
'recordDeleteTitle': 'रिकॉर्डिंग हटाएँ',
'accountAndProfile': 'खाता और प्रोफ़ाइल',
'nicknameUnset': 'कोई उपनाम सेट नहीं',
'nicknameInput': 'उपनाम दर्ज करें',
'loginMethod': 'लॉगिन विधि',
'manageMyLogin': 'लॉगिन प्रबंधित करें',
'logoutAction': 'लॉग आउट',
'warningLabel': 'चेतावनी:',
'deleteAccountWarn': 'आपका खाता और सभी फ़ाइलें स्थायी रूप से हटा दी जाएँगी।',
'icpFiling': 'ICP पंजीकरण',
'logExport': 'लॉग निर्यात करें',
'logExportDesc': 'ऐप लॉग देखें और निर्यात करें',
'permLoadFailed': 'अनुमति समूह लोड करने में विफल',
'permInsufficient': 'अनुमति अस्वीकृत',
'voiceCloneCreating': 'आपकी आवाज़ बनाई जा रही है…',
'voiceCloneCreate': 'मेरी आवाज़ बनाएँ',
'voiceCloneAgree': 'सहमत हों और कथन को ज़ोर से पढ़ें',
'voiceCloneReadPrompt': 'कृपया पढ़ें:',
'voiceCloneFirstMonthFree': 'पहला महीना मुफ़्त',
'voiceCloneEnable': 'वॉयस क्लोन सक्षम करें',
'noMusicPlaying': 'कुछ नहीं चल रहा',
'qqMusicNoSeek': 'QQ Music में सीक करना समर्थित नहीं है',
'bleScanTitle': 'BLE स्कैन',
'bluetoothOn': 'ब्लूटूथ चालू',
'bleScanning': 'स्कैन हो रहा है…',
'bleConnectedDevices': 'कनेक्टेड डिवाइस',
'bleUnknownDevice': 'अज्ञात डिवाइस',
'bleTapToScan': 'BLE डिवाइस खोजने के लिए स्कैन पर टैप करें',
'weekMon': 'सोम',
'weekTue': 'मंगल',
'weekWed': 'बुध',
'weekThu': 'गुरु',
'weekFri': 'शुक्र',
'weekSat': 'शनि',
'weekSun': 'रवि',
'agentMeeting': 'वॉयस सारांश',
'agentMeetingDesc': 'रिकॉर्डिंग का सारांश, मेमोरी हब',
'agentEmai': 'EMAI असिस्टेंट',
'agentEmaiDesc': 'आवाज़ से बात करें और कुछ भी पूछें',
'agentSimultaneous': 'लाइव अनुवाद',
'agentSimultaneousDesc': 'बोलते समय अनुवाद',
'agentFaceToFace': 'आमने-सामने',
'agentFaceToFaceDesc': 'दो-तरफ़ा बातचीत अनुवाद',
'agentCallRecording': 'कॉल रिकॉर्डिंग',
'agentCallRecordingDesc': 'कॉल अपने आप सहेजे जाते हैं',
'agentFieldRecording': 'फ़ील्ड रिकॉर्डिंग',
'agentFieldRecordingDesc': 'डिवाइस या फ़ोन का माइक्रोफ़ोन',
'agentCall': 'कॉल अनुवाद',
'agentCallDesc': 'कॉल के दौरान रीयल-टाइम अनुवाद',
'agentAudioVideo': 'मीडिया अनुवाद',
'agentAudioVideoDesc': 'कैमरा फ़्रेमिंग / ऑडियो और वीडियो अनुवाद',
'agentEnglishChat': 'इंग्लिश चैट',
'agentEnglishChatDesc': 'इमर्सिव बोलने का अभ्यास',
'agentEnglishStudy': 'इंग्लिश स्टडी',
'agentEnglishStudyDesc': 'शब्दावली और व्याकरण पाठ',
'agentKidsStory': 'बच्चों की कहानियाँ',
'agentKidsStoryDesc': 'सोने के समय की कहानियाँ और प्रश्नोत्तर',
'moduleRemovedDesc': 'AI पेज से हटाया गया',
'moduleAddedDesc': 'AI पेज के नीचे जोड़ा गया',
'calNoRecords': 'कोई प्रविष्टि नहीं',
'calNoRecordsDay': 'कुछ भी निर्धारित नहीं',
'calTodos': 'टू-डू',
'calExpenses': 'खर्च',
'calTodoCount': '@count टू-डू',
'calExpenseCount': '@count खर्च',
'calEntryCount': '@count प्रविष्टियाँ',
'calItemsUnit': 'आइटम',
'calCountUnit': 'प्रविष्टियाँ',
'calTotalAmount': 'कुल',
'recordPause': 'रोकें',
'recordResume': 'जारी रखें',
'recordDeleteConfirm': 'यह रिकॉर्डिंग हटाएँ?',
'scopeDay': 'दिन',
'scopeWeek': 'सप्ताह',
'scopeMonth': 'माह',
'allDay': 'पूरे दिन',
'guestModeTitle': 'अतिथि मोड',
'guestModeDesc': 'अतिथि केवल ब्राउज़ कर सकते हैं। वॉयस नोट्स, अनुवाद और रिकॉर्डिंग का उपयोग करने के लिए लॉग इन करें — आपका डेटा आपके खाते में सहेजा जाएगा।',
'guestKeepBrowsing': 'ब्राउज़ करते रहें',
'guestGoLogin': 'लॉग इन करें',
'guestViewOnlyTag': 'केवल देखें',
'autoConnectReenableHint': 'ऑटो-कनेक्ट बंद है। इसे सेटिंग्स → डिवाइस ऑटो-कनेक्ट में फिर से चालू करें।',
'plazaEntryDesc': 'और एजेंट ब्राउज़ करें और जोड़ें',
'agentTextTranslation': 'पाठ अनुवाद',
'agentTextTranslationDesc': 'टाइप या पेस्ट किए गए पाठ का अनुवाद',
'agentPictureTranslation': 'फ़ोटो अनुवाद',
'agentPictureTranslationDesc': 'अनुवाद के लिए फ़ोटो लें या चुनें',
'callRequiredTitle': 'कॉल आवश्यक',
'callRequiredDesc': 'यह सुविधा केवल कॉल के दौरान काम करती है',
'inCallBusyTitle': 'कॉल के दौरान अनुपलब्ध',
'inCallBusyDesc': 'माइक और ऑडियो पाथ कॉल द्वारा उपयोग में हैं। कॉल समाप्त करने के बाद पुनः प्रयास करें।',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'मुफ़्त',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/hr_hr.dart

@ -1920,4 +1920,208 @@ const Map<String, String> hrHR = {
'unmarkFavorite': 'Ukloni iz favorita',
'unnamedDevice': 'Uređaj bez naziva',
'userDataParseFailed': 'Podatke korisnika nije moguće učitati',
'catEarphone': 'AI slušalice',
'catEarphoneDesc': 'Klasične Bluetooth / BLE slušalice',
'catPhoneHolder': 'AI držač telefona',
'catPhoneHolderDesc': 'Uređaji tipa držač',
'catCarAroma': 'AI auto difuzor',
'catCarAromaDesc': 'Difuzor mirisa za auto',
'agreeAndContinue': 'Slažem se i nastavi',
'memberServiceAgreement': 'Ugovor o članskim uslugama',
'memberServiceAgreementTitle': 'Ugovor o članskim uslugama',
'memberServiceAgreementIntro': 'Kako bismo vam pružili bolje članske usluge, pažljivo pročitajte prije pretplate: ',
'memberServiceAgreementOutro': '. Klikom na „Slažem se i nastavi” potvrđujete da ste u cijelosti pročitali i prihvatili gore navedeni ugovor.',
'memberAgreementRejectedTip': 'Za nastavak morate prihvatiti Ugovor o članskim uslugama.',
'translationCallExitTitle': 'Izađi iz prijevoda poziva',
'translationCallExitMessage': 'Odaberite što učiniti prije izlaska',
'translationCallEnd': 'Završi prijevod poziva',
'translationContinueInBackground': 'Nastavi prevoditi u pozadini',
'backgroundRecordingIndicatorBody': 'Snimanje se nastavlja u pozadini. Dodirnite za povratak.',
'backgroundTranslationIndicatorBody': 'Prijevod poziva nastavlja se u pozadini. Dodirnite za povratak.',
'meetingRecordScreenCapturePermissionDenied': 'Za snimanje zvuka sustava potrebno je dopuštenje za snimanje zaslona. Odobrite ga i pokušajte ponovno.',
'pleaseGrantBluetoothPermission': 'Za skeniranje odobrite Bluetooth dopuštenje',
'startScanFailed': 'Pokretanje skeniranja nije uspjelo',
'pleaseLoginFirst': 'Prvo se prijavite',
'deviceUnauthorized': 'Uređaj nije autoriziran, pokušajte ponovno',
'boundDevices': 'Povezani uređaji',
'currentConnection': 'Trenutna veza',
'binding': 'Povezivanje...',
'permissionDisabled': 'Isključeno',
'confirmDisablePermissionTitle': 'Isključi dopuštenje',
'confirmDisablePermissionContent': 'Isključiti dopuštenje {permission}? Za ponovno uključivanje morat ćete otići u postavke sustava.',
'confirmPermanentlyDisallowTitle': 'Prestati pitati?',
'confirmPermanentlyDisallowContent': 'Odbili ste dopuštenje {permission}. Prestati pitati od sada? Ako odaberete „Ne dopusti”, i dalje ga možete kasnije omogućiti u postavkama sustava.',
'notNow': 'Ne sada',
'permanentlyDisallow': 'Ne dopusti',
'otaBesNotSupported': 'Ove slušalice još ne podržavaju nadogradnju firmvera iz aplikacije. Obratite se podršci.',
'tabDevice': 'Uređaji',
'tabPlaza': 'Trg',
'sloganHome': 'Tehnologija za pametniji život',
'sloganAi': 'Inteligencija bez granica',
'sloganDevice': 'Pametni hardver, spreman za budućnost',
'sloganPlaza': 'Pametan svijet koji vas razumije',
'aiEditHint': 'Dugo pritisnite karticu za povlačenje i razmještaj modula',
'aiEmptyTitle': 'Još nema dodanih modula',
'aiEmptyDesc': 'Odaberite agente koje trebate na Trgu',
'goToPlaza': 'Idi na Trg',
'plazaOnline': 'Dostupno',
'plazaPlanned': 'Uskoro',
'plazaSearchHint': 'Pretražite agente, npr. „prijevod”, „snimanje”',
'moduleRemove': 'Ukloni',
'moduleAdd': 'Dodaj',
'moduleRemoved': 'Uklonjeno',
'moduleAdded': 'Dodano',
'goSee': 'Pogledaj',
'comingSoonTag': 'Uskoro',
'needDeviceTag': 'Potreban uređaj',
'featureInDev': 'Ova je značajka u razvoju',
'featureNotWired': 'Još nije dostupno',
'todayLabel': 'Danas',
'deviceConnected': 'Povezano',
'licenseUnknown': 'Licenca nepoznata',
'selectDevice': 'Odaberite uređaj',
'catNoteCard': 'AI kartica za snimanje',
'catNoteCardDesc': 'Snimač u obliku kartice',
'catParkerPen': 'AI olovka',
'catParkerPenDesc': 'Olovke za snimanje glasa',
'catStylusPen': 'AI pisaljka',
'catStylusPenDesc': 'Uređaji za rukopis / pisaljke',
'catStudyMachine': 'AI tablet za učenje',
'catStudyMachineDesc': 'Uređaji za učenje',
'besAddTitle': 'Dodaj AI slušalice',
'besConnecting': 'Povezivanje sa slušalicama…',
'besNotFound': 'Slušalice nisu pronađene',
'besNotFoundIosDesc': 'Najprije povežite slušalice u Postavke → Bluetooth, zatim se vratite ovdje',
'besNotFoundAndroidDesc': 'Najprije uparite slušalice u Postavke → Bluetooth, zatim ih ovdje povežite',
'besReadFailed': 'Čitanje nije uspjelo',
'besConnectFailIos': 'Provjerite prikazuju li se slušalice kao Povezano u Bluetooth postavkama sustava, zatim pokušajte ponovno',
'besConnectFailAndroid': 'Provjerite jesu li slušalice uključene, u blizini i da nisu povezane s drugim uređajem. Nakon povezivanja automatski će se ponovno spojiti.',
'holderAddTitle': 'Dodaj AI stalak za telefon',
'holderConnecting': 'Povezivanje s uređajem…',
'holderSearching': 'Pretraživanje uređaja…',
'holderNotFound': 'Nije pronađen nijedan uređaj',
'holderPairHint': 'Provjerite je li stalak uključen i u načinu uparivanja',
'holderRetryHint': 'Provjerite je li uređaj uključen i u blizini, zatim pokušajte ponovno',
'autoConnectTitle': 'Automatsko povezivanje uređaja',
'autoConnectDesc': 'Probudi aplikaciju radi ponovnog povezivanja i primanja snimki kad se uređaj uključi ili približi',
'autoConnectConsentTitle': 'Omogući automatsko povezivanje',
'autoConnectConsentIntro': 'Kako bi se vaš uređaj (AI slušalice / AI stalak za telefon) automatski povezao i spremao snimke kad pritisnete njegov gumb za snimanje, trebamo vaš pristanak:',
'autoConnectConsentB1': 'Registrira se pozadinsko Bluetooth skeniranje kako bi se aplikacija probudila (automatski pokrenula) kad se otkrije vaš povezani uređaj, radi ponovnog povezivanja i primanja snimki',
'autoConnectConsentB2': 'Nakon buđenja koristi se samo za povezivanje uređaja, snimanje i prijenos — ništa drugo, a druge aplikacije se ne pokreću',
'autoConnectConsentB3': 'Aplikaciju možete koristiti i bez toga — samo svaki put ručno povežite uređaj',
'autoConnectConsentB4': 'Možete ga isključiti bilo kada u Postavke → Automatsko povezivanje uređaja',
'autoConnectAgree': 'Slažem se i omogući',
'autoConnectLater': 'Ne sada',
'autoConnectRequired': 'Automatsko povezivanje je isključeno',
'autoConnectRequiredDesc': 'Omogućite automatsko povezivanje kako biste povezali ovaj uređaj',
'recordTapToStart': 'Dodirnite gumb ispod za početak snimanja',
'recordPaused': 'Pauzirano',
'recordMark': 'Označi',
'recordDeleteTitle': 'Izbriši snimku',
'accountAndProfile': 'Račun i profil',
'nicknameUnset': 'Nadimak nije postavljen',
'nicknameInput': 'Unesite nadimak',
'loginMethod': 'Način prijave',
'manageMyLogin': 'Upravljaj prijavom',
'logoutAction': 'Odjavi se',
'warningLabel': 'Upozorenje:',
'deleteAccountWarn': 'Vaš račun i sve datoteke bit će trajno izbrisani.',
'icpFiling': 'ICP registracija',
'logExport': 'Izvoz zapisnika',
'logExportDesc': 'Pregledajte i izvezite zapisnike aplikacije',
'permLoadFailed': 'Učitavanje grupa dopuštenja nije uspjelo',
'permInsufficient': 'Dopuštenje odbijeno',
'voiceCloneCreating': 'Stvaranje vašeg glasa…',
'voiceCloneCreate': 'Stvori moj glas',
'voiceCloneAgree': 'Slažem se i pročitat ću izjavu naglas',
'voiceCloneReadPrompt': 'Pročitajte:',
'voiceCloneFirstMonthFree': 'Prvi mjesec besplatno',
'voiceCloneEnable': 'Omogući kloniranje glasa',
'noMusicPlaying': 'Ništa se ne reproducira',
'qqMusicNoSeek': 'Premotavanje nije podržano za QQ Music',
'bleScanTitle': 'BLE skeniranje',
'bluetoothOn': 'Bluetooth uključen',
'bleScanning': 'Skeniranje…',
'bleConnectedDevices': 'Povezani uređaji',
'bleUnknownDevice': 'Nepoznat uređaj',
'bleTapToScan': 'Dodirnite Skeniraj za traženje BLE uređaja',
'weekMon': 'Pon',
'weekTue': 'Uto',
'weekWed': 'Sri',
'weekThu': 'Čet',
'weekFri': 'Pet',
'weekSat': 'Sub',
'weekSun': 'Ned',
'agentMeeting': 'Glasovni zapisnik',
'agentMeetingDesc': 'Sažimanje snimki, središte sjećanja',
'agentEmai': 'EMAI asistent',
'agentEmaiDesc': 'Razgovarajte i pitajte bilo što glasom',
'agentSimultaneous': 'Simultani prijevod',
'agentSimultaneousDesc': 'Prijevod dok govorite',
'agentFaceToFace': 'Licem u lice',
'agentFaceToFaceDesc': 'Dvosmjerni prijevod razgovora',
'agentCallRecording': 'Snimanje poziva',
'agentCallRecordingDesc': 'Pozivi se automatski spremaju',
'agentFieldRecording': 'Snimanje na terenu',
'agentFieldRecordingDesc': 'Mikrofon uređaja ili telefona',
'agentCall': 'Prijevod poziva',
'agentCallDesc': 'Prijevod u stvarnom vremenu tijekom poziva',
'agentAudioVideo': 'Prijevod medija',
'agentAudioVideoDesc': 'Kadriranje kamerom / prijevod zvuka i videa',
'agentEnglishChat': 'Razgovor na engleskom',
'agentEnglishChatDesc': 'Imerzivno vježbanje govora',
'agentEnglishStudy': 'Učenje engleskog',
'agentEnglishStudyDesc': 'Lekcije vokabulara i gramatike',
'agentKidsStory': 'Dječje priče',
'agentKidsStoryDesc': 'Priče za laku noć te pitanja i odgovori',
'moduleRemovedDesc': 'uklonjeno s AI stranice',
'moduleAddedDesc': 'dodano na dno AI stranice',
'calNoRecords': 'Nema unosa',
'calNoRecordsDay': 'Ništa nije zakazano',
'calTodos': 'Zadaci',
'calExpenses': 'Troškovi',
'calTodoCount': '@count zadataka',
'calExpenseCount': '@count troškova',
'calEntryCount': '@count unosa',
'calItemsUnit': 'stavki',
'calCountUnit': 'unosa',
'calTotalAmount': 'Ukupno',
'recordPause': 'Pauziraj',
'recordResume': 'Nastavi',
'recordDeleteConfirm': 'Izbrisati ovu snimku?',
'scopeDay': 'Dan',
'scopeWeek': 'Tjedan',
'scopeMonth': 'Mjesec',
'allDay': 'Cijeli dan',
'guestModeTitle': 'Način gosta',
'guestModeDesc': 'Gosti mogu samo pregledavati. Prijavite se za korištenje glasovnih bilješki, prijevoda i snimanja — vaši će podaci biti spremljeni na vaš račun.',
'guestKeepBrowsing': 'Nastavi pregledavati',
'guestGoLogin': 'Prijavi se',
'guestViewOnlyTag': 'Samo pregled',
'autoConnectReenableHint': 'Automatsko povezivanje je isključeno. Ponovno ga uključite u Postavke → Automatsko povezivanje uređaja.',
'plazaEntryDesc': 'Pregledajte i dodajte više agenata',
'agentTextTranslation': 'Prijevod teksta',
'agentTextTranslationDesc': 'Prevedite upisani ili zalijepljeni tekst',
'agentPictureTranslation': 'Prijevod fotografija',
'agentPictureTranslationDesc': 'Snimite ili odaberite fotografiju za prijevod',
'callRequiredTitle': 'Potreban poziv',
'callRequiredDesc': 'Ova značajka radi samo tijekom poziva',
'inCallBusyTitle': 'Nedostupno tijekom poziva',
'inCallBusyDesc': 'Mikrofon i audio put koristi poziv. Pokušajte ponovno nakon prekida poziva.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Besplatno',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/hu_hu.dart

@ -1923,4 +1923,208 @@ const Map<String, String> huHU = {
'unmarkFavorite': 'Eltávolítás a kedvencekből',
'unnamedDevice': 'Névtelen eszköz',
'userDataParseFailed': 'A felhasználói adatok feldolgozása sikertelen',
'catEarphone': 'AI fülhallgató',
'catEarphoneDesc': 'Klasszikus Bluetooth / BLE fülhallgató',
'catPhoneHolder': 'AI telefontartó',
'catPhoneHolderDesc': 'Tartó eszközök',
'catCarAroma': 'AI autóillatosító',
'catCarAromaDesc': 'Autós illatpárologtató',
'agreeAndContinue': 'Elfogadom és folytatom',
'memberServiceAgreement': 'Tagsági szolgáltatási szerződés',
'memberServiceAgreementTitle': 'Tagsági szolgáltatási szerződés',
'memberServiceAgreementIntro': 'A jobb tagsági szolgáltatás érdekében kérjük, előfizetés előtt figyelmesen olvassa el: ',
'memberServiceAgreementOutro': '. Az „Elfogadom és folytatom” gombra koppintva megerősíti, hogy a fenti szerződést teljes egészében elolvasta és elfogadja.',
'memberAgreementRejectedTip': 'A folytatáshoz el kell fogadnia a Tagsági szolgáltatási szerződést.',
'translationCallExitTitle': 'Kilépés a hívásfordításból',
'translationCallExitMessage': 'Válassza ki, mi történjen kilépés előtt',
'translationCallEnd': 'Hívásfordítás befejezése',
'translationContinueInBackground': 'Fordítás folytatása a háttérben',
'backgroundRecordingIndicatorBody': 'A felvétel a háttérben folytatódik. Koppintson a visszatéréshez.',
'backgroundTranslationIndicatorBody': 'A hívásfordítás a háttérben folytatódik. Koppintson a visszatéréshez.',
'meetingRecordScreenCapturePermissionDenied': 'A rendszerhang rögzítéséhez képernyőfelvételi engedély szükséges. Kérjük, engedélyezze, majd próbálja újra.',
'pleaseGrantBluetoothPermission': 'A kereséshez adjon Bluetooth engedélyt',
'startScanFailed': 'A keresés indítása sikertelen',
'pleaseLoginFirst': 'Kérjük, először jelentkezzen be',
'deviceUnauthorized': 'Az eszköz nincs engedélyezve, próbálja újra',
'boundDevices': 'Párosított eszközök',
'currentConnection': 'Jelenlegi kapcsolat',
'binding': 'Párosítás...',
'permissionDisabled': 'Kikapcsolva',
'confirmDisablePermissionTitle': 'Engedély kikapcsolása',
'confirmDisablePermissionContent': 'Kikapcsolja a(z) {permission} engedélyt? Az újbóli bekapcsoláshoz a rendszerbeállításokba kell lépnie.',
'confirmPermanentlyDisallowTitle': 'Ne kérdezzük többé?',
'confirmPermanentlyDisallowContent': 'Megtagadta a(z) {permission} engedélyt. Ne kérdezzük többé? Ha a „Nem engedélyezem” lehetőséget választja, később a rendszerbeállításokban továbbra is bekapcsolhatja.',
'notNow': 'Most nem',
'permanentlyDisallow': 'Nem engedélyezem',
'otaBesNotSupported': 'Ez a fülhallgató még nem támogatja az alkalmazáson belüli firmware-frissítést. Kérjük, forduljon az ügyfélszolgálathoz.',
'tabDevice': 'Eszközök',
'tabPlaza': 'Plaza',
'sloganHome': 'Technológia az okosabb életért',
'sloganAi': 'Határtalan intelligencia',
'sloganDevice': 'Okos hardver, a jövőre készen',
'sloganPlaza': 'Egy okos világ, amely megért',
'aiEditHint': 'Tartson lenyomva egy kártyát a modulok húzásához és átrendezéséhez',
'aiEmptyTitle': 'Még nincs hozzáadott modul',
'aiEmptyDesc': 'Válassza ki a szükséges ügynököket a Plazában',
'goToPlaza': 'Ugrás a Plazába',
'plazaOnline': 'Elérhető',
'plazaPlanned': 'Hamarosan',
'plazaSearchHint': 'Ügynökök keresése, pl. „fordítás”, „felvétel”',
'moduleRemove': 'Eltávolítás',
'moduleAdd': 'Hozzáadás',
'moduleRemoved': 'Eltávolítva',
'moduleAdded': 'Hozzáadva',
'goSee': 'Megtekintés',
'comingSoonTag': 'Hamarosan',
'needDeviceTag': 'Eszköz szükséges',
'featureInDev': 'Ez a funkció fejlesztés alatt áll',
'featureNotWired': 'Még nem érhető el',
'todayLabel': 'Ma',
'deviceConnected': 'Csatlakoztatva',
'licenseUnknown': 'Ismeretlen licenc',
'selectDevice': 'Válasszon eszközt',
'catNoteCard': 'AI diktafonkártya',
'catNoteCardDesc': 'Kártya alakú diktafon',
'catParkerPen': 'AI toll',
'catParkerPenDesc': 'Diktafon tollak',
'catStylusPen': 'AI érintőtoll',
'catStylusPenDesc': 'Kézírás / érintőtoll eszközök',
'catStudyMachine': 'AI tanulótablet',
'catStudyMachineDesc': 'Tanulóeszközök',
'besAddTitle': 'AI fülhallgató hozzáadása',
'besConnecting': 'Csatlakozás a fülhallgatóhoz…',
'besNotFound': 'Fülhallgató nem található',
'besNotFoundIosDesc': 'Először csatlakoztassa a fülhallgatót a Beállítások → Bluetooth menüben, majd térjen vissza ide',
'besNotFoundAndroidDesc': 'Először párosítsa a fülhallgatót a Beállítások → Bluetooth menüben, majd csatlakozzon itt',
'besReadFailed': 'Olvasás sikertelen',
'besConnectFailIos': 'Győződjön meg róla, hogy a fülhallgató Csatlakoztatva állapotban látszik a rendszer Bluetooth-beállításaiban, majd próbálja újra',
'besConnectFailAndroid': 'Győződjön meg róla, hogy a fülhallgató be van kapcsolva, a közelben van, és nincs másik eszközhöz csatlakoztatva. A kapcsolat létrejötte után automatikusan újracsatlakozik.',
'holderAddTitle': 'AI telefontartó hozzáadása',
'holderConnecting': 'Csatlakozás az eszközhöz…',
'holderSearching': 'Eszközök keresése…',
'holderNotFound': 'Nem található eszköz',
'holderPairHint': 'Győződjön meg róla, hogy a tartó be van kapcsolva és párosítási módban van',
'holderRetryHint': 'Győződjön meg róla, hogy az eszköz be van kapcsolva és a közelben van, majd próbálja újra',
'autoConnectTitle': 'Eszközök automatikus csatlakoztatása',
'autoConnectDesc': 'Felébreszti az alkalmazást az újracsatlakozáshoz és a felvételek fogadásához, amikor az eszköz bekapcsol vagy a közelbe kerül',
'autoConnectConsentTitle': 'Automatikus csatlakozás bekapcsolása',
'autoConnectConsentIntro': 'Ahhoz, hogy eszköze (AI fülhallgató / AI telefontartó) automatikusan csatlakozzon, és a felvételgomb megnyomásakor mentse a felvételeket, az Ön hozzájárulására van szükségünk:',
'autoConnectConsentB1': 'Háttérben futó Bluetooth-keresés regisztrálása, hogy az alkalmazás felébredjen (automatikusan elinduljon) a párosított eszköz észlelésekor az újracsatlakozáshoz és a felvételek fogadásához',
'autoConnectConsentB2': 'Felébresztés után csak az eszköz csatlakoztatására, felvételre és feltöltésre használjuk — semmi másra, és más alkalmazás nem indul el',
'autoConnectConsentB3': 'Enélkül is használhatja az alkalmazást — csak minden alkalommal kézzel kell csatlakoztatnia az eszközt',
'autoConnectConsentB4': 'Bármikor kikapcsolhatja a Beállítások → Eszközök automatikus csatlakoztatása menüben',
'autoConnectAgree': 'Elfogadom és bekapcsolom',
'autoConnectLater': 'Most nem',
'autoConnectRequired': 'Az automatikus csatlakozás ki van kapcsolva',
'autoConnectRequiredDesc': 'Az eszköz párosításához kapcsolja be az automatikus csatlakozást',
'recordTapToStart': 'A felvétel indításához koppintson az alábbi gombra',
'recordPaused': 'Szüneteltetve',
'recordMark': 'Megjelölés',
'recordDeleteTitle': 'Felvétel törlése',
'accountAndProfile': 'Fiók és profil',
'nicknameUnset': 'Nincs beállított becenév',
'nicknameInput': 'Adjon meg egy becenevet',
'loginMethod': 'Bejelentkezési mód',
'manageMyLogin': 'Bejelentkezés kezelése',
'logoutAction': 'Kijelentkezés',
'warningLabel': 'Figyelmeztetés:',
'deleteAccountWarn': 'Fiókja és minden fájlja véglegesen törlődik.',
'icpFiling': 'ICP-nyilvántartás',
'logExport': 'Naplók exportálása',
'logExportDesc': 'Az alkalmazás naplóinak megtekintése és exportálása',
'permLoadFailed': 'Az engedélycsoportok betöltése sikertelen',
'permInsufficient': 'Engedély megtagadva',
'voiceCloneCreating': 'Hangjának létrehozása…',
'voiceCloneCreate': 'Saját hang létrehozása',
'voiceCloneAgree': 'Elfogadom és felolvasom a nyilatkozatot',
'voiceCloneReadPrompt': 'Kérjük, olvassa fel:',
'voiceCloneFirstMonthFree': 'Az első hónap ingyenes',
'voiceCloneEnable': 'Hangklónozás bekapcsolása',
'noMusicPlaying': 'Nincs lejátszás',
'qqMusicNoSeek': 'A QQ Music nem támogatja a tekerést',
'bleScanTitle': 'BLE keresés',
'bluetoothOn': 'Bluetooth bekapcsolva',
'bleScanning': 'Keresés…',
'bleConnectedDevices': 'Csatlakoztatott eszközök',
'bleUnknownDevice': 'Ismeretlen eszköz',
'bleTapToScan': 'Koppintson a keresésre a BLE eszközök megtalálásához',
'weekMon': 'H',
'weekTue': 'K',
'weekWed': 'Sze',
'weekThu': 'Cs',
'weekFri': 'P',
'weekSat': 'Szo',
'weekSun': 'V',
'agentMeeting': 'Hangjegyzőkönyv',
'agentMeetingDesc': 'Felvételek összefoglalása, emlékközpont',
'agentEmai': 'EMAI asszisztens',
'agentEmaiDesc': 'Beszélgessen és kérdezzen bármit hanggal',
'agentSimultaneous': 'Élő fordítás',
'agentSimultaneousDesc': 'Fordítás beszéd közben',
'agentFaceToFace': 'Szemtől szemben',
'agentFaceToFaceDesc': 'Kétirányú beszélgetésfordítás',
'agentCallRecording': 'Hívásrögzítés',
'agentCallRecordingDesc': 'A hívások automatikusan mentődnek',
'agentFieldRecording': 'Helyszíni felvétel',
'agentFieldRecordingDesc': 'Eszköz- vagy telefonmikrofon',
'agentCall': 'Hívásfordítás',
'agentCallDesc': 'Valós idejű fordítás hívás közben',
'agentAudioVideo': 'Médiafordítás',
'agentAudioVideoDesc': 'Kamerakép / hang- és videófordítás',
'agentEnglishChat': 'Angol csevegés',
'agentEnglishChatDesc': 'Magával ragadó beszédgyakorlás',
'agentEnglishStudy': 'Angoltanulás',
'agentEnglishStudyDesc': 'Szókincs- és nyelvtanleckék',
'agentKidsStory': 'Gyerekmesék',
'agentKidsStoryDesc': 'Esti mesék és kérdés-válasz',
'moduleRemovedDesc': 'eltávolítva az AI oldalról',
'moduleAddedDesc': 'hozzáadva az AI oldal aljához',
'calNoRecords': 'Nincs bejegyzés',
'calNoRecordsDay': 'Nincs betervezve semmi',
'calTodos': 'Teendők',
'calExpenses': 'Kiadások',
'calTodoCount': '@count teendő',
'calExpenseCount': '@count kiadás',
'calEntryCount': '@count bejegyzés',
'calItemsUnit': 'elem',
'calCountUnit': 'bejegyzés',
'calTotalAmount': 'Összesen',
'recordPause': 'Szünet',
'recordResume': 'Folytatás',
'recordDeleteConfirm': 'Törli ezt a felvételt?',
'scopeDay': 'Nap',
'scopeWeek': 'Hét',
'scopeMonth': 'Hónap',
'allDay': 'Egész nap',
'guestModeTitle': 'Vendég mód',
'guestModeDesc': 'A vendégek csak böngészhetnek. Jelentkezzen be a hangjegyzetek, a fordítás és a felvétel használatához — adatai a fiókjába kerülnek mentésre.',
'guestKeepBrowsing': 'Böngészés folytatása',
'guestGoLogin': 'Bejelentkezés',
'guestViewOnlyTag': 'Csak megtekintés',
'autoConnectReenableHint': 'Az automatikus csatlakozás ki van kapcsolva. Kapcsolja vissza a Beállítások → Eszközök automatikus csatlakoztatása menüben.',
'plazaEntryDesc': 'Böngésszen és adjon hozzá további ügynököket',
'agentTextTranslation': 'Szövegfordítás',
'agentTextTranslationDesc': 'Begépelt vagy beillesztett szöveg fordítása',
'agentPictureTranslation': 'Fotófordítás',
'agentPictureTranslationDesc': 'Készítsen vagy válasszon fotót a fordításhoz',
'callRequiredTitle': 'Hívás szükséges',
'callRequiredDesc': 'Ez a funkció csak hívás közben működik',
'inCallBusyTitle': 'Hívás közben nem érhető el',
'inCallBusyDesc': 'A mikrofont és a hangútvonalat a hívás használja. Próbálja újra a hívás befejezése után.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Ingyenes',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/id_id.dart

@ -1904,4 +1904,208 @@ const Map<String, String> idID = {
'unmarkFavorite': 'Hapus dari favorit',
'unnamedDevice': 'Perangkat tanpa nama',
'userDataParseFailed': 'Gagal membaca data pengguna',
'catEarphone': 'Earbud AI',
'catEarphoneDesc': 'Earbud Bluetooth klasik / BLE',
'catPhoneHolder': 'Dudukan Ponsel AI',
'catPhoneHolderDesc': 'Perangkat dudukan',
'catCarAroma': 'Pengharum Mobil AI',
'catCarAromaDesc': 'Diffuser aroma mobil',
'agreeAndContinue': 'Setuju dan Lanjutkan',
'memberServiceAgreement': 'Perjanjian Layanan Keanggotaan',
'memberServiceAgreementTitle': 'Perjanjian Layanan Keanggotaan',
'memberServiceAgreementIntro': 'Untuk memberikan layanan keanggotaan yang lebih baik, harap baca dengan saksama sebelum berlangganan: ',
'memberServiceAgreementOutro': '. Dengan mengetuk "Setuju dan Lanjutkan", Anda menyatakan telah membaca sepenuhnya dan menerima perjanjian di atas.',
'memberAgreementRejectedTip': 'Anda harus menyetujui Perjanjian Layanan Keanggotaan untuk melanjutkan.',
'translationCallExitTitle': 'Keluar dari Terjemahan Panggilan',
'translationCallExitMessage': 'Pilih tindakan sebelum keluar',
'translationCallEnd': 'Akhiri terjemahan panggilan',
'translationContinueInBackground': 'Lanjutkan terjemahan di latar belakang',
'backgroundRecordingIndicatorBody': 'Perekaman berlanjut di latar belakang. Ketuk untuk kembali.',
'backgroundTranslationIndicatorBody': 'Terjemahan panggilan berlanjut di latar belakang. Ketuk untuk kembali.',
'meetingRecordScreenCapturePermissionDenied': 'Izin perekaman layar diperlukan untuk merekam audio sistem. Berikan izin lalu coba lagi.',
'pleaseGrantBluetoothPermission': 'Berikan izin Bluetooth untuk memindai',
'startScanFailed': 'Gagal memulai pemindaian',
'pleaseLoginFirst': 'Silakan masuk terlebih dahulu',
'deviceUnauthorized': 'Perangkat tidak berwenang, silakan coba lagi',
'boundDevices': 'Perangkat Terikat',
'currentConnection': 'Koneksi Saat Ini',
'binding': 'Mengikat...',
'permissionDisabled': 'Nonaktif',
'confirmDisablePermissionTitle': 'Matikan izin',
'confirmDisablePermissionContent': 'Matikan izin {permission}? Anda perlu membuka pengaturan sistem untuk mengaktifkannya kembali.',
'confirmPermanentlyDisallowTitle': 'Berhenti bertanya?',
'confirmPermanentlyDisallowContent': 'Anda menolak izin {permission}. Berhenti bertanya mulai sekarang? Jika memilih "Jangan izinkan", Anda masih bisa mengaktifkannya nanti di pengaturan sistem.',
'notNow': 'Nanti saja',
'permanentlyDisallow': 'Jangan izinkan',
'otaBesNotSupported': 'Headset ini belum mendukung pembaruan firmware dalam aplikasi. Silakan hubungi dukungan.',
'tabDevice': 'Perangkat',
'tabPlaza': 'Plaza',
'sloganHome': 'Teknologi untuk hidup lebih cerdas',
'sloganAi': 'Kecerdasan tanpa batas',
'sloganDevice': 'Perangkat keras pintar, siap masa depan',
'sloganPlaza': 'Dunia cerdas yang memahami Anda',
'aiEditHint': 'Tekan lama kartu untuk menyeret dan menyusun ulang modul',
'aiEmptyTitle': 'Belum ada modul yang ditambahkan',
'aiEmptyDesc': 'Pilih agen yang Anda butuhkan di Plaza',
'goToPlaza': 'Buka Plaza',
'plazaOnline': 'Tersedia',
'plazaPlanned': 'Segera hadir',
'plazaSearchHint': 'Cari agen, mis. “terjemah”, “rekam”',
'moduleRemove': 'Hapus',
'moduleAdd': 'Tambah',
'moduleRemoved': 'Dihapus',
'moduleAdded': 'Ditambahkan',
'goSee': 'Lihat',
'comingSoonTag': 'Segera hadir',
'needDeviceTag': 'Perlu perangkat',
'featureInDev': 'Fitur ini sedang dalam pengembangan',
'featureNotWired': 'Belum tersedia',
'todayLabel': 'Hari ini',
'deviceConnected': 'Terhubung',
'licenseUnknown': 'Lisensi tidak diketahui',
'selectDevice': 'Pilih perangkat',
'catNoteCard': 'Kartu Perekam AI',
'catNoteCardDesc': 'Perekam berbentuk kartu',
'catParkerPen': 'Pena AI',
'catParkerPenDesc': 'Pena perekam suara',
'catStylusPen': 'Stylus AI',
'catStylusPenDesc': 'Perangkat tulisan tangan / stylus',
'catStudyMachine': 'Tablet Belajar AI',
'catStudyMachineDesc': 'Perangkat belajar',
'besAddTitle': 'Tambah Earbud AI',
'besConnecting': 'Menghubungkan ke earbud…',
'besNotFound': 'Earbud tidak ditemukan',
'besNotFoundIosDesc': 'Hubungkan earbud di Pengaturan → Bluetooth terlebih dahulu, lalu kembali ke sini',
'besNotFoundAndroidDesc': 'Sandingkan earbud di Pengaturan → Bluetooth terlebih dahulu, lalu hubungkan di sini',
'besReadFailed': 'Gagal membaca',
'besConnectFailIos': 'Pastikan earbud berstatus Terhubung di Bluetooth sistem, lalu coba lagi',
'besConnectFailAndroid': 'Pastikan earbud menyala, berada di dekat Anda, dan tidak terhubung ke perangkat lain. Earbud akan tersambung kembali secara otomatis setelah terhubung.',
'holderAddTitle': 'Tambah Penyangga Ponsel AI',
'holderConnecting': 'Menghubungkan ke perangkat…',
'holderSearching': 'Mencari perangkat…',
'holderNotFound': 'Tidak ada perangkat ditemukan',
'holderPairHint': 'Pastikan penyangga menyala dan dalam mode penyandingan',
'holderRetryHint': 'Pastikan perangkat menyala dan berada di dekat Anda, lalu coba lagi',
'autoConnectTitle': 'Sambung otomatis perangkat',
'autoConnectDesc': 'Bangunkan aplikasi untuk menyambung kembali dan menerima rekaman saat perangkat Anda menyala atau berada di dekat',
'autoConnectConsentTitle': 'Aktifkan sambung otomatis',
'autoConnectConsentIntro': 'Agar perangkat Anda (Earbud AI / Penyangga Ponsel AI) dapat tersambung otomatis dan menyimpan rekaman saat Anda menekan tombol rekamnya, kami memerlukan persetujuan Anda:',
'autoConnectConsentB1': 'Mendaftarkan pemindaian Bluetooth latar belakang agar aplikasi dibangunkan (dijalankan otomatis) saat perangkat terikat Anda terdeteksi, untuk menyambung kembali dan menerima rekaman',
'autoConnectConsentB2': 'Setelah dibangunkan, hanya digunakan untuk koneksi perangkat, perekaman, dan pengunggahan — tidak untuk hal lain, dan tidak ada aplikasi lain yang dijalankan',
'autoConnectConsentB3': 'Anda tetap dapat menggunakan aplikasi tanpanya — cukup hubungkan perangkat secara manual setiap kali',
'autoConnectConsentB4': 'Anda dapat menonaktifkannya kapan saja di Pengaturan → Sambung otomatis perangkat',
'autoConnectAgree': 'Setuju dan aktifkan',
'autoConnectLater': 'Nanti saja',
'autoConnectRequired': 'Sambung otomatis nonaktif',
'autoConnectRequiredDesc': 'Aktifkan sambung otomatis untuk mengikat perangkat ini',
'recordTapToStart': 'Ketuk tombol di bawah untuk mulai merekam',
'recordPaused': 'Dijeda',
'recordMark': 'Tandai',
'recordDeleteTitle': 'Hapus rekaman',
'accountAndProfile': 'Akun & profil',
'nicknameUnset': 'Nama panggilan belum diatur',
'nicknameInput': 'Masukkan nama panggilan',
'loginMethod': 'Metode masuk',
'manageMyLogin': 'Kelola metode masuk',
'logoutAction': 'Keluar',
'warningLabel': 'Peringatan:',
'deleteAccountWarn': 'Akun Anda dan semua file akan dihapus secara permanen.',
'icpFiling': 'Pendaftaran ICP',
'logExport': 'Ekspor log',
'logExportDesc': 'Lihat dan ekspor log aplikasi',
'permLoadFailed': 'Gagal memuat grup izin',
'permInsufficient': 'Izin ditolak',
'voiceCloneCreating': 'Membuat suara Anda…',
'voiceCloneCreate': 'Buat suara saya',
'voiceCloneAgree': 'Setuju dan bacakan pernyataan dengan lantang',
'voiceCloneReadPrompt': 'Silakan baca:',
'voiceCloneFirstMonthFree': 'Gratis bulan pertama',
'voiceCloneEnable': 'Aktifkan kloning suara',
'noMusicPlaying': 'Tidak ada yang diputar',
'qqMusicNoSeek': 'Geser posisi tidak didukung untuk QQ Music',
'bleScanTitle': 'Pemindaian BLE',
'bluetoothOn': 'Bluetooth aktif',
'bleScanning': 'Memindai…',
'bleConnectedDevices': 'Perangkat terhubung',
'bleUnknownDevice': 'Perangkat tidak dikenal',
'bleTapToScan': 'Ketuk pindai untuk mencari perangkat BLE',
'weekMon': 'Sen',
'weekTue': 'Sel',
'weekWed': 'Rab',
'weekThu': 'Kam',
'weekFri': 'Jum',
'weekSat': 'Sab',
'weekSun': 'Min',
'agentMeeting': 'Notulen Suara',
'agentMeetingDesc': 'Ringkas rekaman, pusat memori',
'agentEmai': 'Asisten EMAI',
'agentEmaiDesc': 'Mengobrol dan bertanya apa saja lewat suara',
'agentSimultaneous': 'Terjemahan Langsung',
'agentSimultaneousDesc': 'Terjemahkan sambil bicara',
'agentFaceToFace': 'Tatap Muka',
'agentFaceToFaceDesc': 'Terjemahan percakapan dua arah',
'agentCallRecording': 'Rekaman Panggilan',
'agentCallRecordingDesc': 'Panggilan tersimpan otomatis',
'agentFieldRecording': 'Rekaman Lapangan',
'agentFieldRecordingDesc': 'Mikrofon perangkat atau ponsel',
'agentCall': 'Terjemahan Panggilan',
'agentCallDesc': 'Terjemahan waktu nyata selama panggilan',
'agentAudioVideo': 'Terjemahan Media',
'agentAudioVideoDesc': 'Bidikan kamera / terjemahan audio & video',
'agentEnglishChat': 'Obrolan Bahasa Inggris',
'agentEnglishChatDesc': 'Latihan bicara imersif',
'agentEnglishStudy': 'Belajar Bahasa Inggris',
'agentEnglishStudyDesc': 'Pelajaran kosakata dan tata bahasa',
'agentKidsStory': 'Cerita Anak',
'agentKidsStoryDesc': 'Dongeng pengantar tidur dan tanya jawab',
'moduleRemovedDesc': 'dihapus dari halaman AI',
'moduleAddedDesc': 'ditambahkan ke bagian bawah halaman AI',
'calNoRecords': 'Tidak ada entri',
'calNoRecordsDay': 'Tidak ada jadwal',
'calTodos': 'Tugas',
'calExpenses': 'Pengeluaran',
'calTodoCount': '@count tugas',
'calExpenseCount': '@count pengeluaran',
'calEntryCount': '@count entri',
'calItemsUnit': 'item',
'calCountUnit': 'entri',
'calTotalAmount': 'Total',
'recordPause': 'Jeda',
'recordResume': 'Lanjutkan',
'recordDeleteConfirm': 'Hapus rekaman ini?',
'scopeDay': 'Hari',
'scopeWeek': 'Minggu',
'scopeMonth': 'Bulan',
'allDay': 'Sepanjang hari',
'guestModeTitle': 'Mode tamu',
'guestModeDesc': 'Tamu hanya dapat menjelajah. Masuk untuk menggunakan catatan suara, terjemahan, dan perekaman — data Anda akan disimpan ke akun Anda.',
'guestKeepBrowsing': 'Lanjut menjelajah',
'guestGoLogin': 'Masuk',
'guestViewOnlyTag': 'Hanya lihat',
'autoConnectReenableHint': 'Sambung otomatis nonaktif. Aktifkan kembali di Pengaturan → Sambung otomatis perangkat.',
'plazaEntryDesc': 'Jelajahi dan tambahkan lebih banyak agen',
'agentTextTranslation': 'Terjemahan Teks',
'agentTextTranslationDesc': 'Terjemahkan teks yang diketik atau ditempel',
'agentPictureTranslation': 'Terjemahan Foto',
'agentPictureTranslationDesc': 'Ambil atau pilih foto untuk diterjemahkan',
'callRequiredTitle': 'Perlu panggilan',
'callRequiredDesc': 'Fitur ini hanya berfungsi selama panggilan',
'inCallBusyTitle': 'Tidak tersedia selama panggilan',
'inCallBusyDesc': 'Mikrofon dan jalur audio sedang digunakan oleh panggilan. Coba lagi setelah menutup panggilan.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/is_is.dart

@ -1901,4 +1901,208 @@ const Map<String, String> isIS = {
'unmarkFavorite': 'Fjarlægja úr eftirlæti',
'unnamedDevice': 'Ónefnt tæki',
'userDataParseFailed': 'Ekki tókst að lesa notandagögn',
'catEarphone': 'AI eyrnatól',
'catEarphoneDesc': 'Klassísk Bluetooth / BLE eyrnatól',
'catPhoneHolder': 'AI símastandur',
'catPhoneHolderDesc': 'Standtæki',
'catCarAroma': 'AI bílailmur',
'catCarAromaDesc': 'Ilmdreifir fyrir bíl',
'agreeAndContinue': 'Samþykkja og halda áfram',
'memberServiceAgreement': 'Þjónustusamningur meðlima',
'memberServiceAgreementTitle': 'Þjónustusamningur meðlima',
'memberServiceAgreementIntro': 'Til að veita þér betri meðlimaþjónustu, vinsamlegast lestu vandlega áður en þú gerist áskrifandi: ',
'memberServiceAgreementOutro': '. Með því að smella á „Samþykkja og halda áfram“ staðfestir þú að þú hafir lesið samninginn hér að ofan að fullu og samþykkir hann.',
'memberAgreementRejectedTip': 'Þú verður að samþykkja þjónustusamning meðlima til að halda áfram.',
'translationCallExitTitle': 'Hætta símtalsþýðingu',
'translationCallExitMessage': 'Veldu hvað á að gera áður en þú ferð',
'translationCallEnd': 'Ljúka símtalsþýðingu',
'translationContinueInBackground': 'Halda áfram að þýða í bakgrunni',
'backgroundRecordingIndicatorBody': 'Upptaka heldur áfram í bakgrunni. Ýttu til að fara til baka.',
'backgroundTranslationIndicatorBody': 'Símtalsþýðing heldur áfram í bakgrunni. Ýttu til að fara til baka.',
'meetingRecordScreenCapturePermissionDenied': 'Heimild til skjáupptöku er nauðsynleg til að taka upp kerfishljóð. Veittu hana og reyndu aftur.',
'pleaseGrantBluetoothPermission': 'Veittu Bluetooth-heimild til að leita',
'startScanFailed': 'Ekki tókst að hefja leit',
'pleaseLoginFirst': 'Skráðu þig fyrst inn',
'deviceUnauthorized': 'Tæki ekki heimilað, reyndu aftur',
'boundDevices': 'Tengd tæki',
'currentConnection': 'Núverandi tenging',
'binding': 'Tengi...',
'permissionDisabled': 'Óvirkt',
'confirmDisablePermissionTitle': 'Slökkva á heimild',
'confirmDisablePermissionContent': 'Slökkva á {permission} heimild? Þú þarft að fara í kerfisstillingar til að virkja hana aftur.',
'confirmPermanentlyDisallowTitle': 'Hætta að spyrja?',
'confirmPermanentlyDisallowContent': 'Þú hafnaðir {permission} heimild. Hætta að spyrja héðan í frá? Ef þú velur „Ekki leyfa“ geturðu samt virkjað hana síðar í kerfisstillingum.',
'notNow': 'Ekki núna',
'permanentlyDisallow': 'Ekki leyfa',
'otaBesNotSupported': 'Þessi heyrnartól styðja ekki enn fastbúnaðaruppfærslu í forritinu. Hafðu samband við þjónustuver.',
'tabDevice': 'Tæki',
'tabPlaza': 'Plaza',
'sloganHome': 'Tækni fyrir snjallara líf',
'sloganAi': 'Takmarkalaus greind',
'sloganDevice': 'Snjallur vélbúnaður, tilbúinn fyrir framtíðina',
'sloganPlaza': 'Snjall heimur sem skilur þig',
'aiEditHint': 'Haltu spjaldi inni og dragðu til að endurraða einingum',
'aiEmptyTitle': 'Engum einingum bætt við enn',
'aiEmptyDesc': 'Veldu þá aðstoðarmenn sem þú þarft í Plaza',
'goToPlaza': 'Fara í Plaza',
'plazaOnline': 'Í boði',
'plazaPlanned': 'Væntanlegt',
'plazaSearchHint': 'Leita að aðstoðarmönnum, t.d. „þýða“, „taka upp“',
'moduleRemove': 'Fjarlægja',
'moduleAdd': 'Bæta við',
'moduleRemoved': 'Fjarlægt',
'moduleAdded': 'Bætt við',
'goSee': 'Skoða',
'comingSoonTag': 'Væntanlegt',
'needDeviceTag': 'Tæki nauðsynlegt',
'featureInDev': 'Þessi eiginleiki er í þróun',
'featureNotWired': 'Ekki í boði enn',
'todayLabel': 'Í dag',
'deviceConnected': 'Tengt',
'licenseUnknown': 'Leyfi óþekkt',
'selectDevice': 'Veldu tæki',
'catNoteCard': 'AI upptökukort',
'catNoteCardDesc': 'Upptökutæki í kortaformi',
'catParkerPen': 'AI penni',
'catParkerPenDesc': 'Upptökupennar',
'catStylusPen': 'AI snertipenni',
'catStylusPenDesc': 'Rithandar-/snertipennatæki',
'catStudyMachine': 'AI námsspjaldtölva',
'catStudyMachineDesc': 'Námstæki',
'besAddTitle': 'Bæta við AI heyrnartólum',
'besConnecting': 'Tengist heyrnartólum…',
'besNotFound': 'Heyrnartól fundust ekki',
'besNotFoundIosDesc': 'Tengdu heyrnartólin fyrst í Stillingar → Bluetooth og komdu svo til baka',
'besNotFoundAndroidDesc': 'Paraðu heyrnartólin fyrst í Stillingar → Bluetooth og tengdu þau svo hér',
'besReadFailed': 'Lestur mistókst',
'besConnectFailIos': 'Gakktu úr skugga um að heyrnartólin séu sýnd sem „Tengd“ í Bluetooth-stillingum kerfisins, reyndu svo aftur',
'besConnectFailAndroid': 'Gakktu úr skugga um að kveikt sé á heyrnartólunum, þau séu nálægt og ekki tengd öðru tæki. Þau tengjast sjálfkrafa aftur eftir pörun.',
'holderAddTitle': 'Bæta við AI símastandi',
'holderConnecting': 'Tengist tæki…',
'holderSearching': 'Leita að tækjum…',
'holderNotFound': 'Engin tæki fundust',
'holderPairHint': 'Gakktu úr skugga um að standurinn sé kveiktur og í pörunarham',
'holderRetryHint': 'Gakktu úr skugga um að tækið sé kveikt og nálægt, reyndu svo aftur',
'autoConnectTitle': 'Sjálfvirk tenging tækja',
'autoConnectDesc': 'Vekur forritið til að endurtengjast og taka við upptökum þegar kveikt er á tækinu eða það kemur nálægt',
'autoConnectConsentTitle': 'Virkja sjálfvirka tengingu',
'autoConnectConsentIntro': 'Til að tækið þitt (AI heyrnartól / AI símastandur) geti tengst sjálfkrafa og vistað upptökur þegar þú ýtir á upptökuhnapp þess þurfum við samþykki þitt:',
'autoConnectConsentB1': 'Skrá Bluetooth-leit í bakgrunni svo forritið sé vakið (ræst sjálfkrafa) þegar tengt tæki finnst, til að endurtengjast og taka við upptökum',
'autoConnectConsentB2': 'Eftir vakningu er þetta aðeins notað til að tengja tækið, taka upp og hlaða upp — ekkert annað, og engin önnur forrit eru ræst',
'autoConnectConsentB3': 'Þú getur samt notað forritið án þess — tengdu bara tækið handvirkt í hvert sinn',
'autoConnectConsentB4': 'Þú getur slökkt á þessu hvenær sem er í Stillingar → Sjálfvirk tenging tækja',
'autoConnectAgree': 'Samþykkja og virkja',
'autoConnectLater': 'Ekki núna',
'autoConnectRequired': 'Slökkt er á sjálfvirkri tengingu',
'autoConnectRequiredDesc': 'Virkjaðu sjálfvirka tengingu til að tengja þetta tæki',
'recordTapToStart': 'Ýttu á hnappinn hér að neðan til að hefja upptöku',
'recordPaused': 'Í bið',
'recordMark': 'Merkja',
'recordDeleteTitle': 'Eyða upptöku',
'accountAndProfile': 'Reikningur og prófíll',
'nicknameUnset': 'Ekkert gælunafn stillt',
'nicknameInput': 'Sláðu inn gælunafn',
'loginMethod': 'Innskráningarleið',
'manageMyLogin': 'Stjórna innskráningu',
'logoutAction': 'Skrá út',
'warningLabel': 'Viðvörun:',
'deleteAccountWarn': 'Reikningnum þínum og öllum skrám verður eytt varanlega.',
'icpFiling': 'ICP-skráning',
'logExport': 'Flytja út skrár',
'logExportDesc': 'Skoða og flytja út atburðaskrár forritsins',
'permLoadFailed': 'Ekki tókst að hlaða heimildaflokkum',
'permInsufficient': 'Heimild hafnað',
'voiceCloneCreating': 'Bý til röddina þína…',
'voiceCloneCreate': 'Búa til mína rödd',
'voiceCloneAgree': 'Samþykkja og lesa yfirlýsinguna upphátt',
'voiceCloneReadPrompt': 'Lestu upphátt:',
'voiceCloneFirstMonthFree': 'Fyrsti mánuðurinn ókeypis',
'voiceCloneEnable': 'Virkja raddklónun',
'noMusicPlaying': 'Ekkert í spilun',
'qqMusicNoSeek': 'QQ Music styður ekki að spóla',
'bleScanTitle': 'BLE-leit',
'bluetoothOn': 'Kveikt á Bluetooth',
'bleScanning': 'Leita…',
'bleConnectedDevices': 'Tengd tæki',
'bleUnknownDevice': 'Óþekkt tæki',
'bleTapToScan': 'Ýttu á Leita til að finna BLE-tæki',
'weekMon': 'Mán',
'weekTue': 'Þri',
'weekWed': 'Mið',
'weekThu': 'Fim',
'weekFri': 'Fös',
'weekSat': 'Lau',
'weekSun': 'Sun',
'agentMeeting': 'Raddfundargerðir',
'agentMeetingDesc': 'Draga saman upptökur, minnismiðstöð',
'agentEmai': 'EMAI aðstoðarmaður',
'agentEmaiDesc': 'Spjallaðu og spurðu um hvað sem er með röddinni',
'agentSimultaneous': 'Samtímaþýðing',
'agentSimultaneousDesc': 'Þýðir á meðan þú talar',
'agentFaceToFace': 'Augliti til auglitis',
'agentFaceToFaceDesc': 'Tvíátta samtalsþýðing',
'agentCallRecording': 'Símtalsupptaka',
'agentCallRecordingDesc': 'Símtöl vistuð sjálfkrafa',
'agentFieldRecording': 'Vettvangsupptaka',
'agentFieldRecordingDesc': 'Hljóðnemi tækis eða síma',
'agentCall': 'Símtalsþýðing',
'agentCallDesc': 'Rauntímaþýðing í símtölum',
'agentAudioVideo': 'Miðlaþýðing',
'agentAudioVideoDesc': 'Myndavélarrammi / hljóð- og myndþýðing',
'agentEnglishChat': 'Enskuspjall',
'agentEnglishChatDesc': 'Yfirgripsmikil talæfing',
'agentEnglishStudy': 'Enskunám',
'agentEnglishStudyDesc': 'Orðaforða- og málfræðikennsla',
'agentKidsStory': 'Barnasögur',
'agentKidsStoryDesc': 'Kvöldsögur og spurningar og svör',
'moduleRemovedDesc': 'fjarlægt af AI-síðunni',
'moduleAddedDesc': 'bætt neðst á AI-síðuna',
'calNoRecords': 'Engar færslur',
'calNoRecordsDay': 'Ekkert á dagskrá',
'calTodos': 'Verkefni',
'calExpenses': 'Útgjöld',
'calTodoCount': '@count verkefni',
'calExpenseCount': '@count útgjöld',
'calEntryCount': '@count færslur',
'calItemsUnit': 'atriði',
'calCountUnit': 'færslur',
'calTotalAmount': 'Samtals',
'recordPause': 'Hlé',
'recordResume': 'Halda áfram',
'recordDeleteConfirm': 'Eyða þessari upptöku?',
'scopeDay': 'Dagur',
'scopeWeek': 'Vika',
'scopeMonth': 'Mánuður',
'allDay': 'Allan daginn',
'guestModeTitle': 'Gestahamur',
'guestModeDesc': 'Gestir geta aðeins skoðað. Skráðu þig inn til að nota raddminnispunkta, þýðingu og upptöku — gögnin þín verða vistuð á reikninginn þinn.',
'guestKeepBrowsing': 'Halda áfram að skoða',
'guestGoLogin': 'Skrá inn',
'guestViewOnlyTag': 'Aðeins skoðun',
'autoConnectReenableHint': 'Slökkt er á sjálfvirkri tengingu. Kveiktu aftur á henni í Stillingar → Sjálfvirk tenging tækja.',
'plazaEntryDesc': 'Skoðaðu og bættu við fleiri aðstoðarmönnum',
'agentTextTranslation': 'Textaþýðing',
'agentTextTranslationDesc': 'Þýddu innsleginn eða límdan texta',
'agentPictureTranslation': 'Myndaþýðing',
'agentPictureTranslationDesc': 'Taktu eða veldu mynd til að þýða',
'callRequiredTitle': 'Símtal nauðsynlegt',
'callRequiredDesc': 'Þessi eiginleiki virkar aðeins í símtali',
'inCallBusyTitle': 'Ekki í boði í símtali',
'inCallBusyDesc': 'Hljóðneminn og hljóðleiðin eru í notkun í símtalinu. Reyndu aftur eftir að þú leggur á.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Ókeypis',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/it_it.dart

@ -1942,4 +1942,208 @@ const Map<String, String> itIT = {
'unmarkFavorite': 'Rimuovi dai preferiti',
'unnamedDevice': 'Dispositivo senza nome',
'userDataParseFailed': "Impossibile leggere i dati dell'utente",
'catEarphone': 'Auricolari IA',
'catEarphoneDesc': 'Auricolari Bluetooth classico / BLE',
'catPhoneHolder': 'Supporto telefono IA',
'catPhoneHolderDesc': 'Dispositivi di supporto',
'catCarAroma': 'Diffusore auto IA',
'catCarAromaDesc': 'Diffusore di aromi per auto',
'agreeAndContinue': 'Accetta e continua',
'memberServiceAgreement': 'Contratto di servizio per gli abbonati',
'memberServiceAgreementTitle': 'Contratto di servizio per gli abbonati',
'memberServiceAgreementIntro': 'Per offrirti un servizio migliore, leggi attentamente prima di abbonarti: ',
'memberServiceAgreementOutro': '. Facendo clic su "Accetta e continua" confermi di aver letto integralmente e di accettare il contratto sopra indicato.',
'memberAgreementRejectedTip': 'Devi accettare il Contratto di servizio per gli abbonati per continuare.',
'translationCallExitTitle': 'Esci dalla traduzione chiamate',
'translationCallExitMessage': 'Scegli cosa fare prima di uscire',
'translationCallEnd': 'Termina traduzione chiamata',
'translationContinueInBackground': 'Continua a tradurre in background',
'backgroundRecordingIndicatorBody': 'La registrazione continua in background. Tocca per tornare.',
'backgroundTranslationIndicatorBody': 'La traduzione della chiamata continua in background. Tocca per tornare.',
'meetingRecordScreenCapturePermissionDenied': 'Per registrare l\'audio di sistema è necessario il permesso di registrazione dello schermo. Concedilo e riprova.',
'pleaseGrantBluetoothPermission': 'Concedi il permesso Bluetooth per la scansione',
'startScanFailed': 'Impossibile avviare la scansione',
'pleaseLoginFirst': 'Accedi prima di continuare',
'deviceUnauthorized': 'Dispositivo non autorizzato, riprova',
'boundDevices': 'Dispositivi associati',
'currentConnection': 'Connessione attuale',
'binding': 'Associazione...',
'permissionDisabled': 'Disattivato',
'confirmDisablePermissionTitle': 'Disattivare il permesso',
'confirmDisablePermissionContent': 'Disattivare il permesso {permission}? Dovrai riattivarlo dalle impostazioni di sistema.',
'confirmPermanentlyDisallowTitle': 'Non chiedere più?',
'confirmPermanentlyDisallowContent': 'Hai negato il permesso {permission}. Non chiedere più? Se scegli "Non consentire", potrai comunque attivarlo in seguito dalle impostazioni di sistema.',
'notNow': 'Non ora',
'permanentlyDisallow': 'Non consentire',
'otaBesNotSupported': 'Queste cuffie non supportano ancora l\'aggiornamento del firmware dall\'app. Contatta l\'assistenza.',
'tabDevice': 'Dispositivi',
'tabPlaza': 'Plaza',
'sloganHome': 'Tecnologia per una vita più smart',
'sloganAi': 'Intelligenza senza limiti',
'sloganDevice': 'Hardware intelligente, pronto per il futuro',
'sloganPlaza': 'Un mondo smart che ti capisce',
'aiEditHint': 'Tieni premuta una scheda per trascinare e riordinare i moduli',
'aiEmptyTitle': 'Nessun modulo aggiunto',
'aiEmptyDesc': 'Scegli gli agenti che ti servono in Plaza',
'goToPlaza': 'Vai a Plaza',
'plazaOnline': 'Disponibile',
'plazaPlanned': 'Prossimamente',
'plazaSearchHint': 'Cerca agenti, es. “traduci”, “registra”',
'moduleRemove': 'Rimuovi',
'moduleAdd': 'Aggiungi',
'moduleRemoved': 'Rimosso',
'moduleAdded': 'Aggiunto',
'goSee': 'Vedi',
'comingSoonTag': 'Prossimamente',
'needDeviceTag': 'Dispositivo richiesto',
'featureInDev': 'Questa funzione è in fase di sviluppo',
'featureNotWired': 'Non ancora disponibile',
'todayLabel': 'Oggi',
'deviceConnected': 'Connesso',
'licenseUnknown': 'Licenza sconosciuta',
'selectDevice': 'Seleziona un dispositivo',
'catNoteCard': 'Scheda registratore AI',
'catNoteCardDesc': 'Registratore a forma di scheda',
'catParkerPen': 'Penna AI',
'catParkerPenDesc': 'Penne con registratore vocale',
'catStylusPen': 'Stilo AI',
'catStylusPenDesc': 'Dispositivi per scrittura a mano / stilo',
'catStudyMachine': 'Tablet didattico AI',
'catStudyMachineDesc': 'Dispositivi per l\'apprendimento',
'besAddTitle': 'Aggiungi auricolari AI',
'besConnecting': 'Connessione agli auricolari…',
'besNotFound': 'Auricolari non trovati',
'besNotFoundIosDesc': 'Connetti prima gli auricolari in Impostazioni → Bluetooth, poi torna qui',
'besNotFoundAndroidDesc': 'Associa prima gli auricolari in Impostazioni → Bluetooth, poi connettili qui',
'besReadFailed': 'Lettura non riuscita',
'besConnectFailIos': 'Assicurati che gli auricolari risultino Connessi nel Bluetooth di sistema, poi riprova',
'besConnectFailAndroid': 'Assicurati che gli auricolari siano accesi, nelle vicinanze e non connessi a un altro dispositivo. Una volta associati si riconnetteranno automaticamente.',
'holderAddTitle': 'Aggiungi supporto AI per telefono',
'holderConnecting': 'Connessione al dispositivo…',
'holderSearching': 'Ricerca dispositivi…',
'holderNotFound': 'Nessun dispositivo trovato',
'holderPairHint': 'Assicurati che il supporto sia acceso e in modalità di associazione',
'holderRetryHint': 'Assicurati che il dispositivo sia acceso e nelle vicinanze, poi riprova',
'autoConnectTitle': 'Connessione automatica dispositivi',
'autoConnectDesc': 'Riattiva l\'app per riconnettersi e ricevere le registrazioni quando il dispositivo si accende o si avvicina',
'autoConnectConsentTitle': 'Attiva la connessione automatica',
'autoConnectConsentIntro': 'Per consentire al tuo dispositivo (auricolari AI / supporto AI per telefono) di connettersi automaticamente e salvare le registrazioni quando premi il suo tasto di registrazione, abbiamo bisogno del tuo consenso:',
'autoConnectConsentB1': 'Registrare una scansione Bluetooth in background così che l\'app venga riattivata (avviata automaticamente) quando viene rilevato il dispositivo associato, per riconnettersi e ricevere le registrazioni',
'autoConnectConsentB2': 'Una volta riattivata, viene usata solo per la connessione del dispositivo, la registrazione e il caricamento — nient\'altro, e non viene avviata nessun\'altra app',
'autoConnectConsentB3': 'Puoi comunque usare l\'app senza questa funzione — basta connettere il dispositivo manualmente ogni volta',
'autoConnectConsentB4': 'Puoi disattivarla in qualsiasi momento in Impostazioni → Connessione automatica dispositivi',
'autoConnectAgree': 'Accetta e attiva',
'autoConnectLater': 'Non ora',
'autoConnectRequired': 'Connessione automatica disattivata',
'autoConnectRequiredDesc': 'Attiva la connessione automatica per associare questo dispositivo',
'recordTapToStart': 'Tocca il pulsante in basso per iniziare a registrare',
'recordPaused': 'In pausa',
'recordMark': 'Segna',
'recordDeleteTitle': 'Elimina registrazione',
'accountAndProfile': 'Account e profilo',
'nicknameUnset': 'Nessun soprannome impostato',
'nicknameInput': 'Inserisci un soprannome',
'loginMethod': 'Metodo di accesso',
'manageMyLogin': 'Gestisci accesso',
'logoutAction': 'Esci',
'warningLabel': 'Attenzione:',
'deleteAccountWarn': 'Il tuo account e tutti i file verranno eliminati definitivamente.',
'icpFiling': 'Registrazione ICP',
'logExport': 'Esporta log',
'logExportDesc': 'Visualizza ed esporta i log dell\'app',
'permLoadFailed': 'Impossibile caricare i gruppi di permessi',
'permInsufficient': 'Permesso negato',
'voiceCloneCreating': 'Creazione della tua voce…',
'voiceCloneCreate': 'Crea la mia voce',
'voiceCloneAgree': 'Accetta e leggi la dichiarazione ad alta voce',
'voiceCloneReadPrompt': 'Leggi:',
'voiceCloneFirstMonthFree': 'Primo mese gratis',
'voiceCloneEnable': 'Attiva la clonazione vocale',
'noMusicPlaying': 'Nessuna riproduzione in corso',
'qqMusicNoSeek': 'Lo scorrimento non è supportato per QQ Music',
'bleScanTitle': 'Scansione BLE',
'bluetoothOn': 'Bluetooth attivo',
'bleScanning': 'Scansione…',
'bleConnectedDevices': 'Dispositivi connessi',
'bleUnknownDevice': 'Dispositivo sconosciuto',
'bleTapToScan': 'Tocca Scansiona per cercare dispositivi BLE',
'weekMon': 'Lun',
'weekTue': 'Mar',
'weekWed': 'Mer',
'weekThu': 'Gio',
'weekFri': 'Ven',
'weekSat': 'Sab',
'weekSun': 'Dom',
'agentMeeting': 'Verbali vocali',
'agentMeetingDesc': 'Riassume le registrazioni, hub della memoria',
'agentEmai': 'Assistente EMAI',
'agentEmaiDesc': 'Chatta e chiedi qualsiasi cosa con la voce',
'agentSimultaneous': 'Traduzione simultanea',
'agentSimultaneousDesc': 'Traduce mentre parli',
'agentFaceToFace': 'Faccia a faccia',
'agentFaceToFaceDesc': 'Traduzione bidirezionale delle conversazioni',
'agentCallRecording': 'Registrazione chiamate',
'agentCallRecordingDesc': 'Chiamate salvate automaticamente',
'agentFieldRecording': 'Registrazione live',
'agentFieldRecordingDesc': 'Microfono del dispositivo o del telefono',
'agentCall': 'Traduzione chiamate',
'agentCallDesc': 'Traduzione in tempo reale durante le chiamate',
'agentAudioVideo': 'Traduzione multimediale',
'agentAudioVideoDesc': 'Inquadratura fotocamera / traduzione audio e video',
'agentEnglishChat': 'Conversazione in inglese',
'agentEnglishChatDesc': 'Pratica orale immersiva',
'agentEnglishStudy': 'Studio dell\'inglese',
'agentEnglishStudyDesc': 'Lezioni di vocabolario e grammatica',
'agentKidsStory': 'Storie per bambini',
'agentKidsStoryDesc': 'Storie della buonanotte e domande e risposte',
'moduleRemovedDesc': 'rimosso dalla pagina AI',
'moduleAddedDesc': 'aggiunto in fondo alla pagina AI',
'calNoRecords': 'Nessuna voce',
'calNoRecordsDay': 'Niente in programma',
'calTodos': 'Da fare',
'calExpenses': 'Spese',
'calTodoCount': '@count attività',
'calExpenseCount': '@count spese',
'calEntryCount': '@count voci',
'calItemsUnit': 'elementi',
'calCountUnit': 'voci',
'calTotalAmount': 'Totale',
'recordPause': 'Pausa',
'recordResume': 'Riprendi',
'recordDeleteConfirm': 'Eliminare questa registrazione?',
'scopeDay': 'Giorno',
'scopeWeek': 'Settimana',
'scopeMonth': 'Mese',
'allDay': 'Tutto il giorno',
'guestModeTitle': 'Modalità ospite',
'guestModeDesc': 'Gli ospiti possono solo navigare. Accedi per usare note vocali, traduzione e registrazione — i tuoi dati verranno salvati nel tuo account.',
'guestKeepBrowsing': 'Continua a navigare',
'guestGoLogin': 'Accedi',
'guestViewOnlyTag': 'Solo visualizzazione',
'autoConnectReenableHint': 'La connessione automatica è disattivata. Riattivala in Impostazioni → Connessione automatica dispositivi.',
'plazaEntryDesc': 'Sfoglia e aggiungi altri agenti',
'agentTextTranslation': 'Traduzione testo',
'agentTextTranslationDesc': 'Traduci testo digitato o incollato',
'agentPictureTranslation': 'Traduzione foto',
'agentPictureTranslationDesc': 'Scatta o scegli una foto da tradurre',
'callRequiredTitle': 'Chiamata richiesta',
'callRequiredDesc': 'Questa funzione è disponibile solo durante una chiamata',
'inCallBusyTitle': 'Non disponibile durante una chiamata',
'inCallBusyDesc': 'Il microfono e il percorso audio sono in uso dalla chiamata. Riprova dopo aver riagganciato.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

30
apps/client/lib/core/translations/language/ja_jp.dart

@ -1683,6 +1683,8 @@ const Map<String, String> jaJP = {
'catNoteCardDesc': 'カード型レコーダー',
'catPhoneHolder': 'AI スマホスタンド',
'catPhoneHolderDesc': 'スタンド型デバイス',
'catCarAroma': 'AI カーアロマ',
'catCarAromaDesc': '車載アロマディフューザー',
'catParkerPen': 'AI ペン',
'catParkerPenDesc': 'ボイスレコーダーペン',
'catStylusPen': 'AI スタイラス',
@ -1937,4 +1939,32 @@ const Map<String, String> jaJP = {
'unmarkFavorite': 'お気に入りから削除',
'unnamedDevice': '名称未設定のデバイス',
'userDataParseFailed': 'ユーザーデータの解析に失敗しました',
'translationCallExitTitle': '通話翻訳を終了',
'translationCallExitMessage': '離れる前の操作を選択してください',
'translationCallEnd': '通話翻訳を終了する',
'translationContinueInBackground': 'バックグラウンドで翻訳を続ける',
'backgroundRecordingIndicatorBody': 'バックグラウンドで録音を続けています。タップして戻る',
'backgroundTranslationIndicatorBody': 'バックグラウンドで通話翻訳を続けています。タップして戻る',
'permissionDisabled': '無効',
'confirmDisablePermissionTitle': '権限をオフにする',
'confirmDisablePermissionContent': '{permission}の権限をオフにしますか?再度有効にするにはシステム設定から操作する必要があります。',
'confirmPermanentlyDisallowTitle': '今後確認しない?',
'confirmPermanentlyDisallowContent': '{permission}の権限が拒否されました。今後は確認しないようにしますか?「許可しない」を選んでも、後からシステム設定で有効にできます。',
'notNow': '今はしない',
'permanentlyDisallow': '許可しない',
'otaBesNotSupported': 'このイヤホンはアプリ内でのファームウェアアップグレードにまだ対応していません。サポートにお問い合わせください。',
'smartcarConnected': '接続しました',
'smartcarConnectFailed': '接続に失敗しました',
'smartcarConnecting': '接続中…',
'smartcarSearching': '近くの車載アロマを検索中…',
'smartcarNotFound': 'デバイスが見つかりません',
'smartcarScanHint': '電源が入っていること、スマホの近くにあることを確認してください',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

30
apps/client/lib/core/translations/language/ko_kr.dart

@ -1688,6 +1688,8 @@ const Map<String, String> koKR = {
'catNoteCardDesc': '카드형 녹음 기기',
'catPhoneHolder': 'AI 휴대폰 거치대',
'catPhoneHolderDesc': '거치대형 기기',
'catCarAroma': 'AI 차량용 방향제',
'catCarAromaDesc': '차량용 방향제 기기',
'catParkerPen': 'AI 펜',
'catParkerPenDesc': '녹음 펜 기기',
'catStylusPen': 'AI 스타일러스',
@ -1930,4 +1932,32 @@ const Map<String, String> koKR = {
'unmarkFavorite': '즐겨찾기 해제',
'unnamedDevice': '이름 없는 기기',
'userDataParseFailed': '사용자 데이터 분석 실패',
'translationCallExitTitle': '통화 번역 나가기',
'translationCallExitMessage': '나가기 전에 할 작업을 선택하세요',
'translationCallEnd': '통화 번역 종료',
'translationContinueInBackground': '백그라운드에서 번역 계속하기',
'backgroundRecordingIndicatorBody': '백그라운드에서 녹음이 계속되고 있습니다. 탭하여 돌아가기',
'backgroundTranslationIndicatorBody': '백그라운드에서 통화 번역이 계속되고 있습니다. 탭하여 돌아가기',
'permissionDisabled': '비활성화됨',
'confirmDisablePermissionTitle': '권한 끄기',
'confirmDisablePermissionContent': '{permission} 권한을 끄시겠습니까? 다시 켜려면 시스템 설정으로 이동해야 합니다.',
'confirmPermanentlyDisallowTitle': '더 이상 묻지 않을까요?',
'confirmPermanentlyDisallowContent': '{permission} 권한을 거부하셨습니다. 앞으로 다시 묻지 않을까요? \'허용 안 함\'을 선택해도 나중에 시스템 설정에서 켤 수 있습니다.',
'notNow': '나중에',
'permanentlyDisallow': '허용 안 함',
'otaBesNotSupported': '이 이어폰은 아직 앱 내 펌웨어 업그레이드를 지원하지 않습니다. 고객 지원에 문의해 주세요.',
'smartcarConnected': '연결됨',
'smartcarConnectFailed': '연결 실패',
'smartcarConnecting': '연결 중…',
'smartcarSearching': '주변 차량용 방향제 검색 중…',
'smartcarNotFound': '기기를 찾을 수 없습니다',
'smartcarScanHint': '기기 전원이 켜져 있고 휴대폰 근처에 있는지 확인하세요',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/lt_lt.dart

@ -1919,4 +1919,208 @@ const Map<String, String> ltLT = {
'unmarkFavorite': 'Pašalinti iš mėgstamų',
'unnamedDevice': 'Įrenginys be pavadinimo',
'userDataParseFailed': 'Nepavyko nuskaityti naudotojo duomenų',
'catEarphone': 'AI ausinės',
'catEarphoneDesc': 'Klasikinės Bluetooth / BLE ausinės',
'catPhoneHolder': 'AI telefono laikiklis',
'catPhoneHolderDesc': 'Laikiklio įrenginiai',
'catCarAroma': 'AI automobilio kvapas',
'catCarAromaDesc': 'Automobilio aromato difuzorius',
'agreeAndContinue': 'Sutikti ir tęsti',
'memberServiceAgreement': 'Narystės paslaugų sutartis',
'memberServiceAgreementTitle': 'Narystės paslaugų sutartis',
'memberServiceAgreementIntro': 'Kad galėtume teikti geresnes narystės paslaugas, prieš prenumeruodami atidžiai perskaitykite: ',
'memberServiceAgreementOutro': '. Spustelėdami „Sutikti ir tęsti“ patvirtinate, kad visiškai perskaitėte ir sutinkate su aukščiau pateikta sutartimi.',
'memberAgreementRejectedTip': 'Norėdami tęsti, turite sutikti su Narystės paslaugų sutartimi.',
'translationCallExitTitle': 'Išeiti iš skambučių vertimo',
'translationCallExitMessage': 'Pasirinkite, ką daryti prieš išeinant',
'translationCallEnd': 'Baigti skambučio vertimą',
'translationContinueInBackground': 'Tęsti vertimą fone',
'backgroundRecordingIndicatorBody': 'Įrašymas tęsiamas fone. Palieskite, kad grįžtumėte.',
'backgroundTranslationIndicatorBody': 'Skambučio vertimas tęsiamas fone. Palieskite, kad grįžtumėte.',
'meetingRecordScreenCapturePermissionDenied': 'Sistemos garsui įrašyti reikalingas ekrano įrašymo leidimas. Suteikite jį ir bandykite dar kartą.',
'pleaseGrantBluetoothPermission': 'Suteikite Bluetooth leidimą, kad būtų galima skenuoti',
'startScanFailed': 'Nepavyko pradėti skenavimo',
'pleaseLoginFirst': 'Pirmiausia prisijunkite',
'deviceUnauthorized': 'Įrenginys neautorizuotas, bandykite dar kartą',
'boundDevices': 'Susieti įrenginiai',
'currentConnection': 'Dabartinis ryšys',
'binding': 'Siejama...',
'permissionDisabled': 'Išjungta',
'confirmDisablePermissionTitle': 'Išjungti leidimą',
'confirmDisablePermissionContent': 'Išjungti {permission} leidimą? Norėdami jį vėl įjungti, turėsite eiti į sistemos nustatymus.',
'confirmPermanentlyDisallowTitle': 'Daugiau neklausti?',
'confirmPermanentlyDisallowContent': 'Atmetėte {permission} leidimą. Daugiau neklausti? Jei pasirinksite „Neleisti“, vėliau vis tiek galėsite jį įjungti sistemos nustatymuose.',
'notNow': 'Ne dabar',
'permanentlyDisallow': 'Neleisti',
'otaBesNotSupported': 'Šios ausinės dar nepalaiko programinės aparatinės įrangos atnaujinimo per programą. Susisiekite su palaikymo tarnyba.',
'tabDevice': 'Įrenginiai',
'tabPlaza': 'Plaza',
'sloganHome': 'Technologijos išmanesniam gyvenimui',
'sloganAi': 'Beribis intelektas',
'sloganDevice': 'Išmanioji įranga, pasirengusi ateičiai',
'sloganPlaza': 'Išmanus pasaulis, kuris jus supranta',
'aiEditHint': 'Ilgai palaikykite kortelę, kad vilkdami pertvarkytumėte modulius',
'aiEmptyTitle': 'Modulių dar nepridėta',
'aiEmptyDesc': 'Pasirinkite reikalingus agentus „Plaza“ skiltyje',
'goToPlaza': 'Eiti į „Plaza“',
'plazaOnline': 'Prieinama',
'plazaPlanned': 'Netrukus',
'plazaSearchHint': 'Ieškoti agentų, pvz., „versti“, „įrašyti“',
'moduleRemove': 'Pašalinti',
'moduleAdd': 'Pridėti',
'moduleRemoved': 'Pašalinta',
'moduleAdded': 'Pridėta',
'goSee': 'Peržiūrėti',
'comingSoonTag': 'Netrukus',
'needDeviceTag': 'Reikia įrenginio',
'featureInDev': 'Ši funkcija dar kuriama',
'featureNotWired': 'Dar neprieinama',
'todayLabel': 'Šiandien',
'deviceConnected': 'Prisijungta',
'licenseUnknown': 'Licencija nežinoma',
'selectDevice': 'Pasirinkite įrenginį',
'catNoteCard': 'AI įrašymo kortelė',
'catNoteCardDesc': 'Kortelės formos įrašytuvas',
'catParkerPen': 'AI rašiklis',
'catParkerPenDesc': 'Rašikliai su balso įrašymu',
'catStylusPen': 'AI jutiklinis rašiklis',
'catStylusPenDesc': 'Rašymo ranka / jutiklinio rašiklio įrenginiai',
'catStudyMachine': 'AI mokymosi planšetė',
'catStudyMachineDesc': 'Mokymosi įrenginiai',
'besAddTitle': 'Pridėti AI ausines',
'besConnecting': 'Jungiamasi prie ausinių…',
'besNotFound': 'Ausinės nerastos',
'besNotFoundIosDesc': 'Pirmiausia prijunkite ausines per Nustatymai → Bluetooth, tada grįžkite čia',
'besNotFoundAndroidDesc': 'Pirmiausia susiekite ausines per Nustatymai → Bluetooth, tada prijunkite čia',
'besReadFailed': 'Nepavyko nuskaityti',
'besConnectFailIos': 'Įsitikinkite, kad sistemos Bluetooth nustatymuose ausinės rodomos kaip prijungtos, tada bandykite dar kartą',
'besConnectFailAndroid': 'Įsitikinkite, kad ausinės įjungtos, yra netoliese ir neprijungtos prie kito įrenginio. Susiejus jos prisijungs automatiškai.',
'holderAddTitle': 'Pridėti AI telefono laikiklį',
'holderConnecting': 'Jungiamasi prie įrenginio…',
'holderSearching': 'Ieškoma įrenginių…',
'holderNotFound': 'Įrenginių nerasta',
'holderPairHint': 'Įsitikinkite, kad laikiklis įjungtas ir veikia susiejimo režimu',
'holderRetryHint': 'Įsitikinkite, kad įrenginys įjungtas ir yra netoliese, tada bandykite dar kartą',
'autoConnectTitle': 'Automatinis įrenginių prijungimas',
'autoConnectDesc': 'Pažadina programą, kad ji vėl prisijungtų ir gautų įrašus, kai įrenginys įsijungia arba atsiduria netoliese',
'autoConnectConsentTitle': 'Įjungti automatinį prijungimą',
'autoConnectConsentIntro': 'Kad jūsų įrenginys (AI ausinės / AI telefono laikiklis) galėtų prisijungti automatiškai ir išsaugoti įrašus paspaudus jo įrašymo mygtuką, mums reikia jūsų sutikimo:',
'autoConnectConsentB1': 'Užregistruoti Bluetooth skenavimą fone, kad aptikus susietą įrenginį programa būtų pažadinta (paleista automatiškai) ir galėtų vėl prisijungti bei gauti įrašus',
'autoConnectConsentB2': 'Pažadinta programa naudojama tik įrenginio prijungimui, įrašymui ir įkėlimui — niekam kitam, ir jokios kitos programos nepaleidžiamos',
'autoConnectConsentB3': 'Programą galite naudoti ir be to — tiesiog kiekvieną kartą prijunkite įrenginį rankiniu būdu',
'autoConnectConsentB4': 'Bet kada galite tai išjungti: Nustatymai → Automatinis įrenginių prijungimas',
'autoConnectAgree': 'Sutikti ir įjungti',
'autoConnectLater': 'Ne dabar',
'autoConnectRequired': 'Automatinis prijungimas išjungtas',
'autoConnectRequiredDesc': 'Įjunkite automatinį prijungimą, kad susietumėte šį įrenginį',
'recordTapToStart': 'Palieskite mygtuką apačioje, kad pradėtumėte įrašyti',
'recordPaused': 'Pristabdyta',
'recordMark': 'Žymėti',
'recordDeleteTitle': 'Ištrinti įrašą',
'accountAndProfile': 'Paskyra ir profilis',
'nicknameUnset': 'Slapyvardis nenustatytas',
'nicknameInput': 'Įveskite slapyvardį',
'loginMethod': 'Prisijungimo būdas',
'manageMyLogin': 'Tvarkyti prisijungimą',
'logoutAction': 'Atsijungti',
'warningLabel': 'Įspėjimas:',
'deleteAccountWarn': 'Jūsų paskyra ir visi failai bus visam laikui ištrinti.',
'icpFiling': 'ICP registracija',
'logExport': 'Eksportuoti žurnalus',
'logExportDesc': 'Peržiūrėti ir eksportuoti programos žurnalus',
'permLoadFailed': 'Nepavyko įkelti leidimų grupių',
'permInsufficient': 'Leidimas nesuteiktas',
'voiceCloneCreating': 'Kuriamas jūsų balsas…',
'voiceCloneCreate': 'Sukurti mano balsą',
'voiceCloneAgree': 'Sutikti ir garsiai perskaityti pareiškimą',
'voiceCloneReadPrompt': 'Perskaitykite:',
'voiceCloneFirstMonthFree': 'Pirmas mėnuo nemokamai',
'voiceCloneEnable': 'Įjungti balso klonavimą',
'noMusicPlaying': 'Niekas negrojama',
'qqMusicNoSeek': '„QQ Music“ nepalaiko persukimo',
'bleScanTitle': 'BLE skenavimas',
'bluetoothOn': 'Bluetooth įjungtas',
'bleScanning': 'Skenuojama…',
'bleConnectedDevices': 'Prijungti įrenginiai',
'bleUnknownDevice': 'Nežinomas įrenginys',
'bleTapToScan': 'Palieskite „Skenuoti“, kad ieškotumėte BLE įrenginių',
'weekMon': 'Pr',
'weekTue': 'An',
'weekWed': 'Tr',
'weekThu': 'Kt',
'weekFri': 'Pn',
'weekSat': 'Št',
'weekSun': 'Sk',
'agentMeeting': 'Balso protokolai',
'agentMeetingDesc': 'Įrašų santraukos, atminties centras',
'agentEmai': 'EMAI asistentas',
'agentEmaiDesc': 'Kalbėkitės ir klauskite bet ko balsu',
'agentSimultaneous': 'Sinchroninis vertimas',
'agentSimultaneousDesc': 'Verčia jums kalbant',
'agentFaceToFace': 'Akis į akį',
'agentFaceToFaceDesc': 'Dvikryptis pokalbių vertimas',
'agentCallRecording': 'Skambučių įrašymas',
'agentCallRecordingDesc': 'Skambučiai išsaugomi automatiškai',
'agentFieldRecording': 'Tiesioginis įrašymas',
'agentFieldRecordingDesc': 'Įrenginio arba telefono mikrofonas',
'agentCall': 'Skambučių vertimas',
'agentCallDesc': 'Vertimas realiuoju laiku skambučių metu',
'agentAudioVideo': 'Medijos vertimas',
'agentAudioVideoDesc': 'Kameros kadravimas / garso ir vaizdo vertimas',
'agentEnglishChat': 'Pokalbiai anglų kalba',
'agentEnglishChatDesc': 'Įtraukianti kalbėjimo praktika',
'agentEnglishStudy': 'Anglų kalbos mokymasis',
'agentEnglishStudyDesc': 'Žodyno ir gramatikos pamokos',
'agentKidsStory': 'Pasakos vaikams',
'agentKidsStoryDesc': 'Pasakos prieš miegą ir klausimai-atsakymai',
'moduleRemovedDesc': 'pašalinta iš AI puslapio',
'moduleAddedDesc': 'pridėta AI puslapio apačioje',
'calNoRecords': 'Įrašų nėra',
'calNoRecordsDay': 'Nieko nesuplanuota',
'calTodos': 'Darbai',
'calExpenses': 'Išlaidos',
'calTodoCount': 'Darbų: @count',
'calExpenseCount': 'Išlaidų: @count',
'calEntryCount': 'Įrašų: @count',
'calItemsUnit': 'vnt.',
'calCountUnit': 'įrašai',
'calTotalAmount': 'Iš viso',
'recordPause': 'Pristabdyti',
'recordResume': 'Tęsti',
'recordDeleteConfirm': 'Ištrinti šį įrašą?',
'scopeDay': 'Diena',
'scopeWeek': 'Savaitė',
'scopeMonth': 'Mėnuo',
'allDay': 'Visą dieną',
'guestModeTitle': 'Svečio režimas',
'guestModeDesc': 'Svečiai gali tik naršyti. Prisijunkite, kad naudotumėte balso užrašus, vertimą ir įrašymą — jūsų duomenys bus išsaugoti jūsų paskyroje.',
'guestKeepBrowsing': 'Toliau naršyti',
'guestGoLogin': 'Prisijungti',
'guestViewOnlyTag': 'Tik peržiūra',
'autoConnectReenableHint': 'Automatinis prijungimas išjungtas. Vėl įjunkite: Nustatymai → Automatinis įrenginių prijungimas.',
'plazaEntryDesc': 'Naršykite ir pridėkite daugiau agentų',
'agentTextTranslation': 'Teksto vertimas',
'agentTextTranslationDesc': 'Verskite įvestą ar įklijuotą tekstą',
'agentPictureTranslation': 'Nuotraukų vertimas',
'agentPictureTranslationDesc': 'Nufotografuokite arba pasirinkite nuotrauką vertimui',
'callRequiredTitle': 'Reikalingas skambutis',
'callRequiredDesc': 'Ši funkcija veikia tik skambučio metu',
'inCallBusyTitle': 'Neprieinama skambučio metu',
'inCallBusyDesc': 'Mikrofoną ir garso kelią naudoja skambutis. Bandykite dar kartą padėję ragelį.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Nemokama',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/lv_lv.dart

@ -1924,4 +1924,208 @@ const Map<String, String> lvLV = {
'unmarkFavorite': 'Noņemt no izlases',
'unnamedDevice': 'Ierīce bez nosaukuma',
'userDataParseFailed': 'Neizdevās nolasīt lietotāja datus',
'catEarphone': 'AI austiņas',
'catEarphoneDesc': 'Klasiskās Bluetooth / BLE austiņas',
'catPhoneHolder': 'AI tālruņa turētājs',
'catPhoneHolderDesc': 'Turētāja ierīces',
'catCarAroma': 'AI auto aromatizētājs',
'catCarAromaDesc': 'Automašīnas aromāta difuzors',
'agreeAndContinue': 'Piekrist un turpināt',
'memberServiceAgreement': 'Dalības pakalpojumu līgums',
'memberServiceAgreementTitle': 'Dalības pakalpojumu līgums',
'memberServiceAgreementIntro': 'Lai sniegtu jums labākus dalības pakalpojumus, pirms abonēšanas, lūdzu, rūpīgi izlasiet: ',
'memberServiceAgreementOutro': '. Noklikšķinot uz “Piekrist un turpināt”, jūs apstiprināt, ka esat pilnībā izlasījis un piekrītat iepriekš minētajam līgumam.',
'memberAgreementRejectedTip': 'Lai turpinātu, jums jāpiekrīt Dalības pakalpojumu līgumam.',
'translationCallExitTitle': 'Iziet no zvanu tulkošanas',
'translationCallExitMessage': 'Izvēlieties, ko darīt pirms iziešanas',
'translationCallEnd': 'Beigt zvana tulkošanu',
'translationContinueInBackground': 'Turpināt tulkošanu fonā',
'backgroundRecordingIndicatorBody': 'Ierakstīšana turpinās fonā. Pieskarieties, lai atgrieztos.',
'backgroundTranslationIndicatorBody': 'Zvana tulkošana turpinās fonā. Pieskarieties, lai atgrieztos.',
'meetingRecordScreenCapturePermissionDenied': 'Lai ierakstītu sistēmas audio, nepieciešama ekrāna ierakstīšanas atļauja. Piešķiriet to un mēģiniet vēlreiz.',
'pleaseGrantBluetoothPermission': 'Piešķiriet Bluetooth atļauju, lai skenētu',
'startScanFailed': 'Neizdevās sākt skenēšanu',
'pleaseLoginFirst': 'Vispirms piesakieties',
'deviceUnauthorized': 'Ierīce nav autorizēta, mēģiniet vēlreiz',
'boundDevices': 'Saistītās ierīces',
'currentConnection': 'Pašreizējais savienojums',
'binding': 'Saista...',
'permissionDisabled': 'Izslēgta',
'confirmDisablePermissionTitle': 'Izslēgt atļauju',
'confirmDisablePermissionContent': 'Izslēgt {permission} atļauju? Lai to atkal ieslēgtu, būs jādodas uz sistēmas iestatījumiem.',
'confirmPermanentlyDisallowTitle': 'Vairs nejautāt?',
'confirmPermanentlyDisallowContent': 'Jūs noraidījāt {permission} atļauju. Vairs nejautāt? Ja izvēlēsieties “Neatļaut”, vēlāk to joprojām varēsiet ieslēgt sistēmas iestatījumos.',
'notNow': 'Ne tagad',
'permanentlyDisallow': 'Neatļaut',
'otaBesNotSupported': 'Šīs austiņas vēl neatbalsta programmaparatūras atjaunināšanu lietotnē. Lūdzu, sazinieties ar atbalsta dienestu.',
'tabDevice': 'Ierīces',
'tabPlaza': 'Plaza',
'sloganHome': 'Tehnoloģijas viedākai dzīvei',
'sloganAi': 'Neierobežots intelekts',
'sloganDevice': 'Viedā aparatūra, gatava nākotnei',
'sloganPlaza': 'Vieda pasaule, kas jūs saprot',
'aiEditHint': 'Turiet karti nospiestu, lai vilktu un pārkārtotu moduļus',
'aiEmptyTitle': 'Moduļi vēl nav pievienoti',
'aiEmptyDesc': 'Izvēlieties nepieciešamos aģentus sadaļā Plaza',
'goToPlaza': 'Doties uz Plaza',
'plazaOnline': 'Pieejams',
'plazaPlanned': 'Drīzumā',
'plazaSearchHint': 'Meklēt aģentus, piem., “tulkot”, “ierakstīt”',
'moduleRemove': 'Noņemt',
'moduleAdd': 'Pievienot',
'moduleRemoved': 'Noņemts',
'moduleAdded': 'Pievienots',
'goSee': 'Skatīt',
'comingSoonTag': 'Drīzumā',
'needDeviceTag': 'Nepieciešama ierīce',
'featureInDev': 'Šī funkcija vēl tiek izstrādāta',
'featureNotWired': 'Vēl nav pieejams',
'todayLabel': 'Šodien',
'deviceConnected': 'Savienots',
'licenseUnknown': 'Licence nav zināma',
'selectDevice': 'Izvēlieties ierīci',
'catNoteCard': 'AI ierakstītāja karte',
'catNoteCardDesc': 'Kartes formas ierakstītājs',
'catParkerPen': 'AI pildspalva',
'catParkerPenDesc': 'Pildspalvas ar balss ierakstītāju',
'catStylusPen': 'AI irbulis',
'catStylusPenDesc': 'Rokraksta / irbuļa ierīces',
'catStudyMachine': 'AI mācību planšete',
'catStudyMachineDesc': 'Mācību ierīces',
'besAddTitle': 'Pievienot AI austiņas',
'besConnecting': 'Savienojas ar austiņām…',
'besNotFound': 'Austiņas nav atrastas',
'besNotFoundIosDesc': 'Vispirms savienojiet austiņas sadaļā Iestatījumi → Bluetooth, pēc tam atgriezieties šeit',
'besNotFoundAndroidDesc': 'Vispirms savienojiet austiņas pārī sadaļā Iestatījumi → Bluetooth, pēc tam savienojiet šeit',
'besReadFailed': 'Nolasīšana neizdevās',
'besConnectFailIos': 'Pārliecinieties, ka sistēmas Bluetooth iestatījumos austiņas ir redzamas kā savienotas, pēc tam mēģiniet vēlreiz',
'besConnectFailAndroid': 'Pārliecinieties, ka austiņas ir ieslēgtas, tuvumā un nav savienotas ar citu ierīci. Pēc savienošanas pārī tās atkal savienosies automātiski.',
'holderAddTitle': 'Pievienot AI tālruņa turētāju',
'holderConnecting': 'Savienojas ar ierīci…',
'holderSearching': 'Meklē ierīces…',
'holderNotFound': 'Ierīces nav atrastas',
'holderPairHint': 'Pārliecinieties, ka turētājs ir ieslēgts un savienošanas pārī režīmā',
'holderRetryHint': 'Pārliecinieties, ka ierīce ir ieslēgta un tuvumā, pēc tam mēģiniet vēlreiz',
'autoConnectTitle': 'Automātiska ierīču savienošana',
'autoConnectDesc': 'Pamodina lietotni, lai atkal savienotos un saņemtu ierakstus, kad ierīce ieslēdzas vai atrodas tuvumā',
'autoConnectConsentTitle': 'Ieslēgt automātisko savienošanu',
'autoConnectConsentIntro': 'Lai jūsu ierīce (AI austiņas / AI tālruņa turētājs) varētu savienoties automātiski un saglabāt ierakstus, kad nospiežat tās ierakstīšanas pogu, mums nepieciešama jūsu piekrišana:',
'autoConnectConsentB1': 'Reģistrēt Bluetooth skenēšanu fonā, lai lietotne tiktu pamodināta (automātiski palaista), kad tiek atrasta saistītā ierīce, un varētu atkal savienoties un saņemt ierakstus',
'autoConnectConsentB2': 'Pēc pamodināšanas tā tiek izmantota tikai ierīces savienošanai, ierakstīšanai un augšupielādei — nekam citam, un citas lietotnes netiek palaistas',
'autoConnectConsentB3': 'Lietotni varat izmantot arī bez tā — vienkārši katru reizi savienojiet ierīci manuāli',
'autoConnectConsentB4': 'To jebkurā laikā varat izslēgt sadaļā Iestatījumi → Automātiska ierīču savienošana',
'autoConnectAgree': 'Piekrist un ieslēgt',
'autoConnectLater': 'Ne tagad',
'autoConnectRequired': 'Automātiskā savienošana ir izslēgta',
'autoConnectRequiredDesc': 'Ieslēdziet automātisko savienošanu, lai saistītu šo ierīci',
'recordTapToStart': 'Pieskarieties pogai zemāk, lai sāktu ierakstīt',
'recordPaused': 'Pauzēts',
'recordMark': 'Atzīmēt',
'recordDeleteTitle': 'Dzēst ierakstu',
'accountAndProfile': 'Konts un profils',
'nicknameUnset': 'Segvārds nav iestatīts',
'nicknameInput': 'Ievadiet segvārdu',
'loginMethod': 'Pieteikšanās veids',
'manageMyLogin': 'Pārvaldīt pieteikšanos',
'logoutAction': 'Izrakstīties',
'warningLabel': 'Brīdinājums:',
'deleteAccountWarn': 'Jūsu konts un visi faili tiks neatgriezeniski dzēsti.',
'icpFiling': 'ICP reģistrācija',
'logExport': 'Eksportēt žurnālus',
'logExportDesc': 'Skatīt un eksportēt lietotnes žurnālus',
'permLoadFailed': 'Neizdevās ielādēt atļauju grupas',
'permInsufficient': 'Atļauja liegta',
'voiceCloneCreating': 'Veido jūsu balsi…',
'voiceCloneCreate': 'Izveidot manu balsi',
'voiceCloneAgree': 'Piekrist un skaļi nolasīt paziņojumu',
'voiceCloneReadPrompt': 'Lūdzu, nolasiet:',
'voiceCloneFirstMonthFree': 'Pirmais mēnesis bez maksas',
'voiceCloneEnable': 'Ieslēgt balss klonēšanu',
'noMusicPlaying': 'Nekas netiek atskaņots',
'qqMusicNoSeek': 'QQ Music neatbalsta pārtīšanu',
'bleScanTitle': 'BLE skenēšana',
'bluetoothOn': 'Bluetooth ieslēgts',
'bleScanning': 'Skenē…',
'bleConnectedDevices': 'Savienotās ierīces',
'bleUnknownDevice': 'Nezināma ierīce',
'bleTapToScan': 'Pieskarieties Skenēt, lai meklētu BLE ierīces',
'weekMon': 'Pr',
'weekTue': 'Ot',
'weekWed': 'Tr',
'weekThu': 'Ce',
'weekFri': 'Pk',
'weekSat': 'Se',
'weekSun': 'Sv',
'agentMeeting': 'Balss protokoli',
'agentMeetingDesc': 'Ierakstu kopsavilkumi, atmiņas centrs',
'agentEmai': 'EMAI asistents',
'agentEmaiDesc': 'Sarunājieties un jautājiet jebko ar balsi',
'agentSimultaneous': 'Sinhronā tulkošana',
'agentSimultaneousDesc': 'Tulko, kamēr runājat',
'agentFaceToFace': 'Aci pret aci',
'agentFaceToFaceDesc': 'Divvirzienu sarunu tulkošana',
'agentCallRecording': 'Zvanu ierakstīšana',
'agentCallRecordingDesc': 'Zvani tiek saglabāti automātiski',
'agentFieldRecording': 'Ierakstīšana uz vietas',
'agentFieldRecordingDesc': 'Ierīces vai tālruņa mikrofons',
'agentCall': 'Zvanu tulkošana',
'agentCallDesc': 'Tulkošana reāllaikā zvanu laikā',
'agentAudioVideo': 'Mediju tulkošana',
'agentAudioVideoDesc': 'Kameras kadrēšana / audio un video tulkošana',
'agentEnglishChat': 'Sarunas angļu valodā',
'agentEnglishChatDesc': 'Iedziļinoša runāšanas prakse',
'agentEnglishStudy': 'Angļu valodas mācības',
'agentEnglishStudyDesc': 'Vārdu krājuma un gramatikas nodarbības',
'agentKidsStory': 'Bērnu stāsti',
'agentKidsStoryDesc': 'Vakara pasakas un jautājumi-atbildes',
'moduleRemovedDesc': 'noņemts no AI lapas',
'moduleAddedDesc': 'pievienots AI lapas apakšā',
'calNoRecords': 'Nav ierakstu',
'calNoRecordsDay': 'Nekas nav ieplānots',
'calTodos': 'Darāmie darbi',
'calExpenses': 'Izdevumi',
'calTodoCount': 'Darbi: @count',
'calExpenseCount': 'Izdevumi: @count',
'calEntryCount': 'Ieraksti: @count',
'calItemsUnit': 'vienības',
'calCountUnit': 'ieraksti',
'calTotalAmount': 'Kopā',
'recordPause': 'Pauze',
'recordResume': 'Turpināt',
'recordDeleteConfirm': 'Dzēst šo ierakstu?',
'scopeDay': 'Diena',
'scopeWeek': 'Nedēļa',
'scopeMonth': 'Mēnesis',
'allDay': 'Visu dienu',
'guestModeTitle': 'Viesa režīms',
'guestModeDesc': 'Viesi var tikai pārlūkot. Piesakieties, lai izmantotu balss piezīmes, tulkošanu un ierakstīšanu — jūsu dati tiks saglabāti jūsu kontā.',
'guestKeepBrowsing': 'Turpināt pārlūkot',
'guestGoLogin': 'Pieteikties',
'guestViewOnlyTag': 'Tikai skatīšanās',
'autoConnectReenableHint': 'Automātiskā savienošana ir izslēgta. Ieslēdziet to atkal sadaļā Iestatījumi → Automātiska ierīču savienošana.',
'plazaEntryDesc': 'Pārlūkojiet un pievienojiet vairāk aģentu',
'agentTextTranslation': 'Teksta tulkošana',
'agentTextTranslationDesc': 'Tulkojiet ievadītu vai ielīmētu tekstu',
'agentPictureTranslation': 'Foto tulkošana',
'agentPictureTranslationDesc': 'Uzņemiet vai izvēlieties fotoattēlu tulkošanai',
'callRequiredTitle': 'Nepieciešams zvans',
'callRequiredDesc': 'Šī funkcija darbojas tikai zvana laikā',
'inCallBusyTitle': 'Nav pieejams zvana laikā',
'inCallBusyDesc': 'Mikrofonu un audio ceļu izmanto zvans. Mēģiniet vēlreiz pēc zvana beigšanas.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Bezmaksas',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/ms_my.dart

@ -1908,4 +1908,208 @@ const Map<String, String> msMY = {
'unmarkFavorite': 'Buang dari kegemaran',
'unnamedDevice': 'Peranti tanpa nama',
'userDataParseFailed': 'Gagal membaca data pengguna',
'catEarphone': 'Fon Telinga AI',
'catEarphoneDesc': 'Fon telinga Bluetooth klasik / BLE',
'catPhoneHolder': 'Pemegang Telefon AI',
'catPhoneHolderDesc': 'Peranti pemegang',
'catCarAroma': 'Pewangi Kereta AI',
'catCarAromaDesc': 'Penyebar aroma kereta',
'agreeAndContinue': 'Setuju dan Teruskan',
'memberServiceAgreement': 'Perjanjian Perkhidmatan Keahlian',
'memberServiceAgreementTitle': 'Perjanjian Perkhidmatan Keahlian',
'memberServiceAgreementIntro': 'Untuk memberikan perkhidmatan keahlian yang lebih baik, sila baca dengan teliti sebelum melanggan: ',
'memberServiceAgreementOutro': '. Dengan mengetik "Setuju dan Teruskan", anda mengesahkan bahawa anda telah membaca sepenuhnya dan menerima perjanjian di atas.',
'memberAgreementRejectedTip': 'Anda mesti bersetuju dengan Perjanjian Perkhidmatan Keahlian untuk meneruskan.',
'translationCallExitTitle': 'Keluar Penterjemahan Panggilan',
'translationCallExitMessage': 'Pilih tindakan sebelum keluar',
'translationCallEnd': 'Tamatkan penterjemahan panggilan',
'translationContinueInBackground': 'Teruskan penterjemahan di latar belakang',
'backgroundRecordingIndicatorBody': 'Rakaman diteruskan di latar belakang. Ketik untuk kembali.',
'backgroundTranslationIndicatorBody': 'Penterjemahan panggilan diteruskan di latar belakang. Ketik untuk kembali.',
'meetingRecordScreenCapturePermissionDenied': 'Kebenaran rakaman skrin diperlukan untuk merakam audio sistem. Sila berikan kebenaran dan cuba lagi.',
'pleaseGrantBluetoothPermission': 'Sila berikan kebenaran Bluetooth untuk mengimbas',
'startScanFailed': 'Gagal memulakan imbasan',
'pleaseLoginFirst': 'Sila log masuk dahulu',
'deviceUnauthorized': 'Peranti tidak dibenarkan, sila cuba lagi',
'boundDevices': 'Peranti Terikat',
'currentConnection': 'Sambungan Semasa',
'binding': 'Mengikat...',
'permissionDisabled': 'Dimatikan',
'confirmDisablePermissionTitle': 'Matikan kebenaran',
'confirmDisablePermissionContent': 'Matikan kebenaran {permission}? Anda perlu ke tetapan sistem untuk mengaktifkannya semula.',
'confirmPermanentlyDisallowTitle': 'Berhenti bertanya?',
'confirmPermanentlyDisallowContent': 'Anda menolak kebenaran {permission}. Berhenti bertanya mulai sekarang? Jika anda memilih "Jangan benarkan", anda masih boleh mengaktifkannya kemudian dalam tetapan sistem.',
'notNow': 'Bukan sekarang',
'permanentlyDisallow': 'Jangan benarkan',
'otaBesNotSupported': 'Set kepala ini belum menyokong naik taraf firmware dalam aplikasi. Sila hubungi sokongan.',
'tabDevice': 'Peranti',
'tabPlaza': 'Plaza',
'sloganHome': 'Teknologi untuk kehidupan lebih bijak',
'sloganAi': 'Kecerdasan tanpa had',
'sloganDevice': 'Perkakasan pintar, sedia untuk masa depan',
'sloganPlaza': 'Dunia pintar yang memahami anda',
'aiEditHint': 'Tekan lama kad untuk seret dan susun semula modul',
'aiEmptyTitle': 'Tiada modul ditambah lagi',
'aiEmptyDesc': 'Pilih ejen yang anda perlukan di Plaza',
'goToPlaza': 'Pergi ke Plaza',
'plazaOnline': 'Tersedia',
'plazaPlanned': 'Akan datang',
'plazaSearchHint': 'Cari ejen, cth. “terjemah”, “rakam”',
'moduleRemove': 'Buang',
'moduleAdd': 'Tambah',
'moduleRemoved': 'Dibuang',
'moduleAdded': 'Ditambah',
'goSee': 'Lihat',
'comingSoonTag': 'Akan datang',
'needDeviceTag': 'Perlukan peranti',
'featureInDev': 'Ciri ini sedang dibangunkan',
'featureNotWired': 'Belum tersedia',
'todayLabel': 'Hari ini',
'deviceConnected': 'Disambungkan',
'licenseUnknown': 'Lesen tidak diketahui',
'selectDevice': 'Pilih peranti',
'catNoteCard': 'Kad Perakam AI',
'catNoteCardDesc': 'Perakam berbentuk kad',
'catParkerPen': 'Pen AI',
'catParkerPenDesc': 'Pen perakam suara',
'catStylusPen': 'Stilus AI',
'catStylusPenDesc': 'Peranti tulisan tangan / stilus',
'catStudyMachine': 'Pad Pembelajaran AI',
'catStudyMachineDesc': 'Peranti pembelajaran',
'besAddTitle': 'Tambah Earbud AI',
'besConnecting': 'Menyambung ke earbud…',
'besNotFound': 'Earbud tidak ditemui',
'besNotFoundIosDesc': 'Sambungkan earbud di Tetapan → Bluetooth dahulu, kemudian kembali ke sini',
'besNotFoundAndroidDesc': 'Pasangkan earbud di Tetapan → Bluetooth dahulu, kemudian sambung di sini',
'besReadFailed': 'Gagal membaca',
'besConnectFailIos': 'Pastikan earbud dipaparkan sebagai Disambungkan dalam Bluetooth sistem, kemudian cuba lagi',
'besConnectFailAndroid': 'Pastikan earbud dihidupkan, berdekatan dan tidak disambungkan ke peranti lain. Earbud akan menyambung semula secara automatik selepas dipautkan.',
'holderAddTitle': 'Tambah Pemegang Telefon AI',
'holderConnecting': 'Menyambung ke peranti…',
'holderSearching': 'Mencari peranti…',
'holderNotFound': 'Tiada peranti ditemui',
'holderPairHint': 'Pastikan pemegang dihidupkan dan dalam mod berpasangan',
'holderRetryHint': 'Pastikan peranti dihidupkan dan berdekatan, kemudian cuba lagi',
'autoConnectTitle': 'Sambung automatik peranti',
'autoConnectDesc': 'Bangunkan aplikasi untuk menyambung semula dan menerima rakaman apabila peranti anda dihidupkan atau berdekatan',
'autoConnectConsentTitle': 'Aktifkan sambung automatik',
'autoConnectConsentIntro': 'Untuk membolehkan peranti anda (Earbud AI / Pemegang Telefon AI) menyambung secara automatik dan menyimpan rakaman apabila anda menekan butang rakamnya, kami memerlukan persetujuan anda:',
'autoConnectConsentB1': 'Mendaftarkan imbasan Bluetooth latar belakang supaya aplikasi dibangunkan (dimulakan automatik) apabila peranti terikat anda dikesan, untuk menyambung semula dan menerima rakaman',
'autoConnectConsentB2': 'Setelah dibangunkan, ia hanya digunakan untuk sambungan peranti, rakaman dan muat naik — tiada yang lain, dan tiada aplikasi lain dilancarkan',
'autoConnectConsentB3': 'Anda masih boleh menggunakan aplikasi tanpanya — cuma sambungkan peranti secara manual setiap kali',
'autoConnectConsentB4': 'Anda boleh mematikannya pada bila-bila masa di Tetapan → Sambung automatik peranti',
'autoConnectAgree': 'Setuju dan aktifkan',
'autoConnectLater': 'Bukan sekarang',
'autoConnectRequired': 'Sambung automatik dimatikan',
'autoConnectRequiredDesc': 'Aktifkan sambung automatik untuk mengikat peranti ini',
'recordTapToStart': 'Ketik butang di bawah untuk mula merakam',
'recordPaused': 'Dijeda',
'recordMark': 'Tanda',
'recordDeleteTitle': 'Padam rakaman',
'accountAndProfile': 'Akaun & profil',
'nicknameUnset': 'Nama panggilan belum ditetapkan',
'nicknameInput': 'Masukkan nama panggilan',
'loginMethod': 'Kaedah log masuk',
'manageMyLogin': 'Urus log masuk',
'logoutAction': 'Log keluar',
'warningLabel': 'Amaran:',
'deleteAccountWarn': 'Akaun anda dan semua fail akan dipadam secara kekal.',
'icpFiling': 'Pendaftaran ICP',
'logExport': 'Eksport log',
'logExportDesc': 'Lihat dan eksport log aplikasi',
'permLoadFailed': 'Gagal memuatkan kumpulan kebenaran',
'permInsufficient': 'Kebenaran ditolak',
'voiceCloneCreating': 'Mencipta suara anda…',
'voiceCloneCreate': 'Cipta suara saya',
'voiceCloneAgree': 'Setuju dan baca kenyataan dengan kuat',
'voiceCloneReadPrompt': 'Sila baca:',
'voiceCloneFirstMonthFree': 'Bulan pertama percuma',
'voiceCloneEnable': 'Aktifkan klon suara',
'noMusicPlaying': 'Tiada yang dimainkan',
'qqMusicNoSeek': 'Melangkau kedudukan tidak disokong untuk QQ Music',
'bleScanTitle': 'Imbasan BLE',
'bluetoothOn': 'Bluetooth dihidupkan',
'bleScanning': 'Mengimbas…',
'bleConnectedDevices': 'Peranti disambungkan',
'bleUnknownDevice': 'Peranti tidak dikenali',
'bleTapToScan': 'Ketik imbas untuk mencari peranti BLE',
'weekMon': 'Isn',
'weekTue': 'Sel',
'weekWed': 'Rab',
'weekThu': 'Kha',
'weekFri': 'Jum',
'weekSat': 'Sab',
'weekSun': 'Ahd',
'agentMeeting': 'Minit Suara',
'agentMeetingDesc': 'Ringkaskan rakaman, hab memori',
'agentEmai': 'Pembantu EMAI',
'agentEmaiDesc': 'Berbual dan tanya apa sahaja melalui suara',
'agentSimultaneous': 'Penterjemahan Langsung',
'agentSimultaneousDesc': 'Terjemah semasa anda bercakap',
'agentFaceToFace': 'Bersemuka',
'agentFaceToFaceDesc': 'Penterjemahan perbualan dua hala',
'agentCallRecording': 'Rakaman Panggilan',
'agentCallRecordingDesc': 'Panggilan disimpan secara automatik',
'agentFieldRecording': 'Rakaman Lapangan',
'agentFieldRecordingDesc': 'Mikrofon peranti atau telefon',
'agentCall': 'Penterjemahan Panggilan',
'agentCallDesc': 'Terjemahan masa nyata semasa panggilan',
'agentAudioVideo': 'Penterjemahan Media',
'agentAudioVideoDesc': 'Bingkai kamera / terjemahan audio & video',
'agentEnglishChat': 'Sembang Bahasa Inggeris',
'agentEnglishChatDesc': 'Latihan bertutur secara imersif',
'agentEnglishStudy': 'Belajar Bahasa Inggeris',
'agentEnglishStudyDesc': 'Pelajaran kosa kata dan tatabahasa',
'agentKidsStory': 'Cerita Kanak-kanak',
'agentKidsStoryDesc': 'Cerita sebelum tidur dan soal jawab',
'moduleRemovedDesc': 'dibuang daripada halaman AI',
'moduleAddedDesc': 'ditambah ke bahagian bawah halaman AI',
'calNoRecords': 'Tiada entri',
'calNoRecordsDay': 'Tiada jadual',
'calTodos': 'Tugasan',
'calExpenses': 'Perbelanjaan',
'calTodoCount': '@count tugasan',
'calExpenseCount': '@count perbelanjaan',
'calEntryCount': '@count entri',
'calItemsUnit': 'item',
'calCountUnit': 'entri',
'calTotalAmount': 'Jumlah',
'recordPause': 'Jeda',
'recordResume': 'Sambung semula',
'recordDeleteConfirm': 'Padam rakaman ini?',
'scopeDay': 'Hari',
'scopeWeek': 'Minggu',
'scopeMonth': 'Bulan',
'allDay': 'Sepanjang hari',
'guestModeTitle': 'Mod tetamu',
'guestModeDesc': 'Tetamu hanya boleh melayari. Log masuk untuk menggunakan nota suara, penterjemahan dan rakaman — data anda akan disimpan ke akaun anda.',
'guestKeepBrowsing': 'Terus melayari',
'guestGoLogin': 'Log masuk',
'guestViewOnlyTag': 'Lihat sahaja',
'autoConnectReenableHint': 'Sambung automatik dimatikan. Hidupkan semula di Tetapan → Sambung automatik peranti.',
'plazaEntryDesc': 'Layari dan tambah lebih banyak ejen',
'agentTextTranslation': 'Penterjemahan Teks',
'agentTextTranslationDesc': 'Terjemah teks yang ditaip atau ditampal',
'agentPictureTranslation': 'Penterjemahan Foto',
'agentPictureTranslationDesc': 'Ambil atau pilih foto untuk diterjemah',
'callRequiredTitle': 'Panggilan diperlukan',
'callRequiredDesc': 'Ciri ini hanya berfungsi semasa panggilan',
'inCallBusyTitle': 'Tidak tersedia semasa panggilan',
'inCallBusyDesc': 'Mikrofon dan laluan audio sedang digunakan oleh panggilan. Cuba lagi selepas menamatkan panggilan.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Percuma',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/nb_no.dart

@ -1902,4 +1902,208 @@ const Map<String, String> nbNO = {
'unmarkFavorite': 'Fjern fra favoritter',
'unnamedDevice': 'Enhet uten navn',
'userDataParseFailed': 'Kunne ikke lese brukerdata',
'catEarphone': 'AI-øreplugger',
'catEarphoneDesc': 'Klassiske Bluetooth- / BLE-øreplugger',
'catPhoneHolder': 'AI-mobilholder',
'catPhoneHolderDesc': 'Holderenheter',
'catCarAroma': 'AI-bilduft',
'catCarAromaDesc': 'Aromaspreder for bil',
'agreeAndContinue': 'Godta og fortsett',
'memberServiceAgreement': 'Medlemstjenesteavtale',
'memberServiceAgreementTitle': 'Medlemstjenesteavtale',
'memberServiceAgreementIntro': 'For å gi deg bedre medlemstjenester ber vi deg lese nøye før du abonnerer: ',
'memberServiceAgreementOutro': '. Ved å trykke på «Godta og fortsett» bekrefter du at du har lest og godtar avtalen ovenfor.',
'memberAgreementRejectedTip': 'Du må godta medlemstjenesteavtalen for å fortsette.',
'translationCallExitTitle': 'Forlat samtaleoversettelse',
'translationCallExitMessage': 'Velg hva som skal skje før du forlater',
'translationCallEnd': 'Avslutt samtaleoversettelse',
'translationContinueInBackground': 'Fortsett oversettelsen i bakgrunnen',
'backgroundRecordingIndicatorBody': 'Opptaket fortsetter i bakgrunnen. Trykk for å gå tilbake.',
'backgroundTranslationIndicatorBody': 'Samtaleoversettelsen fortsetter i bakgrunnen. Trykk for å gå tilbake.',
'meetingRecordScreenCapturePermissionDenied': 'Skjermopptakstillatelse kreves for å ta opp systemlyd. Gi tillatelse og prøv igjen.',
'pleaseGrantBluetoothPermission': 'Gi Bluetooth-tillatelse for å skanne',
'startScanFailed': 'Kunne ikke starte skanning',
'pleaseLoginFirst': 'Logg inn først',
'deviceUnauthorized': 'Enheten er ikke autorisert, prøv igjen',
'boundDevices': 'Tilknyttede enheter',
'currentConnection': 'Gjeldende tilkobling',
'binding': 'Knytter til...',
'permissionDisabled': 'Deaktivert',
'confirmDisablePermissionTitle': 'Slå av tillatelse',
'confirmDisablePermissionContent': 'Slå av {permission}-tillatelse? Du må gå til systeminnstillingene for å aktivere den igjen.',
'confirmPermanentlyDisallowTitle': 'Slutte å spørre?',
'confirmPermanentlyDisallowContent': 'Du avslo {permission}-tillatelse. Slutte å spørre fra nå av? Hvis du velger «Ikke tillat», kan du fortsatt aktivere den senere i systeminnstillingene.',
'notNow': 'Ikke nå',
'permanentlyDisallow': 'Ikke tillat',
'otaBesNotSupported': 'Dette headsetet støtter ennå ikke firmware-oppgradering i appen. Kontakt kundestøtte.',
'tabDevice': 'Enheter',
'tabPlaza': 'Plaza',
'sloganHome': 'Teknologi for et smartere liv',
'sloganAi': 'Grenseløs intelligens',
'sloganDevice': 'Smart maskinvare, klar for fremtiden',
'sloganPlaza': 'En smart verden som forstår deg',
'aiEditHint': 'Hold inne et kort for å dra og omorganisere moduler',
'aiEmptyTitle': 'Ingen moduler lagt til ennå',
'aiEmptyDesc': 'Velg agentene du trenger i Plaza',
'goToPlaza': 'Gå til Plaza',
'plazaOnline': 'Tilgjengelig',
'plazaPlanned': 'Kommer snart',
'plazaSearchHint': 'Søk etter agenter, f.eks. «oversett», «ta opp»',
'moduleRemove': 'Fjern',
'moduleAdd': 'Legg til',
'moduleRemoved': 'Fjernet',
'moduleAdded': 'Lagt til',
'goSee': 'Se',
'comingSoonTag': 'Kommer snart',
'needDeviceTag': 'Krever enhet',
'featureInDev': 'Denne funksjonen er under utvikling',
'featureNotWired': 'Ikke tilgjengelig ennå',
'todayLabel': 'I dag',
'deviceConnected': 'Tilkoblet',
'licenseUnknown': 'Lisens ukjent',
'selectDevice': 'Velg en enhet',
'catNoteCard': 'AI-opptakerkort',
'catNoteCardDesc': 'Kortformet opptaker',
'catParkerPen': 'AI-penn',
'catParkerPenDesc': 'Stemmeopptakerpenner',
'catStylusPen': 'AI-pekepenn',
'catStylusPenDesc': 'Håndskrift-/pekepennenheter',
'catStudyMachine': 'AI-læringsnettbrett',
'catStudyMachineDesc': 'Læringsenheter',
'besAddTitle': 'Legg til AI-ørepropper',
'besConnecting': 'Kobler til øreproppene…',
'besNotFound': 'Fant ikke øreproppene',
'besNotFoundIosDesc': 'Koble til øreproppene i Innstillinger → Bluetooth først, og kom så tilbake',
'besNotFoundAndroidDesc': 'Par øreproppene i Innstillinger → Bluetooth først, og koble til her etterpå',
'besReadFailed': 'Lesing mislyktes',
'besConnectFailIos': 'Sørg for at øreproppene vises som Tilkoblet i systemets Bluetooth, og prøv igjen',
'besConnectFailAndroid': 'Sørg for at øreproppene er på, i nærheten og ikke koblet til en annen enhet. De kobler til på nytt automatisk når de er paret.',
'holderAddTitle': 'Legg til AI-telefonholder',
'holderConnecting': 'Kobler til enheten…',
'holderSearching': 'Søker etter enheter…',
'holderNotFound': 'Ingen enheter funnet',
'holderPairHint': 'Sørg for at holderen er på og i paringsmodus',
'holderRetryHint': 'Sørg for at enheten er på og i nærheten, og prøv igjen',
'autoConnectTitle': 'Koble til enheter automatisk',
'autoConnectDesc': 'Vekk appen for å koble til på nytt og motta opptak når enheten din slås på eller kommer i nærheten',
'autoConnectConsentTitle': 'Aktiver automatisk tilkobling',
'autoConnectConsentIntro': 'For at enheten din (AI-ørepropper / AI-telefonholder) skal kunne koble til automatisk og lagre opptak når du trykker på opptaksknappen, trenger vi ditt samtykke:',
'autoConnectConsentB1': 'Registrere en Bluetooth-skanning i bakgrunnen slik at appen vekkes (startes automatisk) når den tilknyttede enheten oppdages, for å koble til på nytt og motta opptak',
'autoConnectConsentB2': 'Når appen er vekket, brukes den kun til enhetstilkobling, opptak og opplasting — ingenting annet, og ingen andre apper startes',
'autoConnectConsentB3': 'Du kan fortsatt bruke appen uten dette — bare koble til enheten manuelt hver gang',
'autoConnectConsentB4': 'Du kan slå det av når som helst i Innstillinger → Koble til enheter automatisk',
'autoConnectAgree': 'Godta og aktiver',
'autoConnectLater': 'Ikke nå',
'autoConnectRequired': 'Automatisk tilkobling er av',
'autoConnectRequiredDesc': 'Aktiver automatisk tilkobling for å knytte til denne enheten',
'recordTapToStart': 'Trykk på knappen nedenfor for å starte opptaket',
'recordPaused': 'Satt på pause',
'recordMark': 'Marker',
'recordDeleteTitle': 'Slett opptak',
'accountAndProfile': 'Konto og profil',
'nicknameUnset': 'Ingen kallenavn angitt',
'nicknameInput': 'Skriv inn et kallenavn',
'loginMethod': 'Innloggingsmetode',
'manageMyLogin': 'Administrer innlogging',
'logoutAction': 'Logg ut',
'warningLabel': 'Advarsel:',
'deleteAccountWarn': 'Kontoen din og alle filer slettes permanent.',
'icpFiling': 'ICP-registrering',
'logExport': 'Eksporter logger',
'logExportDesc': 'Se og eksporter app-logger',
'permLoadFailed': 'Kunne ikke laste tillatelsesgrupper',
'permInsufficient': 'Tillatelse avslått',
'voiceCloneCreating': 'Oppretter stemmen din…',
'voiceCloneCreate': 'Opprett min stemme',
'voiceCloneAgree': 'Godta og les erklæringen høyt',
'voiceCloneReadPrompt': 'Vennligst les:',
'voiceCloneFirstMonthFree': 'Første måned gratis',
'voiceCloneEnable': 'Aktiver stemmekloning',
'noMusicPlaying': 'Ingenting spilles av',
'qqMusicNoSeek': 'Spoling støttes ikke for QQ Music',
'bleScanTitle': 'BLE-skanning',
'bluetoothOn': 'Bluetooth er på',
'bleScanning': 'Skanner…',
'bleConnectedDevices': 'Tilkoblede enheter',
'bleUnknownDevice': 'Ukjent enhet',
'bleTapToScan': 'Trykk på skann for å søke etter BLE-enheter',
'weekMon': 'Man',
'weekTue': 'Tir',
'weekWed': 'Ons',
'weekThu': 'Tor',
'weekFri': 'Fre',
'weekSat': 'Lør',
'weekSun': 'Søn',
'agentMeeting': 'Talereferat',
'agentMeetingDesc': 'Oppsummer opptak, minnesenter',
'agentEmai': 'EMAI-assistent',
'agentEmaiDesc': 'Chat og spør om alt med stemmen',
'agentSimultaneous': 'Sanntidsoversettelse',
'agentSimultaneousDesc': 'Oversett mens du snakker',
'agentFaceToFace': 'Ansikt-til-ansikt',
'agentFaceToFaceDesc': 'Toveis samtaleoversettelse',
'agentCallRecording': 'Samtaleopptak',
'agentCallRecordingDesc': 'Samtaler lagres automatisk',
'agentFieldRecording': 'Feltopptak',
'agentFieldRecordingDesc': 'Enhetens eller telefonens mikrofon',
'agentCall': 'Samtaleoversettelse',
'agentCallDesc': 'Sanntidsoversettelse under samtaler',
'agentAudioVideo': 'Medieoversettelse',
'agentAudioVideoDesc': 'Kamerainnramming / lyd- og videooversettelse',
'agentEnglishChat': 'Engelsk samtale',
'agentEnglishChatDesc': 'Oppslukende taletrening',
'agentEnglishStudy': 'Engelskstudier',
'agentEnglishStudyDesc': 'Ordforråds- og grammatikkleksjoner',
'agentKidsStory': 'Barnefortellinger',
'agentKidsStoryDesc': 'Godnatthistorier og spørsmål og svar',
'moduleRemovedDesc': 'fjernet fra AI-siden',
'moduleAddedDesc': 'lagt til nederst på AI-siden',
'calNoRecords': 'Ingen oppføringer',
'calNoRecordsDay': 'Ingenting planlagt',
'calTodos': 'To-do',
'calExpenses': 'Utgifter',
'calTodoCount': '@count to-dos',
'calExpenseCount': '@count utgifter',
'calEntryCount': '@count oppføringer',
'calItemsUnit': 'stk.',
'calCountUnit': 'oppføringer',
'calTotalAmount': 'Totalt',
'recordPause': 'Pause',
'recordResume': 'Fortsett',
'recordDeleteConfirm': 'Slette dette opptaket?',
'scopeDay': 'Dag',
'scopeWeek': 'Uke',
'scopeMonth': 'Måned',
'allDay': 'Hele dagen',
'guestModeTitle': 'Gjestemodus',
'guestModeDesc': 'Gjester kan bare se seg rundt. Logg inn for å bruke talereferat, oversettelse og opptak — dataene dine lagres på kontoen din.',
'guestKeepBrowsing': 'Fortsett å se deg rundt',
'guestGoLogin': 'Logg inn',
'guestViewOnlyTag': 'Kun visning',
'autoConnectReenableHint': 'Automatisk tilkobling er av. Slå det på igjen i Innstillinger → Koble til enheter automatisk.',
'plazaEntryDesc': 'Bla gjennom og legg til flere agenter',
'agentTextTranslation': 'Tekstoversettelse',
'agentTextTranslationDesc': 'Oversett skrevet eller limt inn tekst',
'agentPictureTranslation': 'Bildeoversettelse',
'agentPictureTranslationDesc': 'Ta eller velg et bilde for å oversette',
'callRequiredTitle': 'Samtale kreves',
'callRequiredDesc': 'Denne funksjonen fungerer bare under en samtale',
'inCallBusyTitle': 'Ikke tilgjengelig under en samtale',
'inCallBusyDesc': 'Mikrofonen og lydbanen brukes av samtalen. Prøv igjen etter at du har lagt på.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

359
apps/client/lib/core/translations/language/nl_nl.dart

@ -1801,4 +1801,363 @@ const Map<String, String> nlNL = {
'unmarkFavorite': 'Uit favorieten verwijderen',
'unnamedDevice': 'Naamloos apparaat',
'userDataParseFailed': 'Kan gebruikersgegevens niet verwerken',
'catEarphone': 'AI-oordopjes',
'catEarphoneDesc': 'Klassieke Bluetooth- / BLE-oordopjes',
'catPhoneHolder': 'AI-telefoonhouder',
'catPhoneHolderDesc': 'Houderapparaten',
'catCarAroma': 'AI-autoverfrisser',
'catCarAromaDesc': 'Auto-aromaverspreider',
'agreeAndContinue': 'Akkoord en doorgaan',
'memberServiceAgreement': 'Lidmaatschapsserviceovereenkomst',
'memberServiceAgreementTitle': 'Lidmaatschapsserviceovereenkomst',
'memberServiceAgreementIntro': 'Om u betere lidmaatschapsservices te bieden, verzoeken wij u dit aandachtig te lezen voordat u zich abonneert: ',
'memberServiceAgreementOutro': '. Door op "Akkoord en doorgaan" te klikken, bevestigt u dat u de bovenstaande overeenkomst volledig hebt gelezen en accepteert.',
'memberAgreementRejectedTip': 'U moet akkoord gaan met de Lidmaatschapsserviceovereenkomst om door te gaan.',
'exploreAi': 'Ontdek de AI-functies van Eaimar',
'aiFunction': 'AI-functies',
'allRoundAssistant': 'Alround assistent',
'allRoundAssistantInfo': 'Helpt bij allerlei vragen en problemen',
'simultaneousTranslation': 'Realtime vertaling',
'faceToFaceTranslation': 'Face-to-face vertaling',
'audioVideoTranslation': 'Multimediavertaling',
'callTranslation': 'Gespreksvertaling',
'simultaneousTranslationInfo': 'Directe vertaling in meer dan 80 talen',
'aiGirlfriend': 'Ouder-kindband',
'aiGirlfriendInfo': 'Slim gezelschap, altijd om mee te praten',
'healthConsultant': 'Multifunctionele AI-chat',
'healthConsultantInfo': 'Meer AI-chatfuncties',
'conferenceAssistant': 'Zakelijke vergaderassistent',
'conferenceAssistantInfo': 'Realtime notulen en meertalige vertaling',
'usageStats': 'Gebruiksstatistieken',
'usageStatsInfo': 'Bekijk uw gebruiksstatistieken',
'scene': 'Scenario\'s',
'aiChat': 'AI-chat',
'translationStats': 'Vertaalstatistieken',
'meetingStats': 'Vergaderstatistieken',
'today': 'Vandaag',
'thisWeek': 'Deze week',
'thisMonth': 'Deze maand',
'history': 'Geschiedenis',
'currentSessionStats': 'Statistieken huidige sessie',
'translationCount': 'Aantal vertalingen',
'sessionDuration': 'Sessieduur',
'characters': 'Tekens',
'recordingDuration': 'Opnameduur',
'totalApiCalls': 'Totaal API-aanroepen',
'sessionCount': 'Aantal sessies',
'totalCharacters': 'Totaal aantal tekens',
'apiCallStats': 'API-aanroepstatistieken',
'translationApi': 'Vertaal-API',
'asr': 'ASR',
'tts': 'TTS',
'dataVolumeStats': 'Datavolumestatistieken',
'sourceTextCharacters': 'Tekens brontekst',
'ttsTextCharacters': 'Tekens TTS-tekst',
'effectiveAudioDuration': 'Effectieve audioduur',
'totalSessionDuration': 'Totale sessieduur',
'costStats': 'Kostenstatistieken',
'estimatedCost': 'Geschatte kosten',
'dailyCostThreshold': 'Dagelijkse kostenlimiet',
'monthlyCostThreshold': 'Maandelijkse kostenlimiet',
'usageBehaviorStats': 'Gebruiksgedragstatistieken',
'mostUsedLanguagePair': 'Meest gebruikte talenpaar',
'mostUsedMode': 'Meest gebruikte modus',
'asrApiCalls': 'ASR-aanroepen',
'totalAudioDuration': 'Totale audioduur',
'audioFileSize': 'Grootte audiobestand',
'transcriptBytes': 'Transcriptbytes',
'loadFailed': 'Laden mislukt',
'statsLoadFailedTryLater': 'Laden van statistieken mislukt. Probeer het later opnieuw.',
'availablePackages': 'Beschikbare pakketten',
'remainingQuota': 'Resterend quota',
'totalQuota': 'Totaal quota',
'remaining': 'Resterend',
'featureDescription': 'Functiebeschrijving',
'paymentSuccess': 'Betaling geslaagd',
'gotIt': 'Begrepen',
'paymentFailedTryAgain': 'Betaling mislukt. Probeer het opnieuw.',
'alipayPay': 'Alipay',
'paypalPay': 'PayPal',
'appleIap': 'Apple IAP',
'googleIap': 'Google IAP',
'wechatOrderCreateFailed': 'Aanmaken van WeChat-bestelling mislukt',
'alipayOrderCreateFailed': 'Aanmaken van Alipay-bestelling mislukt',
'appleOrderCreateFailed': 'Aanmaken van Apple-bestelling mislukt',
'googleOrderCreateFailed': 'Aanmaken van Google-bestelling mislukt',
'paypalOrderCreateFailed': 'Aanmaken van PayPal-bestelling mislukt',
'paymentException': 'Betalingsfout',
'paymentLaunchFailed': 'Starten van betaling mislukt',
'paymentExceptionWithError': 'Betalingsfout: @error',
'paymentProcessingCheckLater': 'Uw bestelling wordt verwerkt. Controleer het later opnieuw.',
'paymentResultQueryException': 'Opvragen van betalingsresultaat mislukt: @error',
'orderConfirmedQuotaRefreshed': 'Bestelling bevestigd. Uw quota is bijgewerkt.',
'paymentCompletedConfirming': 'Betaling voltooid. Bevestigen…',
'paymentCancelled': 'Betaling geannuleerd',
'paypalOpenedCompleteInBrowser': 'PayPal is geopend. Voltooi de betaling in uw browser.',
'paypalOpenFailed': 'Kan de PayPal-betaalpagina niet openen',
'paymentTestTitle': 'Betalingstest',
'paymentTestOrderInfo': 'Testbestelgegevens',
'paymentTestProductId': 'Product-ID',
'paymentTestProductDescription': 'Productbeschrijving',
'paymentTestWechatDescription': 'WeChat Pay-betalingstest',
'paymentTestAlipayDescription': 'Alipay-betalingstest',
'paymentTestAmount': 'Bedrag',
'paymentTestSelectAmount': 'Selecteer testbedrag',
'paying': 'Bezig met betalen...',
'wechatPayWithAmount': 'WeChat Pay ¥@amount',
'alipayPayWithAmount': 'Alipay ¥@amount',
'paymentStatusTitle': 'Betalingsstatus',
'errorInfoWithMessage': 'Fout: @message',
'retest': 'Opnieuw testen',
'paymentTestInstructionsTitle': 'Instructies',
'paymentTestInstructionsContent': '1. Selecteer een testbedrag (aanbevolen: ¥0,01)\n2. Tik op de knop "WeChat Pay"\n3. De app roept de WeChat Pay SDK aan\n4. Voltooi de betaling in WeChat\n5. Keer terug naar de app om het resultaat te bekijken\n\nLet op: dit is een testomgeving. Er wordt niets in rekening gebracht.',
'startingWechatPayment': 'WeChat Pay wordt gestart...',
'startingAlipayPayment': 'Alipay wordt gestart...',
'wechatPaymentSuccess': 'WeChat Pay-betaling geslaagd!',
'wechatPaymentFailed': 'WeChat Pay-betaling mislukt',
'wechatPaymentLaunchFailed': 'Starten van WeChat Pay mislukt',
'cannotLaunchWechatPayment': 'Kan WeChat Pay niet starten',
'wechatPaymentException': 'WeChat Pay-fout',
'wechatPaymentTestCompleted': 'WeChat Pay-test voltooid',
'alipayPaymentSuccess': 'Alipay-betaling geslaagd!',
'alipayPaymentFailed': 'Alipay-betaling mislukt',
'alipayPaymentLaunchFailed': 'Starten van Alipay mislukt',
'cannotLaunchAlipayPayment': 'Kan Alipay niet starten',
'alipayPaymentException': 'Alipay-fout',
'insufficientIntegral': 'Onvoldoende tegoed',
'insufficientBalanceDesc': 'Onvoldoende saldo. Ga naar de bijbehorende opwaardeerpagina om op te waarderen.',
'recharge': 'Naar opwaarderen',
'aiMemberExpiredDesc': 'Uw AI-lidmaatschap is verlopen. Ga naar de lidmaatschapspagina om te verlengen.',
'musicSearchPlaybackControl': 'Muziekbibliotheek doorzoeken en afspelen bedienen',
'smartPlaylistAndSimilarRecommendations': 'Slimme afspeellijsten en aanbevelingen van vergelijkbare nummers',
'routePlanningAndCongestionAvoidance': 'Routeplanning en files vermijden',
'voiceBroadcastAndWaypoints': 'Gesproken aanwijzingen en tussenpunten instellen',
'realTimeWeatherForecast': 'Actueel weer en weersverwachting',
'clothingAdviceAndTravelReminders': 'Kledingadvies en reisherinneringen',
'poiSearchFoodGasParking': 'POI-zoeken: eten / tanken / parkeren',
'ratingsHoursAndDirectRoutes': 'Beoordelingen, openingstijden en directe routes',
'hotNewsAndTopicOverview': 'Actueel nieuws en onderwerpoverzicht',
'longArticleSummariesAndKeyPoints': 'Samenvattingen en kernpunten van lange artikelen',
'helpCallAndSMS': 'Hulp bij bellen en sms\'en',
'templatedMessagesAndScheduleReminders': 'Sjabloonberichten en agendaherinneringen',
'aiDisclaimer': 'Functies zijn afhankelijk van apparaatmachtigingen, beschikbaarheid van de service en netwerkomstandigheden.',
'realtimeTranscription': 'Realtime transcriptie',
'sceneLexicon': 'Scenariowoordenlijst',
'subtitleDisplay': 'Ondertitels',
'transHeaderDesc1': 'Realtime simultaanvertaling en tekstvertaling met spraak met lage latentie.',
'transHeaderDesc2': 'Geschikt voor reizen, zakelijk gebruik, toespraken en onderwijs.',
'translateZhEn': 'Chinees-Engelse vertaling',
'lowLatencyVoiceBroadcast': 'Spraakweergave met lage latentie',
'multiLangTextTranslation': 'Meertalige tekstvertaling',
'termOptimizationContextPreserve': 'Terminologie-optimalisatie en contextbehoud',
'lexiconTourBusinessLife': 'Woordenlijsten voor reizen/zakelijk/dagelijks leven',
'personalVocabularyMemory': 'Persoonlijk woordenschatgeheugen',
'conversationSubtitleLargeText': 'Gespreksondertitels / grote-tekstmodus',
'colorHighContrast': 'Hoog kleurcontrast',
'transDisclaimer': 'De vertaalkwaliteit is afhankelijk van de microfoon, netwerklatentie en omgevingsgeluid.',
'meetingRecording': 'Vergaderopname',
'meetingHeaderDesc1': 'Audio-opname, transcriptie, zoeken op trefwoord en automatische samenvatting voor efficiëntere vergaderingen.',
'meetingAutoRecording': 'Automatisch opnemen en segmenteren',
'meetingDisclaimer': 'Gebruik in een rustige omgeving; de prestaties zijn afhankelijk van microfoon en netwerk.',
'crossDeviceSync': 'Synchronisatie tussen apparaten',
'trans_001_name': 'Vertaling Basis 100',
'trans_002_name': 'Vertaling Basis 500',
'trans_003_name': 'Vertaling Basis 1000',
'trans_001_desc': 'Bevat 100 vertalingen (spraak/tekst).',
'trans_002_desc': 'Bevat 500 vertalingen (spraak/tekst).',
'trans_003_desc': 'Bevat 1000 vertalingen (spraak/tekst).',
'translationCallExitTitle': 'Gespreksvertaling verlaten',
'translationCallExitMessage': 'Kies wat u wilt doen voordat u vertrekt',
'translationCallEnd': 'Gespreksvertaling beëindigen',
'translationContinueInBackground': 'Vertaling op de achtergrond voortzetten',
'backgroundRecordingIndicatorBody': 'De opname gaat door op de achtergrond. Tik om terug te keren.',
'backgroundTranslationIndicatorBody': 'De gespreksvertaling gaat door op de achtergrond. Tik om terug te keren.',
'meetingRecordScreenCapturePermissionDenied': 'Schermopnamemachtiging is vereist om systeemaudio op te nemen. Verleen deze en probeer het opnieuw.',
'pleaseGrantBluetoothPermission': 'Verleen Bluetooth-machtiging om te scannen',
'startScanFailed': 'Starten van scan mislukt',
'pleaseLoginFirst': 'Log eerst in',
'deviceUnauthorized': 'Apparaat niet geautoriseerd, probeer het opnieuw',
'boundDevices': 'Gekoppelde apparaten',
'currentConnection': 'Huidige verbinding',
'binding': 'Koppelen...',
'permissionDisabled': 'Uitgeschakeld',
'confirmDisablePermissionTitle': 'Machtiging uitschakelen',
'confirmDisablePermissionContent': 'Machtiging {permission} uitschakelen? U moet naar de systeeminstellingen gaan om deze opnieuw in te schakelen.',
'confirmPermanentlyDisallowTitle': 'Niet meer vragen?',
'confirmPermanentlyDisallowContent': 'U hebt de machtiging {permission} geweigerd. Voortaan niet meer vragen? Als u "Niet toestaan" kiest, kunt u deze later nog steeds inschakelen in de systeeminstellingen.',
'notNow': 'Nu niet',
'permanentlyDisallow': 'Niet toestaan',
'otaBesNotSupported': 'Deze headset ondersteunt nog geen firmware-upgrade via de app. Neem contact op met de klantenservice.',
'乌拉圭': 'Uruguay',
'黎巴嫩': 'Libanon',
'tabDevice': 'Apparaten',
'tabPlaza': 'Plaza',
'sloganHome': 'Technologie voor een slimmer leven',
'sloganAi': 'Grenzeloze intelligentie',
'sloganDevice': 'Slimme hardware, klaar voor de toekomst',
'sloganPlaza': 'Een slimme wereld die u begrijpt',
'aiEditHint': 'Houd een kaart ingedrukt om modules te verslepen en te herschikken',
'aiEmptyTitle': 'Nog geen modules toegevoegd',
'aiEmptyDesc': 'Kies de agents die u nodig hebt in Plaza',
'goToPlaza': 'Naar Plaza',
'plazaOnline': 'Beschikbaar',
'plazaPlanned': 'Binnenkort beschikbaar',
'plazaSearchHint': 'Zoek agents, bijv. “vertalen”, “opnemen”',
'moduleRemove': 'Verwijderen',
'moduleAdd': 'Toevoegen',
'moduleRemoved': 'Verwijderd',
'moduleAdded': 'Toegevoegd',
'goSee': 'Bekijken',
'comingSoonTag': 'Binnenkort',
'needDeviceTag': 'Apparaat vereist',
'featureInDev': 'Deze functie is in ontwikkeling',
'featureNotWired': 'Nog niet beschikbaar',
'todayLabel': 'Vandaag',
'deviceConnected': 'Verbonden',
'licenseUnknown': 'Licentie onbekend',
'selectDevice': 'Selecteer een apparaat',
'catNoteCard': 'AI-opnamekaart',
'catNoteCardDesc': 'Recorder in kaartvorm',
'catParkerPen': 'AI-pen',
'catParkerPenDesc': 'Pennen met spraakrecorder',
'catStylusPen': 'AI-stylus',
'catStylusPenDesc': 'Handschrift-/stylusapparaten',
'catStudyMachine': 'AI-leertablet',
'catStudyMachineDesc': 'Leerapparaten',
'besAddTitle': 'AI-oortjes toevoegen',
'besConnecting': 'Verbinden met oortjes…',
'besNotFound': 'Oortjes niet gevonden',
'besNotFoundIosDesc': 'Verbind de oortjes eerst via Instellingen → Bluetooth en kom daarna terug',
'besNotFoundAndroidDesc': 'Koppel de oortjes eerst via Instellingen → Bluetooth en verbind ze daarna hier',
'besReadFailed': 'Lezen mislukt',
'besConnectFailIos': 'Controleer of de oortjes als Verbonden worden weergegeven in de Bluetooth-instellingen en probeer het opnieuw',
'besConnectFailAndroid': 'Zorg dat de oortjes aan staan, in de buurt zijn en niet met een ander apparaat verbonden zijn. Zodra ze gekoppeld zijn, verbinden ze automatisch opnieuw.',
'holderAddTitle': 'AI-telefoonhouder toevoegen',
'holderConnecting': 'Verbinden met apparaat…',
'holderSearching': 'Apparaten zoeken…',
'holderNotFound': 'Geen apparaten gevonden',
'holderPairHint': 'Zorg dat de houder aan staat en in koppelmodus is',
'holderRetryHint': 'Zorg dat het apparaat aan staat en in de buurt is, en probeer het opnieuw',
'autoConnectTitle': 'Apparaten automatisch verbinden',
'autoConnectDesc': 'De app wekken om opnieuw te verbinden en opnames te ontvangen wanneer uw apparaat wordt ingeschakeld of in de buurt komt',
'autoConnectConsentTitle': 'Automatisch verbinden inschakelen',
'autoConnectConsentIntro': 'Om uw apparaat (AI-oortjes / AI-telefoonhouder) automatisch te laten verbinden en opnames op te slaan wanneer u op de opnameknop drukt, hebben we uw toestemming nodig:',
'autoConnectConsentB1': 'Een Bluetooth-scan op de achtergrond registreren zodat de app wordt gewekt (automatisch gestart) wanneer uw gekoppelde apparaat wordt gedetecteerd, om opnieuw te verbinden en opnames te ontvangen',
'autoConnectConsentB2': 'Na het wekken wordt dit alleen gebruikt voor apparaatverbinding, opname en uploaden — niets anders, en er worden geen andere apps gestart',
'autoConnectConsentB3': 'U kunt de app ook zonder gebruiken — verbind het apparaat dan telkens handmatig',
'autoConnectConsentB4': 'U kunt dit altijd uitschakelen via Instellingen → Apparaten automatisch verbinden',
'autoConnectAgree': 'Akkoord en inschakelen',
'autoConnectLater': 'Nu niet',
'autoConnectRequired': 'Automatisch verbinden staat uit',
'autoConnectRequiredDesc': 'Schakel automatisch verbinden in om dit apparaat te koppelen',
'recordTapToStart': 'Tik op de onderstaande knop om de opname te starten',
'recordPaused': 'Gepauzeerd',
'recordMark': 'Markeren',
'recordDeleteTitle': 'Opname verwijderen',
'accountAndProfile': 'Account en profiel',
'nicknameUnset': 'Geen bijnaam ingesteld',
'nicknameInput': 'Voer een bijnaam in',
'loginMethod': 'Inlogmethode',
'manageMyLogin': 'Inloggen beheren',
'logoutAction': 'Uitloggen',
'warningLabel': 'Waarschuwing:',
'deleteAccountWarn': 'Uw account en alle bestanden worden permanent verwijderd.',
'icpFiling': 'ICP-registratie',
'logExport': 'Logboeken exporteren',
'logExportDesc': 'App-logboeken bekijken en exporteren',
'permLoadFailed': 'Laden van machtigingsgroepen mislukt',
'permInsufficient': 'Machtiging geweigerd',
'voiceCloneCreating': 'Uw stem wordt aangemaakt…',
'voiceCloneCreate': 'Mijn stem aanmaken',
'voiceCloneAgree': 'Akkoord en lees de verklaring hardop voor',
'voiceCloneReadPrompt': 'Lees voor:',
'voiceCloneFirstMonthFree': 'Eerste maand gratis',
'voiceCloneEnable': 'Stemklonen inschakelen',
'noMusicPlaying': 'Er wordt niets afgespeeld',
'qqMusicNoSeek': 'Doorspoelen wordt niet ondersteund voor QQ Music',
'bleScanTitle': 'BLE-scan',
'bluetoothOn': 'Bluetooth aan',
'bleScanning': 'Scannen…',
'bleConnectedDevices': 'Verbonden apparaten',
'bleUnknownDevice': 'Onbekend apparaat',
'bleTapToScan': 'Tik op scannen om BLE-apparaten te zoeken',
'weekMon': 'Ma',
'weekTue': 'Di',
'weekWed': 'Wo',
'weekThu': 'Do',
'weekFri': 'Vr',
'weekSat': 'Za',
'weekSun': 'Zo',
'agentMeeting': 'Spraaknotulen',
'agentMeetingDesc': 'Opnames samenvatten, geheugencentrum',
'agentEmai': 'EMAI-assistent',
'agentEmaiDesc': 'Chat en vraag alles met uw stem',
'agentSimultaneous': 'Live vertaling',
'agentSimultaneousDesc': 'Vertalen terwijl u spreekt',
'agentFaceToFace': 'Face-to-face',
'agentFaceToFaceDesc': 'Tweerichtingsgespreksvertaling',
'agentCallRecording': 'Gespreksopname',
'agentCallRecordingDesc': 'Gesprekken worden automatisch opgeslagen',
'agentFieldRecording': 'Opname ter plaatse',
'agentFieldRecordingDesc': 'Microfoon van apparaat of telefoon',
'agentCall': 'Gespreksvertaling',
'agentCallDesc': 'Realtime vertaling tijdens gesprekken',
'agentAudioVideo': 'Mediavertaling',
'agentAudioVideoDesc': 'Camerabeeld / audio- en videovertaling',
'agentEnglishChat': 'Engelse conversatie',
'agentEnglishChatDesc': 'Meeslepende spreekoefening',
'agentEnglishStudy': 'Engels leren',
'agentEnglishStudyDesc': 'Woordenschat- en grammaticalessen',
'agentKidsStory': 'Kinderverhalen',
'agentKidsStoryDesc': 'Verhaaltjes voor het slapengaan en vragen',
'moduleRemovedDesc': 'verwijderd van de AI-pagina',
'moduleAddedDesc': 'onderaan de AI-pagina toegevoegd',
'calNoRecords': 'Geen items',
'calNoRecordsDay': 'Niets gepland',
'calTodos': 'Taken',
'calExpenses': 'Uitgaven',
'calTodoCount': '@count taken',
'calExpenseCount': '@count uitgaven',
'calEntryCount': '@count items',
'calItemsUnit': 'items',
'calCountUnit': 'items',
'calTotalAmount': 'Totaal',
'recordPause': 'Pauzeren',
'recordResume': 'Hervatten',
'recordDeleteConfirm': 'Deze opname verwijderen?',
'scopeDay': 'Dag',
'scopeWeek': 'Week',
'scopeMonth': 'Maand',
'allDay': 'Hele dag',
'guestModeTitle': 'Gastmodus',
'guestModeDesc': 'Gasten kunnen alleen bekijken. Log in om spraaknotities, vertaling en opname te gebruiken — uw gegevens worden dan in uw account opgeslagen.',
'guestKeepBrowsing': 'Verder kijken',
'guestGoLogin': 'Inloggen',
'guestViewOnlyTag': 'Alleen bekijken',
'autoConnectReenableHint': 'Automatisch verbinden staat uit. Schakel het weer in via Instellingen → Apparaten automatisch verbinden.',
'plazaEntryDesc': 'Blader door en voeg meer agents toe',
'agentTextTranslation': 'Tekstvertaling',
'agentTextTranslationDesc': 'Getypte of geplakte tekst vertalen',
'agentPictureTranslation': 'Fotovertaling',
'agentPictureTranslationDesc': 'Maak of kies een foto om te vertalen',
'callRequiredTitle': 'Gesprek vereist',
'callRequiredDesc': 'Deze functie werkt alleen tijdens een gesprek',
'inCallBusyTitle': 'Niet beschikbaar tijdens een gesprek',
'inCallBusyDesc': 'De microfoon en het audiopad worden gebruikt door het gesprek. Probeer het opnieuw na het ophangen.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/pl_pl.dart

@ -1930,4 +1930,208 @@ const Map<String, String> plPL = {
'unmarkFavorite': 'Usuń z ulubionych',
'unnamedDevice': 'Urządzenie bez nazwy',
'userDataParseFailed': 'Nie udało się odczytać danych użytkownika',
'catEarphone': 'Słuchawki AI',
'catEarphoneDesc': 'Słuchawki Bluetooth Classic / BLE',
'catPhoneHolder': 'Uchwyt na telefon AI',
'catPhoneHolderDesc': 'Urządzenia typu uchwyt',
'catCarAroma': 'Zapach samochodowy AI',
'catCarAromaDesc': 'Dyfuzor zapachu do auta',
'agreeAndContinue': 'Zgadzam się i kontynuuję',
'memberServiceAgreement': 'Umowa o usługi członkowskie',
'memberServiceAgreementTitle': 'Umowa o usługi członkowskie',
'memberServiceAgreementIntro': 'Aby zapewnić Ci lepsze usługi członkowskie, przed subskrypcją uważnie przeczytaj: ',
'memberServiceAgreementOutro': '. Klikając „Zgadzam się i kontynuuję”, potwierdzasz, że w pełni zapoznałeś(-aś) się z powyższą umową i ją akceptujesz.',
'memberAgreementRejectedTip': 'Aby kontynuować, musisz zaakceptować Umowę o usługi członkowskie.',
'translationCallExitTitle': 'Wyjdź z tłumaczenia rozmowy',
'translationCallExitMessage': 'Wybierz, co zrobić przed wyjściem',
'translationCallEnd': 'Zakończ tłumaczenie rozmowy',
'translationContinueInBackground': 'Kontynuuj tłumaczenie w tle',
'backgroundRecordingIndicatorBody': 'Nagrywanie trwa w tle. Dotknij, aby wrócić.',
'backgroundTranslationIndicatorBody': 'Tłumaczenie rozmowy trwa w tle. Dotknij, aby wrócić.',
'meetingRecordScreenCapturePermissionDenied': 'Do przechwytywania dźwięku systemowego wymagane jest uprawnienie do nagrywania ekranu. Przyznaj je i spróbuj ponownie.',
'pleaseGrantBluetoothPermission': 'Aby skanować, przyznaj uprawnienie Bluetooth',
'startScanFailed': 'Nie udało się rozpocząć skanowania',
'pleaseLoginFirst': 'Najpierw się zaloguj',
'deviceUnauthorized': 'Urządzenie nieautoryzowane, spróbuj ponownie',
'boundDevices': 'Powiązane urządzenia',
'currentConnection': 'Bieżące połączenie',
'binding': 'Wiązanie…',
'permissionDisabled': 'Wyłączone',
'confirmDisablePermissionTitle': 'Wyłącz uprawnienie',
'confirmDisablePermissionContent': 'Wyłączyć uprawnienie {permission}? Aby je ponownie włączyć, trzeba będzie przejść do ustawień systemu.',
'confirmPermanentlyDisallowTitle': 'Przestać pytać?',
'confirmPermanentlyDisallowContent': 'Odmówiono uprawnienia {permission}. Przestać pytać od teraz? Jeśli wybierzesz „Nie zezwalaj”, nadal możesz je później włączyć w ustawieniach systemu.',
'notNow': 'Nie teraz',
'permanentlyDisallow': 'Nie zezwalaj',
'otaBesNotSupported': 'Te słuchawki nie obsługują jeszcze aktualizacji oprogramowania z poziomu aplikacji. Skontaktuj się z pomocą techniczną.',
'tabDevice': 'Urządzenia',
'tabPlaza': 'Plaza',
'sloganHome': 'Technologia dla mądrzejszego życia',
'sloganAi': 'Inteligencja bez granic',
'sloganDevice': 'Inteligentny sprzęt gotowy na przyszłość',
'sloganPlaza': 'Inteligentny świat, który Cię rozumie',
'aiEditHint': 'Przytrzymaj kartę, aby przeciągnąć i zmienić układ modułów',
'aiEmptyTitle': 'Nie dodano jeszcze modułów',
'aiEmptyDesc': 'Wybierz potrzebnych agentów w Plaza',
'goToPlaza': 'Przejdź do Plaza',
'plazaOnline': 'Dostępne',
'plazaPlanned': 'Wkrótce',
'plazaSearchHint': 'Szukaj agentów, np. „tłumacz”, „nagraj”',
'moduleRemove': 'Usuń',
'moduleAdd': 'Dodaj',
'moduleRemoved': 'Usunięto',
'moduleAdded': 'Dodano',
'goSee': 'Zobacz',
'comingSoonTag': 'Wkrótce',
'needDeviceTag': 'Wymaga urządzenia',
'featureInDev': 'Ta funkcja jest w przygotowaniu',
'featureNotWired': 'Jeszcze niedostępne',
'todayLabel': 'Dziś',
'deviceConnected': 'Połączono',
'licenseUnknown': 'Licencja nieznana',
'selectDevice': 'Wybierz urządzenie',
'catNoteCard': 'Karta dyktafonu AI',
'catNoteCardDesc': 'Dyktafon w kształcie karty',
'catParkerPen': 'Długopis AI',
'catParkerPenDesc': 'Długopisy z dyktafonem',
'catStylusPen': 'Rysik AI',
'catStylusPenDesc': 'Urządzenia do pisma odręcznego / rysiki',
'catStudyMachine': 'Tablet edukacyjny AI',
'catStudyMachineDesc': 'Urządzenia edukacyjne',
'besAddTitle': 'Dodaj słuchawki AI',
'besConnecting': 'Łączenie ze słuchawkami…',
'besNotFound': 'Nie znaleziono słuchawek',
'besNotFoundIosDesc': 'Najpierw połącz słuchawki w Ustawienia → Bluetooth, a potem wróć tutaj',
'besNotFoundAndroidDesc': 'Najpierw sparuj słuchawki w Ustawienia → Bluetooth, a potem połącz je tutaj',
'besReadFailed': 'Odczyt nie powiódł się',
'besConnectFailIos': 'Upewnij się, że słuchawki są widoczne jako Połączone w systemowym Bluetooth, a następnie spróbuj ponownie',
'besConnectFailAndroid': 'Upewnij się, że słuchawki są włączone, w pobliżu i niepołączone z innym urządzeniem. Po powiązaniu będą łączyć się automatycznie.',
'holderAddTitle': 'Dodaj podstawkę AI na telefon',
'holderConnecting': 'Łączenie z urządzeniem…',
'holderSearching': 'Wyszukiwanie urządzeń…',
'holderNotFound': 'Nie znaleziono urządzeń',
'holderPairHint': 'Upewnij się, że podstawka jest włączona i w trybie parowania',
'holderRetryHint': 'Upewnij się, że urządzenie jest włączone i w pobliżu, a następnie spróbuj ponownie',
'autoConnectTitle': 'Automatyczne łączenie urządzeń',
'autoConnectDesc': 'Budzi aplikację, aby ponownie połączyć się i odebrać nagrania, gdy urządzenie zostanie włączone lub znajdzie się w pobliżu',
'autoConnectConsentTitle': 'Włącz automatyczne łączenie',
'autoConnectConsentIntro': 'Aby Twoje urządzenie (słuchawki AI / podstawka AI na telefon) mogło łączyć się automatycznie i zapisywać nagrania po naciśnięciu przycisku nagrywania, potrzebujemy Twojej zgody:',
'autoConnectConsentB1': 'Zarejestrowanie skanowania Bluetooth w tle, aby aplikacja była budzona (uruchamiana automatycznie) po wykryciu powiązanego urządzenia w celu ponownego połączenia i odbioru nagrań',
'autoConnectConsentB2': 'Po wybudzeniu jest używana wyłącznie do łączenia z urządzeniem, nagrywania i przesyłania — nic więcej, żadne inne aplikacje nie są uruchamiane',
'autoConnectConsentB3': 'Nadal możesz korzystać z aplikacji bez tego — wystarczy za każdym razem połączyć urządzenie ręcznie',
'autoConnectConsentB4': 'Możesz to wyłączyć w dowolnym momencie w Ustawienia → Automatyczne łączenie urządzeń',
'autoConnectAgree': 'Zgadzam się i włączam',
'autoConnectLater': 'Nie teraz',
'autoConnectRequired': 'Automatyczne łączenie jest wyłączone',
'autoConnectRequiredDesc': 'Włącz automatyczne łączenie, aby powiązać to urządzenie',
'recordTapToStart': 'Dotknij przycisku poniżej, aby rozpocząć nagrywanie',
'recordPaused': 'Wstrzymano',
'recordMark': 'Znacznik',
'recordDeleteTitle': 'Usuń nagranie',
'accountAndProfile': 'Konto i profil',
'nicknameUnset': 'Nie ustawiono pseudonimu',
'nicknameInput': 'Wpisz pseudonim',
'loginMethod': 'Metoda logowania',
'manageMyLogin': 'Zarządzaj logowaniem',
'logoutAction': 'Wyloguj się',
'warningLabel': 'Ostrzeżenie:',
'deleteAccountWarn': 'Twoje konto i wszystkie pliki zostaną trwale usunięte.',
'icpFiling': 'Rejestracja ICP',
'logExport': 'Eksport dzienników',
'logExportDesc': 'Przeglądaj i eksportuj dzienniki aplikacji',
'permLoadFailed': 'Nie udało się wczytać grup uprawnień',
'permInsufficient': 'Brak uprawnień',
'voiceCloneCreating': 'Tworzenie Twojego głosu…',
'voiceCloneCreate': 'Utwórz mój głos',
'voiceCloneAgree': 'Zgadzam się i odczytam oświadczenie na głos',
'voiceCloneReadPrompt': 'Przeczytaj:',
'voiceCloneFirstMonthFree': 'Pierwszy miesiąc za darmo',
'voiceCloneEnable': 'Włącz klonowanie głosu',
'noMusicPlaying': 'Nic nie jest odtwarzane',
'qqMusicNoSeek': 'QQ Music nie obsługuje przewijania',
'bleScanTitle': 'Skanowanie BLE',
'bluetoothOn': 'Bluetooth włączony',
'bleScanning': 'Skanowanie…',
'bleConnectedDevices': 'Połączone urządzenia',
'bleUnknownDevice': 'Nieznane urządzenie',
'bleTapToScan': 'Dotknij Skanuj, aby wyszukać urządzenia BLE',
'weekMon': 'Pn',
'weekTue': 'Wt',
'weekWed': 'Śr',
'weekThu': 'Cz',
'weekFri': 'Pt',
'weekSat': 'So',
'weekSun': 'Nd',
'agentMeeting': 'Notatki głosowe',
'agentMeetingDesc': 'Podsumowania nagrań, centrum pamięci',
'agentEmai': 'Asystent EMAI',
'agentEmaiDesc': 'Rozmawiaj i pytaj o wszystko głosem',
'agentSimultaneous': 'Tłumaczenie na żywo',
'agentSimultaneousDesc': 'Tłumaczenie w trakcie mówienia',
'agentFaceToFace': 'Twarzą w twarz',
'agentFaceToFaceDesc': 'Dwukierunkowe tłumaczenie rozmowy',
'agentCallRecording': 'Nagrywanie rozmów',
'agentCallRecordingDesc': 'Rozmowy zapisywane automatycznie',
'agentFieldRecording': 'Nagrywanie w terenie',
'agentFieldRecordingDesc': 'Mikrofon urządzenia lub telefonu',
'agentCall': 'Tłumaczenie rozmów',
'agentCallDesc': 'Tłumaczenie w czasie rzeczywistym podczas rozmów',
'agentAudioVideo': 'Tłumaczenie mediów',
'agentAudioVideoDesc': 'Kadr aparatu / tłumaczenie audio i wideo',
'agentEnglishChat': 'Rozmowa po angielsku',
'agentEnglishChatDesc': 'Immersyjna nauka mówienia',
'agentEnglishStudy': 'Nauka angielskiego',
'agentEnglishStudyDesc': 'Lekcje słownictwa i gramatyki',
'agentKidsStory': 'Bajki dla dzieci',
'agentKidsStoryDesc': 'Bajki na dobranoc i pytania z odpowiedziami',
'moduleRemovedDesc': 'usunięto ze strony AI',
'moduleAddedDesc': 'dodano na dole strony AI',
'calNoRecords': 'Brak wpisów',
'calNoRecordsDay': 'Brak zaplanowanych zdarzeń',
'calTodos': 'Zadania',
'calExpenses': 'Wydatki',
'calTodoCount': 'Zadania: @count',
'calExpenseCount': 'Wydatki: @count',
'calEntryCount': 'Wpisy: @count',
'calItemsUnit': 'pozycji',
'calCountUnit': 'wpisów',
'calTotalAmount': 'Razem',
'recordPause': 'Wstrzymaj',
'recordResume': 'Wznów',
'recordDeleteConfirm': 'Usunąć to nagranie?',
'scopeDay': 'Dzień',
'scopeWeek': 'Tydzień',
'scopeMonth': 'Miesiąc',
'allDay': 'Cały dzień',
'guestModeTitle': 'Tryb gościa',
'guestModeDesc': 'Goście mogą tylko przeglądać. Zaloguj się, aby korzystać z notatek głosowych, tłumaczenia i nagrywania — Twoje dane zostaną zapisane na Twoim koncie.',
'guestKeepBrowsing': 'Przeglądaj dalej',
'guestGoLogin': 'Zaloguj się',
'guestViewOnlyTag': 'Tylko podgląd',
'autoConnectReenableHint': 'Automatyczne łączenie jest wyłączone. Włącz je ponownie w Ustawienia → Automatyczne łączenie urządzeń.',
'plazaEntryDesc': 'Przeglądaj i dodawaj kolejnych agentów',
'agentTextTranslation': 'Tłumaczenie tekstu',
'agentTextTranslationDesc': 'Tłumacz wpisany lub wklejony tekst',
'agentPictureTranslation': 'Tłumaczenie zdjęć',
'agentPictureTranslationDesc': 'Zrób lub wybierz zdjęcie do tłumaczenia',
'callRequiredTitle': 'Wymagana rozmowa',
'callRequiredDesc': 'Ta funkcja działa tylko podczas rozmowy',
'inCallBusyTitle': 'Niedostępne podczas rozmowy',
'inCallBusyDesc': 'Mikrofon i tor audio są używane przez rozmowę. Spróbuj ponownie po rozłączeniu.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Bezpłatne',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

30
apps/client/lib/core/translations/language/pt_pt.dart

@ -1885,6 +1885,8 @@ const Map<String, String> ptPT = {
'catNoteCardDesc': 'Gravador em formato de cartão',
'catPhoneHolder': 'Suporte de telemóvel IA',
'catPhoneHolderDesc': 'Dispositivos de suporte',
'catCarAroma': 'Difusor de carro IA',
'catCarAromaDesc': 'Difusor de aroma para carro',
'catParkerPen': 'Caneta IA',
'catParkerPenDesc': 'Canetas gravadoras',
'catStylusPen': 'Stylus IA',
@ -2142,4 +2144,32 @@ const Map<String, String> ptPT = {
'unmarkFavorite': 'Remover dos favoritos',
'unnamedDevice': 'Dispositivo sem nome',
'userDataParseFailed': 'Falha ao processar os dados do utilizador',
'translationCallExitTitle': 'Sair da Tradução de Chamadas',
'translationCallExitMessage': 'Escolha o que fazer antes de sair',
'translationCallEnd': 'Terminar tradução de chamada',
'translationContinueInBackground': 'Continuar a traduzir em segundo plano',
'backgroundRecordingIndicatorBody': 'A gravação continua em segundo plano. Toque para voltar.',
'backgroundTranslationIndicatorBody': 'A tradução de chamada continua em segundo plano. Toque para voltar.',
'permissionDisabled': 'Desativado',
'confirmDisablePermissionTitle': 'Desativar permissão',
'confirmDisablePermissionContent': 'Desativar a permissão de {permission}? Terá de ir às definições do sistema para a reativar.',
'confirmPermanentlyDisallowTitle': 'Parar de perguntar?',
'confirmPermanentlyDisallowContent': 'Recusou a permissão de {permission}. Deixar de perguntar a partir de agora? Se escolher «Não permitir», poderá ativá-la mais tarde nas definições do sistema.',
'notNow': 'Agora não',
'permanentlyDisallow': 'Não permitir',
'otaBesNotSupported': 'Estes auscultadores ainda não suportam a atualização de firmware na app. Contacte o suporte.',
'smartcarConnected': 'Ligado',
'smartcarConnectFailed': 'Falha na ligação',
'smartcarConnecting': 'A ligar…',
'smartcarSearching': 'A procurar difusores de carro próximos…',
'smartcarNotFound': 'Nenhum dispositivo encontrado',
'smartcarScanHint': 'Certifique-se de que o dispositivo está ligado e perto do telemóvel',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/ro_ro.dart

@ -1932,4 +1932,208 @@ const Map<String, String> roRO = {
'unmarkFavorite': 'Elimină de la favorite',
'unnamedDevice': 'Dispozitiv fără nume',
'userDataParseFailed': 'Datele utilizatorului nu au putut fi citite',
'catEarphone': 'Căști AI',
'catEarphoneDesc': 'Căști Bluetooth clasic / BLE',
'catPhoneHolder': 'Suport telefon AI',
'catPhoneHolderDesc': 'Dispozitive de tip suport',
'catCarAroma': 'Difuzor auto AI',
'catCarAromaDesc': 'Difuzor de arome auto',
'agreeAndContinue': 'Sunt de acord și continuă',
'memberServiceAgreement': 'Acordul privind serviciile de membru',
'memberServiceAgreementTitle': 'Acordul privind serviciile de membru',
'memberServiceAgreementIntro': 'Pentru a vă oferi servicii de membru mai bune, vă rugăm să citiți cu atenție înainte de a vă abona: ',
'memberServiceAgreementOutro': '. Făcând clic pe „Sunt de acord și continuă”, confirmați că ați citit integral și acceptați acordul de mai sus.',
'memberAgreementRejectedTip': 'Trebuie să acceptați Acordul privind serviciile de membru pentru a continua.',
'translationCallExitTitle': 'Ieșire din traducerea apelului',
'translationCallExitMessage': 'Alege ce să faci înainte de a ieși',
'translationCallEnd': 'Încheie traducerea apelului',
'translationContinueInBackground': 'Continuă traducerea în fundal',
'backgroundRecordingIndicatorBody': 'Înregistrarea continuă în fundal. Atinge pentru a reveni.',
'backgroundTranslationIndicatorBody': 'Traducerea apelului continuă în fundal. Atinge pentru a reveni.',
'meetingRecordScreenCapturePermissionDenied': 'Este necesară permisiunea de înregistrare a ecranului pentru a capta sunetul sistemului. Acordați-o și reîncercați.',
'pleaseGrantBluetoothPermission': 'Acordați permisiunea Bluetooth pentru a scana',
'startScanFailed': 'Pornirea scanării a eșuat',
'pleaseLoginFirst': 'Autentificați-vă mai întâi',
'deviceUnauthorized': 'Dispozitiv neautorizat, reîncercați',
'boundDevices': 'Dispozitive asociate',
'currentConnection': 'Conexiune curentă',
'binding': 'Se asociază...',
'permissionDisabled': 'Dezactivat',
'confirmDisablePermissionTitle': 'Dezactivează permisiunea',
'confirmDisablePermissionContent': 'Dezactivezi permisiunea {permission}? Va trebui să mergi în setările sistemului pentru a o reactiva.',
'confirmPermanentlyDisallowTitle': 'Nu mai întreba?',
'confirmPermanentlyDisallowContent': 'Ai refuzat permisiunea {permission}. Nu mai întrebăm de acum înainte? Dacă alegi „Nu permite”, o poți activa ulterior din setările sistemului.',
'notNow': 'Nu acum',
'permanentlyDisallow': 'Nu permite',
'otaBesNotSupported': 'Aceste căști nu acceptă încă actualizarea firmware-ului din aplicație. Contactați asistența.',
'tabDevice': 'Dispozitive',
'tabPlaza': 'Piața',
'sloganHome': 'Tehnologie pentru o viață mai inteligentă',
'sloganAi': 'Inteligență fără limite',
'sloganDevice': 'Hardware inteligent, pregătit pentru viitor',
'sloganPlaza': 'O lume inteligentă care te înțelege',
'aiEditHint': 'Apasă lung pe un card pentru a trage și rearanja modulele',
'aiEmptyTitle': 'Niciun modul adăugat încă',
'aiEmptyDesc': 'Alege agenții de care ai nevoie din Piață',
'goToPlaza': 'Mergi la Piață',
'plazaOnline': 'Disponibil',
'plazaPlanned': 'În curând',
'plazaSearchHint': 'Caută agenți, de ex. „traducere”, „înregistrare”',
'moduleRemove': 'Elimină',
'moduleAdd': 'Adaugă',
'moduleRemoved': 'Eliminat',
'moduleAdded': 'Adăugat',
'goSee': 'Vezi',
'comingSoonTag': 'În curând',
'needDeviceTag': 'Necesită dispozitiv',
'featureInDev': 'Această funcție este în dezvoltare',
'featureNotWired': 'Momentan indisponibil',
'todayLabel': 'Astăzi',
'deviceConnected': 'Conectat',
'licenseUnknown': 'Licență necunoscută',
'selectDevice': 'Selectează un dispozitiv',
'catNoteCard': 'Card de înregistrare AI',
'catNoteCardDesc': 'Reportofon în formă de card',
'catParkerPen': 'Stilou AI',
'catParkerPenDesc': 'Stilouri cu reportofon',
'catStylusPen': 'Stylus AI',
'catStylusPenDesc': 'Dispozitive de scris de mână / stylus',
'catStudyMachine': 'Tabletă de învățare AI',
'catStudyMachineDesc': 'Dispozitive de învățare',
'besAddTitle': 'Adaugă căști AI',
'besConnecting': 'Se conectează la căști…',
'besNotFound': 'Căștile nu au fost găsite',
'besNotFoundIosDesc': 'Conectează mai întâi căștile din Setări → Bluetooth, apoi revino aici',
'besNotFoundAndroidDesc': 'Asociază mai întâi căștile din Setări → Bluetooth, apoi conectează-le aici',
'besReadFailed': 'Citirea a eșuat',
'besConnectFailIos': 'Asigură-te că în Bluetooth-ul sistemului căștile apar ca Conectate, apoi reîncearcă',
'besConnectFailAndroid': 'Asigură-te că căștile sunt pornite, în apropiere și neconectate la alt dispozitiv. Odată asociate, se vor reconecta automat.',
'holderAddTitle': 'Adaugă suport de telefon AI',
'holderConnecting': 'Se conectează la dispozitiv…',
'holderSearching': 'Se caută dispozitive…',
'holderNotFound': 'Niciun dispozitiv găsit',
'holderPairHint': 'Asigură-te că suportul este pornit și în modul de asociere',
'holderRetryHint': 'Asigură-te că dispozitivul este pornit și în apropiere, apoi reîncearcă',
'autoConnectTitle': 'Conectare automată a dispozitivelor',
'autoConnectDesc': 'Trezește aplicația pentru a se reconecta și a primi înregistrări când dispozitivul pornește sau se apropie',
'autoConnectConsentTitle': 'Activează conectarea automată',
'autoConnectConsentIntro': 'Pentru ca dispozitivul tău (căști AI / suport de telefon AI) să se conecteze automat și să salveze înregistrările când apeși butonul său de înregistrare, avem nevoie de consimțământul tău:',
'autoConnectConsentB1': 'Înregistrarea unei scanări Bluetooth în fundal, astfel încât aplicația să fie trezită (pornită automat) când dispozitivul asociat este detectat, pentru a se reconecta și a primi înregistrări',
'autoConnectConsentB2': 'Odată trezită, este folosită doar pentru conectarea dispozitivului, înregistrare și încărcare — nimic altceva, și nu sunt lansate alte aplicații',
'autoConnectConsentB3': 'Poți folosi aplicația și fără aceasta — doar conectează dispozitivul manual de fiecare dată',
'autoConnectConsentB4': 'O poți dezactiva oricând din Setări → Conectare automată a dispozitivelor',
'autoConnectAgree': 'Sunt de acord și activează',
'autoConnectLater': 'Nu acum',
'autoConnectRequired': 'Conectarea automată este dezactivată',
'autoConnectRequiredDesc': 'Activează conectarea automată pentru a asocia acest dispozitiv',
'recordTapToStart': 'Atinge butonul de mai jos pentru a începe înregistrarea',
'recordPaused': 'În pauză',
'recordMark': 'Marchează',
'recordDeleteTitle': 'Șterge înregistrarea',
'accountAndProfile': 'Cont și profil',
'nicknameUnset': 'Niciun pseudonim setat',
'nicknameInput': 'Introdu un pseudonim',
'loginMethod': 'Metodă de autentificare',
'manageMyLogin': 'Gestionează autentificarea',
'logoutAction': 'Deconectare',
'warningLabel': 'Avertisment:',
'deleteAccountWarn': 'Contul tău și toate fișierele vor fi șterse definitiv.',
'icpFiling': 'Înregistrare ICP',
'logExport': 'Export jurnale',
'logExportDesc': 'Vezi și exportă jurnalele aplicației',
'permLoadFailed': 'Încărcarea grupurilor de permisiuni a eșuat',
'permInsufficient': 'Permisiune refuzată',
'voiceCloneCreating': 'Se creează vocea ta…',
'voiceCloneCreate': 'Creează vocea mea',
'voiceCloneAgree': 'Sunt de acord și voi citi declarația cu voce tare',
'voiceCloneReadPrompt': 'Te rugăm să citești:',
'voiceCloneFirstMonthFree': 'Prima lună gratuită',
'voiceCloneEnable': 'Activează clonarea vocii',
'noMusicPlaying': 'Nu se redă nimic',
'qqMusicNoSeek': 'Derularea nu este acceptată pentru QQ Music',
'bleScanTitle': 'Scanare BLE',
'bluetoothOn': 'Bluetooth pornit',
'bleScanning': 'Se scanează…',
'bleConnectedDevices': 'Dispozitive conectate',
'bleUnknownDevice': 'Dispozitiv necunoscut',
'bleTapToScan': 'Atinge Scanează pentru a căuta dispozitive BLE',
'weekMon': 'Lun',
'weekTue': 'Mar',
'weekWed': 'Mie',
'weekThu': 'Joi',
'weekFri': 'Vin',
'weekSat': 'Sâm',
'weekSun': 'Dum',
'agentMeeting': 'Note vocale de ședință',
'agentMeetingDesc': 'Rezumă înregistrările, centru de amintiri',
'agentEmai': 'Asistent EMAI',
'agentEmaiDesc': 'Conversează și întreabă orice prin voce',
'agentSimultaneous': 'Traducere simultană',
'agentSimultaneousDesc': 'Traduce pe măsură ce vorbești',
'agentFaceToFace': 'Față în față',
'agentFaceToFaceDesc': 'Traducere bidirecțională a conversației',
'agentCallRecording': 'Înregistrare apel',
'agentCallRecordingDesc': 'Apelurile sunt salvate automat',
'agentFieldRecording': 'Înregistrare pe teren',
'agentFieldRecordingDesc': 'Microfonul dispozitivului sau al telefonului',
'agentCall': 'Traducere apeluri',
'agentCallDesc': 'Traducere în timp real în timpul apelurilor',
'agentAudioVideo': 'Traducere media',
'agentAudioVideoDesc': 'Încadrare cu camera / traducere audio și video',
'agentEnglishChat': 'Conversație în engleză',
'agentEnglishChatDesc': 'Exersare imersivă a vorbirii',
'agentEnglishStudy': 'Studiul englezei',
'agentEnglishStudyDesc': 'Lecții de vocabular și gramatică',
'agentKidsStory': 'Povești pentru copii',
'agentKidsStoryDesc': 'Povești de seară și întrebări și răspunsuri',
'moduleRemovedDesc': 'eliminat din pagina AI',
'moduleAddedDesc': 'adăugat la finalul paginii AI',
'calNoRecords': 'Nicio intrare',
'calNoRecordsDay': 'Nimic programat',
'calTodos': 'De făcut',
'calExpenses': 'Cheltuieli',
'calTodoCount': '@count sarcini',
'calExpenseCount': '@count cheltuieli',
'calEntryCount': '@count intrări',
'calItemsUnit': 'elemente',
'calCountUnit': 'intrări',
'calTotalAmount': 'Total',
'recordPause': 'Pauză',
'recordResume': 'Reia',
'recordDeleteConfirm': 'Ștergi această înregistrare?',
'scopeDay': 'Zi',
'scopeWeek': 'Săptămână',
'scopeMonth': 'Lună',
'allDay': 'Toată ziua',
'guestModeTitle': 'Mod vizitator',
'guestModeDesc': 'Vizitatorii pot doar naviga. Autentifică-te pentru a folosi notele vocale, traducerea și înregistrarea — datele tale vor fi salvate în contul tău.',
'guestKeepBrowsing': 'Continuă navigarea',
'guestGoLogin': 'Autentificare',
'guestViewOnlyTag': 'Doar vizualizare',
'autoConnectReenableHint': 'Conectarea automată este dezactivată. Reactiveaz-o din Setări → Conectare automată a dispozitivelor.',
'plazaEntryDesc': 'Răsfoiește și adaugă mai mulți agenți',
'agentTextTranslation': 'Traducere text',
'agentTextTranslationDesc': 'Traduce text tastat sau lipit',
'agentPictureTranslation': 'Traducere foto',
'agentPictureTranslationDesc': 'Fotografiază sau alege o poză pentru traducere',
'callRequiredTitle': 'Necesită un apel',
'callRequiredDesc': 'Această funcție funcționează doar în timpul unui apel',
'inCallBusyTitle': 'Indisponibil în timpul apelului',
'inCallBusyDesc': 'Microfonul și calea audio sunt folosite de apel. Reîncearcă după ce închizi.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratuit',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

30
apps/client/lib/core/translations/language/ru_ru.dart

@ -1863,6 +1863,8 @@ const Map<String, String> ruRU = {
'catNoteCardDesc': 'Диктофон в форме карты',
'catPhoneHolder': 'ИИ-держатель телефона',
'catPhoneHolderDesc': 'Устройства-держатели',
'catCarAroma': 'ИИ-ароматизатор для авто',
'catCarAromaDesc': 'Автомобильный ароматизатор',
'catParkerPen': 'ИИ-ручка',
'catParkerPenDesc': 'Ручки-диктофоны',
'catStylusPen': 'ИИ-стилус',
@ -2117,4 +2119,32 @@ const Map<String, String> ruRU = {
'unmarkFavorite': 'Убрать из избранного',
'unnamedDevice': 'Устройство без имени',
'userDataParseFailed': 'Не удалось обработать данные пользователя',
'translationCallExitTitle': 'Выйти из перевода звонка',
'translationCallExitMessage': 'Выберите, что сделать перед выходом',
'translationCallEnd': 'Завершить перевод звонка',
'translationContinueInBackground': 'Продолжить перевод в фоне',
'backgroundRecordingIndicatorBody': 'Запись продолжается в фоновом режиме. Нажмите, чтобы вернуться.',
'backgroundTranslationIndicatorBody': 'Перевод звонка продолжается в фоновом режиме. Нажмите, чтобы вернуться.',
'permissionDisabled': 'Отключено',
'confirmDisablePermissionTitle': 'Отключить разрешение',
'confirmDisablePermissionContent': 'Отключить разрешение «{permission}»? Чтобы включить его снова, нужно будет перейти в настройки системы.',
'confirmPermanentlyDisallowTitle': 'Больше не спрашивать?',
'confirmPermanentlyDisallowContent': 'Вы отклонили разрешение «{permission}». Больше не спрашивать? Если выбрать «Не разрешать», его можно будет включить позже в настройках системы.',
'notNow': 'Не сейчас',
'permanentlyDisallow': 'Не разрешать',
'otaBesNotSupported': 'Эта гарнитура пока не поддерживает обновление прошивки из приложения. Обратитесь в службу поддержки.',
'smartcarConnected': 'Подключено',
'smartcarConnectFailed': 'Ошибка подключения',
'smartcarConnecting': 'Подключение…',
'smartcarSearching': 'Поиск автомобильных ароматизаторов поблизости…',
'smartcarNotFound': 'Устройство не найдено',
'smartcarScanHint': 'Убедитесь, что устройство включено и находится рядом с телефоном',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/sk_sk.dart

@ -1913,4 +1913,208 @@ const Map<String, String> skSK = {
'unmarkFavorite': 'Odstrániť z obľúbených',
'unnamedDevice': 'Nepomenované zariadenie',
'userDataParseFailed': 'Údaje používateľa sa nepodarilo načítať',
'catEarphone': 'AI slúchadlá',
'catEarphoneDesc': 'Klasické Bluetooth / BLE slúchadlá',
'catPhoneHolder': 'AI držiak telefónu',
'catPhoneHolderDesc': 'Zariadenia typu držiak',
'catCarAroma': 'AI vôňa do auta',
'catCarAromaDesc': 'Difuzér vône do auta',
'agreeAndContinue': 'Súhlasím a pokračovať',
'memberServiceAgreement': 'Zmluva o členských službách',
'memberServiceAgreementTitle': 'Zmluva o členských službách',
'memberServiceAgreementIntro': 'Aby sme vám mohli poskytovať lepšie členské služby, pred predplatením si pozorne prečítajte: ',
'memberServiceAgreementOutro': '. Ťuknutím na „Súhlasím a pokračovať“ potvrdzujete, že ste si vyššie uvedenú zmluvu celú prečítali a prijímate ju.',
'memberAgreementRejectedTip': 'Na pokračovanie musíte súhlasiť so Zmluvou o členských službách.',
'translationCallExitTitle': 'Opustiť preklad hovoru',
'translationCallExitMessage': 'Vyberte, čo urobiť pred odchodom',
'translationCallEnd': 'Ukončiť preklad hovoru',
'translationContinueInBackground': 'Pokračovať v preklade na pozadí',
'backgroundRecordingIndicatorBody': 'Nahrávanie pokračuje na pozadí. Ťuknutím sa vrátite.',
'backgroundTranslationIndicatorBody': 'Preklad hovoru pokračuje na pozadí. Ťuknutím sa vrátite.',
'meetingRecordScreenCapturePermissionDenied': 'Na nahrávanie systémového zvuku je potrebné povolenie na nahrávanie obrazovky. Udeľte ho a skúste to znova.',
'pleaseGrantBluetoothPermission': 'Na skenovanie udeľte povolenie Bluetooth',
'startScanFailed': 'Spustenie skenovania zlyhalo',
'pleaseLoginFirst': 'Najprv sa prihláste',
'deviceUnauthorized': 'Zariadenie nie je autorizované, skúste to znova',
'boundDevices': 'Prepojené zariadenia',
'currentConnection': 'Aktuálne pripojenie',
'binding': 'Prepája sa…',
'permissionDisabled': 'Vypnuté',
'confirmDisablePermissionTitle': 'Vypnúť povolenie',
'confirmDisablePermissionContent': 'Vypnúť povolenie {permission}? Na opätovné zapnutie bude potrebné prejsť do nastavení systému.',
'confirmPermanentlyDisallowTitle': 'Prestať sa pýtať?',
'confirmPermanentlyDisallowContent': 'Zamietli ste povolenie {permission}. Prestať sa odteraz pýtať? Ak zvolíte „Nepovoliť“, môžete ho neskôr zapnúť v nastaveniach systému.',
'notNow': 'Teraz nie',
'permanentlyDisallow': 'Nepovoliť',
'otaBesNotSupported': 'Tieto slúchadlá zatiaľ nepodporujú aktualizáciu firmvéru v aplikácii. Kontaktujte podporu.',
'tabDevice': 'Zariadenia',
'tabPlaza': 'Plaza',
'sloganHome': 'Technológie pre inteligentnejší život',
'sloganAi': 'Inteligencia bez hraníc',
'sloganDevice': 'Inteligentný hardvér pripravený na budúcnosť',
'sloganPlaza': 'Inteligentný svet, ktorý vám rozumie',
'aiEditHint': 'Dlhým stlačením karty presuniete a preusporiadate moduly',
'aiEmptyTitle': 'Zatiaľ žiadne moduly',
'aiEmptyDesc': 'Vyberte si potrebných agentov v Plaza',
'goToPlaza': 'Prejsť do Plaza',
'plazaOnline': 'K dispozícii',
'plazaPlanned': 'Už čoskoro',
'plazaSearchHint': 'Hľadať agentov, napr. „preklad“, „nahrávanie“',
'moduleRemove': 'Odstrániť',
'moduleAdd': 'Pridať',
'moduleRemoved': 'Odstránené',
'moduleAdded': 'Pridané',
'goSee': 'Zobraziť',
'comingSoonTag': 'Už čoskoro',
'needDeviceTag': 'Vyžaduje zariadenie',
'featureInDev': 'Táto funkcia sa pripravuje',
'featureNotWired': 'Zatiaľ nie je k dispozícii',
'todayLabel': 'Dnes',
'deviceConnected': 'Pripojené',
'licenseUnknown': 'Licencia neznáma',
'selectDevice': 'Vyberte zariadenie',
'catNoteCard': 'AI záznamová karta',
'catNoteCardDesc': 'Záznamník v tvare karty',
'catParkerPen': 'AI pero',
'catParkerPenDesc': 'Perá s hlasovým záznamníkom',
'catStylusPen': 'AI stylus',
'catStylusPenDesc': 'Zariadenia na ručné písanie / stylusy',
'catStudyMachine': 'AI výukový tablet',
'catStudyMachineDesc': 'Výukové zariadenia',
'besAddTitle': 'Pridať AI slúchadlá',
'besConnecting': 'Pripája sa k slúchadlám…',
'besNotFound': 'Slúchadlá sa nenašli',
'besNotFoundIosDesc': 'Najprv pripojte slúchadlá v Nastavenia → Bluetooth a potom sa vráťte',
'besNotFoundAndroidDesc': 'Najprv spárujte slúchadlá v Nastavenia → Bluetooth a potom ich tu pripojte',
'besReadFailed': 'Čítanie zlyhalo',
'besConnectFailIos': 'Uistite sa, že sa slúchadlá v systémovom Bluetooth zobrazujú ako Pripojené, a skúste to znova',
'besConnectFailAndroid': 'Uistite sa, že sú slúchadlá zapnuté, nablízku a nie sú pripojené k inému zariadeniu. Po prepojení sa budú pripájať automaticky.',
'holderAddTitle': 'Pridať AI stojan na telefón',
'holderConnecting': 'Pripája sa k zariadeniu…',
'holderSearching': 'Hľadajú sa zariadenia…',
'holderNotFound': 'Nenašli sa žiadne zariadenia',
'holderPairHint': 'Uistite sa, že je stojan zapnutý a v režime párovania',
'holderRetryHint': 'Uistite sa, že je zariadenie zapnuté a nablízku, a skúste to znova',
'autoConnectTitle': 'Automatické pripojenie zariadení',
'autoConnectDesc': 'Prebudí aplikáciu, aby sa znova pripojila a prijala nahrávky, keď sa vaše zariadenie zapne alebo priblíži',
'autoConnectConsentTitle': 'Zapnúť automatické pripojenie',
'autoConnectConsentIntro': 'Aby sa vaše zariadenie (AI slúchadlá / AI stojan na telefón) mohlo pripájať automaticky a ukladať nahrávky po stlačení tlačidla nahrávania, potrebujeme váš súhlas:',
'autoConnectConsentB1': 'Zaregistrovať skenovanie Bluetooth na pozadí, aby sa aplikácia pri zistení prepojeného zariadenia prebudila (automaticky spustila), znova pripojila a prijala nahrávky',
'autoConnectConsentB2': 'Po prebudení sa používa iba na pripojenie zariadenia, nahrávanie a odosielanie — nič iné, žiadne ďalšie aplikácie sa nespúšťajú',
'autoConnectConsentB3': 'Aplikáciu môžete používať aj bez toho — stačí zariadenie zakaždým pripojiť ručne',
'autoConnectConsentB4': 'Kedykoľvek to môžete vypnúť v Nastavenia → Automatické pripojenie zariadení',
'autoConnectAgree': 'Súhlasím a zapnúť',
'autoConnectLater': 'Teraz nie',
'autoConnectRequired': 'Automatické pripojenie je vypnuté',
'autoConnectRequiredDesc': 'Na prepojenie tohto zariadenia zapnite automatické pripojenie',
'recordTapToStart': 'Ťuknutím na tlačidlo nižšie spustíte nahrávanie',
'recordPaused': 'Pozastavené',
'recordMark': 'Značka',
'recordDeleteTitle': 'Odstrániť nahrávku',
'accountAndProfile': 'Účet a profil',
'nicknameUnset': 'Prezývka nie je nastavená',
'nicknameInput': 'Zadajte prezývku',
'loginMethod': 'Spôsob prihlásenia',
'manageMyLogin': 'Spravovať prihlásenie',
'logoutAction': 'Odhlásiť sa',
'warningLabel': 'Upozornenie:',
'deleteAccountWarn': 'Váš účet a všetky súbory budú natrvalo odstránené.',
'icpFiling': 'Registrácia ICP',
'logExport': 'Export protokolov',
'logExportDesc': 'Zobraziť a exportovať protokoly aplikácie',
'permLoadFailed': 'Načítanie skupín povolení zlyhalo',
'permInsufficient': 'Povolenie zamietnuté',
'voiceCloneCreating': 'Vytvára sa váš hlas…',
'voiceCloneCreate': 'Vytvoriť môj hlas',
'voiceCloneAgree': 'Súhlasím a prečítam vyhlásenie nahlas',
'voiceCloneReadPrompt': 'Prečítajte:',
'voiceCloneFirstMonthFree': 'Prvý mesiac zadarmo',
'voiceCloneEnable': 'Zapnúť klonovanie hlasu',
'noMusicPlaying': 'Nič sa neprehráva',
'qqMusicNoSeek': 'QQ Music nepodporuje pretáčanie',
'bleScanTitle': 'Skenovanie BLE',
'bluetoothOn': 'Bluetooth zapnutý',
'bleScanning': 'Skenuje sa…',
'bleConnectedDevices': 'Pripojené zariadenia',
'bleUnknownDevice': 'Neznáme zariadenie',
'bleTapToScan': 'Ťuknutím na Skenovať vyhľadáte BLE zariadenia',
'weekMon': 'Po',
'weekTue': 'Ut',
'weekWed': 'St',
'weekThu': 'Št',
'weekFri': 'Pi',
'weekSat': 'So',
'weekSun': 'Ne',
'agentMeeting': 'Hlasové zápisnice',
'agentMeetingDesc': 'Zhrnutie nahrávok, centrum pamäte',
'agentEmai': 'Asistent EMAI',
'agentEmaiDesc': 'Rozprávajte sa a pýtajte sa hlasom na čokoľvek',
'agentSimultaneous': 'Živý preklad',
'agentSimultaneousDesc': 'Preklad počas reči',
'agentFaceToFace': 'Tvárou v tvár',
'agentFaceToFaceDesc': 'Obojsmerný preklad konverzácie',
'agentCallRecording': 'Nahrávanie hovorov',
'agentCallRecordingDesc': 'Hovory sa ukladajú automaticky',
'agentFieldRecording': 'Terénne nahrávanie',
'agentFieldRecordingDesc': 'Mikrofón zariadenia alebo telefónu',
'agentCall': 'Preklad hovoru',
'agentCallDesc': 'Preklad v reálnom čase počas hovorov',
'agentAudioVideo': 'Preklad médií',
'agentAudioVideoDesc': 'Záber kamery / preklad audia a videa',
'agentEnglishChat': 'Konverzácia v angličtine',
'agentEnglishChatDesc': 'Imerzívne precvičovanie rozprávania',
'agentEnglishStudy': 'Štúdium angličtiny',
'agentEnglishStudyDesc': 'Lekcie slovnej zásoby a gramatiky',
'agentKidsStory': 'Detské rozprávky',
'agentKidsStoryDesc': 'Rozprávky na dobrú noc a otázky s odpoveďami',
'moduleRemovedDesc': 'odstránené zo stránky AI',
'moduleAddedDesc': 'pridané na koniec stránky AI',
'calNoRecords': 'Žiadne záznamy',
'calNoRecordsDay': 'Nič naplánované',
'calTodos': 'Úlohy',
'calExpenses': 'Výdavky',
'calTodoCount': 'Úlohy: @count',
'calExpenseCount': 'Výdavky: @count',
'calEntryCount': 'Záznamy: @count',
'calItemsUnit': 'položiek',
'calCountUnit': 'záznamov',
'calTotalAmount': 'Spolu',
'recordPause': 'Pozastaviť',
'recordResume': 'Pokračovať',
'recordDeleteConfirm': 'Odstrániť túto nahrávku?',
'scopeDay': 'Deň',
'scopeWeek': 'Týždeň',
'scopeMonth': 'Mesiac',
'allDay': 'Celý deň',
'guestModeTitle': 'Režim hosťa',
'guestModeDesc': 'Hostia môžu iba prezerať. Prihláste sa a používajte hlasové poznámky, preklad a nahrávanie — vaše údaje sa uložia k vášmu účtu.',
'guestKeepBrowsing': 'Pokračovať v prezeraní',
'guestGoLogin': 'Prihlásiť sa',
'guestViewOnlyTag': 'Iba prezeranie',
'autoConnectReenableHint': 'Automatické pripojenie je vypnuté. Znova ho zapnite v Nastavenia → Automatické pripojenie zariadení.',
'plazaEntryDesc': 'Prezerajte a pridávajte ďalších agentov',
'agentTextTranslation': 'Preklad textu',
'agentTextTranslationDesc': 'Preklad napísaného alebo vloženého textu',
'agentPictureTranslation': 'Preklad fotiek',
'agentPictureTranslationDesc': 'Odfoťte alebo vyberte fotku na preklad',
'callRequiredTitle': 'Vyžaduje sa hovor',
'callRequiredDesc': 'Táto funkcia funguje iba počas hovoru',
'inCallBusyTitle': 'Počas hovoru nedostupné',
'inCallBusyDesc': 'Mikrofón a zvukovú cestu používa hovor. Skúste to znova po zavesení.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Zadarmo',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/sl_si.dart

@ -1916,4 +1916,208 @@ const Map<String, String> slSI = {
'unmarkFavorite': 'Odstrani iz priljubljenih',
'unnamedDevice': 'Neimenovana naprava',
'userDataParseFailed': 'Uporabniških podatkov ni bilo mogoče prebrati',
'catEarphone': 'AI slušalke',
'catEarphoneDesc': 'Klasične Bluetooth / BLE slušalke',
'catPhoneHolder': 'AI držalo za telefon',
'catPhoneHolderDesc': 'Naprave tipa držalo',
'catCarAroma': 'AI avtomobilski difuzor',
'catCarAromaDesc': 'Difuzor vonja za avto',
'agreeAndContinue': 'Strinjam se in nadaljuj',
'memberServiceAgreement': 'Pogodba o članskih storitvah',
'memberServiceAgreementTitle': 'Pogodba o članskih storitvah',
'memberServiceAgreementIntro': 'Da bi vam zagotovili boljše članske storitve, pred naročnino pozorno preberite: ',
'memberServiceAgreementOutro': '. S klikom na „Strinjam se in nadaljuj“ potrjujete, da ste v celoti prebrali in sprejeli zgornjo pogodbo.',
'memberAgreementRejectedTip': 'Za nadaljevanje morate sprejeti Pogodbo o članskih storitvah.',
'translationCallExitTitle': 'Izhod iz prevajanja klica',
'translationCallExitMessage': 'Izberite dejanje pred izhodom',
'translationCallEnd': 'Končaj prevajanje klica',
'translationContinueInBackground': 'Nadaljuj prevajanje v ozadju',
'backgroundRecordingIndicatorBody': 'Snemanje se nadaljuje v ozadju. Tapnite za vrnitev.',
'backgroundTranslationIndicatorBody': 'Prevajanje klica se nadaljuje v ozadju. Tapnite za vrnitev.',
'meetingRecordScreenCapturePermissionDenied': 'Za snemanje sistemskega zvoka je potrebno dovoljenje za snemanje zaslona. Odobrite ga in poskusite znova.',
'pleaseGrantBluetoothPermission': 'Za iskanje dovolite dostop do Bluetootha',
'startScanFailed': 'Zagon iskanja ni uspel',
'pleaseLoginFirst': 'Najprej se prijavite',
'deviceUnauthorized': 'Naprava ni pooblaščena, poskusite znova',
'boundDevices': 'Povezane naprave',
'currentConnection': 'Trenutna povezava',
'binding': 'Povezovanje...',
'permissionDisabled': 'Izklopljeno',
'confirmDisablePermissionTitle': 'Izklopi dovoljenje',
'confirmDisablePermissionContent': 'Želite izklopiti dovoljenje {permission}? Za ponovno omogočanje boste morali v sistemske nastavitve.',
'confirmPermanentlyDisallowTitle': 'Ne sprašuj več?',
'confirmPermanentlyDisallowContent': 'Zavrnili ste dovoljenje {permission}. Naj vas ne sprašujemo več? Če izberete „Ne dovoli“, ga lahko pozneje še vedno omogočite v sistemskih nastavitvah.',
'notNow': 'Ne zdaj',
'permanentlyDisallow': 'Ne dovoli',
'otaBesNotSupported': 'Te slušalke še ne podpirajo nadgradnje vdelane programske opreme v aplikaciji. Obrnite se na podporo.',
'tabDevice': 'Naprave',
'tabPlaza': 'Trg',
'sloganHome': 'Tehnologija za pametnejše življenje',
'sloganAi': 'Inteligenca brez meja',
'sloganDevice': 'Pametna strojna oprema, pripravljena na prihodnost',
'sloganPlaza': 'Pameten svet, ki vas razume',
'aiEditHint': 'Dolgo pritisnite kartico, da povlečete in prerazporedite module',
'aiEmptyTitle': 'Ni še dodanih modulov',
'aiEmptyDesc': 'Na Trgu izberite agente, ki jih potrebujete',
'goToPlaza': 'Pojdi na Trg',
'plazaOnline': 'Na voljo',
'plazaPlanned': 'Kmalu',
'plazaSearchHint': 'Iščite agente, npr. „prevod“, „snemanje“',
'moduleRemove': 'Odstrani',
'moduleAdd': 'Dodaj',
'moduleRemoved': 'Odstranjeno',
'moduleAdded': 'Dodano',
'goSee': 'Poglej',
'comingSoonTag': 'Kmalu',
'needDeviceTag': 'Potrebna naprava',
'featureInDev': 'Ta funkcija je v razvoju',
'featureNotWired': 'Še ni na voljo',
'todayLabel': 'Danes',
'deviceConnected': 'Povezano',
'licenseUnknown': 'Licenca neznana',
'selectDevice': 'Izberite napravo',
'catNoteCard': 'AI snemalna kartica',
'catNoteCardDesc': 'Snemalnik v obliki kartice',
'catParkerPen': 'AI pisalo',
'catParkerPenDesc': 'Pisala za snemanje glasu',
'catStylusPen': 'AI stilus',
'catStylusPenDesc': 'Naprave za rokopis / stilusi',
'catStudyMachine': 'AI učna tablica',
'catStudyMachineDesc': 'Naprave za učenje',
'besAddTitle': 'Dodaj AI slušalke',
'besConnecting': 'Povezovanje s slušalkami…',
'besNotFound': 'Slušalke niso najdene',
'besNotFoundIosDesc': 'Najprej povežite slušalke v Nastavitve → Bluetooth, nato se vrnite sem',
'besNotFoundAndroidDesc': 'Najprej seznanite slušalke v Nastavitve → Bluetooth, nato jih povežite tukaj',
'besReadFailed': 'Branje ni uspelo',
'besConnectFailIos': 'Prepričajte se, da so slušalke v sistemskem Bluetoothu prikazane kot Povezane, nato poskusite znova',
'besConnectFailAndroid': 'Prepričajte se, da so slušalke vklopljene, v bližini in niso povezane z drugo napravo. Ko bodo povezane, se bodo samodejno znova povezale.',
'holderAddTitle': 'Dodaj AI stojalo za telefon',
'holderConnecting': 'Povezovanje z napravo…',
'holderSearching': 'Iskanje naprav…',
'holderNotFound': 'Ni najdenih naprav',
'holderPairHint': 'Prepričajte se, da je stojalo vklopljeno in v načinu seznanjanja',
'holderRetryHint': 'Prepričajte se, da je naprava vklopljena in v bližini, nato poskusite znova',
'autoConnectTitle': 'Samodejno povezovanje naprav',
'autoConnectDesc': 'Prebudi aplikacijo za ponovno povezavo in prejem posnetkov, ko se naprava vklopi ali približa',
'autoConnectConsentTitle': 'Omogoči samodejno povezovanje',
'autoConnectConsentIntro': 'Da se vaša naprava (AI slušalke / AI stojalo za telefon) samodejno poveže in shrani posnetke, ko pritisnete njen gumb za snemanje, potrebujemo vaše soglasje:',
'autoConnectConsentB1': 'Registracija Bluetooth iskanja v ozadju, da se aplikacija prebudi (samodejno zažene), ko je zaznana vaša povezana naprava, za ponovno povezavo in prejem posnetkov',
'autoConnectConsentB2': 'Po prebuditvi se uporablja samo za povezavo naprave, snemanje in nalaganje — nič drugega, druge aplikacije se ne zaženejo',
'autoConnectConsentB3': 'Aplikacijo lahko uporabljate tudi brez tega — napravo le vsakič povežite ročno',
'autoConnectConsentB4': 'Kadarkoli ga lahko izklopite v Nastavitve → Samodejno povezovanje naprav',
'autoConnectAgree': 'Strinjam se in omogoči',
'autoConnectLater': 'Ne zdaj',
'autoConnectRequired': 'Samodejno povezovanje je izklopljeno',
'autoConnectRequiredDesc': 'Za povezavo te naprave omogočite samodejno povezovanje',
'recordTapToStart': 'Tapnite spodnji gumb za začetek snemanja',
'recordPaused': 'Začasno ustavljeno',
'recordMark': 'Označi',
'recordDeleteTitle': 'Izbriši posnetek',
'accountAndProfile': 'Račun in profil',
'nicknameUnset': 'Vzdevek ni nastavljen',
'nicknameInput': 'Vnesite vzdevek',
'loginMethod': 'Način prijave',
'manageMyLogin': 'Upravljaj prijavo',
'logoutAction': 'Odjava',
'warningLabel': 'Opozorilo:',
'deleteAccountWarn': 'Vaš račun in vse datoteke bodo trajno izbrisani.',
'icpFiling': 'ICP registracija',
'logExport': 'Izvoz dnevnikov',
'logExportDesc': 'Oglejte si in izvozite dnevnike aplikacije',
'permLoadFailed': 'Nalaganje skupin dovoljenj ni uspelo',
'permInsufficient': 'Dovoljenje zavrnjeno',
'voiceCloneCreating': 'Ustvarjanje vašega glasu…',
'voiceCloneCreate': 'Ustvari moj glas',
'voiceCloneAgree': 'Strinjam se in bom izjavo prebral naglas',
'voiceCloneReadPrompt': 'Preberite:',
'voiceCloneFirstMonthFree': 'Prvi mesec brezplačno',
'voiceCloneEnable': 'Omogoči kloniranje glasu',
'noMusicPlaying': 'Nič se ne predvaja',
'qqMusicNoSeek': 'Previjanje ni podprto za QQ Music',
'bleScanTitle': 'BLE iskanje',
'bluetoothOn': 'Bluetooth vklopljen',
'bleScanning': 'Iskanje…',
'bleConnectedDevices': 'Povezane naprave',
'bleUnknownDevice': 'Neznana naprava',
'bleTapToScan': 'Tapnite Išči za iskanje naprav BLE',
'weekMon': 'Pon',
'weekTue': 'Tor',
'weekWed': 'Sre',
'weekThu': 'Čet',
'weekFri': 'Pet',
'weekSat': 'Sob',
'weekSun': 'Ned',
'agentMeeting': 'Glasovni zapisnik',
'agentMeetingDesc': 'Povzemanje posnetkov, središče spominov',
'agentEmai': 'Pomočnik EMAI',
'agentEmaiDesc': 'Klepetajte in vprašajte karkoli z glasom',
'agentSimultaneous': 'Simultano prevajanje',
'agentSimultaneousDesc': 'Prevajanje med govorjenjem',
'agentFaceToFace': 'Iz oči v oči',
'agentFaceToFaceDesc': 'Dvosmerno prevajanje pogovora',
'agentCallRecording': 'Snemanje klicev',
'agentCallRecordingDesc': 'Klici se samodejno shranijo',
'agentFieldRecording': 'Terensko snemanje',
'agentFieldRecordingDesc': 'Mikrofon naprave ali telefona',
'agentCall': 'Prevajanje klicev',
'agentCallDesc': 'Prevajanje v realnem času med klici',
'agentAudioVideo': 'Prevajanje medijev',
'agentAudioVideoDesc': 'Kadriranje s kamero / prevajanje avdia in videa',
'agentEnglishChat': 'Klepet v angleščini',
'agentEnglishChatDesc': 'Poglobljena vadba govora',
'agentEnglishStudy': 'Učenje angleščine',
'agentEnglishStudyDesc': 'Lekcije besedišča in slovnice',
'agentKidsStory': 'Otroške zgodbe',
'agentKidsStoryDesc': 'Zgodbe za lahko noč ter vprašanja in odgovori',
'moduleRemovedDesc': 'odstranjeno s strani AI',
'moduleAddedDesc': 'dodano na dno strani AI',
'calNoRecords': 'Ni vnosov',
'calNoRecordsDay': 'Nič načrtovanega',
'calTodos': 'Opravila',
'calExpenses': 'Stroški',
'calTodoCount': '@count opravil',
'calExpenseCount': '@count stroškov',
'calEntryCount': '@count vnosov',
'calItemsUnit': 'elementov',
'calCountUnit': 'vnosov',
'calTotalAmount': 'Skupaj',
'recordPause': 'Premor',
'recordResume': 'Nadaljuj',
'recordDeleteConfirm': 'Želite izbrisati ta posnetek?',
'scopeDay': 'Dan',
'scopeWeek': 'Teden',
'scopeMonth': 'Mesec',
'allDay': 'Ves dan',
'guestModeTitle': 'Način gosta',
'guestModeDesc': 'Gostje lahko samo brskajo. Prijavite se za uporabo glasovnih zapiskov, prevajanja in snemanja — vaši podatki bodo shranjeni v vašem računu.',
'guestKeepBrowsing': 'Nadaljuj brskanje',
'guestGoLogin': 'Prijava',
'guestViewOnlyTag': 'Samo ogled',
'autoConnectReenableHint': 'Samodejno povezovanje je izklopljeno. Znova ga vklopite v Nastavitve → Samodejno povezovanje naprav.',
'plazaEntryDesc': 'Prebrskajte in dodajte več agentov',
'agentTextTranslation': 'Prevajanje besedila',
'agentTextTranslationDesc': 'Prevedite vneseno ali prilepljeno besedilo',
'agentPictureTranslation': 'Prevajanje fotografij',
'agentPictureTranslationDesc': 'Posnemite ali izberite fotografijo za prevod',
'callRequiredTitle': 'Potreben je klic',
'callRequiredDesc': 'Ta funkcija deluje samo med klicem',
'inCallBusyTitle': 'Ni na voljo med klicem',
'inCallBusyDesc': 'Mikrofon in zvočno pot uporablja klic. Poskusite znova, ko odložite.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Brezplačno',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/sr_rs.dart

@ -1912,4 +1912,208 @@ const Map<String, String> srRS = {
'unmarkFavorite': 'Уклони из омиљених',
'unnamedDevice': 'Уређај без назива',
'userDataParseFailed': 'Подаци корисника нису могли да се учитају',
'catEarphone': 'AI slušalice',
'catEarphoneDesc': 'Klasične Bluetooth / BLE slušalice',
'catPhoneHolder': 'AI držač telefona',
'catPhoneHolderDesc': 'Uređaji tipa držač',
'catCarAroma': 'AI auto difuzor',
'catCarAromaDesc': 'Difuzor mirisa za auto',
'agreeAndContinue': 'Prihvati i nastavi',
'memberServiceAgreement': 'Ugovor o članskim uslugama',
'memberServiceAgreementTitle': 'Ugovor o članskim uslugama',
'memberServiceAgreementIntro': 'Da bismo vam pružili bolje članske usluge, pažljivo pročitajte pre pretplate: ',
'memberServiceAgreementOutro': '. Klikom na „Prihvati i nastavi“ potvrđujete da ste u potpunosti pročitali i prihvatili gore navedeni ugovor.',
'memberAgreementRejectedTip': 'Morate prihvatiti Ugovor o članskim uslugama da biste nastavili.',
'translationCallExitTitle': 'Izlaz iz prevoda poziva',
'translationCallExitMessage': 'Izaberite šta želite da uradite pre izlaska',
'translationCallEnd': 'Završi prevod poziva',
'translationContinueInBackground': 'Nastavi prevod u pozadini',
'backgroundRecordingIndicatorBody': 'Snimanje se nastavlja u pozadini. Dodirnite da se vratite.',
'backgroundTranslationIndicatorBody': 'Prevod poziva se nastavlja u pozadini. Dodirnite da se vratite.',
'meetingRecordScreenCapturePermissionDenied': 'Za snimanje sistemskog zvuka potrebna je dozvola za snimanje ekrana. Odobrite je i pokušajte ponovo.',
'pleaseGrantBluetoothPermission': 'Dozvolite pristup Bluetooth-u za pretraživanje',
'startScanFailed': 'Pokretanje pretrage nije uspelo',
'pleaseLoginFirst': 'Prvo se prijavite',
'deviceUnauthorized': 'Uređaj nije autorizovan, pokušajte ponovo',
'boundDevices': 'Vezani uređaji',
'currentConnection': 'Trenutna veza',
'binding': 'Vezivanje...',
'permissionDisabled': 'Isključeno',
'confirmDisablePermissionTitle': 'Isključi dozvolu',
'confirmDisablePermissionContent': 'Isključiti dozvolu {permission}? Moraćete da je ponovo uključite u sistemskim podešavanjima.',
'confirmPermanentlyDisallowTitle': 'Ne pitati više?',
'confirmPermanentlyDisallowContent': 'Odbili ste dozvolu {permission}. Da više ne pitamo? Ako izaberete „Ne dozvoli“, i dalje je možete kasnije uključiti u sistemskim podešavanjima.',
'notNow': 'Ne sada',
'permanentlyDisallow': 'Ne dozvoli',
'otaBesNotSupported': 'Ove slušalice još ne podržavaju ažuriranje firmvera iz aplikacije. Obratite se podršci.',
'tabDevice': 'Uređaji',
'tabPlaza': 'Trg',
'sloganHome': 'Tehnologija za pametniji život',
'sloganAi': 'Inteligencija bez granica',
'sloganDevice': 'Pametan hardver, spreman za budućnost',
'sloganPlaza': 'Pametan svet koji te razume',
'aiEditHint': 'Dugo pritisnite karticu da prevučete i preuredite module',
'aiEmptyTitle': 'Još nema dodatih modula',
'aiEmptyDesc': 'Izaberite potrebne agente na Trgu',
'goToPlaza': 'Idi na Trg',
'plazaOnline': 'Dostupno',
'plazaPlanned': 'Uskoro',
'plazaSearchHint': 'Pretraži agente, npr. „prevod“, „snimanje“',
'moduleRemove': 'Ukloni',
'moduleAdd': 'Dodaj',
'moduleRemoved': 'Uklonjeno',
'moduleAdded': 'Dodato',
'goSee': 'Pogledaj',
'comingSoonTag': 'Uskoro',
'needDeviceTag': 'Potreban uređaj',
'featureInDev': 'Ova funkcija je u razvoju',
'featureNotWired': 'Još nije dostupno',
'todayLabel': 'Danas',
'deviceConnected': 'Povezano',
'licenseUnknown': 'Licenca nepoznata',
'selectDevice': 'Izaberite uređaj',
'catNoteCard': 'AI kartica za snimanje',
'catNoteCardDesc': 'Diktafon u obliku kartice',
'catParkerPen': 'AI olovka',
'catParkerPenDesc': 'Olovke sa diktafonom',
'catStylusPen': 'AI stilus',
'catStylusPenDesc': 'Uređaji za rukopis / stilus',
'catStudyMachine': 'AI tablet za učenje',
'catStudyMachineDesc': 'Uređaji za učenje',
'besAddTitle': 'Dodaj AI slušalice',
'besConnecting': 'Povezivanje sa slušalicama…',
'besNotFound': 'Slušalice nisu pronađene',
'besNotFoundIosDesc': 'Prvo povežite slušalice u Podešavanja → Bluetooth, pa se vratite ovde',
'besNotFoundAndroidDesc': 'Prvo uparite slušalice u Podešavanja → Bluetooth, pa ih povežite ovde',
'besReadFailed': 'Čitanje nije uspelo',
'besConnectFailIos': 'Proverite da su slušalice prikazane kao „Povezano“ u sistemskom Bluetooth-u, pa pokušajte ponovo',
'besConnectFailAndroid': 'Proverite da su slušalice uključene, u blizini i da nisu povezane sa drugim uređajem. Nakon povezivanja povezivaće se automatski.',
'holderAddTitle': 'Dodaj AI držač telefona',
'holderConnecting': 'Povezivanje sa uređajem…',
'holderSearching': 'Traženje uređaja…',
'holderNotFound': 'Nije pronađen nijedan uređaj',
'holderPairHint': 'Proverite da je držač uključen i u režimu uparivanja',
'holderRetryHint': 'Proverite da je uređaj uključen i u blizini, pa pokušajte ponovo',
'autoConnectTitle': 'Automatsko povezivanje uređaja',
'autoConnectDesc': 'Budi aplikaciju radi ponovnog povezivanja i prijema snimaka kada se uređaj uključi ili nađe u blizini',
'autoConnectConsentTitle': 'Uključi automatsko povezivanje',
'autoConnectConsentIntro': 'Da bi se vaš uređaj (AI slušalice / AI držač telefona) automatski povezivao i čuvao snimke kada pritisnete dugme za snimanje, potrebna nam je vaša saglasnost:',
'autoConnectConsentB1': 'Registrovanje pozadinske Bluetooth pretrage kako bi se aplikacija probudila (automatski pokrenula) kada se otkrije vaš vezani uređaj, radi ponovnog povezivanja i prijema snimaka',
'autoConnectConsentB2': 'Nakon buđenja koristi se samo za povezivanje uređaja, snimanje i otpremanje — ništa drugo, i ne pokreću se druge aplikacije',
'autoConnectConsentB3': 'Aplikaciju možete koristiti i bez toga — samo svaki put ručno povežite uređaj',
'autoConnectConsentB4': 'Možete je isključiti bilo kada u Podešavanja → Automatsko povezivanje uređaja',
'autoConnectAgree': 'Prihvati i uključi',
'autoConnectLater': 'Ne sada',
'autoConnectRequired': 'Automatsko povezivanje je isključeno',
'autoConnectRequiredDesc': 'Uključite automatsko povezivanje da biste vezali ovaj uređaj',
'recordTapToStart': 'Dodirnite dugme ispod da započnete snimanje',
'recordPaused': 'Pauzirano',
'recordMark': 'Oznaka',
'recordDeleteTitle': 'Obriši snimak',
'accountAndProfile': 'Nalog i profil',
'nicknameUnset': 'Nadimak nije postavljen',
'nicknameInput': 'Unesite nadimak',
'loginMethod': 'Način prijave',
'manageMyLogin': 'Upravljaj prijavom',
'logoutAction': 'Odjavi se',
'warningLabel': 'Upozorenje:',
'deleteAccountWarn': 'Vaš nalog i sve datoteke biće trajno obrisani.',
'icpFiling': 'ICP registracija',
'logExport': 'Izvoz dnevnika',
'logExportDesc': 'Pregled i izvoz dnevnika aplikacije',
'permLoadFailed': 'Učitavanje grupa dozvola nije uspelo',
'permInsufficient': 'Dozvola odbijena',
'voiceCloneCreating': 'Kreiranje vašeg glasa…',
'voiceCloneCreate': 'Kreiraj moj glas',
'voiceCloneAgree': 'Prihvati i pročitaj izjavu naglas',
'voiceCloneReadPrompt': 'Pročitajte:',
'voiceCloneFirstMonthFree': 'Prvi mesec besplatno',
'voiceCloneEnable': 'Uključi kloniranje glasa',
'noMusicPlaying': 'Ništa se ne reprodukuje',
'qqMusicNoSeek': 'Premotavanje nije podržano za QQ Music',
'bleScanTitle': 'BLE pretraga',
'bluetoothOn': 'Bluetooth je uključen',
'bleScanning': 'Pretraživanje…',
'bleConnectedDevices': 'Povezani uređaji',
'bleUnknownDevice': 'Nepoznat uređaj',
'bleTapToScan': 'Dodirnite „Pretraži“ da potražite BLE uređaje',
'weekMon': 'Pon',
'weekTue': 'Uto',
'weekWed': 'Sre',
'weekThu': 'Čet',
'weekFri': 'Pet',
'weekSat': 'Sub',
'weekSun': 'Ned',
'agentMeeting': 'Glasovne beleške',
'agentMeetingDesc': 'Rezimei snimaka, centar memorije',
'agentEmai': 'EMAI asistent',
'agentEmaiDesc': 'Razgovarajte i pitajte bilo šta glasom',
'agentSimultaneous': 'Prevod uživo',
'agentSimultaneousDesc': 'Prevod dok govorite',
'agentFaceToFace': 'Licem u lice',
'agentFaceToFaceDesc': 'Dvosmerni prevod razgovora',
'agentCallRecording': 'Snimanje poziva',
'agentCallRecordingDesc': 'Pozivi se čuvaju automatski',
'agentFieldRecording': 'Snimanje na terenu',
'agentFieldRecordingDesc': 'Mikrofon uređaja ili telefona',
'agentCall': 'Prevod poziva',
'agentCallDesc': 'Prevod u realnom vremenu tokom poziva',
'agentAudioVideo': 'Prevod medija',
'agentAudioVideoDesc': 'Kadriranje kamerom / prevod audio i video sadržaja',
'agentEnglishChat': 'Razgovor na engleskom',
'agentEnglishChatDesc': 'Imerzivno vežbanje govora',
'agentEnglishStudy': 'Učenje engleskog',
'agentEnglishStudyDesc': 'Lekcije iz vokabulara i gramatike',
'agentKidsStory': 'Priče za decu',
'agentKidsStoryDesc': 'Priče za laku noć i pitanja i odgovori',
'moduleRemovedDesc': 'uklonjeno sa AI stranice',
'moduleAddedDesc': 'dodato na dno AI stranice',
'calNoRecords': 'Nema unosa',
'calNoRecordsDay': 'Ništa nije zakazano',
'calTodos': 'Zadaci',
'calExpenses': 'Troškovi',
'calTodoCount': '@count zadataka',
'calExpenseCount': '@count troškova',
'calEntryCount': '@count unosa',
'calItemsUnit': 'stavki',
'calCountUnit': 'unosa',
'calTotalAmount': 'Ukupno',
'recordPause': 'Pauziraj',
'recordResume': 'Nastavi',
'recordDeleteConfirm': 'Obrisati ovaj snimak?',
'scopeDay': 'Dan',
'scopeWeek': 'Nedelja',
'scopeMonth': 'Mesec',
'allDay': 'Ceo dan',
'guestModeTitle': 'Režim gosta',
'guestModeDesc': 'Gosti mogu samo da pregledaju. Prijavite se da biste koristili glasovne beleške, prevod i snimanje — vaši podaci će se čuvati na vašem nalogu.',
'guestKeepBrowsing': 'Nastavi pregled',
'guestGoLogin': 'Prijavi se',
'guestViewOnlyTag': 'Samo pregled',
'autoConnectReenableHint': 'Automatsko povezivanje je isključeno. Ponovo ga uključite u Podešavanja → Automatsko povezivanje uređaja.',
'plazaEntryDesc': 'Pregledajte i dodajte još agenata',
'agentTextTranslation': 'Prevod teksta',
'agentTextTranslationDesc': 'Prevod ukucanog ili nalepljenog teksta',
'agentPictureTranslation': 'Prevod fotografija',
'agentPictureTranslationDesc': 'Slikajte ili izaberite fotografiju za prevod',
'callRequiredTitle': 'Potreban je poziv',
'callRequiredDesc': 'Ova funkcija radi samo tokom poziva',
'inCallBusyTitle': 'Nedostupno tokom poziva',
'inCallBusyDesc': 'Mikrofon i audio putanja su zauzeti pozivom. Pokušajte ponovo nakon završetka poziva.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Besplatno',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/sv_se.dart

@ -1908,4 +1908,208 @@ const Map<String, String> svSE = {
'unmarkFavorite': 'Ta bort från favoriter',
'unnamedDevice': 'Namnlös enhet',
'userDataParseFailed': 'Det gick inte att läsa användardata',
'catEarphone': 'AI-öronsnäckor',
'catEarphoneDesc': 'Klassiska Bluetooth- / BLE-öronsnäckor',
'catPhoneHolder': 'AI-mobilhållare',
'catPhoneHolderDesc': 'Hållarenheter',
'catCarAroma': 'AI-bildoft',
'catCarAromaDesc': 'Aromdiffusor för bil',
'agreeAndContinue': 'Godkänn och fortsätt',
'memberServiceAgreement': 'Medlemstjänstavtal',
'memberServiceAgreementTitle': 'Medlemstjänstavtal',
'memberServiceAgreementIntro': 'För att ge dig bättre medlemstjänster, läs noga innan du prenumererar: ',
'memberServiceAgreementOutro': '. Genom att trycka på ”Godkänn och fortsätt” bekräftar du att du har läst och godkänner avtalet ovan.',
'memberAgreementRejectedTip': 'Du måste godkänna medlemstjänstavtalet för att fortsätta.',
'translationCallExitTitle': 'Lämna samtalsöversättning',
'translationCallExitMessage': 'Välj vad som ska hända innan du lämnar',
'translationCallEnd': 'Avsluta samtalsöversättning',
'translationContinueInBackground': 'Fortsätt översätta i bakgrunden',
'backgroundRecordingIndicatorBody': 'Inspelningen fortsätter i bakgrunden. Tryck för att gå tillbaka.',
'backgroundTranslationIndicatorBody': 'Samtalsöversättningen fortsätter i bakgrunden. Tryck för att gå tillbaka.',
'meetingRecordScreenCapturePermissionDenied': 'Behörighet för skärminspelning krävs för att spela in systemljud. Ge behörighet och försök igen.',
'pleaseGrantBluetoothPermission': 'Ge Bluetooth-behörighet för att skanna',
'startScanFailed': 'Det gick inte att starta skanningen',
'pleaseLoginFirst': 'Logga in först',
'deviceUnauthorized': 'Enheten är inte auktoriserad, försök igen',
'boundDevices': 'Kopplade enheter',
'currentConnection': 'Aktuell anslutning',
'binding': 'Kopplar...',
'permissionDisabled': 'Inaktiverad',
'confirmDisablePermissionTitle': 'Stäng av behörighet',
'confirmDisablePermissionContent': 'Stänga av behörigheten {permission}? Du måste gå till systeminställningarna för att aktivera den igen.',
'confirmPermanentlyDisallowTitle': 'Sluta fråga?',
'confirmPermanentlyDisallowContent': 'Du nekade behörigheten {permission}. Sluta fråga från och med nu? Om du väljer ”Tillåt inte” kan du fortfarande aktivera den senare i systeminställningarna.',
'notNow': 'Inte nu',
'permanentlyDisallow': 'Tillåt inte',
'otaBesNotSupported': 'Det här headsetet stöder ännu inte firmware-uppgradering i appen. Kontakta supporten.',
'tabDevice': 'Enheter',
'tabPlaza': 'Plaza',
'sloganHome': 'Teknik för ett smartare liv',
'sloganAi': 'Gränslös intelligens',
'sloganDevice': 'Smart hårdvara, redo för framtiden',
'sloganPlaza': 'En smart värld som förstår dig',
'aiEditHint': 'Håll in ett kort för att dra och ordna om moduler',
'aiEmptyTitle': 'Inga moduler tillagda ännu',
'aiEmptyDesc': 'Välj de agenter du behöver i Plaza',
'goToPlaza': 'Gå till Plaza',
'plazaOnline': 'Tillgänglig',
'plazaPlanned': 'Kommer snart',
'plazaSearchHint': 'Sök efter agenter, t.ex. ”översätt”, ”spela in”',
'moduleRemove': 'Ta bort',
'moduleAdd': 'Lägg till',
'moduleRemoved': 'Borttagen',
'moduleAdded': 'Tillagd',
'goSee': 'Visa',
'comingSoonTag': 'Kommer snart',
'needDeviceTag': 'Kräver enhet',
'featureInDev': 'Den här funktionen är under utveckling',
'featureNotWired': 'Inte tillgänglig ännu',
'todayLabel': 'Idag',
'deviceConnected': 'Ansluten',
'licenseUnknown': 'Licens okänd',
'selectDevice': 'Välj en enhet',
'catNoteCard': 'AI-inspelarkort',
'catNoteCardDesc': 'Kortformad inspelare',
'catParkerPen': 'AI-penna',
'catParkerPenDesc': 'Röstinspelningspennor',
'catStylusPen': 'AI-stylus',
'catStylusPenDesc': 'Handskrifts-/stylusenheter',
'catStudyMachine': 'AI-lärplatta',
'catStudyMachineDesc': 'Lärenheter',
'besAddTitle': 'Lägg till AI-hörlurar',
'besConnecting': 'Ansluter till hörlurarna…',
'besNotFound': 'Hörlurarna hittades inte',
'besNotFoundIosDesc': 'Anslut hörlurarna i Inställningar → Bluetooth först, och kom sedan tillbaka',
'besNotFoundAndroidDesc': 'Parkoppla hörlurarna i Inställningar → Bluetooth först, och anslut sedan här',
'besReadFailed': 'Läsning misslyckades',
'besConnectFailIos': 'Se till att hörlurarna visas som Ansluten i systemets Bluetooth, och försök igen',
'besConnectFailAndroid': 'Se till att hörlurarna är på, i närheten och inte anslutna till en annan enhet. De återansluter automatiskt när de är parkopplade.',
'holderAddTitle': 'Lägg till AI-telefonhållare',
'holderConnecting': 'Ansluter till enheten…',
'holderSearching': 'Söker efter enheter…',
'holderNotFound': 'Inga enheter hittades',
'holderPairHint': 'Se till att hållaren är på och i parkopplingsläge',
'holderRetryHint': 'Se till att enheten är på och i närheten, och försök igen',
'autoConnectTitle': 'Anslut enheter automatiskt',
'autoConnectDesc': 'Väck appen för att återansluta och ta emot inspelningar när din enhet slås på eller kommer i närheten',
'autoConnectConsentTitle': 'Aktivera automatisk anslutning',
'autoConnectConsentIntro': 'För att din enhet (AI-hörlurar / AI-telefonhållare) ska kunna ansluta automatiskt och spara inspelningar när du trycker på dess inspelningsknapp behöver vi ditt samtycke:',
'autoConnectConsentB1': 'Registrera en Bluetooth-skanning i bakgrunden så att appen väcks (startas automatiskt) när din kopplade enhet upptäcks, för att återansluta och ta emot inspelningar',
'autoConnectConsentB2': 'När appen väckts används den bara för enhetsanslutning, inspelning och uppladdning — inget annat, och inga andra appar startas',
'autoConnectConsentB3': 'Du kan fortfarande använda appen utan det — anslut bara enheten manuellt varje gång',
'autoConnectConsentB4': 'Du kan stänga av det när som helst i Inställningar → Anslut enheter automatiskt',
'autoConnectAgree': 'Godkänn och aktivera',
'autoConnectLater': 'Inte nu',
'autoConnectRequired': 'Automatisk anslutning är av',
'autoConnectRequiredDesc': 'Aktivera automatisk anslutning för att koppla den här enheten',
'recordTapToStart': 'Tryck på knappen nedan för att starta inspelningen',
'recordPaused': 'Pausad',
'recordMark': 'Markera',
'recordDeleteTitle': 'Ta bort inspelning',
'accountAndProfile': 'Konto och profil',
'nicknameUnset': 'Inget smeknamn angivet',
'nicknameInput': 'Ange ett smeknamn',
'loginMethod': 'Inloggningsmetod',
'manageMyLogin': 'Hantera inloggning',
'logoutAction': 'Logga ut',
'warningLabel': 'Varning:',
'deleteAccountWarn': 'Ditt konto och alla filer raderas permanent.',
'icpFiling': 'ICP-registrering',
'logExport': 'Exportera loggar',
'logExportDesc': 'Visa och exportera appens loggar',
'permLoadFailed': 'Det gick inte att läsa in behörighetsgrupper',
'permInsufficient': 'Behörighet nekad',
'voiceCloneCreating': 'Skapar din röst…',
'voiceCloneCreate': 'Skapa min röst',
'voiceCloneAgree': 'Godkänn och läs upp uttalandet högt',
'voiceCloneReadPrompt': 'Läs upp:',
'voiceCloneFirstMonthFree': 'Första månaden gratis',
'voiceCloneEnable': 'Aktivera röstkloning',
'noMusicPlaying': 'Inget spelas',
'qqMusicNoSeek': 'Spolning stöds inte för QQ Music',
'bleScanTitle': 'BLE-skanning',
'bluetoothOn': 'Bluetooth är på',
'bleScanning': 'Skannar…',
'bleConnectedDevices': 'Anslutna enheter',
'bleUnknownDevice': 'Okänd enhet',
'bleTapToScan': 'Tryck på skanna för att söka efter BLE-enheter',
'weekMon': 'Mån',
'weekTue': 'Tis',
'weekWed': 'Ons',
'weekThu': 'Tors',
'weekFri': 'Fre',
'weekSat': 'Lör',
'weekSun': 'Sön',
'agentMeeting': 'Röstprotokoll',
'agentMeetingDesc': 'Sammanfatta inspelningar, minnescenter',
'agentEmai': 'EMAI-assistent',
'agentEmaiDesc': 'Chatta och fråga vad som helst med rösten',
'agentSimultaneous': 'Realtidsöversättning',
'agentSimultaneousDesc': 'Översätt medan du talar',
'agentFaceToFace': 'Ansikte mot ansikte',
'agentFaceToFaceDesc': 'Tvåvägs samtalsöversättning',
'agentCallRecording': 'Samtalsinspelning',
'agentCallRecordingDesc': 'Samtal sparas automatiskt',
'agentFieldRecording': 'Fältinspelning',
'agentFieldRecordingDesc': 'Enhetens eller telefonens mikrofon',
'agentCall': 'Samtalsöversättning',
'agentCallDesc': 'Realtidsöversättning under samtal',
'agentAudioVideo': 'Medieöversättning',
'agentAudioVideoDesc': 'Kamerainramning / ljud- och videoöversättning',
'agentEnglishChat': 'Engelsk konversation',
'agentEnglishChatDesc': 'Uppslukande talträning',
'agentEnglishStudy': 'Engelskstudier',
'agentEnglishStudyDesc': 'Ordförråds- och grammatiklektioner',
'agentKidsStory': 'Barnsagor',
'agentKidsStoryDesc': 'Godnattsagor och frågor och svar',
'moduleRemovedDesc': 'borttagen från AI-sidan',
'moduleAddedDesc': 'tillagd längst ner på AI-sidan',
'calNoRecords': 'Inga poster',
'calNoRecordsDay': 'Inget planerat',
'calTodos': 'Att göra',
'calExpenses': 'Utgifter',
'calTodoCount': '@count att göra',
'calExpenseCount': '@count utgifter',
'calEntryCount': '@count poster',
'calItemsUnit': 'st',
'calCountUnit': 'poster',
'calTotalAmount': 'Totalt',
'recordPause': 'Pausa',
'recordResume': 'Fortsätt',
'recordDeleteConfirm': 'Ta bort den här inspelningen?',
'scopeDay': 'Dag',
'scopeWeek': 'Vecka',
'scopeMonth': 'Månad',
'allDay': 'Hela dagen',
'guestModeTitle': 'Gästläge',
'guestModeDesc': 'Gäster kan bara titta. Logga in för att använda röstprotokoll, översättning och inspelning — dina data sparas på ditt konto.',
'guestKeepBrowsing': 'Fortsätt titta',
'guestGoLogin': 'Logga in',
'guestViewOnlyTag': 'Endast visning',
'autoConnectReenableHint': 'Automatisk anslutning är av. Slå på den igen i Inställningar → Anslut enheter automatiskt.',
'plazaEntryDesc': 'Bläddra och lägg till fler agenter',
'agentTextTranslation': 'Textöversättning',
'agentTextTranslationDesc': 'Översätt skriven eller inklistrad text',
'agentPictureTranslation': 'Bildöversättning',
'agentPictureTranslationDesc': 'Ta eller välj ett foto att översätta',
'callRequiredTitle': 'Samtal krävs',
'callRequiredDesc': 'Den här funktionen fungerar bara under ett samtal',
'inCallBusyTitle': 'Inte tillgänglig under samtal',
'inCallBusyDesc': 'Mikrofonen och ljudvägen används av samtalet. Försök igen när du har lagt på.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/th_th.dart

@ -1861,4 +1861,208 @@ const Map<String, String> thTH = {
'unmarkFavorite': 'นำออกจากรายการโปรด',
'unnamedDevice': 'อุปกรณ์ที่ไม่มีชื่อ',
'userDataParseFailed': 'อ่านข้อมูลผู้ใช้ไม่สำเร็จ',
'catEarphone': 'หูฟัง AI',
'catEarphoneDesc': 'หูฟัง Bluetooth คลาสสิก / BLE',
'catPhoneHolder': 'ขาตั้งโทรศัพท์ AI',
'catPhoneHolderDesc': 'อุปกรณ์ประเภทขาตั้ง',
'catCarAroma': 'เครื่องหอมในรถ AI',
'catCarAromaDesc': 'เครื่องกระจายกลิ่นในรถ',
'agreeAndContinue': 'ยอมรับและดำเนินการต่อ',
'memberServiceAgreement': 'ข้อตกลงการให้บริการสมาชิก',
'memberServiceAgreementTitle': 'ข้อตกลงการให้บริการสมาชิก',
'memberServiceAgreementIntro': 'เพื่อให้บริการสมาชิกที่ดียิ่งขึ้น กรุณาอ่านอย่างละเอียดก่อนสมัคร: ',
'memberServiceAgreementOutro': ' เมื่อแตะ "ยอมรับและดำเนินการต่อ" ถือว่าคุณได้อ่านและยอมรับข้อตกลงข้างต้นครบถ้วนแล้ว',
'memberAgreementRejectedTip': 'คุณต้องยอมรับข้อตกลงการให้บริการสมาชิกจึงจะดำเนินการต่อได้',
'translationCallExitTitle': 'ออกจากการแปลการโทร',
'translationCallExitMessage': 'เลือกสิ่งที่ต้องการทำก่อนออก',
'translationCallEnd': 'สิ้นสุดการแปลการโทร',
'translationContinueInBackground': 'แปลต่อในเบื้องหลัง',
'backgroundRecordingIndicatorBody': 'กำลังบันทึกต่อในเบื้องหลัง แตะเพื่อกลับ',
'backgroundTranslationIndicatorBody': 'กำลังแปลการโทรต่อในเบื้องหลัง แตะเพื่อกลับ',
'meetingRecordScreenCapturePermissionDenied': 'ต้องได้รับสิทธิ์บันทึกหน้าจอเพื่อบันทึกเสียงของระบบ กรุณาอนุญาตแล้วลองใหม่',
'pleaseGrantBluetoothPermission': 'กรุณาอนุญาตสิทธิ์บลูทูธเพื่อสแกน',
'startScanFailed': 'เริ่มสแกนไม่สำเร็จ',
'pleaseLoginFirst': 'กรุณาเข้าสู่ระบบก่อน',
'deviceUnauthorized': 'อุปกรณ์ไม่ได้รับอนุญาต กรุณาลองใหม่',
'boundDevices': 'อุปกรณ์ที่ผูกไว้',
'currentConnection': 'การเชื่อมต่อปัจจุบัน',
'binding': 'กำลังผูกอุปกรณ์...',
'permissionDisabled': 'ปิดอยู่',
'confirmDisablePermissionTitle': 'ปิดสิทธิ์',
'confirmDisablePermissionContent': 'ปิดสิทธิ์ {permission} หรือไม่? คุณจะต้องไปเปิดใหม่ในการตั้งค่าระบบ',
'confirmPermanentlyDisallowTitle': 'ไม่ถามอีก?',
'confirmPermanentlyDisallowContent': 'คุณปฏิเสธสิทธิ์ {permission} ต้องการให้ไม่ถามอีกหรือไม่? หากเลือก "ไม่อนุญาต" คุณยังเปิดได้ภายหลังในการตั้งค่าระบบ',
'notNow': 'ไว้ทีหลัง',
'permanentlyDisallow': 'ไม่อนุญาต',
'otaBesNotSupported': 'หูฟังนี้ยังไม่รองรับการอัปเกรดเฟิร์มแวร์ในแอป กรุณาติดต่อฝ่ายสนับสนุน',
'tabDevice': 'อุปกรณ์',
'tabPlaza': 'พลาซ่า',
'sloganHome': 'เทคโนโลยีเพื่อชีวิตที่ฉลาดขึ้น',
'sloganAi': 'ความอัจฉริยะไร้ขีดจำกัด',
'sloganDevice': 'ฮาร์ดแวร์อัจฉริยะ พร้อมสู่อนาคต',
'sloganPlaza': 'โลกอัจฉริยะที่เข้าใจคุณ',
'aiEditHint': 'กดค้างที่การ์ดเพื่อลากจัดเรียงโมดูล',
'aiEmptyTitle': 'ยังไม่ได้เพิ่มโมดูล',
'aiEmptyDesc': 'เลือกเอเจนต์ที่คุณต้องการได้ในพลาซ่า',
'goToPlaza': 'ไปที่พลาซ่า',
'plazaOnline': 'ใช้งานได้',
'plazaPlanned': 'เร็วๆ นี้',
'plazaSearchHint': 'ค้นหาเอเจนต์ เช่น “แปล”, “บันทึก”',
'moduleRemove': 'นำออก',
'moduleAdd': 'เพิ่ม',
'moduleRemoved': 'นำออกแล้ว',
'moduleAdded': 'เพิ่มแล้ว',
'goSee': 'ดู',
'comingSoonTag': 'เร็วๆ นี้',
'needDeviceTag': 'ต้องใช้อุปกรณ์',
'featureInDev': 'ฟีเจอร์นี้อยู่ระหว่างการพัฒนา',
'featureNotWired': 'ยังไม่พร้อมใช้งาน',
'todayLabel': 'วันนี้',
'deviceConnected': 'เชื่อมต่อแล้ว',
'licenseUnknown': 'ไม่ทราบสถานะสิทธิ์การใช้งาน',
'selectDevice': 'เลือกอุปกรณ์',
'catNoteCard': 'การ์ดบันทึกเสียง AI',
'catNoteCardDesc': 'เครื่องบันทึกเสียงทรงการ์ด',
'catParkerPen': 'ปากกา AI',
'catParkerPenDesc': 'ปากกาบันทึกเสียง',
'catStylusPen': 'สไตลัส AI',
'catStylusPenDesc': 'อุปกรณ์เขียนด้วยลายมือ / สไตลัส',
'catStudyMachine': 'แท็บเล็ตการเรียนรู้ AI',
'catStudyMachineDesc': 'อุปกรณ์เพื่อการเรียนรู้',
'besAddTitle': 'เพิ่มหูฟัง AI',
'besConnecting': 'กำลังเชื่อมต่อหูฟัง…',
'besNotFound': 'ไม่พบหูฟัง',
'besNotFoundIosDesc': 'กรุณาเชื่อมต่อหูฟังใน การตั้งค่า → บลูทูธ ก่อน แล้วกลับมาที่นี่',
'besNotFoundAndroidDesc': 'กรุณาจับคู่หูฟังใน การตั้งค่า → บลูทูธ ก่อน แล้วมาเชื่อมต่อที่นี่',
'besReadFailed': 'อ่านข้อมูลไม่สำเร็จ',
'besConnectFailIos': 'ตรวจสอบว่าหูฟังแสดงสถานะ "เชื่อมต่อแล้ว" ในบลูทูธของระบบ แล้วลองใหม่',
'besConnectFailAndroid': 'ตรวจสอบว่าหูฟังเปิดอยู่ อยู่ใกล้ๆ และไม่ได้เชื่อมต่อกับอุปกรณ์อื่น เมื่อเชื่อมต่อแล้วจะเชื่อมต่อใหม่โดยอัตโนมัติ',
'holderAddTitle': 'เพิ่มขาตั้งโทรศัพท์ AI',
'holderConnecting': 'กำลังเชื่อมต่ออุปกรณ์…',
'holderSearching': 'กำลังค้นหาอุปกรณ์…',
'holderNotFound': 'ไม่พบอุปกรณ์',
'holderPairHint': 'ตรวจสอบว่าขาตั้งเปิดอยู่และอยู่ในโหมดจับคู่',
'holderRetryHint': 'ตรวจสอบว่าอุปกรณ์เปิดอยู่และอยู่ใกล้ๆ แล้วลองใหม่',
'autoConnectTitle': 'เชื่อมต่ออุปกรณ์อัตโนมัติ',
'autoConnectDesc': 'ปลุกแอปเพื่อเชื่อมต่อใหม่และรับการบันทึกเมื่ออุปกรณ์เปิดเครื่องหรืออยู่ใกล้',
'autoConnectConsentTitle': 'เปิดการเชื่อมต่ออัตโนมัติ',
'autoConnectConsentIntro': 'เพื่อให้อุปกรณ์ของคุณ (หูฟัง AI / ขาตั้งโทรศัพท์ AI) เชื่อมต่อโดยอัตโนมัติและบันทึกเสียงเมื่อคุณกดปุ่มบันทึกบนอุปกรณ์ เราต้องขอความยินยอมจากคุณ:',
'autoConnectConsentB1': 'ลงทะเบียนการสแกนบลูทูธในเบื้องหลัง เพื่อให้แอปถูกปลุก (เริ่มทำงานอัตโนมัติ) เมื่อตรวจพบอุปกรณ์ที่ผูกไว้ สำหรับเชื่อมต่อใหม่และรับการบันทึก',
'autoConnectConsentB2': 'เมื่อถูกปลุกแล้ว จะใช้เพื่อเชื่อมต่ออุปกรณ์ บันทึกเสียง และอัปโหลดเท่านั้น ไม่ทำอย่างอื่น และไม่เปิดแอปอื่น',
'autoConnectConsentB3': 'คุณยังใช้แอปได้โดยไม่เปิดฟีเจอร์นี้ เพียงเชื่อมต่ออุปกรณ์ด้วยตนเองทุกครั้ง',
'autoConnectConsentB4': 'คุณปิดได้ทุกเมื่อใน การตั้งค่า → เชื่อมต่ออุปกรณ์อัตโนมัติ',
'autoConnectAgree': 'ยอมรับและเปิดใช้',
'autoConnectLater': 'ไว้ทีหลัง',
'autoConnectRequired': 'การเชื่อมต่ออัตโนมัติปิดอยู่',
'autoConnectRequiredDesc': 'เปิดการเชื่อมต่ออัตโนมัติเพื่อผูกอุปกรณ์นี้',
'recordTapToStart': 'แตะปุ่มด้านล่างเพื่อเริ่มบันทึก',
'recordPaused': 'หยุดชั่วคราว',
'recordMark': 'ทำเครื่องหมาย',
'recordDeleteTitle': 'ลบการบันทึก',
'accountAndProfile': 'บัญชีและโปรไฟล์',
'nicknameUnset': 'ยังไม่ได้ตั้งชื่อเล่น',
'nicknameInput': 'กรอกชื่อเล่น',
'loginMethod': 'วิธีเข้าสู่ระบบ',
'manageMyLogin': 'จัดการการเข้าสู่ระบบ',
'logoutAction': 'ออกจากระบบ',
'warningLabel': 'คำเตือน:',
'deleteAccountWarn': 'บัญชีและไฟล์ทั้งหมดของคุณจะถูกลบอย่างถาวร',
'icpFiling': 'การจดทะเบียน ICP',
'logExport': 'ส่งออกบันทึกระบบ',
'logExportDesc': 'ดูและส่งออกบันทึกระบบของแอป',
'permLoadFailed': 'โหลดกลุ่มสิทธิ์ไม่สำเร็จ',
'permInsufficient': 'ไม่ได้รับสิทธิ์',
'voiceCloneCreating': 'กำลังสร้างเสียงของคุณ…',
'voiceCloneCreate': 'สร้างเสียงของฉัน',
'voiceCloneAgree': 'ยอมรับและอ่านข้อความออกเสียง',
'voiceCloneReadPrompt': 'กรุณาอ่าน:',
'voiceCloneFirstMonthFree': 'ฟรีเดือนแรก',
'voiceCloneEnable': 'เปิดใช้การโคลนเสียง',
'noMusicPlaying': 'ไม่มีเพลงที่กำลังเล่น',
'qqMusicNoSeek': 'QQ Music ไม่รองรับการเลื่อนตำแหน่งเล่น',
'bleScanTitle': 'สแกน BLE',
'bluetoothOn': 'บลูทูธเปิดอยู่',
'bleScanning': 'กำลังสแกน…',
'bleConnectedDevices': 'อุปกรณ์ที่เชื่อมต่อแล้ว',
'bleUnknownDevice': 'อุปกรณ์ที่ไม่รู้จัก',
'bleTapToScan': 'แตะสแกนเพื่อค้นหาอุปกรณ์ BLE',
'weekMon': 'จ.',
'weekTue': 'อ.',
'weekWed': 'พ.',
'weekThu': 'พฤ.',
'weekFri': 'ศ.',
'weekSat': 'ส.',
'weekSun': 'อา.',
'agentMeeting': 'บันทึกเสียงสรุป',
'agentMeetingDesc': 'สรุปการบันทึกเสียง ศูนย์รวมความจำ',
'agentEmai': 'ผู้ช่วย EMAI',
'agentEmaiDesc': 'พูดคุยและถามได้ทุกเรื่องด้วยเสียง',
'agentSimultaneous': 'แปลสด',
'agentSimultaneousDesc': 'แปลทันทีขณะที่คุณพูด',
'agentFaceToFace': 'แปลแบบเห็นหน้า',
'agentFaceToFaceDesc': 'แปลบทสนทนาสองทาง',
'agentCallRecording': 'บันทึกการโทร',
'agentCallRecordingDesc': 'บันทึกสายโทรอัตโนมัติ',
'agentFieldRecording': 'บันทึกเสียงภาคสนาม',
'agentFieldRecordingDesc': 'ผ่านอุปกรณ์หรือไมโครโฟนโทรศัพท์',
'agentCall': 'แปลการโทร',
'agentCallDesc': 'แปลแบบเรียลไทม์ระหว่างโทร',
'agentAudioVideo': 'แปลสื่อ',
'agentAudioVideoDesc': 'จัดเฟรมกล้อง / แปลเสียงและวิดีโอ',
'agentEnglishChat': 'สนทนาภาษาอังกฤษ',
'agentEnglishChatDesc': 'ฝึกพูดแบบเสมือนจริง',
'agentEnglishStudy': 'เรียนภาษาอังกฤษ',
'agentEnglishStudyDesc': 'บทเรียนคำศัพท์และไวยากรณ์',
'agentKidsStory': 'นิทานเด็ก',
'agentKidsStoryDesc': 'นิทานก่อนนอนและถาม-ตอบ',
'moduleRemovedDesc': 'ถูกนำออกจากหน้า AI แล้ว',
'moduleAddedDesc': 'ถูกเพิ่มไว้ท้ายหน้า AI แล้ว',
'calNoRecords': 'ไม่มีรายการ',
'calNoRecordsDay': 'ไม่มีกำหนดการ',
'calTodos': 'สิ่งที่ต้องทำ',
'calExpenses': 'ค่าใช้จ่าย',
'calTodoCount': 'สิ่งที่ต้องทำ @count รายการ',
'calExpenseCount': 'ค่าใช้จ่าย @count รายการ',
'calEntryCount': '@count รายการ',
'calItemsUnit': 'รายการ',
'calCountUnit': 'รายการ',
'calTotalAmount': 'รวม',
'recordPause': 'หยุดชั่วคราว',
'recordResume': 'บันทึกต่อ',
'recordDeleteConfirm': 'ลบการบันทึกนี้หรือไม่?',
'scopeDay': 'วัน',
'scopeWeek': 'สัปดาห์',
'scopeMonth': 'เดือน',
'allDay': 'ทั้งวัน',
'guestModeTitle': 'โหมดผู้เยี่ยมชม',
'guestModeDesc': 'ผู้เยี่ยมชมดูได้อย่างเดียว เข้าสู่ระบบเพื่อใช้บันทึกเสียงสรุป การแปล และการบันทึก ข้อมูลของคุณจะถูกบันทึกไว้ในบัญชี',
'guestKeepBrowsing': 'ดูต่อ',
'guestGoLogin': 'เข้าสู่ระบบ',
'guestViewOnlyTag': 'ดูได้อย่างเดียว',
'autoConnectReenableHint': 'การเชื่อมต่ออัตโนมัติปิดอยู่ เปิดใหม่ได้ใน การตั้งค่า → เชื่อมต่ออุปกรณ์อัตโนมัติ',
'plazaEntryDesc': 'สำรวจและเพิ่มเอเจนต์เพิ่มเติม',
'agentTextTranslation': 'แปลข้อความ',
'agentTextTranslationDesc': 'แปลข้อความที่พิมพ์หรือวาง',
'agentPictureTranslation': 'แปลรูปภาพ',
'agentPictureTranslationDesc': 'ถ่ายหรือเลือกรูปเพื่อแปล',
'callRequiredTitle': 'ต้องอยู่ระหว่างการโทร',
'callRequiredDesc': 'ฟีเจอร์นี้ใช้ได้เฉพาะระหว่างการโทรเท่านั้น',
'inCallBusyTitle': 'ใช้งานไม่ได้ระหว่างการโทร',
'inCallBusyDesc': 'ไมโครโฟนและเส้นทางเสียงกำลังถูกใช้โดยการโทร กรุณาลองใหม่หลังวางสาย',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'ฟรี',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/tl_ph.dart

@ -1946,4 +1946,208 @@ const Map<String, String> tlPH = {
'unmarkFavorite': 'Alisin sa mga paborito',
'unnamedDevice': 'Device na walang pangalan',
'userDataParseFailed': 'Nabigong basahin ang data ng user',
'catEarphone': 'AI Earbuds',
'catEarphoneDesc': 'Classic Bluetooth / BLE earbuds',
'catPhoneHolder': 'AI Phone Stand',
'catPhoneHolderDesc': 'Mga stand device',
'catCarAroma': 'AI Car Diffuser',
'catCarAromaDesc': 'Pang-kotseng aroma diffuser',
'agreeAndContinue': 'Sumang-ayon at Magpatuloy',
'memberServiceAgreement': 'Kasunduan sa Serbisyo ng Membership',
'memberServiceAgreementTitle': 'Kasunduan sa Serbisyo ng Membership',
'memberServiceAgreementIntro': 'Para mabigyan ka ng mas mahusay na serbisyo ng membership, pakibasa nang mabuti bago mag-subscribe: ',
'memberServiceAgreementOutro': '. Sa pag-tap sa "Sumang-ayon at Magpatuloy", kinukumpirma mong nabasa mo nang buo at tinatanggap ang kasunduan sa itaas.',
'memberAgreementRejectedTip': 'Kailangan mong sumang-ayon sa Kasunduan sa Serbisyo ng Membership para magpatuloy.',
'translationCallExitTitle': 'Umalis sa Pagsasalin ng Tawag',
'translationCallExitMessage': 'Piliin ang gagawin bago umalis',
'translationCallEnd': 'Tapusin ang pagsasalin ng tawag',
'translationContinueInBackground': 'Ipagpatuloy ang pagsasalin sa background',
'backgroundRecordingIndicatorBody': 'Nagpapatuloy ang pag-record sa background. I-tap para bumalik.',
'backgroundTranslationIndicatorBody': 'Nagpapatuloy ang pagsasalin ng tawag sa background. I-tap para bumalik.',
'meetingRecordScreenCapturePermissionDenied': 'Kailangan ng pahintulot sa screen recording para ma-record ang audio ng system. Pakibigay ito at subukang muli.',
'pleaseGrantBluetoothPermission': 'Pakibigay ang pahintulot sa Bluetooth para mag-scan',
'startScanFailed': 'Nabigong simulan ang pag-scan',
'pleaseLoginFirst': 'Mag-login muna',
'deviceUnauthorized': 'Hindi awtorisado ang device, pakisubukang muli',
'boundDevices': 'Mga Naka-bind na Device',
'currentConnection': 'Kasalukuyang Koneksyon',
'binding': 'Nagbi-bind...',
'permissionDisabled': 'Naka-off',
'confirmDisablePermissionTitle': 'I-off ang pahintulot',
'confirmDisablePermissionContent': 'I-off ang pahintulot sa {permission}? Kailangan mong pumunta sa mga setting ng system para i-enable itong muli.',
'confirmPermanentlyDisallowTitle': 'Huwag nang magtanong?',
'confirmPermanentlyDisallowContent': 'Tinanggihan mo ang pahintulot sa {permission}. Huwag nang magtanong mula ngayon? Kung pipiliin mo ang "Huwag payagan", maaari mo pa rin itong i-enable sa mga setting ng system.',
'notNow': 'Hindi ngayon',
'permanentlyDisallow': 'Huwag payagan',
'otaBesNotSupported': 'Hindi pa sinusuportahan ng headset na ito ang in-app na pag-upgrade ng firmware. Makipag-ugnayan sa support.',
'tabDevice': 'Mga Device',
'tabPlaza': 'Plaza',
'sloganHome': 'Teknolohiya para sa mas matalinong buhay',
'sloganAi': 'Walang hangganang talino',
'sloganDevice': 'Matalinong hardware, handa sa hinaharap',
'sloganPlaza': 'Matalinong mundong nakakaintindi sa iyo',
'aiEditHint': 'Pindutin nang matagal ang card para i-drag at ayusin ang mga module',
'aiEmptyTitle': 'Wala pang naidadagdag na module',
'aiEmptyDesc': 'Piliin ang mga agent na kailangan mo sa Plaza',
'goToPlaza': 'Pumunta sa Plaza',
'plazaOnline': 'Available',
'plazaPlanned': 'Malapit na',
'plazaSearchHint': 'Maghanap ng agent, hal. “isalin”, “i-record”',
'moduleRemove': 'Alisin',
'moduleAdd': 'Idagdag',
'moduleRemoved': 'Inalis',
'moduleAdded': 'Naidagdag',
'goSee': 'Tingnan',
'comingSoonTag': 'Malapit na',
'needDeviceTag': 'Kailangan ng device',
'featureInDev': 'Ginagawa pa ang feature na ito',
'featureNotWired': 'Hindi pa available',
'todayLabel': 'Ngayon',
'deviceConnected': 'Konektado',
'licenseUnknown': 'Hindi alam ang lisensya',
'selectDevice': 'Pumili ng device',
'catNoteCard': 'AI Recorder Card',
'catNoteCardDesc': 'Recorder na hugis card',
'catParkerPen': 'AI Pen',
'catParkerPenDesc': 'Mga voice recorder pen',
'catStylusPen': 'AI Stylus',
'catStylusPenDesc': 'Mga device para sa sulat-kamay / stylus',
'catStudyMachine': 'AI Learning Pad',
'catStudyMachineDesc': 'Mga device para sa pag-aaral',
'besAddTitle': 'Magdagdag ng AI Earbuds',
'besConnecting': 'Kumokonekta sa earbuds…',
'besNotFound': 'Hindi natagpuan ang earbuds',
'besNotFoundIosDesc': 'Ikonekta muna ang earbuds sa Settings → Bluetooth, pagkatapos ay bumalik dito',
'besNotFoundAndroidDesc': 'I-pair muna ang earbuds sa Settings → Bluetooth, pagkatapos ay kumonekta dito',
'besReadFailed': 'Nabigo ang pagbasa',
'besConnectFailIos': 'Siguraduhing nakalagay na Connected ang earbuds sa Bluetooth ng system, pagkatapos ay subukang muli',
'besConnectFailAndroid': 'Siguraduhing naka-on ang earbuds, malapit, at hindi konektado sa ibang device. Awtomatiko itong muling kokonekta kapag na-link na.',
'holderAddTitle': 'Magdagdag ng AI Phone Stand',
'holderConnecting': 'Kumokonekta sa device…',
'holderSearching': 'Naghahanap ng mga device…',
'holderNotFound': 'Walang natagpuang device',
'holderPairHint': 'Siguraduhing naka-on ang stand at nasa pairing mode',
'holderRetryHint': 'Siguraduhing naka-on at malapit ang device, pagkatapos ay subukang muli',
'autoConnectTitle': 'Auto-connect ng mga device',
'autoConnectDesc': 'Gisingin ang app para muling kumonekta at tumanggap ng mga recording kapag naka-on o malapit ang iyong device',
'autoConnectConsentTitle': 'I-enable ang auto-connect',
'autoConnectConsentIntro': 'Para awtomatikong kumonekta ang iyong device (AI Earbuds / AI Phone Stand) at ma-save ang mga recording kapag pinindot mo ang record button nito, kailangan namin ang iyong pahintulot:',
'autoConnectConsentB1': 'Magrehistro ng background Bluetooth scan para magising (awtomatikong masimulan) ang app kapag na-detect ang iyong naka-bind na device, para muling kumonekta at tumanggap ng mga recording',
'autoConnectConsentB2': 'Kapag nagising, gagamitin lang ito para sa koneksyon ng device, pag-record at pag-upload — wala nang iba, at walang ibang app na bubuksan',
'autoConnectConsentB3': 'Magagamit mo pa rin ang app kahit wala ito — ikonekta na lang nang manu-mano ang device sa bawat pagkakataon',
'autoConnectConsentB4': 'Maaari mo itong i-off anumang oras sa Settings → Auto-connect ng mga device',
'autoConnectAgree': 'Sumang-ayon at i-enable',
'autoConnectLater': 'Hindi ngayon',
'autoConnectRequired': 'Naka-off ang auto-connect',
'autoConnectRequiredDesc': 'I-enable ang auto-connect para i-bind ang device na ito',
'recordTapToStart': 'I-tap ang button sa ibaba para simulan ang pag-record',
'recordPaused': 'Naka-pause',
'recordMark': 'Markahan',
'recordDeleteTitle': 'Burahin ang recording',
'accountAndProfile': 'Account at profile',
'nicknameUnset': 'Walang nakatakdang nickname',
'nicknameInput': 'Maglagay ng nickname',
'loginMethod': 'Paraan ng pag-login',
'manageMyLogin': 'Pamahalaan ang pag-login',
'logoutAction': 'Mag-logout',
'warningLabel': 'Babala:',
'deleteAccountWarn': 'Permanenteng mabubura ang iyong account at lahat ng file.',
'icpFiling': 'ICP filing',
'logExport': 'I-export ang mga log',
'logExportDesc': 'Tingnan at i-export ang mga log ng app',
'permLoadFailed': 'Nabigong i-load ang mga grupo ng pahintulot',
'permInsufficient': 'Tinanggihan ang pahintulot',
'voiceCloneCreating': 'Ginagawa ang iyong boses…',
'voiceCloneCreate': 'Gawin ang aking boses',
'voiceCloneAgree': 'Sumang-ayon at basahin nang malakas ang pahayag',
'voiceCloneReadPrompt': 'Pakibasa:',
'voiceCloneFirstMonthFree': 'Libre ang unang buwan',
'voiceCloneEnable': 'I-enable ang pag-clone ng boses',
'noMusicPlaying': 'Walang tumutugtog',
'qqMusicNoSeek': 'Hindi suportado ang pag-seek para sa QQ Music',
'bleScanTitle': 'BLE scan',
'bluetoothOn': 'Naka-on ang Bluetooth',
'bleScanning': 'Nag-i-scan…',
'bleConnectedDevices': 'Mga konektadong device',
'bleUnknownDevice': 'Hindi kilalang device',
'bleTapToScan': 'I-tap ang scan para maghanap ng mga BLE device',
'weekMon': 'Lun',
'weekTue': 'Mar',
'weekWed': 'Miy',
'weekThu': 'Huw',
'weekFri': 'Biy',
'weekSat': 'Sab',
'weekSun': 'Lin',
'agentMeeting': 'Voice Minutes',
'agentMeetingDesc': 'Buod ng mga recording, memory hub',
'agentEmai': 'EMAI Assistant',
'agentEmaiDesc': 'Makipag-chat at magtanong ng kahit ano gamit ang boses',
'agentSimultaneous': 'Live na Pagsasalin',
'agentSimultaneousDesc': 'Isinasalin habang nagsasalita ka',
'agentFaceToFace': 'Harapan',
'agentFaceToFaceDesc': 'Two-way na pagsasalin ng usapan',
'agentCallRecording': 'Pag-record ng Tawag',
'agentCallRecordingDesc': 'Awtomatikong nase-save ang mga tawag',
'agentFieldRecording': 'Field Recording',
'agentFieldRecordingDesc': 'Mikropono ng device o telepono',
'agentCall': 'Pagsasalin ng Tawag',
'agentCallDesc': 'Real-time na pagsasalin habang tumatawag',
'agentAudioVideo': 'Pagsasalin ng Media',
'agentAudioVideoDesc': 'Camera framing / pagsasalin ng audio at video',
'agentEnglishChat': 'English Chat',
'agentEnglishChatDesc': 'Immersive na pagsasanay sa pagsasalita',
'agentEnglishStudy': 'Pag-aaral ng English',
'agentEnglishStudyDesc': 'Mga aralin sa bokabularyo at grammar',
'agentKidsStory': 'Mga Kuwentong Pambata',
'agentKidsStoryDesc': 'Mga kuwento bago matulog at Q&A',
'moduleRemovedDesc': 'inalis sa AI page',
'moduleAddedDesc': 'idinagdag sa ibaba ng AI page',
'calNoRecords': 'Walang entry',
'calNoRecordsDay': 'Walang nakaiskedyul',
'calTodos': 'Mga gagawin',
'calExpenses': 'Gastos',
'calTodoCount': '@count gagawin',
'calExpenseCount': '@count gastos',
'calEntryCount': '@count entry',
'calItemsUnit': 'item',
'calCountUnit': 'entry',
'calTotalAmount': 'Kabuuan',
'recordPause': 'I-pause',
'recordResume': 'Ipagpatuloy',
'recordDeleteConfirm': 'Burahin ang recording na ito?',
'scopeDay': 'Araw',
'scopeWeek': 'Linggo',
'scopeMonth': 'Buwan',
'allDay': 'Buong araw',
'guestModeTitle': 'Guest mode',
'guestModeDesc': 'Ang mga guest ay maaari lamang mag-browse. Mag-login para gamitin ang voice notes, pagsasalin at pag-record — mase-save ang iyong data sa iyong account.',
'guestKeepBrowsing': 'Magpatuloy sa pag-browse',
'guestGoLogin': 'Mag-login',
'guestViewOnlyTag': 'Tingnan lamang',
'autoConnectReenableHint': 'Naka-off ang auto-connect. I-on itong muli sa Settings → Auto-connect ng mga device.',
'plazaEntryDesc': 'Mag-browse at magdagdag ng higit pang agent',
'agentTextTranslation': 'Pagsasalin ng Teksto',
'agentTextTranslationDesc': 'Isalin ang na-type o na-paste na teksto',
'agentPictureTranslation': 'Pagsasalin ng Larawan',
'agentPictureTranslationDesc': 'Kumuha o pumili ng larawan para isalin',
'callRequiredTitle': 'Kailangan ng tawag',
'callRequiredDesc': 'Gumagana lang ang feature na ito habang may tawag',
'inCallBusyTitle': 'Hindi available habang may tawag',
'inCallBusyDesc': 'Ginagamit ng tawag ang mikropono at audio path. Subukang muli pagkatapos ibaba ang tawag.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Libre',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

206
apps/client/lib/core/translations/language/tr_tr.dart

@ -1900,4 +1900,210 @@ const Map<String, String> trTR = {
'unmarkFavorite': 'Favorilerden çıkar',
'unnamedDevice': 'Adsız cihaz',
'userDataParseFailed': 'Kullanıcı verileri işlenemedi',
'catEarphone': 'AI Kulaklık',
'catEarphoneDesc': 'Klasik Bluetooth / BLE kulaklık',
'catPhoneHolder': 'AI Telefon Tutucu',
'catPhoneHolderDesc': 'Tutucu cihazlar',
'catCarAroma': 'AI Araç Kokusu',
'catCarAromaDesc': 'Araç içi koku difüzörü',
'agreeAndContinue': 'Kabul et ve devam et',
'memberServiceAgreement': 'Üyelik Hizmet Sözleşmesi',
'memberServiceAgreementTitle': 'Üyelik Hizmet Sözleşmesi',
'memberServiceAgreementIntro': 'Size daha iyi üyelik hizmetleri sunabilmemiz için lütfen abone olmadan önce dikkatlice okuyun: ',
'memberServiceAgreementOutro': '. "Kabul et ve devam et"e dokunarak yukarıdaki sözleşmeyi tamamen okuduğunuzu ve kabul ettiğinizi onaylarsınız.',
'memberAgreementRejectedTip': 'Devam etmek için Üyelik Hizmet Sözleşmesi\'ni kabul etmelisiniz.',
'translationCallExitTitle': 'Arama Çevirisinden Çık',
'translationCallExitMessage': 'Ayrılmadan önce ne yapılacağını seçin',
'translationCallEnd': 'Arama çevirisini bitir',
'translationContinueInBackground': 'Arka planda çeviriye devam et',
'backgroundRecordingIndicatorBody': 'Kayıt arka planda devam ediyor. Geri dönmek için dokunun.',
'backgroundTranslationIndicatorBody': 'Arama çevirisi arka planda devam ediyor. Geri dönmek için dokunun.',
'meetingRecordScreenCapturePermissionDenied': 'Sistem sesini kaydetmek için ekran kaydı izni gerekir. Lütfen izin verip tekrar deneyin.',
'pleaseGrantBluetoothPermission': 'Tarama için lütfen Bluetooth izni verin',
'startScanFailed': 'Tarama başlatılamadı',
'pleaseLoginFirst': 'Lütfen önce giriş yapın',
'deviceUnauthorized': 'Cihaz yetkili değil, lütfen tekrar deneyin',
'boundDevices': 'Bağlı Cihazlar',
'currentConnection': 'Mevcut Bağlantı',
'binding': 'Bağlanıyor...',
'permissionDisabled': 'Kapalı',
'confirmDisablePermissionTitle': 'İzni kapat',
'confirmDisablePermissionContent': '{permission} izni kapatılsın mı? Yeniden etkinleştirmek için sistem ayarlarına gitmeniz gerekecek.',
'confirmPermanentlyDisallowTitle': 'Bir daha sorulmasın mı?',
'confirmPermanentlyDisallowContent': '{permission} iznini reddettiniz. Bundan sonra sorulmasın mı? "İzin verme"yi seçerseniz daha sonra sistem ayarlarından yine etkinleştirebilirsiniz.',
'notNow': 'Şimdi değil',
'permanentlyDisallow': 'İzin verme',
'otaUpgradeStarted': 'OTA güncellemesi başladı...',
'otaUpgradeProgressWithPercent': 'Güncelleme ilerlemesi: @percent%',
'otaBesNotSupported': 'Bu kulaklık henüz uygulama içi donanım yazılımı güncellemesini desteklemiyor. Lütfen destek ile iletişime geçin.',
'tabDevice': 'Cihazlar',
'tabPlaza': 'Plaza',
'sloganHome': 'Daha akıllı bir yaşam için teknoloji',
'sloganAi': 'Sınırsız zekâ',
'sloganDevice': 'Akıllı donanım, geleceğe hazır',
'sloganPlaza': 'Sizi anlayan akıllı bir dünya',
'aiEditHint': 'Modülleri sürükleyip yeniden sıralamak için bir karta uzun basın',
'aiEmptyTitle': 'Henüz modül eklenmedi',
'aiEmptyDesc': 'İhtiyacınız olan ajanları Plaza\'dan seçin',
'goToPlaza': 'Plaza\'ya git',
'plazaOnline': 'Kullanılabilir',
'plazaPlanned': 'Çok yakında',
'plazaSearchHint': 'Ajan ara, örn. “çeviri”, “kayıt”',
'moduleRemove': 'Kaldır',
'moduleAdd': 'Ekle',
'moduleRemoved': 'Kaldırıldı',
'moduleAdded': 'Eklendi',
'goSee': 'Görüntüle',
'comingSoonTag': 'Çok yakında',
'needDeviceTag': 'Cihaz gerekli',
'featureInDev': 'Bu özellik geliştirme aşamasında',
'featureNotWired': 'Henüz kullanılamıyor',
'todayLabel': 'Bugün',
'deviceConnected': 'Bağlandı',
'licenseUnknown': 'Lisans bilinmiyor',
'selectDevice': 'Bir cihaz seçin',
'catNoteCard': 'AI Kayıt Kartı',
'catNoteCardDesc': 'Kart şeklinde kayıt cihazı',
'catParkerPen': 'AI Kalem',
'catParkerPenDesc': 'Ses kayıt kalemleri',
'catStylusPen': 'AI Stylus',
'catStylusPenDesc': 'El yazısı / stylus cihazları',
'catStudyMachine': 'AI Öğrenme Tableti',
'catStudyMachineDesc': 'Öğrenme cihazları',
'besAddTitle': 'AI Kulaklık Ekle',
'besConnecting': 'Kulaklığa bağlanılıyor…',
'besNotFound': 'Kulaklık bulunamadı',
'besNotFoundIosDesc': 'Önce Ayarlar → Bluetooth\'tan kulaklığı bağlayın, sonra buraya dönün',
'besNotFoundAndroidDesc': 'Önce Ayarlar → Bluetooth\'tan kulaklığı eşleştirin, sonra buradan bağlanın',
'besReadFailed': 'Okuma başarısız',
'besConnectFailIos': 'Kulaklığın sistem Bluetooth\'unda Bağlı olarak göründüğünden emin olup tekrar deneyin',
'besConnectFailAndroid': 'Kulaklığın açık, yakında ve başka bir cihaza bağlı olmadığından emin olun. Bağlandıktan sonra otomatik olarak yeniden bağlanacaktır.',
'holderAddTitle': 'AI Telefon Tutucu Ekle',
'holderConnecting': 'Cihaza bağlanılıyor…',
'holderSearching': 'Cihazlar aranıyor…',
'holderNotFound': 'Cihaz bulunamadı',
'holderPairHint': 'Tutucunun açık ve eşleştirme modunda olduğundan emin olun',
'holderRetryHint': 'Cihazın açık ve yakında olduğundan emin olup tekrar deneyin',
'autoConnectTitle': 'Cihazlara otomatik bağlan',
'autoConnectDesc': 'Cihazınız açıldığında veya yaklaştığında yeniden bağlanmak ve kayıtları almak için uygulamayı uyandırır',
'autoConnectConsentTitle': 'Otomatik bağlanmayı etkinleştir',
'autoConnectConsentIntro': 'Cihazınızın (AI Kulaklık / AI Telefon Tutucu) otomatik bağlanabilmesi ve kayıt düğmesine bastığınızda kayıtların kaydedilebilmesi için onayınıza ihtiyacımız var:',
'autoConnectConsentB1': 'Bağlı cihazınız algılandığında uygulamanın uyandırılması (otomatik başlatılması), yeniden bağlanması ve kayıtları alması için arka planda Bluetooth taraması kaydedilir',
'autoConnectConsentB2': 'Uyandırıldıktan sonra yalnızca cihaz bağlantısı, kayıt ve yükleme için kullanılır — başka hiçbir şey için değil, başka uygulama da başlatılmaz',
'autoConnectConsentB3': 'Bunu kullanmadan da uygulamayı kullanabilirsiniz — her seferinde cihazı elle bağlamanız yeterlidir',
'autoConnectConsentB4': 'Ayarlar → Cihazlara otomatik bağlan bölümünden istediğiniz zaman kapatabilirsiniz',
'autoConnectAgree': 'Kabul et ve etkinleştir',
'autoConnectLater': 'Şimdi değil',
'autoConnectRequired': 'Otomatik bağlanma kapalı',
'autoConnectRequiredDesc': 'Bu cihazı bağlamak için otomatik bağlanmayı etkinleştirin',
'recordTapToStart': 'Kaydı başlatmak için aşağıdaki düğmeye dokunun',
'recordPaused': 'Duraklatıldı',
'recordMark': 'İşaretle',
'recordDeleteTitle': 'Kaydı sil',
'accountAndProfile': 'Hesap ve profil',
'nicknameUnset': 'Takma ad ayarlanmadı',
'nicknameInput': 'Takma ad girin',
'loginMethod': 'Giriş yöntemi',
'manageMyLogin': 'Girişi yönet',
'logoutAction': 'Çıkış yap',
'warningLabel': 'Uyarı:',
'deleteAccountWarn': 'Hesabınız ve tüm dosyalarınız kalıcı olarak silinecek.',
'icpFiling': 'ICP kaydı',
'logExport': 'Günlükleri dışa aktar',
'logExportDesc': 'Uygulama günlüklerini görüntüleyin ve dışa aktarın',
'permLoadFailed': 'İzin grupları yüklenemedi',
'permInsufficient': 'İzin reddedildi',
'voiceCloneCreating': 'Sesiniz oluşturuluyor…',
'voiceCloneCreate': 'Sesimi oluştur',
'voiceCloneAgree': 'Kabul et ve metni sesli oku',
'voiceCloneReadPrompt': 'Lütfen okuyun:',
'voiceCloneFirstMonthFree': 'İlk ay ücretsiz',
'voiceCloneEnable': 'Ses klonlamayı etkinleştir',
'noMusicPlaying': 'Hiçbir şey çalmıyor',
'qqMusicNoSeek': 'QQ Music için ileri/geri sarma desteklenmiyor',
'bleScanTitle': 'BLE taraması',
'bluetoothOn': 'Bluetooth açık',
'bleScanning': 'Taranıyor…',
'bleConnectedDevices': 'Bağlı cihazlar',
'bleUnknownDevice': 'Bilinmeyen cihaz',
'bleTapToScan': 'BLE cihazlarını aramak için tara\'ya dokunun',
'weekMon': 'Pzt',
'weekTue': 'Sal',
'weekWed': 'Çar',
'weekThu': 'Per',
'weekFri': 'Cum',
'weekSat': 'Cmt',
'weekSun': 'Paz',
'agentMeeting': 'Sesli Tutanak',
'agentMeetingDesc': 'Kayıtları özetleyin, hafıza merkezi',
'agentEmai': 'EMAI Asistan',
'agentEmaiDesc': 'Sesle sohbet edin ve her şeyi sorun',
'agentSimultaneous': 'Canlı Çeviri',
'agentSimultaneousDesc': 'Konuşurken çevirin',
'agentFaceToFace': 'Yüz Yüze',
'agentFaceToFaceDesc': 'İki yönlü konuşma çevirisi',
'agentCallRecording': 'Arama Kaydı',
'agentCallRecordingDesc': 'Aramalar otomatik kaydedilir',
'agentFieldRecording': 'Saha Kaydı',
'agentFieldRecordingDesc': 'Cihaz veya telefon mikrofonu',
'agentCall': 'Arama Çevirisi',
'agentCallDesc': 'Aramalar sırasında gerçek zamanlı çeviri',
'agentAudioVideo': 'Medya Çevirisi',
'agentAudioVideoDesc': 'Kamera çerçeveleme / ses ve video çevirisi',
'agentEnglishChat': 'İngilizce Sohbet',
'agentEnglishChatDesc': 'Sürükleyici konuşma pratiği',
'agentEnglishStudy': 'İngilizce Çalışma',
'agentEnglishStudyDesc': 'Kelime ve dil bilgisi dersleri',
'agentKidsStory': 'Çocuk Hikâyeleri',
'agentKidsStoryDesc': 'Uyku hikâyeleri ve soru-cevap',
'moduleRemovedDesc': 'AI sayfasından kaldırıldı',
'moduleAddedDesc': 'AI sayfasının altına eklendi',
'calNoRecords': 'Kayıt yok',
'calNoRecordsDay': 'Planlanmış bir şey yok',
'calTodos': 'Yapılacaklar',
'calExpenses': 'Harcamalar',
'calTodoCount': '@count yapılacak',
'calExpenseCount': '@count harcama',
'calEntryCount': '@count kayıt',
'calItemsUnit': 'öğe',
'calCountUnit': 'kayıt',
'calTotalAmount': 'Toplam',
'recordPause': 'Duraklat',
'recordResume': 'Devam et',
'recordDeleteConfirm': 'Bu kayıt silinsin mi?',
'scopeDay': 'Gün',
'scopeWeek': 'Hafta',
'scopeMonth': 'Ay',
'allDay': 'Tüm gün',
'guestModeTitle': 'Misafir modu',
'guestModeDesc': 'Misafirler yalnızca göz atabilir. Sesli notlar, çeviri ve kayıt için giriş yapın — verileriniz hesabınıza kaydedilecek.',
'guestKeepBrowsing': 'Gezinmeye devam et',
'guestGoLogin': 'Giriş yap',
'guestViewOnlyTag': 'Yalnızca görüntüleme',
'autoConnectReenableHint': 'Otomatik bağlanma kapalı. Ayarlar → Cihazlara otomatik bağlan bölümünden yeniden açın.',
'plazaEntryDesc': 'Daha fazla ajana göz atın ve ekleyin',
'agentTextTranslation': 'Metin Çevirisi',
'agentTextTranslationDesc': 'Yazılan veya yapıştırılan metni çevirin',
'agentPictureTranslation': 'Fotoğraf Çevirisi',
'agentPictureTranslationDesc': 'Çevirmek için fotoğraf çekin veya seçin',
'callRequiredTitle': 'Arama gerekli',
'callRequiredDesc': 'Bu özellik yalnızca arama sırasında çalışır',
'inCallBusyTitle': 'Arama sırasında kullanılamaz',
'inCallBusyDesc': 'Mikrofon ve ses yolu arama tarafından kullanılıyor. Aramayı kapattıktan sonra tekrar deneyin.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Ücretsiz',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/uk_ua.dart

@ -1920,4 +1920,208 @@ const Map<String, String> ukUA = {
'unmarkFavorite': 'Прибрати з обраного',
'unnamedDevice': 'Пристрій без назви',
'userDataParseFailed': 'Не вдалося обробити дані користувача',
'catEarphone': 'ШІ-навушники',
'catEarphoneDesc': 'Навушники Bluetooth Classic / BLE',
'catPhoneHolder': 'ШІ-тримач телефона',
'catPhoneHolderDesc': 'Пристрої-тримачі',
'catCarAroma': 'ШІ-ароматизатор для авто',
'catCarAromaDesc': 'Автомобільний ароматизатор',
'agreeAndContinue': 'Погодитися й продовжити',
'memberServiceAgreement': 'Угода про членські послуги',
'memberServiceAgreementTitle': 'Угода про членські послуги',
'memberServiceAgreementIntro': 'Щоб надавати вам кращі членські послуги, уважно прочитайте перед оформленням підписки: ',
'memberServiceAgreementOutro': '. Натискаючи «Погодитися й продовжити», ви підтверджуєте, що повністю прочитали й приймаєте наведену вище угоду.',
'memberAgreementRejectedTip': 'Щоб продовжити, потрібно погодитися з Угодою про членські послуги.',
'translationCallExitTitle': 'Вийти з перекладу дзвінка',
'translationCallExitMessage': 'Виберіть, що зробити перед виходом',
'translationCallEnd': 'Завершити переклад дзвінка',
'translationContinueInBackground': 'Продовжити переклад у фоні',
'backgroundRecordingIndicatorBody': 'Запис триває у фоновому режимі. Торкніться, щоб повернутися.',
'backgroundTranslationIndicatorBody': 'Переклад дзвінка триває у фоновому режимі. Торкніться, щоб повернутися.',
'meetingRecordScreenCapturePermissionDenied': 'Для запису системного звуку потрібен дозвіл на запис екрана. Надайте його та повторіть спробу.',
'pleaseGrantBluetoothPermission': 'Надайте дозвіл на Bluetooth для сканування',
'startScanFailed': 'Не вдалося почати сканування',
'pleaseLoginFirst': 'Спочатку увійдіть',
'deviceUnauthorized': 'Пристрій не авторизовано, повторіть спробу',
'boundDevices': 'Прив’язані пристрої',
'currentConnection': 'Поточне підключення',
'binding': 'Прив’язування...',
'permissionDisabled': 'Вимкнено',
'confirmDisablePermissionTitle': 'Вимкнути дозвіл',
'confirmDisablePermissionContent': 'Вимкнути дозвіл {permission}? Щоб увімкнути його знову, потрібно буде перейти в системні налаштування.',
'confirmPermanentlyDisallowTitle': 'Більше не запитувати?',
'confirmPermanentlyDisallowContent': 'Ви відхилили дозвіл {permission}. Більше не запитувати? Якщо вибрати «Не дозволяти», його все одно можна буде ввімкнути пізніше в системних налаштуваннях.',
'notNow': 'Не зараз',
'permanentlyDisallow': 'Не дозволяти',
'otaBesNotSupported': 'Ці навушники поки не підтримують оновлення прошивки із застосунку. Зверніться до служби підтримки.',
'tabDevice': 'Пристрої',
'tabPlaza': 'Площа',
'sloganHome': 'Технології для розумнішого життя',
'sloganAi': 'Безмежний інтелект',
'sloganDevice': 'Розумне обладнання, готове до майбутнього',
'sloganPlaza': 'Розумний світ, який тебе розуміє',
'aiEditHint': 'Утримуйте картку, щоб перетягнути й упорядкувати модулі',
'aiEmptyTitle': 'Модулів ще не додано',
'aiEmptyDesc': 'Оберіть потрібних агентів на Площі',
'goToPlaza': 'Перейти на Площу',
'plazaOnline': 'Доступно',
'plazaPlanned': 'Незабаром',
'plazaSearchHint': 'Пошук агентів, напр. «переклад», «запис»',
'moduleRemove': 'Видалити',
'moduleAdd': 'Додати',
'moduleRemoved': 'Видалено',
'moduleAdded': 'Додано',
'goSee': 'Переглянути',
'comingSoonTag': 'Незабаром',
'needDeviceTag': 'Потрібен пристрій',
'featureInDev': 'Ця функція в розробці',
'featureNotWired': 'Поки що недоступно',
'todayLabel': 'Сьогодні',
'deviceConnected': 'Підключено',
'licenseUnknown': 'Ліцензія невідома',
'selectDevice': 'Виберіть пристрій',
'catNoteCard': 'ШІ-картка-диктофон',
'catNoteCardDesc': 'Диктофон у формі картки',
'catParkerPen': 'ШІ-ручка',
'catParkerPenDesc': 'Ручки з диктофоном',
'catStylusPen': 'ШІ-стилус',
'catStylusPenDesc': 'Пристрої для рукопису / стилуси',
'catStudyMachine': 'ШІ-планшет для навчання',
'catStudyMachineDesc': 'Навчальні пристрої',
'besAddTitle': 'Додати ШІ-навушники',
'besConnecting': 'Підключення до навушників…',
'besNotFound': 'Навушники не знайдено',
'besNotFoundIosDesc': 'Спочатку підключіть навушники в Налаштування → Bluetooth, а потім поверніться сюди',
'besNotFoundAndroidDesc': 'Спочатку створіть пару з навушниками в Налаштування → Bluetooth, а потім підключіть їх тут',
'besReadFailed': 'Помилка читання',
'besConnectFailIos': 'Переконайтеся, що навушники позначено як «Підключено» в системному Bluetooth, і повторіть спробу',
'besConnectFailAndroid': 'Переконайтеся, що навушники ввімкнено, вони поруч і не підключені до іншого пристрою. Після з’єднання вони підключатимуться автоматично.',
'holderAddTitle': 'Додати ШІ-тримач телефона',
'holderConnecting': 'Підключення до пристрою…',
'holderSearching': 'Пошук пристроїв…',
'holderNotFound': 'Пристроїв не знайдено',
'holderPairHint': 'Переконайтеся, що тримач увімкнено й він у режимі створення пари',
'holderRetryHint': 'Переконайтеся, що пристрій увімкнено й він поруч, і повторіть спробу',
'autoConnectTitle': 'Автопідключення пристроїв',
'autoConnectDesc': 'Пробуджує застосунок для повторного підключення та отримання записів, коли пристрій вмикається або опиняється поруч',
'autoConnectConsentTitle': 'Увімкнути автопідключення',
'autoConnectConsentIntro': 'Щоб ваш пристрій (ШІ-навушники / ШІ-тримач телефона) підключався автоматично та зберігав записи після натискання кнопки запису, нам потрібна ваша згода:',
'autoConnectConsentB1': 'Реєстрація фонового сканування Bluetooth, щоб застосунок пробуджувався (запускався автоматично) при виявленні прив’язаного пристрою для повторного підключення та отримання записів',
'autoConnectConsentB2': 'Після пробудження використовується лише для підключення пристрою, запису та завантаження — нічого іншого, інші застосунки не запускаються',
'autoConnectConsentB3': 'Застосунком можна користуватися й без цього — просто підключайте пристрій вручну щоразу',
'autoConnectConsentB4': 'Це можна будь-коли вимкнути в Налаштування → Автопідключення пристроїв',
'autoConnectAgree': 'Погодитися й увімкнути',
'autoConnectLater': 'Не зараз',
'autoConnectRequired': 'Автопідключення вимкнено',
'autoConnectRequiredDesc': 'Увімкніть автопідключення, щоб прив’язати цей пристрій',
'recordTapToStart': 'Натисніть кнопку нижче, щоб почати запис',
'recordPaused': 'Призупинено',
'recordMark': 'Позначка',
'recordDeleteTitle': 'Видалити запис',
'accountAndProfile': 'Обліковий запис і профіль',
'nicknameUnset': 'Псевдонім не задано',
'nicknameInput': 'Введіть псевдонім',
'loginMethod': 'Спосіб входу',
'manageMyLogin': 'Керувати входом',
'logoutAction': 'Вийти',
'warningLabel': 'Увага:',
'deleteAccountWarn': 'Ваш обліковий запис і всі файли буде видалено назавжди.',
'icpFiling': 'Реєстрація ICP',
'logExport': 'Експорт журналів',
'logExportDesc': 'Перегляд і експорт журналів застосунку',
'permLoadFailed': 'Не вдалося завантажити групи дозволів',
'permInsufficient': 'Немає дозволу',
'voiceCloneCreating': 'Створення вашого голосу…',
'voiceCloneCreate': 'Створити мій голос',
'voiceCloneAgree': 'Погодитися й прочитати заяву вголос',
'voiceCloneReadPrompt': 'Прочитайте:',
'voiceCloneFirstMonthFree': 'Перший місяць безкоштовно',
'voiceCloneEnable': 'Увімкнути клонування голосу',
'noMusicPlaying': 'Нічого не відтворюється',
'qqMusicNoSeek': 'Перемотування не підтримується для QQ Music',
'bleScanTitle': 'Сканування BLE',
'bluetoothOn': 'Bluetooth увімкнено',
'bleScanning': 'Сканування…',
'bleConnectedDevices': 'Підключені пристрої',
'bleUnknownDevice': 'Невідомий пристрій',
'bleTapToScan': 'Натисніть «Сканувати», щоб знайти BLE-пристрої',
'weekMon': 'Пн',
'weekTue': 'Вт',
'weekWed': 'Ср',
'weekThu': 'Чт',
'weekFri': 'Пт',
'weekSat': 'Сб',
'weekSun': 'Нд',
'agentMeeting': 'Голосові нотатки',
'agentMeetingDesc': 'Підсумки записів, центр пам’яті',
'agentEmai': 'Асистент EMAI',
'agentEmaiDesc': 'Спілкуйтеся й запитуйте будь-що голосом',
'agentSimultaneous': 'Синхронний переклад',
'agentSimultaneousDesc': 'Переклад під час мовлення',
'agentFaceToFace': 'Віч-на-віч',
'agentFaceToFaceDesc': 'Двосторонній переклад розмови',
'agentCallRecording': 'Запис дзвінка',
'agentCallRecordingDesc': 'Дзвінки зберігаються автоматично',
'agentFieldRecording': 'Запис на місці',
'agentFieldRecordingDesc': 'Мікрофон пристрою або телефона',
'agentCall': 'Переклад дзвінків',
'agentCallDesc': 'Переклад у реальному часі під час дзвінка',
'agentAudioVideo': 'Переклад медіа',
'agentAudioVideoDesc': 'Кадрування камерою / переклад аудіо та відео',
'agentEnglishChat': 'Розмова англійською',
'agentEnglishChatDesc': 'Занурення в розмовну практику',
'agentEnglishStudy': 'Вивчення англійської',
'agentEnglishStudyDesc': 'Уроки лексики та граматики',
'agentKidsStory': 'Дитячі казки',
'agentKidsStoryDesc': 'Казки на ніч і відповіді на запитання',
'moduleRemovedDesc': 'видалено зі сторінки ШІ',
'moduleAddedDesc': 'додано в кінець сторінки ШІ',
'calNoRecords': 'Немає записів',
'calNoRecordsDay': 'Нічого не заплановано',
'calTodos': 'Завдання',
'calExpenses': 'Витрати',
'calTodoCount': 'Завдань: @count',
'calExpenseCount': 'Витрат: @count',
'calEntryCount': 'Записів: @count',
'calItemsUnit': 'елементів',
'calCountUnit': 'записів',
'calTotalAmount': 'Разом',
'recordPause': 'Пауза',
'recordResume': 'Продовжити',
'recordDeleteConfirm': 'Видалити цей запис?',
'scopeDay': 'День',
'scopeWeek': 'Тиждень',
'scopeMonth': 'Місяць',
'allDay': 'Увесь день',
'guestModeTitle': 'Гостьовий режим',
'guestModeDesc': 'Гості можуть лише переглядати. Увійдіть, щоб користуватися голосовими нотатками, перекладом і записом — ваші дані зберігатимуться в обліковому записі.',
'guestKeepBrowsing': 'Продовжити перегляд',
'guestGoLogin': 'Увійти',
'guestViewOnlyTag': 'Лише перегляд',
'autoConnectReenableHint': 'Автопідключення вимкнено. Увімкніть його знову в Налаштування → Автопідключення пристроїв.',
'plazaEntryDesc': 'Переглядайте й додавайте більше агентів',
'agentTextTranslation': 'Текстовий переклад',
'agentTextTranslationDesc': 'Переклад введеного або вставленого тексту',
'agentPictureTranslation': 'Переклад фото',
'agentPictureTranslationDesc': 'Зробіть або виберіть фото для перекладу',
'callRequiredTitle': 'Потрібен дзвінок',
'callRequiredDesc': 'Ця функція працює лише під час дзвінка',
'inCallBusyTitle': 'Недоступно під час дзвінка',
'inCallBusyDesc': 'Мікрофон і аудіотракт зайняті дзвінком. Спробуйте ще раз після завершення дзвінка.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Безкоштовно',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

204
apps/client/lib/core/translations/language/vi_vn.dart

@ -1880,4 +1880,208 @@ const Map<String, String> viVN = {
'unmarkFavorite': 'Bỏ khỏi yêu thích',
'unnamedDevice': 'Thiết bị chưa đặt tên',
'userDataParseFailed': 'Không đọc được dữ liệu người dùng',
'catEarphone': 'Tai nghe AI',
'catEarphoneDesc': 'Tai nghe Bluetooth cổ điển / BLE',
'catPhoneHolder': 'Giá đỡ điện thoại AI',
'catPhoneHolderDesc': 'Thiết bị giá đỡ',
'catCarAroma': 'Máy khuếch tán hương ô tô AI',
'catCarAromaDesc': 'Máy khuếch tán hương trên xe',
'agreeAndContinue': 'Đồng ý và tiếp tục',
'memberServiceAgreement': 'Thỏa thuận dịch vụ hội viên',
'memberServiceAgreementTitle': 'Thỏa thuận dịch vụ hội viên',
'memberServiceAgreementIntro': 'Để cung cấp dịch vụ hội viên tốt hơn, vui lòng đọc kỹ trước khi đăng ký: ',
'memberServiceAgreementOutro': '. Khi nhấn "Đồng ý và tiếp tục", bạn xác nhận đã đọc đầy đủ và chấp nhận thỏa thuận trên.',
'memberAgreementRejectedTip': 'Bạn phải đồng ý Thỏa thuận dịch vụ hội viên để tiếp tục.',
'translationCallExitTitle': 'Thoát dịch cuộc gọi',
'translationCallExitMessage': 'Chọn thao tác trước khi rời đi',
'translationCallEnd': 'Kết thúc dịch cuộc gọi',
'translationContinueInBackground': 'Tiếp tục dịch trong nền',
'backgroundRecordingIndicatorBody': 'Đang tiếp tục ghi âm trong nền. Nhấn để quay lại.',
'backgroundTranslationIndicatorBody': 'Đang tiếp tục dịch cuộc gọi trong nền. Nhấn để quay lại.',
'meetingRecordScreenCapturePermissionDenied': 'Cần quyền ghi màn hình để ghi âm thanh hệ thống. Vui lòng cấp quyền rồi thử lại.',
'pleaseGrantBluetoothPermission': 'Vui lòng cấp quyền Bluetooth để quét',
'startScanFailed': 'Không thể bắt đầu quét',
'pleaseLoginFirst': 'Vui lòng đăng nhập trước',
'deviceUnauthorized': 'Thiết bị chưa được cấp phép, vui lòng thử lại',
'boundDevices': 'Thiết bị đã liên kết',
'currentConnection': 'Kết nối hiện tại',
'binding': 'Đang liên kết...',
'permissionDisabled': 'Đã tắt',
'confirmDisablePermissionTitle': 'Tắt quyền',
'confirmDisablePermissionContent': 'Tắt quyền {permission}? Bạn sẽ cần vào cài đặt hệ thống để bật lại.',
'confirmPermanentlyDisallowTitle': 'Không hỏi lại?',
'confirmPermanentlyDisallowContent': 'Bạn đã từ chối quyền {permission}. Không hỏi lại từ giờ? Nếu chọn "Không cho phép", bạn vẫn có thể bật lại sau trong cài đặt hệ thống.',
'notNow': 'Để sau',
'permanentlyDisallow': 'Không cho phép',
'otaBesNotSupported': 'Tai nghe này chưa hỗ trợ nâng cấp firmware trong ứng dụng. Vui lòng liên hệ bộ phận hỗ trợ.',
'tabDevice': 'Thiết bị',
'tabPlaza': 'Plaza',
'sloganHome': 'Công nghệ cho cuộc sống thông minh hơn',
'sloganAi': 'Trí tuệ không giới hạn',
'sloganDevice': 'Phần cứng thông minh, sẵn sàng cho tương lai',
'sloganPlaza': 'Thế giới thông minh thấu hiểu bạn',
'aiEditHint': 'Nhấn giữ thẻ để kéo và sắp xếp lại mô-đun',
'aiEmptyTitle': 'Chưa thêm mô-đun nào',
'aiEmptyDesc': 'Chọn tác nhân bạn cần trong Plaza',
'goToPlaza': 'Đến Plaza',
'plazaOnline': 'Sẵn sàng',
'plazaPlanned': 'Sắp ra mắt',
'plazaSearchHint': 'Tìm tác nhân, ví dụ “dịch”, “ghi âm”',
'moduleRemove': 'Gỡ',
'moduleAdd': 'Thêm',
'moduleRemoved': 'Đã gỡ',
'moduleAdded': 'Đã thêm',
'goSee': 'Xem',
'comingSoonTag': 'Sắp ra mắt',
'needDeviceTag': 'Cần thiết bị',
'featureInDev': 'Tính năng này đang được phát triển',
'featureNotWired': 'Chưa khả dụng',
'todayLabel': 'Hôm nay',
'deviceConnected': 'Đã kết nối',
'licenseUnknown': 'Không rõ trạng thái cấp phép',
'selectDevice': 'Chọn thiết bị',
'catNoteCard': 'Thẻ ghi âm AI',
'catNoteCardDesc': 'Máy ghi âm dạng thẻ',
'catParkerPen': 'Bút AI',
'catParkerPenDesc': 'Bút ghi âm',
'catStylusPen': 'Bút cảm ứng AI',
'catStylusPenDesc': 'Thiết bị viết tay / bút cảm ứng',
'catStudyMachine': 'Máy học tập AI',
'catStudyMachineDesc': 'Thiết bị học tập',
'besAddTitle': 'Thêm tai nghe AI',
'besConnecting': 'Đang kết nối tai nghe…',
'besNotFound': 'Không tìm thấy tai nghe',
'besNotFoundIosDesc': 'Hãy kết nối tai nghe trong Cài đặt → Bluetooth trước, rồi quay lại đây',
'besNotFoundAndroidDesc': 'Hãy ghép nối tai nghe trong Cài đặt → Bluetooth trước, rồi kết nối tại đây',
'besReadFailed': 'Đọc dữ liệu thất bại',
'besConnectFailIos': 'Hãy chắc chắn tai nghe hiển thị "Đã kết nối" trong Bluetooth hệ thống, rồi thử lại',
'besConnectFailAndroid': 'Hãy chắc chắn tai nghe đã bật, ở gần và không kết nối với thiết bị khác. Sau khi liên kết, tai nghe sẽ tự động kết nối lại.',
'holderAddTitle': 'Thêm giá đỡ điện thoại AI',
'holderConnecting': 'Đang kết nối thiết bị…',
'holderSearching': 'Đang tìm thiết bị…',
'holderNotFound': 'Không tìm thấy thiết bị',
'holderPairHint': 'Hãy chắc chắn giá đỡ đã bật và đang ở chế độ ghép nối',
'holderRetryHint': 'Hãy chắc chắn thiết bị đã bật và ở gần, rồi thử lại',
'autoConnectTitle': 'Tự động kết nối thiết bị',
'autoConnectDesc': 'Đánh thức ứng dụng để kết nối lại và nhận bản ghi âm khi thiết bị bật nguồn hoặc ở gần',
'autoConnectConsentTitle': 'Bật tự động kết nối',
'autoConnectConsentIntro': 'Để thiết bị của bạn (tai nghe AI / giá đỡ điện thoại AI) tự động kết nối và lưu bản ghi âm khi bạn nhấn nút ghi âm trên thiết bị, chúng tôi cần sự đồng ý của bạn:',
'autoConnectConsentB1': 'Đăng ký quét Bluetooth trong nền để ứng dụng được đánh thức (tự khởi động) khi phát hiện thiết bị đã liên kết, nhằm kết nối lại và nhận bản ghi âm',
'autoConnectConsentB2': 'Sau khi được đánh thức, chỉ dùng để kết nối thiết bị, ghi âm và tải lên — không làm gì khác và không mở ứng dụng nào khác',
'autoConnectConsentB3': 'Bạn vẫn có thể dùng ứng dụng mà không bật tính năng này — chỉ cần kết nối thiết bị thủ công mỗi lần',
'autoConnectConsentB4': 'Bạn có thể tắt bất cứ lúc nào trong Cài đặt → Tự động kết nối thiết bị',
'autoConnectAgree': 'Đồng ý và bật',
'autoConnectLater': 'Để sau',
'autoConnectRequired': 'Tự động kết nối đang tắt',
'autoConnectRequiredDesc': 'Bật tự động kết nối để liên kết thiết bị này',
'recordTapToStart': 'Nhấn nút bên dưới để bắt đầu ghi âm',
'recordPaused': 'Đã tạm dừng',
'recordMark': 'Đánh dấu',
'recordDeleteTitle': 'Xóa bản ghi',
'accountAndProfile': 'Tài khoản và hồ sơ',
'nicknameUnset': 'Chưa đặt biệt danh',
'nicknameInput': 'Nhập biệt danh',
'loginMethod': 'Phương thức đăng nhập',
'manageMyLogin': 'Quản lý đăng nhập',
'logoutAction': 'Đăng xuất',
'warningLabel': 'Cảnh báo:',
'deleteAccountWarn': 'Tài khoản và toàn bộ tệp của bạn sẽ bị xóa vĩnh viễn.',
'icpFiling': 'Đăng ký ICP',
'logExport': 'Xuất nhật ký',
'logExportDesc': 'Xem và xuất nhật ký ứng dụng',
'permLoadFailed': 'Không tải được nhóm quyền',
'permInsufficient': 'Không đủ quyền',
'voiceCloneCreating': 'Đang tạo giọng nói của bạn…',
'voiceCloneCreate': 'Tạo giọng nói của tôi',
'voiceCloneAgree': 'Đồng ý và đọc to đoạn tuyên bố',
'voiceCloneReadPrompt': 'Vui lòng đọc:',
'voiceCloneFirstMonthFree': 'Miễn phí tháng đầu',
'voiceCloneEnable': 'Bật sao chép giọng nói',
'noMusicPlaying': 'Không có nhạc đang phát',
'qqMusicNoSeek': 'QQ Music không hỗ trợ tua nhạc',
'bleScanTitle': 'Quét BLE',
'bluetoothOn': 'Bluetooth đã bật',
'bleScanning': 'Đang quét…',
'bleConnectedDevices': 'Thiết bị đã kết nối',
'bleUnknownDevice': 'Thiết bị không xác định',
'bleTapToScan': 'Nhấn quét để tìm thiết bị BLE',
'weekMon': 'T2',
'weekTue': 'T3',
'weekWed': 'T4',
'weekThu': 'T5',
'weekFri': 'T6',
'weekSat': 'T7',
'weekSun': 'CN',
'agentMeeting': 'Biên bản giọng nói',
'agentMeetingDesc': 'Tóm tắt bản ghi âm, trung tâm ký ức',
'agentEmai': 'Trợ lý EMAI',
'agentEmaiDesc': 'Trò chuyện và hỏi mọi thứ bằng giọng nói',
'agentSimultaneous': 'Dịch trực tiếp',
'agentSimultaneousDesc': 'Dịch ngay khi bạn nói',
'agentFaceToFace': 'Mặt đối mặt',
'agentFaceToFaceDesc': 'Dịch hội thoại hai chiều',
'agentCallRecording': 'Ghi âm cuộc gọi',
'agentCallRecordingDesc': 'Tự động lưu cuộc gọi',
'agentFieldRecording': 'Ghi âm hiện trường',
'agentFieldRecordingDesc': 'Qua thiết bị hoặc micro điện thoại',
'agentCall': 'Dịch cuộc gọi',
'agentCallDesc': 'Dịch thời gian thực trong cuộc gọi',
'agentAudioVideo': 'Dịch đa phương tiện',
'agentAudioVideoDesc': 'Khung hình camera / dịch âm thanh và video',
'agentEnglishChat': 'Trò chuyện tiếng Anh',
'agentEnglishChatDesc': 'Luyện nói theo phong cách nhập vai',
'agentEnglishStudy': 'Học tiếng Anh',
'agentEnglishStudyDesc': 'Bài học từ vựng và ngữ pháp',
'agentKidsStory': 'Truyện thiếu nhi',
'agentKidsStoryDesc': 'Truyện trước khi ngủ và hỏi đáp',
'moduleRemovedDesc': 'đã gỡ khỏi trang AI',
'moduleAddedDesc': 'đã thêm vào cuối trang AI',
'calNoRecords': 'Không có mục nào',
'calNoRecordsDay': 'Không có lịch trình',
'calTodos': 'Việc cần làm',
'calExpenses': 'Chi tiêu',
'calTodoCount': '@count việc cần làm',
'calExpenseCount': '@count khoản chi',
'calEntryCount': '@count mục',
'calItemsUnit': 'mục',
'calCountUnit': 'mục',
'calTotalAmount': 'Tổng',
'recordPause': 'Tạm dừng',
'recordResume': 'Tiếp tục',
'recordDeleteConfirm': 'Xóa bản ghi này?',
'scopeDay': 'Ngày',
'scopeWeek': 'Tuần',
'scopeMonth': 'Tháng',
'allDay': 'Cả ngày',
'guestModeTitle': 'Chế độ khách',
'guestModeDesc': 'Khách chỉ có thể xem. Đăng nhập để dùng biên bản giọng nói, dịch và ghi âm — dữ liệu của bạn sẽ được lưu vào tài khoản.',
'guestKeepBrowsing': 'Tiếp tục xem',
'guestGoLogin': 'Đăng nhập',
'guestViewOnlyTag': 'Chỉ xem',
'autoConnectReenableHint': 'Tự động kết nối đang tắt. Bật lại trong Cài đặt → Tự động kết nối thiết bị.',
'plazaEntryDesc': 'Khám phá và thêm tác nhân khác',
'agentTextTranslation': 'Dịch văn bản',
'agentTextTranslationDesc': 'Dịch văn bản đã nhập hoặc dán',
'agentPictureTranslation': 'Dịch hình ảnh',
'agentPictureTranslationDesc': 'Chụp hoặc chọn ảnh để dịch',
'callRequiredTitle': 'Cần có cuộc gọi',
'callRequiredDesc': 'Tính năng này chỉ hoạt động trong cuộc gọi',
'inCallBusyTitle': 'Không khả dụng trong cuộc gọi',
'inCallBusyDesc': 'Micro và đường âm thanh đang được cuộc gọi sử dụng. Hãy thử lại sau khi cúp máy.',
'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Miễn phí',
'smartcarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

16
apps/client/lib/core/translations/language/zh_cn.dart

@ -1685,6 +1685,8 @@ const Map<String, String> zhCN = {
'catNoteCardDesc': '卡片式录音设备',
'catPhoneHolder': 'AI手机支架',
'catPhoneHolderDesc': '支架类设备',
'catCarAroma': 'AI车载香薰',
'catCarAromaDesc': '车载香薰设备',
'catParkerPen': 'AI派克笔',
'catParkerPenDesc': '录音笔类设备',
'catStylusPen': 'AI电容笔',
@ -1924,4 +1926,18 @@ const Map<String, String> zhCN = {
'unmarkFavorite': '取消收藏',
'unnamedDevice': '未命名设备',
'userDataParseFailed': '用户数据解析失败',
'smartcarConnected': '已连接',
'smartcarConnectFailed': '连接失败',
'smartcarConnecting': '正在连接…',
'smartcarSearching': '正在搜索附近的车载香薰…',
'smartcarNotFound': '没有找到设备',
'smartcarScanHint': '请确认设备已通电,并靠近手机',
'vipRequiredTitle': '需要会员',
'vipRequiredDesc': '翻译与会议纪要需要会员。续费会员,或绑定带会员的设备即可使用。',
'vipRequiredGo': '去开通',
'vipFromDevice': '来自设备',
'computeSummary': '算力:设备 @device · 账户 @user',
'computePack': '算力包',
'vipExpiringSoon': '会员还有 @days 天到期,续费后可继续使用翻译与会议纪要',
'computeLow': '算力不多了,用完后将无法继续使用',
};

30
apps/client/lib/core/translations/language/zh_hk.dart

@ -1660,6 +1660,8 @@ const Map<String, String> zhHK = {
'catNoteCardDesc': '卡片式錄音裝置',
'catPhoneHolder': 'AI 手機支架',
'catPhoneHolderDesc': '支架類裝置',
'catCarAroma': 'AI 車載香薰',
'catCarAromaDesc': '車載香薰裝置',
'catParkerPen': 'AI 派克筆',
'catParkerPenDesc': '錄音筆類裝置',
'catStylusPen': 'AI 觸控筆',
@ -1907,4 +1909,32 @@ const Map<String, String> zhHK = {
'unmarkFavorite': '取消收藏',
'unnamedDevice': '未命名裝置',
'userDataParseFailed': '使用者資料解析失敗',
'translationCallExitTitle': '退出通話翻譯',
'translationCallExitMessage': '請選擇離開後的操作',
'translationCallEnd': '結束通話翻譯',
'translationContinueInBackground': '背景繼續翻譯',
'backgroundRecordingIndicatorBody': '正在背景繼續錄音,點擊返回',
'backgroundTranslationIndicatorBody': '正在背景繼續通話翻譯,點擊返回',
'permissionDisabled': '已停用',
'confirmDisablePermissionTitle': '關閉權限',
'confirmDisablePermissionContent': '確定要永久關閉{permission}權限嗎?關閉後需要前往系統設定重新開啟。',
'confirmPermanentlyDisallowTitle': '是否永久不允許',
'confirmPermanentlyDisallowContent': '你拒絕了{permission}權限,是否以後都不再詢問?選擇「永久不允許」後,如需開啟請前往系統設定。',
'notNow': '暫不',
'permanentlyDisallow': '永久不允許',
'otaBesNotSupported': '此耳機暫不支援在 App 內升級韌體,請聯絡客服獲取升級方式',
'smartcarConnected': '已連接',
'smartcarConnectFailed': '連接失敗',
'smartcarConnecting': '正在連接…',
'smartcarSearching': '正在搜尋附近的車載香薰…',
'smartcarNotFound': '沒有找到裝置',
'smartcarScanHint': '請確認裝置已通電,並靠近手機',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

30
apps/client/lib/core/translations/language/zh_tw.dart

@ -1660,6 +1660,8 @@ const Map<String, String> zhTW = {
'catNoteCardDesc': '卡片式錄音裝置',
'catPhoneHolder': 'AI 手機支架',
'catPhoneHolderDesc': '支架類裝置',
'catCarAroma': 'AI 車載香氛',
'catCarAromaDesc': '車載香氛裝置',
'catParkerPen': 'AI 派克筆',
'catParkerPenDesc': '錄音筆類裝置',
'catStylusPen': 'AI 觸控筆',
@ -1907,4 +1909,32 @@ const Map<String, String> zhTW = {
'unmarkFavorite': '取消收藏',
'unnamedDevice': '未命名裝置',
'userDataParseFailed': '使用者資料解析失敗',
'translationCallExitTitle': '退出通話翻譯',
'translationCallExitMessage': '請選擇離開後的操作',
'translationCallEnd': '結束通話翻譯',
'translationContinueInBackground': '背景繼續翻譯',
'backgroundRecordingIndicatorBody': '正在背景繼續錄音,點選返回',
'backgroundTranslationIndicatorBody': '正在背景繼續通話翻譯,點選返回',
'permissionDisabled': '已停用',
'confirmDisablePermissionTitle': '關閉權限',
'confirmDisablePermissionContent': '確定要永久關閉{permission}權限嗎?關閉後需要前往系統設定重新開啟。',
'confirmPermanentlyDisallowTitle': '是否永久不允許',
'confirmPermanentlyDisallowContent': '您拒絕了{permission}權限,是否以後都不再詢問?選擇「永久不允許」後,如需開啟請前往系統設定。',
'notNow': '暫不',
'permanentlyDisallow': '永久不允許',
'otaBesNotSupported': '此耳機暫不支援在 App 內升級韌體,請聯絡客服取得升級方式',
'smartcarConnected': '已連接',
'smartcarConnectFailed': '連接失敗',
'smartcarConnecting': '正在連接…',
'smartcarSearching': '正在搜尋附近的車載香氛…',
'smartcarNotFound': '沒有找到裝置',
'smartcarScanHint': '請確認裝置已通電,並靠近手機',
'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership',
'vipFromDevice': 'from device',
'computeSummary': 'Compute: device @device · account @user',
'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
};

88
apps/client/lib/core/utils/device_connection.dart

@ -1,54 +1,33 @@
import 'package:echomeet_device_sdk/echomeet_device_sdk.dart';
import 'package:get/get.dart';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import '../../data/models/device_images.dart';
import '../../data/models/user_Info.dart';
import '../../data/services/bes_bluetooth_service.dart';
import '../../data/services/holder_device_service.dart';
import '../../modules/home/controllers/home_controller.dart';
import '../../devices/device_hub.dart';
/// 「当前有没有连着设备」的统一判断。
/// 「当前有没有连着设备」的统一判断——[DeviceHub] 之上的薄门面。
///
/// 项目里有三条互相独立的蓝牙链路,之前各页面各判各的:设备页三条都看,
/// AI 页却只看了杰理那条,于是恒玄耳机连上后「通话录音 / 通话翻译」还是灰的。
/// 统一收到这里,谁都别再自己拼。
/// 早先三条蓝牙链路各页面各判各的(设备页三条都看,AI 页只看杰理那条),
/// 现在只有一个真相源。**这里不出现任何厂商名**,要按能力区分就问
/// [DeviceHub.has]。
class DeviceConnection {
DeviceConnection._();
/// 杰理 BLE 耳机
static bool get jieliConnected =>
Get.isRegistered<HomeController>() &&
HomeController.to.isFullyConnectedRx.value;
static DeviceHub get _hub => DeviceHub.to;
/// 蓝讯手机支架
static bool get holderConnected =>
Get.isRegistered<HolderDeviceService>() &&
HolderDeviceService.to.connection.value ==
EchoMeetConnectionState.connected;
/// 任意一台设备在线即算「已连接设备」
static bool get any => _hub.anyConnected.value;
/// 恒玄耳机(经典蓝牙 / iOS 走 GATT 桥接)
static bool get besConnected =>
Get.isRegistered<BesBluetoothService>() &&
BesBluetoothService.to.deviceStatus.value == 2;
/// 任意一条链路连上即算「已连接设备」
static bool get any => jieliConnected || holderConnected || besConnected;
/// 依赖设备的功能是否可用(预留:将来若某功能只认某条链路,在这里区分)
/// 依赖设备的功能是否可用
static bool get canUseDeviceFeatures => any && User.isLoggedIn();
/// 是否正处于通话中。
///
/// 以耳机上报为准:0xD1/0x02 接通 → true,0xD2/0x03 挂断 → false。
/// 是否正处于通话中(由声明了 callState 能力的设备上报)。
/// 通话录音和通话翻译都依赖双路通话音频,不在通话里拿不到数据。
static bool get inCall =>
besConnected && BesBluetoothService.to.isCallOngoing.value;
static bool get inCall => _hub.inCall.value;
/// 有没有一台设备具备该能力
static bool has(DeviceCapability c) => _hub.capabilities.contains(c);
/// 通话中被禁用的功能。
///
/// 通话时耳机的麦克风和音频通路被通话占用(`AA 51` 之后是双路通话流),
/// 这些功能要么抢不到麦、要么会把通话打断,所以直接禁掉:
/// 现场录音、同声翻译、面对面翻译、多媒体翻译。
/// 通话中被禁用的功能:通话时设备的麦克风和音频通路被通话占用。
/// 通话录音和通话翻译反过来——只有通话中才可用。
static const Set<String> blockedDuringCall = {
'fieldRecording',
@ -57,45 +36,22 @@ class DeviceConnection {
'audioVideo',
};
/// 该功能此刻是否因为「正在通话」而不可用
static bool isBlockedByCall(String moduleId) =>
inCall && blockedDuringCall.contains(moduleId);
/// 当前连着的设备名(恒玄耳机 / 蓝讯支架);都没连返回空
static String get connectedName {
if (besConnected) return BesBluetoothService.to.connectedDeviceName.value;
if (holderConnected) return HolderDeviceService.to.connectedName;
return '';
}
/// 当前连着的设备名;都没连返回空
static String get connectedName => _hub.primaryName.value;
/// 当前(或最近一次)连接设备的**产品主图**。
///
/// 取图规则统一在 [DeviceImages]:按设备名找 `<设备名>.png`,找不到退默认图。
/// 设备页、固件升级页、设备管理页都走这一份,避免出现「设备页显示 A、
/// 固件升级页显示 B」这种对不上的情况。
static String currentAssetImage() => DeviceImages.imageFor(_nameForImage());
/// 同一台设备的**小图标**(列表、卡片里用,别拿主图去塞 44px 的位置)
/// 同一台设备的**小图标**
static String currentIconImage() => DeviceImages.iconFor(_nameForImage());
/// 取图用的设备名:优先当前连着的,其次最近连过的。
///
/// 断连后仍然认「最近连过的那台」是刻意的——用户拔掉耳机不代表他换了设备,
/// 这时候把图换成通用图会让人以为设备丢了。从没连过任何设备才返回空。
static String _nameForImage() {
if (besConnected) return BesBluetoothService.to.connectedDeviceName.value;
if (holderConnected) return HolderDeviceService.to.connectedName;
if (Get.isRegistered<HolderDeviceService>()) {
final n = HolderDeviceService.to.lastDeviceName;
if (n.isNotEmpty) return n;
}
if (Get.isRegistered<BesBluetoothService>() &&
BesBluetoothService.to.hasPairedDevice) {
return BesBluetoothService.to.lastDeviceName;
}
return '';
}
/// 断连后仍然认「最近连过的那台」是刻意的——用户拔掉耳机不代表他换了设备。
static String _nameForImage() => _hub.lastKnownName;
/// 是否曾经连过设备(用来决定断连时要不要把图压暗)
static bool get hasKnownDevice => _nameForImage().isNotEmpty;
static bool get hasKnownDevice => _hub.hasKnownDevice;
}

34
apps/client/lib/core/utils/version_compare.dart

@ -0,0 +1,34 @@
/// 版本号比较,与服务端 `comm.CompareVersion` 同口径:按 `.` 切段、每段只取
/// 前导数字(`3+4` → 3,`0-beta` → 0),逐段按整数比。
///
/// 用 `int.parse` 的写法遇到 `1.0.0-beta` / `1.2.3+4` 会直接抛,被外层 catch
/// 吞成「无更新」——表现是「明明有新版本却说已是最新」。
class VersionCompare {
VersionCompare._();
static List<int> split(String v) {
final s = v.trim();
if (s.isEmpty) return const [];
return s.split('.').map((seg) {
final m = RegExp(r'^\d+').firstMatch(seg.trim());
return m == null ? 0 : int.parse(m.group(0)!);
}).toList();
}
/// 负数:a < b;0:相等;正数:a > b
static int compare(String a, String b) {
final pa = split(a);
final pb = split(b);
if (pa.isEmpty || pb.isEmpty) return a.trim().compareTo(b.trim());
final len = pa.length > pb.length ? pa.length : pb.length;
for (var i = 0; i < len; i++) {
final x = i < pa.length ? pa[i] : 0;
final y = i < pb.length ? pb[i] : 0;
if (x != y) return x > y ? 1 : -1;
}
return 0;
}
/// [a] 是否比 [b] 新
static bool isNewer(String a, String b) => compare(a, b) > 0;
}

145
apps/client/lib/data/models/agent_module.dart

@ -5,8 +5,7 @@ import 'device_category.dart';
/// 功能模块(agent)定义。
///
/// AI 页与广场页共用同一份注册表:广场展示全部,AI 页只展示用户启用的那些,
/// 顺序由 [AgentModuleService] 持久化。新增 agent 只需往 [AgentModules.all]
/// 里加一条,两个页面自动生效。
/// 顺序由 [AgentModuleService] 持久化。新增 agent 见 `lib/modules/module_registry.dart`。
class AgentModule {
const AgentModule({
required this.id,
@ -16,6 +15,7 @@ class AgentModule {
this.subtitle,
this.requiresDevice = false,
this.comingSoon = false,
this.bailianAppId,
});
/// 唯一标识,持久化时存的就是它,改名会导致用户已有配置失效
@ -36,6 +36,25 @@ class AgentModule {
/// 尚未实现的占位模块:可以加到 AI 页排版,但点击只提示"敬请期待"
final bool comingSoon;
/// 这个模块用哪个百炼应用(app_id)。null = 用全局默认(EMAI 那个)。
///
/// **设备按键唤醒时用它决定接哪个助手**:`DeviceAiSessionService` 按
/// 厂商 → 品类 → 默认编排第一个模块 → 这个字段,找到该说话的那个 agent。
/// 车载香薰唤醒的是「Smartcar 车载助手」而不是 EMAI,就是靠这一条。
///
/// ⚠️ 放在 data 层的基类而不是 `AgentModuleDescriptor`(modules 层),是为了让
/// `DeviceAiSessionService`(data 层)读得到 —— **data 层不反向依赖 modules 层**。
final String? bailianAppId;
}
/// 按模块 id 取它要接的百炼 app_id。查不到返回 null(用全局默认)。
String? bailianAppIdForModule(String? moduleId) {
if (moduleId == null) return null;
for (final m in AgentModules.all) {
if (m.id == moduleId) return m.bailianAppId;
}
return null;
}
/// 全部可用模块的注册表
@ -53,6 +72,9 @@ class AgentModules {
/// EMAI 助手:原来 AI 页底部那颗悬浮球,改成广场里的一个正式 agent
static const String emaiAssistant = 'emaiAssistant';
/// Smartcar 车载助手(车载香薰的对话 agent,另一个百炼应用)
static const String smartcarChat = 'smartcarChat';
static const String textTranslation = 'textTranslation';
static const String pictureTranslation = 'pictureTranslation';
@ -61,106 +83,10 @@ class AgentModules {
static const String englishStudy = 'englishStudy';
static const String kidsStory = 'kidsStory';
static const List<AgentModule> all = [
AgentModule(
id: meeting,
title: 'agentMeeting',
subtitle: 'agentMeetingDesc',
icon: Icons.groups_2,
gradient: [Color(0xFFAB47BC), Color(0xFFEC407A)],
),
AgentModule(
id: emaiAssistant,
title: 'agentEmai',
subtitle: 'agentEmaiDesc',
icon: Icons.auto_awesome,
gradient: [Color(0xFF5B8DEF), Color(0xFF7E57C2)],
),
AgentModule(
id: simultaneous,
title: 'agentSimultaneous',
subtitle: 'agentSimultaneousDesc',
icon: Icons.record_voice_over,
gradient: [Color(0xFF4CAF50), Color(0xFF009688)],
),
AgentModule(
id: faceToFace,
title: 'agentFaceToFace',
subtitle: 'agentFaceToFaceDesc',
icon: Icons.forum,
gradient: [Color(0xFF42A5F5), Color(0xFF1E88E5)],
),
AgentModule(
id: callRecording,
title: 'agentCallRecording',
subtitle: 'agentCallRecordingDesc',
icon: Icons.fiber_manual_record,
gradient: [Color(0xFFEF5350), Color(0xFFE53935)],
requiresDevice: true,
),
AgentModule(
id: fieldRecording,
title: 'agentFieldRecording',
// 有设备走设备录音通路,没设备退回手机麦克风,两种情况都能用
subtitle: 'agentFieldRecordingDesc',
icon: Icons.mic,
gradient: [Color(0xFF26C6DA), Color(0xFF00838F)],
),
AgentModule(
id: call,
title: 'agentCall',
subtitle: 'agentCallDesc',
icon: Icons.phone_in_talk,
gradient: [Color(0xFFFFA726), Color(0xFFFB8C00)],
requiresDevice: true,
),
AgentModule(
id: audioVideo,
title: 'agentAudioVideo',
subtitle: 'agentAudioVideoDesc',
// 放映机图标:这个功能是音视频内容翻译,用投影/放映的意象更贴切
icon: Icons.slideshow,
gradient: [Color(0xFF5C6BC0), Color(0xFF3949AB)],
),
AgentModule(
id: textTranslation,
title: 'agentTextTranslation',
subtitle: 'agentTextTranslationDesc',
icon: Icons.translate,
gradient: [Color(0xFF26A69A), Color(0xFF00796B)],
),
AgentModule(
id: pictureTranslation,
title: 'agentPictureTranslation',
subtitle: 'agentPictureTranslationDesc',
icon: Icons.image_search,
gradient: [Color(0xFFEC407A), Color(0xFFAD1457)],
),
AgentModule(
id: englishChat,
title: 'agentEnglishChat',
subtitle: 'agentEnglishChatDesc',
icon: Icons.chat_bubble_outline,
gradient: [Color(0xFF7E57C2), Color(0xFF5E35B1)],
comingSoon: true,
),
AgentModule(
id: englishStudy,
title: 'agentEnglishStudy',
subtitle: 'agentEnglishStudyDesc',
icon: Icons.menu_book,
gradient: [Color(0xFF29B6F6), Color(0xFF0288D1)],
comingSoon: true,
),
AgentModule(
id: kidsStory,
title: 'agentKidsStory',
subtitle: 'agentKidsStoryDesc',
icon: Icons.auto_stories,
gradient: [Color(0xFFFF7043), Color(0xFFF4511E)],
comingSoon: true,
),
];
/// 全部模块。由 `ModuleRegistry.install()` 在启动时注入——注册表本体在
/// `lib/modules/module_registry.dart`(描述符要引用各模块的页面,只能放在
/// modules 层;data 层不反向依赖它)。
static List<AgentModule> all = const [];
/// AI 页首次进入时的默认模块。
///
@ -187,11 +113,22 @@ class AgentModules {
/// key 用的是 [DeviceCategories] 的品类 id。只在该品类首次连接时生效一次
/// (由 AgentModuleService 记录已应用过的品类),之后用户怎么增删排序都不再覆盖。
static const Map<String, List<String>> defaultsByCategory = {
// AI手机支架 / AI录音卡片:只有录音相关能力,AI 页就只留这三个
DeviceCategories.phoneHolder: [meeting, fieldRecording, callRecording],
// AI手机支架(EaiRec):连上后 AI 页默认就这三个 —— 语音纪要 / 现场录音 /
// EMAI助手。**不含通话录音**:支架没有接管通话音频的能力,摆在这儿点进去
// 也用不了。
DeviceCategories.phoneHolder: [meeting, fieldRecording, emaiAssistant],
// AI录音卡片是另一条产品线,保持原编排不动。
DeviceCategories.noteCard: [meeting, fieldRecording, callRecording],
// AI耳机(恒玄):通话 + 翻译整套都能跑,铺开全量默认编排
DeviceCategories.earphone: defaultEnabled,
// AI车载香薰(Smartcar):随时开聊 + 现场录音 + 语音纪要。
// 这三个都不依赖设备能力(只有通话录音/翻译才声明 requiredCapabilities),
// 所以连上香薰就能直接用,不会灰。
// ⚠️ 这是**默认编排不是权限限制**——用户仍可去广场把别的功能加回来,
// 这是产品有意接受的(落地文档 6.5)。
// ⚠️ 2026-09-11 从「只有 smartcarChat」改成这三个;已经铺过旧编排的装机
// 由 AgentModuleService 的一次性补铺处理,见那边的 _migrateCarAromaDefaults。
DeviceCategories.carAroma: [smartcarChat, fieldRecording, meeting],
};
static List<String> defaultsForCategory(String? categoryId) {

5
apps/client/lib/data/models/appconfig.dart

@ -49,11 +49,6 @@ class AppConfig {
return names;
}
static List<String> allproductUuids() {
List<String> uuids = config.products.map((user) => user.scanuuid).toList();
return uuids;
}
/// 根据产品名称获取产品配置,查不到返回 null。
///
/// ⚠️ **不要改回 `firstWhere`**:它没有 orElse,查不到时抛 StateError 而不是

29
apps/client/lib/data/models/appconfig_model.dart

@ -3,11 +3,19 @@ import 'package:get/get.dart';
part 'appconfig_model.g.dart'; // 生成的文件名
/// ⚠️ 每个非空字段都带 `defaultValue`:服务端少下发一个键时用默认值,
/// **而不是**整个 fromJson 抛 TypeError 让产品表/设备列表整体空白
/// (见 CLAUDE.md「客户端模型是手写镜像」那一段踩过的坑)。
/// 契约测试在 test/appconfig_model_test.dart。
@JsonSerializable()
class UserGetAppConfigResp {
@JsonKey(defaultValue: <String, String>{})
final Map<String, String> env;
@JsonKey(defaultValue: <DBAgent>[])
final List<DBAgent> agents;
@JsonKey(defaultValue: <String, DBMCPServer>{})
final Map<String, DBMCPServer> mcps;
@JsonKey(defaultValue: <DBProduct>[])
final List<DBProduct> products;
UserGetAppConfigResp(
@ -23,12 +31,19 @@ class UserGetAppConfigResp {
@JsonSerializable()
class DBAgent {
@JsonKey(defaultValue: '')
final String id;
@JsonKey(defaultValue: '')
final String name;
@JsonKey(defaultValue: '')
final String avatarUrl;
@JsonKey(defaultValue: '')
final String description;
@JsonKey(defaultValue: '')
final String tag;
@JsonKey(defaultValue: false)
final bool isOnline;
@JsonKey(defaultValue: '')
final String voice;
final String? welcomeMessage;
final String? voiceWelcomeMessage;
@ -56,9 +71,13 @@ class DBAgent {
@JsonSerializable()
class DBMCPServer {
@JsonKey(defaultValue: '')
final String servername;
@JsonKey(defaultValue: '')
final String url;
@JsonKey(defaultValue: 0)
final int type;
@JsonKey(defaultValue: '')
final String tools;
DBMCPServer({
required this.servername,
@ -74,12 +93,19 @@ class DBMCPServer {
@JsonSerializable()
class DBProduct {
@JsonKey(defaultValue: 0)
final int id;
@JsonKey(defaultValue: 0)
final int factoryid;
/// 服务端已废弃(恒为 0),只为老包兼容保留
@JsonKey(defaultValue: 0)
final int devicetype;
@JsonKey(defaultValue: '')
final String devicename;
final String? productimage;
final String scanuuid;
// ⚠️ scanuuid 已于 2026-09-08 删除:设备校验早就改成按 MAC 走,绑定奖励也是按 MAC 判
// 「这台设备有没有被绑过」,跟这个 UUID 没关系。服务端仍会下发这个键(恒为空串,
// 只为老 App 包不炸),新端直接不解析。
final String? version;
bool? isforceupdate;
@ -103,7 +129,6 @@ class DBProduct {
required this.devicetype,
required this.devicename,
required this.productimage,
required this.scanuuid,
required this.version,
required this.isforceupdate, //该产品固件版本是否强制更新
this.updateonmismatch, //可选:老服务端不下发这个字段,缺省即 null→按 false

59
apps/client/lib/data/models/appconfig_model.g.dart

@ -9,16 +9,23 @@ part of 'appconfig_model.dart';
UserGetAppConfigResp _$UserGetAppConfigRespFromJson(
Map<String, dynamic> json) =>
UserGetAppConfigResp(
env: Map<String, String>.from(json['env'] as Map),
agents: (json['agents'] as List<dynamic>)
.map((e) => DBAgent.fromJson(e as Map<String, dynamic>))
.toList(),
mcps: (json['mcps'] as Map<String, dynamic>).map(
(k, e) => MapEntry(k, DBMCPServer.fromJson(e as Map<String, dynamic>)),
),
products: (json['products'] as List<dynamic>)
.map((e) => DBProduct.fromJson(e as Map<String, dynamic>))
.toList(),
env: (json['env'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
) ??
{},
agents: (json['agents'] as List<dynamic>?)
?.map((e) => DBAgent.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
mcps: (json['mcps'] as Map<String, dynamic>?)?.map(
(k, e) =>
MapEntry(k, DBMCPServer.fromJson(e as Map<String, dynamic>)),
) ??
{},
products: (json['products'] as List<dynamic>?)
?.map((e) => DBProduct.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
Map<String, dynamic> _$UserGetAppConfigRespToJson(
@ -31,13 +38,13 @@ Map<String, dynamic> _$UserGetAppConfigRespToJson(
};
DBAgent _$DBAgentFromJson(Map<String, dynamic> json) => DBAgent(
id: json['id'] as String,
name: json['name'] as String,
avatarUrl: json['avatarUrl'] as String,
description: json['description'] as String,
tag: json['tag'] as String,
isOnline: json['isOnline'] as bool,
voice: json['voice'] as String,
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
avatarUrl: json['avatarUrl'] as String? ?? '',
description: json['description'] as String? ?? '',
tag: json['tag'] as String? ?? '',
isOnline: json['isOnline'] as bool? ?? false,
voice: json['voice'] as String? ?? '',
welcomeMessage: json['welcomeMessage'] as String?,
voiceWelcomeMessage: json['voiceWelcomeMessage'] as String?,
systemPrompt: json['systemPrompt'] as String?,
@ -59,10 +66,10 @@ Map<String, dynamic> _$DBAgentToJson(DBAgent instance) => <String, dynamic>{
};
DBMCPServer _$DBMCPServerFromJson(Map<String, dynamic> json) => DBMCPServer(
servername: json['servername'] as String,
url: json['url'] as String,
type: (json['type'] as num).toInt(),
tools: json['tools'] as String,
servername: json['servername'] as String? ?? '',
url: json['url'] as String? ?? '',
type: (json['type'] as num?)?.toInt() ?? 0,
tools: json['tools'] as String? ?? '',
);
Map<String, dynamic> _$DBMCPServerToJson(DBMCPServer instance) =>
@ -74,12 +81,11 @@ Map<String, dynamic> _$DBMCPServerToJson(DBMCPServer instance) =>
};
DBProduct _$DBProductFromJson(Map<String, dynamic> json) => DBProduct(
id: (json['id'] as num).toInt(),
factoryid: (json['factoryid'] as num).toInt(),
devicetype: (json['devicetype'] as num).toInt(),
devicename: json['devicename'] as String,
id: (json['id'] as num?)?.toInt() ?? 0,
factoryid: (json['factoryid'] as num?)?.toInt() ?? 0,
devicetype: (json['devicetype'] as num?)?.toInt() ?? 0,
devicename: json['devicename'] as String? ?? '',
productimage: json['productimage'] as String?,
scanuuid: json['scanuuid'] as String,
version: json['version'] as String?,
isforceupdate: json['isforceupdate'] as bool?,
updateonmismatch: json['updateonmismatch'] as bool?,
@ -96,7 +102,6 @@ Map<String, dynamic> _$DBProductToJson(DBProduct instance) => <String, dynamic>{
'devicetype': instance.devicetype,
'devicename': instance.devicename,
'productimage': instance.productimage,
'scanuuid': instance.scanuuid,
'version': instance.version,
'isforceupdate': instance.isforceupdate,
'updateonmismatch': instance.updateonmismatch,

87
apps/client/lib/data/models/assistant_directive.dart

@ -4,6 +4,10 @@ import 'package:get/get.dart';
import 'memory_item.dart';
/// 端侧指令处理器:由各能力模块注册,处理完自己把 [AssistantDirective.status]
/// / `note` 填好(见 `AssistantDirectiveService.registerHandlers`)。
typedef DirectiveHandler = void Function(AssistantDirective directive);
/// 助手指令:百炼多模态在 `RespondingContent.extra_info.tool_calls` 里下发的
/// 端侧函数调用。
///
@ -90,7 +94,10 @@ class AssistantDirective {
final extra = output['extra_info'];
if (extra is! Map) return const [];
final calls = extra['tool_calls'];
if (calls is! List || calls.isEmpty) return const [];
final commands = extra['commands'];
final hasCalls = calls is List && calls.isNotEmpty;
final hasCommands = commands is List && commands.isNotEmpty;
if (!hasCalls && !hasCommands) return const [];
final dialogId = (output['dialog_id'] ?? '').toString();
final roundId = (output['round_id'] ?? '').toString();
@ -98,6 +105,16 @@ class AssistantDirective {
final now = DateTime.now();
final result = <AssistantDirective>[];
if (!hasCalls) {
_appendCommands(commands, result,
dialogId: dialogId,
roundId: roundId,
llmRequestId: llmRequestId,
source: source,
now: now,
replyText: (output['text'] ?? '').toString());
return result;
}
for (var i = 0; i < calls.length; i++) {
final call = calls[i];
if (call is! Map) continue;
@ -130,9 +147,77 @@ class AssistantDirective {
replyText: (output['text'] ?? '').toString(),
));
}
if (hasCommands) {
_appendCommands(commands, result,
dialogId: dialogId,
roundId: roundId,
llmRequestId: llmRequestId,
source: source,
now: now,
replyText: (output['text'] ?? '').toString());
}
return result;
}
/// 解析 `extra_info.commands` —— **另一种指令形状**。
///
/// ⚠️ 2026-09-11 真机发现:Smartcar 车载助手下发的指令不在 `tool_calls` 里,
/// 而是 `extra_info.commands`,形如
/// ```json
/// "commands":[{"intent_info":{"domain":"general_command","intent":"quit"},
/// "name":"quit","params":[]}]
/// ```
/// 只解析 `tool_calls` 的话这里恒为 0 条 —— 表现就是"AI 说好的已经关闭了,
/// 但实际什么都没发生"。两种形状都要认,且**不能互斥**:同一帧可能都带。
static void _appendCommands(
List commands,
List<AssistantDirective> out, {
required String dialogId,
required String roundId,
required String llmRequestId,
required String source,
required DateTime now,
required String replyText,
}) {
for (var i = 0; i < commands.length; i++) {
final c = commands[i];
if (c is! Map) continue;
final name = (c['name'] ?? '').toString();
if (name.isEmpty) continue;
// params 形状没有文档,实测是空数组。能转成 Map 就转,转不成就原样留着,
// 别为了凑格式把信息丢了。
final params = c['params'];
final args = <String, dynamic>{};
if (params is Map) {
args.addAll(params.cast<String, dynamic>());
} else if (params is List) {
for (final p in params) {
if (p is Map && p['name'] != null) {
args[p['name'].toString()] = p['value'];
}
}
}
final intent = c['intent_info'];
if (intent is Map && intent['intent'] != null) {
args.putIfAbsent('intent', () => intent['intent']);
}
out.add(AssistantDirective(
id: '${roundId.isEmpty ? now.microsecondsSinceEpoch : roundId}#cmd$i',
name: name,
args: args,
rawArguments: json.encode(c),
dialogId: dialogId,
roundId: roundId,
llmRequestId: llmRequestId,
source: source,
createdAt: now,
replyText: replyText,
));
}
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,

73
apps/client/lib/data/models/device_category.dart

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'appconfig.dart';
import 'appconfig_model.dart';
import 'device_images.dart';
/// 「添加设备」第一步要用户选的产品品类。
///
@ -58,17 +59,21 @@ class DeviceCategories {
static const String studyMachine = 'studyMachine';
static const String noteCard = 'noteCard';
/// AI车载香薰(Smartcar)。2026-09-08 新增。
static const String carAroma = 'carAroma';
// ⚠️ 这里原来有一组写死的图片常量(earphoneImage / earphoneIconImage /
// noteCardImage / phoneHolderImage)和一个按关键字猜图的 imageForDeviceName()。
// 已全部删除:取图改成「按设备名找 <设备名>.png / <设备名>_icon.png」,
// 统一在 [DeviceImages]。加新产品只要按这个命名把图丢进
// assets/images/devices/ 即可,不用再回这里加一条映射。
// ⚠️ 下面的 deviceTypes / nameKeywords 是按服务端现有产品数据推测的,
// 需要产品侧确认后再定稿。未配置的品类会退回扫描全部 UUID。
// ⚠️ **deviceTypes 这个筛选条件已经失效**:服务端 DBProduct.devicetype 于
// 2026-09-08 废弃,下发的值恒为 0,再也匹配不到 1/2/3。留着字段只为不动
// 老数据结构,**新品类一律用 nameKeywords 圈定**。圈不到时 productsOf 返回空。
//
// 「选择设备」页当前只上架 AI耳机(恒玄)这一条品类,手机支架暂时下架到
// [hidden](2026-08-26),要恢复直接把条目挪回 all 即可。
// 「选择设备」页当前上架三条:AI耳机(恒玄)、AI手机支架(EaiRec)、AI车载香薰(Smartcar)。
// 下架某条 = 把它挪回 [hidden]。
static const List<DeviceCategory> all = [
DeviceCategory(
id: earphone,
@ -76,11 +81,32 @@ class DeviceCategories {
subtitle: 'catEarphoneDesc',
icon: Icons.headphones,
gradient: [Color(0xFFAB47BC), Color(0xFF7B1FA2)],
deviceTypes: [
1, // classicBluetoothHeadset
2, // bleBluetoothDoubleHeadset
3, // bleBluetoothSingleHeadset
],
imageAsset: '${DeviceImages.dir}Echo-one.png',
// deviceTypes 已失效(见上),耳机改由名字圈定
nameKeywords: ['echo-one', 'deepvoice', 'deepsound', 'buds', 'ai80', 'a80'],
),
// AI手机支架 = EaiRec(EAIMAR 圆盘)。2026-09-08 上架,取代原先挂在这个
// 品类下、已停售的EaiRec Echomeet 支架 —— 所以沿用 phoneHolder 这个 id:
// AI 页的默认功能编排(会议/现场录音/通话录音)本来就是按它配的,换 id 要一起改。
// ⚠️ 蓝牙链路还没接,点击不进扫描页(见 device_category_view._openCategory)。
DeviceCategory(
id: phoneHolder,
title: 'catPhoneHolder',
subtitle: 'catPhoneHolderDesc',
icon: Icons.stay_current_portrait,
gradient: [Color(0xFF42A5F5), Color(0xFF1E88E5)],
imageAsset: '${DeviceImages.dir}EaiRec.png',
nameKeywords: ['echomeet', 'eairec', 'eaimar', '支架', 'holder', 'stand'],
),
// AI车载香薰 = Smartcar。2026-09-08 新增,蓝牙链路同样未接。
DeviceCategory(
id: carAroma,
title: 'catCarAroma',
subtitle: 'catCarAromaDesc',
icon: Icons.air,
gradient: [Color(0xFFFFB74D), Color(0xFFE65100)],
imageAsset: '${DeviceImages.dir}Smartcar.png',
nameKeywords: ['smartcar', '车载', '香薰', 'aroma', 'diffuser'],
),
];
@ -89,15 +115,8 @@ class DeviceCategories {
/// 只是不展示,定义都留着:AI 页的默认功能编排([AgentModules.defaultsByCategory])
/// 和主图映射还按 id 引用它们,老用户存下来的数据也要能查得到,
/// 所以 [byId] 仍然会在这里查。上架时把条目挪回 [all] 就行。
// 注:phoneHolder 已于 2026-09-08 挪到 [all] 上架(产品换成 EaiRec)。
static const List<DeviceCategory> hidden = [
DeviceCategory(
id: phoneHolder,
title: 'catPhoneHolder',
subtitle: 'catPhoneHolderDesc',
icon: Icons.stay_current_portrait,
gradient: [Color(0xFF42A5F5), Color(0xFF1E88E5)],
nameKeywords: ['支架', 'holder', 'stand', 'echomeet'],
),
DeviceCategory(
id: noteCard,
title: 'catNoteCard',
@ -157,21 +176,7 @@ class DeviceCategories {
}).toList(growable: false);
}
/// 扫描该品类时要下发给 BLE SDK 的 UUID 列表。
///
/// 圈不出产品(品类没配筛选条件,或服务端产品表里暂时没有这类产品)时
/// 返回全部 UUID —— 宁可多扫,也不能让用户扫不到自己的设备。
static List<String> scanUuidsFor(String? categoryId) {
if (!AppConfig.isInitialized()) return const [];
final category = byId(categoryId);
if (category == null) return AppConfig.allproductUuids();
final uuids = productsOf(category)
.map((p) => p.scanuuid)
.where((u) => u.isNotEmpty)
.toSet()
.toList(growable: false);
return uuids.isEmpty ? AppConfig.allproductUuids() : uuids;
}
// ⚠️ 这里原来有个 scanUuidsFor(categoryId),按品类把产品的 scanuuid 拼成 BLE 扫描
// 白名单。scanuuid 已于 2026-09-08 整体废弃(设备校验与绑定奖励都改成按 MAC 判定),
// 扫描不再做 UUID 过滤,改由扫到之后按名字/品类筛。
}

28
apps/client/lib/data/models/goods.dart

@ -13,9 +13,10 @@ enum UsageType {
Meet, // 2
/// 智能体
AI, // 3
/// 智能体
Vip; // 4
/// Vip
/// VIP 时长
Vip, // 4
/// 算力包(2026-09 起;商品的 compute 字段即本次发放量,归账号、不过期)
Compute; // 5
// 解析支持 int 或 string(大小写不敏感)
static UsageType from(dynamic value) {
@ -31,6 +32,8 @@ enum UsageType {
return UsageType.AI;
case 4:
return UsageType.Vip;
case 5:
return UsageType.Compute;
default:
return UsageType.UsageNull;
}
@ -52,6 +55,8 @@ enum UsageType {
return UsageType.AI;
case 'vip':
return UsageType.Vip;
case 'compute':
return UsageType.Compute;
default:
return UsageType.UsageNull;
}
@ -71,6 +76,8 @@ enum UsageType {
return 3;
case UsageType.Vip:
return 4;
case UsageType.Compute:
return 5;
}
}
@ -86,6 +93,8 @@ enum UsageType {
return 'AI';
case UsageType.Vip:
return 'Vip';
case UsageType.Compute:
return 'Compute';
}
}
}
@ -107,6 +116,13 @@ class DBGoods {
final UsageType usagetype;
final int usagenum;
/// 本商品发放的算力(服务端 DBGoods.compute,2026-09 起)。
/// 老商品没配就是 0,服务端发货时会把旧资源点按换算系数折成算力,这里只用于展示。
final int compute;
/// 本商品发放的 VIP 天数(服务端 DBGoods.viptime)。
final int viptime;
const DBGoods({
required this.id,
required this.enable,
@ -115,6 +131,8 @@ class DBGoods {
required this.price,
required this.usagetype,
required this.usagenum,
this.compute = 0,
this.viptime = 0,
});
/// 解析后端 JSON
@ -150,6 +168,8 @@ class DBGoods {
price: _asInt(json['price']),
usagetype: UsageType.from(json['usagetype']),
usagenum: _asInt(json['usagenum']),
compute: _asInt(json['compute']),
viptime: _asInt(json['viptime']),
);
}
@ -163,6 +183,8 @@ class DBGoods {
'price': price,
'usagetype': usagetype.toInt,
'usagenum': usagenum,
'compute': compute,
'viptime': viptime,
};
}

18
apps/client/lib/data/models/user_Info.dart

@ -288,4 +288,22 @@ class UserDevice {
'license': license,
'cmei': cmei,
};
/// 这台设备有没有「值得展示」的授权码。
///
/// ⚠️ 光判非空不够:服务端绑定时写回的是**从设备表查到的** code
/// (`api_binddevice.go` 的 `License: model.Code`,不是客户端传的空串),
/// 而后台「导入 MAC」模式建的记录,`code` 就是 MAC 串本身
/// (`device_mac` 合表迁移里 `code := devicemac`)。于是界面上会出现
/// **MAC 一行、授权码一行、两行值一模一样**,看着像显示了两个 MAC。
/// 这种占位值不展示;真授权码(如 `71E5E5C3A797C043B5A7`)照常展示。
///
/// ⚠️ 规则只放这一份:设备页和「我的设备」页都用它,各写各的必然走岔。
bool get hasRealLicense {
final lic = license.trim();
if (lic.isEmpty) return false;
String norm(String v) =>
v.replaceAll(':', '').replaceAll('-', '').toUpperCase();
return norm(lic) != norm(devicemac);
}
}

6
apps/client/lib/data/services/account_service.dart

@ -10,7 +10,7 @@ import 'db/database_helper.dart';
import '../../data/models/user_Info.dart';
import 'meeting/meeting_upload_service.dart';
import '../../core/utils/toast_throttle.dart';
import 'bes_device_auth.dart';
import '../../devices/device_auth.dart';
import 'mcp_token_service.dart';
import 'memory_reminder_scheduler.dart';
import 'memory_service.dart';
@ -43,7 +43,7 @@ class AccountService extends GetxService {
User.clearUserSession();
// 换账号后同一台耳机要重新登记到新账号名下,
// 不清这个 set 的话「本进程已登记过」会把新账号的绑定吞掉。
BesDeviceAuth.reset();
DeviceAuth.reset();
// MCP 令牌是按 uid 签的:不清的话新账号会拿着上一个人的令牌去调 MCP,
// 读到的是别人的会议纪要和待办。
McpTokenService.reset();
@ -70,7 +70,7 @@ class AccountService extends GetxService {
try {
await _storage.erase();
User.clearUserSession();
BesDeviceAuth.reset();
DeviceAuth.reset();
McpTokenService.reset();
if (Get.isRegistered<MemoryService>()) {
MemoryService.to.clear();

67
apps/client/lib/data/services/agent_module_defaults.dart

@ -1,35 +1,58 @@
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import '../../devices/device_hub.dart';
import '../../devices/device_vendors.dart';
import '../models/agent_module.dart';
import '../models/device_category.dart';
import 'agent_module_service.dart';
/// 设备品类首次连接时,把该品类的默认功能铺到 AI 页。
/// 设备品类**首次连接成功**时,把该品类的默认功能铺到 AI 页。
///
/// AgentModuleService 是 MainTabBinding 里 put 的,蓝牙服务可能比它先跑,
/// 没注册就先建出来(permanent,和 MainTabBinding 里的守卫互不冲突)。
void applyAgentDefaultsFor(String categoryId) {
try {
final service = Get.isRegistered<AgentModuleService>()
? AgentModuleService.to
: Get.put<AgentModuleService>(AgentModuleService(), permanent: true);
service.applyDeviceDefaults(categoryId);
} catch (_) {
// 铺默认功能失败不能影响连接本身
}
}
/// 便捷别名,避免各处再去 import device_category
/// 挂在 [DeviceHub.sessions] 上,谁上线就按厂商 → 品类映射铺一次;
/// 「只铺一次」由 [AgentModuleService.applyDeviceDefaults] 记录。
class AgentDefaults {
AgentDefaults._();
static void onHolderConnected() =>
applyAgentDefaultsFor(DeviceCategories.phoneHolder);
static const Map<String, String> _vendorCategory = {
DeviceVendors.bes: DeviceCategories.earphone,
DeviceVendors.holder: DeviceCategories.phoneHolder,
DeviceVendors.smartcar: DeviceCategories.carAroma,
};
/// 厂商 → 品类。设备按键唤醒时用它找该接哪个 agent(见
/// `DeviceAiSessionService._resolveAgentAppId`)。
static String? categoryForVendor(String vendor) => _vendorCategory[vendor];
static void onEarphoneConnected() =>
applyAgentDefaultsFor(DeviceCategories.earphone);
/// 这个品类默认该由哪个 agent 接管对话 —— 取默认编排里的第一个模块。
/// 找不到返回 null,调用方回退到全局默认(EMAI)。
static String? primaryModuleForVendor(String vendor) {
final cat = _vendorCategory[vendor];
if (cat == null) return null;
final list = AgentModules.defaultsForCategory(cat);
return list.isEmpty ? null : list.first;
}
static Worker? _worker;
static void bind(DeviceHub hub) {
_worker?.dispose();
_worker = ever<List<DeviceSession>>(hub.sessions, (list) {
for (final s in list) {
final cat = _vendorCategory[s.vendor];
if (cat != null) applyAgentDefaultsFor(cat);
}
});
}
static void onJieliConnected(String? categoryId) {
if (categoryId == null || categoryId.isEmpty) return;
applyAgentDefaultsFor(categoryId);
static void applyAgentDefaultsFor(String categoryId) {
try {
final service = Get.isRegistered<AgentModuleService>()
? AgentModuleService.to
: Get.put<AgentModuleService>(AgentModuleService(), permanent: true);
service.applyDeviceDefaults(categoryId);
} catch (_) {
// 铺默认功能失败不能影响连接本身
}
}
}

58
apps/client/lib/data/services/agent_module_service.dart

@ -16,6 +16,10 @@ class AgentModuleService extends GetxService {
/// 已经应用过默认编排的设备品类 id,保证「第一次连接」只铺一次
static const String _appliedCategoriesKey = 'ai_tab_device_defaults_applied';
/// 上一次**自动铺**下去的编排清单。用来判断用户有没有自己动过,
/// 见 [applyDeviceDefaults]。
static const String _lastAppliedKey = 'ai_tab_device_defaults_last';
final GetStorage _storage = GetStorage();
/// AI 页当前展示的模块 id,顺序即展示顺序
@ -140,35 +144,53 @@ class AgentModuleService extends GetxService {
return i == query.length;
}
/// 某个设备品类**第一次连接成功**时铺开它的默认功能。
/// 连上某品类的设备时,把 AI 页铺成这个品类的默认编排。
///
/// 规则只有一条:**只要用户没自己动过编排,AI 页就跟着当前设备走**。
/// 于是「连上车载香薰 → 只剩 Smartcar / 现场录音 / 语音纪要」「换回耳机 → 恢复全量」
/// 都成立,而用户一旦增删或排序过,就再也不会被自动改动。
///
/// 判据是 [_lastAppliedKey]:上一次**自动铺**下去的那份清单。
/// 当前编排和它逐项相同 = 用户没动过;不同 = 动过,直接放手。
///
/// - 只生效一次:品类 id 记在 [_appliedCategoriesKey] 里,之后再连同类设备
/// 不会覆盖用户自己的增删和排序;
/// - 采用「补齐」而不是「替换」:用户已有的模块和顺序原样保留,
/// 缺的按品类默认顺序追加到后面。
/// ⚠️ 2026-09-11 之前的规则是「每个品类只铺一次」,切设备时编排不跟着换——
/// 表现是先连耳机(铺 11 个)再连香薰,香薰的三个编排永远铺不上。
/// 别改回去。
///
/// 三条蓝牙链路(蓝讯支架 / 恒玄耳机 / 杰理)连上后各自调用它,
/// 传自己的 [DeviceCategories] 品类 id。
/// ⚠️ 老装机没有 [_lastAppliedKey] 这条记录,无法判断有没有动过,
/// 按「允许铺一次」处理:升级后第一次连设备会按品类重铺一次 AI 页。
///
/// 采用**整套替换**而不是追加:支架/香薰只有录音与开聊,AI 页就该只剩那几个;
/// 耳机能跑通话和翻译,就铺全量。追加式做不到「只显示这几个」。
bool applyDeviceDefaults(String? categoryId) {
final defaults = AgentModules.defaultsForCategory(categoryId);
if (defaults.isEmpty) return false;
if (defaults.isEmpty || categoryId == null) return false;
final applied = (_storage.read<List>(_appliedCategoriesKey) ?? const [])
.map((e) => e.toString())
.toList();
if (applied.contains(categoryId)) return false;
// 按该设备的能力**整套铺开**,而不是往用户现有编排上追加:
// 支架/录音卡片只有录音能力,AI 页就该只剩三个;耳机能跑通话和翻译,
// 就铺全量。追加式做不到「只显示」这个要求。
final next = defaults
.where((id) => AgentModules.byId(id) != null)
.toList(growable: false);
if (next.isEmpty) return false;
final hasRecord = _storage.hasData(_lastAppliedKey);
if (hasRecord) {
final last = (_storage.read<List>(_lastAppliedKey) ?? const [])
.map((e) => e.toString())
.toList();
// 用户动过编排 → 不再自动改动
if (!_sameOrder(enabledIds, last)) return false;
}
final changed = !_sameOrder(enabledIds, next);
if (changed) enabledIds.assignAll(next);
_storage.write(_lastAppliedKey, next);
applied.add(categoryId!);
_storage.write(_appliedCategoriesKey, applied);
final applied = (_storage.read<List>(_appliedCategoriesKey) ?? const [])
.map((e) => e.toString())
.toList();
if (!applied.contains(categoryId)) {
applied.add(categoryId);
_storage.write(_appliedCategoriesKey, applied);
}
if (changed) _persist();
return changed;
}

19
apps/client/lib/data/services/assistant_directive_service.dart

@ -43,6 +43,14 @@ class AssistantDirectiveService extends GetxService {
final GetStorage _storage = GetStorage();
/// 各能力模块注册的指令处理器(`ModuleRegistry.directiveHandlers`)。
/// 查不到的指令走 [_dispatch] 里的内置处理(SET_clock)或记 unsupported。
final Map<String, DirectiveHandler> _handlers = {};
void registerHandlers(Map<String, DirectiveHandler> handlers) {
_handlers.addAll(handlers);
}
/// 指令流水,新的在前
final RxList<AssistantDirective> directives = <AssistantDirective>[].obs;
@ -98,6 +106,17 @@ class AssistantDirectiveService extends GetxService {
return;
}
final h = _handlers[d.name];
if (h != null) {
try {
h(d);
} catch (e) {
d.status = DirectiveStatus.failed;
d.note = '模块处理失败: $e';
Logger.e(_tag, '指令 ${d.name} 处理失败: $e');
}
return;
}
switch (d.name) {
case 'SET_clock':
_handleSetClock(d);

23
apps/client/lib/data/services/bailian_multimodal_service.dart

@ -87,7 +87,26 @@ class BailianEvent {
/// - `ALIBABA_OPENSPEECH_APP_KEY`:DashScope API Key(与语音翻译、拍照翻译共用),**没有默认值**
/// - `ALIBABA_BAILIAN_WORKSPACE_ID`:业务空间 ID,默认 [_defaultWorkspaceId]
/// - `ALIBABA_BAILIAN_APP_ID`:多模态应用 ID,默认 [_defaultAppId]
///
/// ## 一个应用一个实例
///
/// [appId] / [workspaceId] 传了就用传的,**不传(默认)完全走上面那套全局配置**——
/// 也就是说 EMAI 与设备唤醒会话的行为一个字节都没变。
///
/// 之所以做成实例级而不是改全局:不同 agent 用的是百炼里不同的应用
/// (EMAI 一个、Smartcar 车载助手另一个),改全局值会把所有 agent 一起换掉。
///
/// ⚠️ 每个应用**必须各自 new 一个实例**,不能共用:`start()` 第一件事就是 `stop()`,
/// 共用会让两边互相把对方的会话掐掉,下行音频还会串到对方的播放器上。
class BailianMultimodalService extends GetxService {
BailianMultimodalService({String? appId, String? workspaceId})
: _appIdOverride = appId,
_workspaceIdOverride = workspaceId;
/// 实例级覆盖;null/空 = 用全局配置
final String? _appIdOverride;
final String? _workspaceIdOverride;
static const String _tag = 'BailianMultimodal';
static BailianMultimodalService get to => Get.find();
@ -132,11 +151,15 @@ class BailianMultimodalService extends GetxService {
String get _apiKey => AppConfig.env('ALIBABA_OPENSPEECH_APP_KEY') ?? '';
String get _workspaceId {
final o = _workspaceIdOverride?.trim() ?? '';
if (o.isNotEmpty) return o;
final v = (AppConfig.env('ALIBABA_BAILIAN_WORKSPACE_ID') ?? '').trim();
return v.isEmpty ? _defaultWorkspaceId : v;
}
String get _appId {
final o = _appIdOverride?.trim() ?? '';
if (o.isNotEmpty) return o;
final v = (AppConfig.env('ALIBABA_BAILIAN_APP_ID') ?? '').trim();
return v.isEmpty ? _defaultAppId : v;
}

666
apps/client/lib/data/services/device_ai_session_service.dart

@ -0,0 +1,666 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import '../../core/utils/logger.dart';
import '../../devices/device_hub.dart';
import '../models/agent_module.dart';
import '../models/emai_message.dart';
import '../models/user_Info.dart' as user_info;
import '../utils/cue_tone.dart';
import 'agent_module_defaults.dart';
import 'assistant_directive_service.dart';
import 'bailian_multimodal_service.dart';
import 'emai_chat_store.dart';
import 'mcp_token_service.dart';
/// 设备按键唤醒的 AI 实时对话会话——**厂商无关**,要求设备具备
/// [DeviceCapability.aiWake]。
///
/// 把「设备按键唤醒」接到阿里百炼多模态上:设备麦的音频由插件解码成 16k PCM
/// (`openMic(ai)`)推给百炼,百炼回的 16k PCM 交给 `openSpeaker(ai)` 由插件/原生
/// 编码后按节奏发回设备播放。整条链路上这里只搬运 PCM。
///
/// **握手是设备主导的**,App 只能应答(恒玄时序,其它厂商在插件里对齐到同一语义):
/// ```
/// 设备按键 ──► wakeEvents(ptt) 恒玄: BB 64
/// App ──► ai.accept 恒玄: AA 6A,必须 3 秒内,否则设备停止送音频
/// …会话进行中…
/// App ──► ai.exit 恒玄: AA 65
/// 设备 ──► wakeEvents(hangup) 恒玄: BB E5;2 秒不回就本地强制结束
/// ```
///
/// 常驻服务:按键在任意页面(含 App 在后台、EMAI 页没打开)都要能唤醒。
class DeviceAiSessionService extends GetxService {
static const String _tag = 'DeviceAiSession';
static DeviceAiSessionService get to => Get.find<DeviceAiSessionService>();
/// 设备侧的会话与手机 EMAI 页各用一条独立的 WebSocket。
/// 共用一个单例的话两边会互相 stop 掉对方、下行音频也会串。
///
/// ⚠️ **接哪个百炼应用要看是哪台设备唤醒的**:耳机/支架接 EMAI,车载香薰接
/// 「Smartcar 车载助手」(各自的 app_id 不同)。所以这里不能是 final 单例,
/// 由 [_ensureService] 按厂商换实例;app_id 没变就复用,不白开 WebSocket。
BailianMultimodalService _svc = BailianMultimodalService();
String? _svcAppId;
/// 厂商 → 该接哪个 agent 的 app_id。走的是「厂商 → 品类 → 默认编排第一个模块
/// → 模块声明的 bailianAppId」这条链,**设备层不认识 agent、agent 层不认识厂商**。
/// 查不到返回 null = 用全局默认(EMAI)。
String? _resolveAgentAppId(String vendor) {
try {
return bailianAppIdForModule(AgentDefaults.primaryModuleForVendor(vendor));
} catch (e) {
Logger.w(_tag, '解析 agent app_id 失败(回退默认): $e');
return null;
}
}
/// 按需换一个指向目标 app_id 的服务实例。
void _ensureService(String? appId) {
final want = (appId ?? '').trim();
final cur = (_svcAppId ?? '').trim();
if (want == cur) return;
Logger.i(_tag,
'切换设备侧百炼应用: ${cur.isEmpty ? '(默认)' : cur} → ${want.isEmpty ? '(默认)' : want}');
// 旧实例可能还挂着 WebSocket,先停掉再丢
unawaited(_svc.stop());
_svc = BailianMultimodalService(appId: want.isEmpty ? null : want);
_svcAppId = want;
}
/// 设备上行 PCM 就是 16k/16bit/mono,与百炼上行要求一致
static const int _deviceRate = 16000;
/// 上行攒够这么多字节再发一包:3200B = 100ms @16k。设备每 20ms 上来一包,
/// 逐包转发就是 50 条 WS 消息/秒,攒成 100ms 一条降到 1/5。
static const int _micBatchBytes = 3200;
final BytesBuilder _micAccum = BytesBuilder(copy: false);
/// 收到唤醒后必须在这个时间内应答。我们是立即回,这个常量只是把固件约束写在代码里。
static const Duration _ackDeadline = Duration(seconds: 3);
/// 发出 ai.exit 后等设备回 hangup 的时间,超时就本地强制结束
static const Duration _stopAckTimeout = Duration(seconds: 2);
StreamSubscription? _wakeSub;
StreamSubscription? _hubSub;
StreamSubscription? _micSub;
StreamSubscription? _eventSub;
StreamSubscription? _audioSub;
DeviceSession? _session;
DeviceAudioSource? _mic;
DeviceAudioSink? _spk;
Timer? _stopAckTimer;
Timer? _playoutTimer;
/// 会话是否在跑(已应答,还没收到/发出结束)
final RxBool isActive = false.obs;
/// 手机 EMAI 页(打开时由它自己 attach 上来)。
/// 不用 `Get.put<EmaiSessionOwner>(controller)`:GetX 的 put 会对实例调
/// onInit,而页面正是在自己的 onInit 里注册的,会递归到 Stack Overflow。
EmaiSessionOwner? _phonePage;
int _respondingBytes = 0;
DateTime? _respondingStartedAt;
/// 上行是否被门控住(AI 正在设备里播报期间)。
/// 设备没有把下发的音频接进自己的 AEC,播出去的 AI 声音会被麦克风原样收回,
/// duplex 模式下服务端 VAD 把它当成用户插话。没有 AEC 就只能做半双工。
bool _uplinkGated = false;
Timer? _gateTimer;
bool _tearingDown = false;
static const Duration _gateTail = Duration(milliseconds: 500);
static const Duration _idleTimeout = Duration(seconds: 5);
Timer? _idleTimer;
DateTime? _firstAudioAt;
int _micPackets = 0;
int _downPackets = 0;
EmaiChatStore get _store => EmaiChatStore.to;
EmaiMessage? _streamingUser;
EmaiMessage? _streamingAssistant;
@override
void onInit() {
super.onInit();
final hub = DeviceHub.to;
_wakeSub = hub.wakeEvents.listen(_onWake, onError: (e) {
Logger.e(_tag, '唤醒事件流错误: $e');
});
_hubSub = hub.sessionEvents.listen((e) {
if (e.type == DeviceSessionEventType.connectionStateChanged &&
e.connectionState == DeviceConnectionState.disconnected &&
isActive.value &&
_session?.deviceId == e.deviceId) {
Logger.w(_tag, '设备断连,结束 AI 会话');
_teardown(notifyDevice: false, playEndTone: false);
}
});
Logger.i(_tag, '设备 AI 会话服务已就绪,等待按键唤醒');
}
@override
void onClose() {
_wakeSub?.cancel();
_hubSub?.cancel();
_teardown(notifyDevice: true, playEndTone: false);
super.onClose();
}
// ---------------- 设备事件 ----------------
void _onWake(DeviceWakeEvent e) {
switch (e.reason) {
case WakeReason.ptt:
case WakeReason.voiceWake:
_handleStart(e.deviceId);
break;
case WakeReason.hangup:
Logger.i(_tag, '设备通知结束 AI');
_teardown(notifyDevice: false);
break;
default:
break;
}
}
void _onMicPcm(Uint8List pcm) {
if (!isActive.value || pcm.isEmpty) return;
_micPackets++;
if (_micPackets == 1 || _micPackets % 50 == 0) {
Logger.i(_tag,
'设备上行 #$_micPackets (${pcm.length}B/包)${_uplinkGated ? ' [门控中]' : ''}');
}
// 门控期间推等长静音而不是断流:上行保持连续,服务端 VAD 收到的是
// "真的没人说话",而不是一段数据缺口
_micAccum.add(_uplinkGated ? Uint8List(pcm.length) : pcm);
if (_micAccum.length >= _micBatchBytes) {
_svc.pushAudio(_micAccum.takeBytes());
}
}
Future<void> _handleStart(String deviceId) async {
if (isActive.value) {
Logger.w(_tag, '已有会话在跑,忽略重复唤醒');
return;
}
final hub = DeviceHub.to;
final session =
hub.sessions.firstWhereOrNull((s) => s.deviceId == deviceId) ??
hub.sessionWith(DeviceCapability.aiWake);
if (session == null || session.state != DeviceConnectionState.ready) {
Logger.e(_tag, '设备未处于已连接状态,拒绝本次唤醒');
return;
}
// ★ 先按厂商选中该说话的那个 agent(车载香薰 → Smartcar 车载助手),
// 再去看 isConfigured —— 反过来的话检查的是上一台设备的应用
_ensureService(_resolveAgentAppId(session.vendor));
Logger.i(_tag,
'设备按键唤醒 AI (${session.vendor},应用=${_svcAppId?.isNotEmpty == true ? _svcAppId : '默认'})');
// 起不来就别应答,直接让设备退出 AI 模式——应答之后设备会一直送音频,
// 而我们这边没有会话接,它会白等到超时
if (!_svc.isConfigured) {
Logger.e(_tag, '百炼未配置(${_svc.configHint}),拒绝本次唤醒');
await _sendExit(session);
return;
}
// 固件要求 3 秒内确认,先应答再做后面的初始化。
// 反过来的话,百炼握手(一次 WebSocket 往返)可能就吃掉大半个预算。
final ackAt = DateTime.now();
_session = session;
try {
await session.invokeFeature(DeviceFeatures.aiAccept);
} catch (e) {
Logger.e(_tag, '应答唤醒失败: $e');
_session = null;
return;
}
isActive.value = true;
// 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持)
_aiStateUnsupported = false;
_quitAfterPlayout = false;
_micPackets = 0;
_downPackets = 0;
_uplinkGated = false;
_micAccum.clear();
await _yieldPhonePage();
try {
_mic = await session.openMic(route: AudioRoute.ai);
_micSub = _mic!.pcm.listen(_onMicPcm);
_eventSub = _svc.events.listen(_onBailianEvent);
// 下行 16k:G.722 编码器就是 16k,让服务端直接出 16k 省掉一次重采样
_audioSub = _svc.audioOut.listen(_onDownstreamPcm);
_spk = await session.openSpeaker(route: AudioRoute.ai);
} catch (e) {
Logger.e(_tag, '打开设备音频通路失败: $e');
await _teardown(notifyDevice: true, playEndTone: false);
return;
}
// 先给一声「叮」,让用户立刻知道已经在听了——百炼握手还要一次往返
await _playCue(CueTone.ding(sampleRate: _deviceRate));
_touchActivity();
final ok = await _svc.start(
mode: 'duplex',
downstreamRate: _deviceRate,
userId: _currentUserId(),
source: 'earphone',
mcpToken: await McpTokenService.get(),
);
final elapsed = DateTime.now().difference(ackAt);
if (elapsed > _ackDeadline) {
Logger.w(
_tag,
'会话初始化耗时 ${elapsed.inMilliseconds}ms,已超过固件 '
'${_ackDeadline.inSeconds}s 预算(应答已提前回,通常无碍)');
}
if (!ok) {
Logger.e(_tag, '百炼会话启动失败,结束');
await _teardown(notifyDevice: true);
}
}
// ---------------- 百炼事件 ----------------
void _onBailianEvent(BailianEvent e) {
Logger.i(_tag, '百炼事件 $e');
switch (e.type) {
case BailianEventType.speechStarted:
case BailianEventType.speechContent:
case BailianEventType.speechEnded:
case BailianEventType.respondingStarted:
case BailianEventType.respondingContent:
case BailianEventType.respondingEnded:
_touchActivity();
break;
default:
break;
}
switch (e.type) {
case BailianEventType.speechStarted:
// 只有真人插话才丢队列。门控期间的 speechStarted 是 AI 自己的回声触发的
if (!_uplinkGated) {
_spk?.discardPending();
_cancelPlayoutTimer();
}
_streamingUser = null;
break;
case BailianEventType.speechContent:
_streamingUser ??= _store
.append(EmaiMessage(isUser: true, text: '', fromEarphone: true));
_store.applyText(_streamingUser!, e.text);
if (e.isFinal) {
_store.finalize(_streamingUser);
_streamingUser = null;
}
break;
case BailianEventType.speechEnded:
if (e.text.isNotEmpty && _streamingUser != null) {
_store.applyText(_streamingUser!, e.text);
}
_store.finalize(_streamingUser);
_streamingUser = null;
break;
case BailianEventType.respondingStarted:
_respondingBytes = 0;
_firstAudioAt = null;
_respondingStartedAt = DateTime.now();
_cancelPlayoutTimer();
_streamingAssistant = null;
break;
case BailianEventType.respondingContent:
if (e.directives.isNotEmpty &&
Get.isRegistered<AssistantDirectiveService>()) {
AssistantDirectiveService.to.handle(e.directives);
}
// 「关闭对话」:模型会下发 quit 指令并同时回一句告别语。
// 不能立刻结束——那样告别语还没播完就被掐了;标记下来,等播完再收尾。
if (e.directives.any((d) => d.name == _quitDirective)) {
Logger.i(_tag, '收到 quit 指令,本轮播完后结束会话');
_quitAfterPlayout = true;
}
// 纯指令帧没有文本,别建空气泡
if (e.text.isEmpty && e.finishReason == 'command_calls') break;
_streamingAssistant ??= _store
.append(EmaiMessage(isUser: false, text: '', fromEarphone: true));
_store.applyText(_streamingAssistant!, e.text);
break;
case BailianEventType.respondingEnded:
if (e.text.isNotEmpty && _streamingAssistant != null) {
_store.applyText(_streamingAssistant!, e.text);
}
_store.finalize(_streamingAssistant);
_streamingAssistant = null;
_logResponseAudioStats();
_schedulePlayoutDone();
break;
case BailianEventType.error:
Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}');
_store.dropIfEmpty(_streamingUser);
_streamingUser = null;
_store.dropIfEmpty(_streamingAssistant);
_streamingAssistant = null;
if (e.text.isNotEmpty) {
_store.append(EmaiMessage(
isUser: false,
text: e.text,
isFinal: true,
isError: true,
fromEarphone: true));
_store.persist();
}
if (e.fatal) _teardown(notifyDevice: true);
break;
case BailianEventType.stateChanged:
// 把会话状态同步给设备,驱动它的指示灯/表情动画。
// ⚠️ 只有声明了这个 feature 的厂商会真发字节,其余抛 not_supported 被吞掉。
switch (e.state) {
case BailianDialogState.listening:
_pushAiState(DeviceAiState.listening);
break;
case BailianDialogState.thinking:
_pushAiState(DeviceAiState.thinking);
break;
case BailianDialogState.responding:
_pushAiState(DeviceAiState.speaking);
break;
default:
break;
}
break;
case BailianEventType.closed:
if (isActive.value) {
Logger.w(_tag, '百炼会话关闭,结束设备 AI');
_teardown(notifyDevice: true);
}
break;
default:
break;
}
}
void _onDownstreamPcm(Uint8List pcm) {
if (!isActive.value || pcm.isEmpty) return;
if (_firstAudioAt == null) {
_firstAudioAt = DateTime.now();
_openGate();
}
_respondingBytes += pcm.length;
_downPackets++;
if (_downPackets == 1 || _downPackets % 50 == 0) {
Logger.i(_tag, '下行给设备 #$_downPackets (${pcm.length}B)');
}
_spk?.write(pcm);
}
/// 有动静就把静默计时重新拨回 5 秒。播报期间不计时。
/// 设备是否支持 [DeviceFeatures.aiState]。不支持就别每次都试、别每次都打日志。
bool _aiStateUnsupported = false;
/// 模型下发的「结束对话」指令名(`extra_info.commands` 里的 `name`)
static const String _quitDirective = 'quit';
/// 收到 quit 指令,等这一轮告别语播完就收尾
bool _quitAfterPlayout = false;
/// 把 AI 会话状态推给设备(驱动指示灯/表情)。
///
/// **fire-and-forget**:设备不支持、或这条命令失败,都不该影响对话本身,
/// 所以既不 await 也不往上抛。
void _pushAiState(DeviceAiState state) {
if (_aiStateUnsupported) return;
final s = _session;
if (s == null || s.state != DeviceConnectionState.ready) return;
s.invokeFeature(DeviceFeatures.aiState, {'state': state}).catchError(
(Object e) {
if (e is DeviceException && e.code == DeviceErrorCode.notSupported) {
// 这台设备没有表情/指示灯,记一次就够了
_aiStateUnsupported = true;
Logger.i(_tag, '设备不支持 ai.state,后续不再推送会话状态');
} else {
Logger.w(_tag, '推送 ai.state($state) 失败: $e');
}
return <String, Object?>{};
},
);
}
void _touchActivity() {
_idleTimer?.cancel();
_idleTimer = null;
if (!isActive.value || _uplinkGated) return;
_idleTimer = Timer(_idleTimeout, () {
if (!isActive.value) return;
Logger.i(_tag, '双方 ${_idleTimeout.inSeconds}s 无声,自动结束 AI 会话');
requestStop();
});
}
/// 在设备里放一段提示音(走 AI 下行通道)。播放期间开门控。
Future<void> _playCue(Uint8List pcm, {bool waitForPlayout = false}) async {
final spk = _spk;
if (spk == null || _session?.state != DeviceConnectionState.ready) return;
_openGate();
await spk.write(pcm);
final ms = pcm.length * 1000 ~/ (_deviceRate * 2);
if (waitForPlayout) {
// 结束音要等它真的发完+播完,否则紧接着关通道会把它清掉
await Future.delayed(Duration(milliseconds: ms + 400));
} else {
_gateTimer?.cancel();
_gateTimer = Timer(Duration(milliseconds: ms + 300), _closeGate);
}
}
void _openGate() {
_gateTimer?.cancel();
_gateTimer = null;
if (_uplinkGated) return;
_uplinkGated = true;
_idleTimer?.cancel();
_idleTimer = null;
Logger.i(_tag, '上行门控开启(AI 播报中)');
}
void _closeGate() {
_gateTimer?.cancel();
_gateTimer = null;
if (!_uplinkGated) return;
_uplinkGated = false;
Logger.i(_tag, '上行门控解除');
_touchActivity();
}
void _logResponseAudioStats() {
final start = _firstAudioAt;
if (start == null || _respondingBytes == 0) return;
final ms = DateTime.now().difference(start).inMilliseconds;
final audioMs = _respondingBytes * 1000 ~/ (_deviceRate * 2);
Logger.i(_tag,
'本轮下行音频 ${_respondingBytes}B ≈ ${audioMs}ms(按16k算),实际用时 ${ms}ms');
}
/// 估算设备什么时候把这一轮播完,然后回 `LocalRespondingEnded`。
void _schedulePlayoutDone() {
_cancelPlayoutTimer();
const bytesPerSecond = _deviceRate * 2;
final totalMs = _respondingBytes * 1000 ~/ bytesPerSecond;
final elapsedMs = DateTime.now()
.difference(_respondingStartedAt ?? DateTime.now())
.inMilliseconds;
// 上限 10 分钟,跟原生下行队列的容量对齐
final remainMs = (totalMs - elapsedMs).clamp(0, 600000);
Logger.i(_tag, '预计还需 ${remainMs}ms 播完(已推 ${totalMs}ms 音频)');
_playoutTimer = Timer(Duration(milliseconds: remainMs), () {
if (!isActive.value) return;
_svc.notifyLocalRespondingEnded();
_gateTimer = Timer(_gateTail, _closeGate);
if (_quitAfterPlayout) {
_quitAfterPlayout = false;
Logger.i(_tag, '告别语已播完,按 quit 指令结束会话');
_teardown(notifyDevice: true, playEndTone: false);
}
});
}
void _cancelPlayoutTimer() {
_playoutTimer?.cancel();
_playoutTimer = null;
}
// ---------------- 结束 ----------------
/// 主动结束:发 ai.exit,等设备回 hangup;超时就本地强制收尾。
Future<void> requestStop() async {
if (!isActive.value) return;
Logger.i(_tag, '请求设备退出 AI 模式');
final s = _session;
if (s != null) await _sendExit(s);
_stopAckTimer?.cancel();
_stopAckTimer = Timer(_stopAckTimeout, () {
if (!isActive.value) return;
Logger.w(_tag, '${_stopAckTimeout.inSeconds}s 未收到设备的结束应答,本地强制结束');
_teardown(notifyDevice: false);
});
}
Future<void> _sendExit(DeviceSession s) async {
if (s.state != DeviceConnectionState.ready) return;
try {
await s.invokeFeature(DeviceFeatures.aiExit);
} catch (e) {
Logger.w(_tag, '发送 ai.exit 失败: $e');
}
}
/// [notifyDevice] 为 true 时补发 ai.exit:不是设备主导的结束,必须告诉设备退出。
/// [playEndTone] 为 false 用于设备已经断连的场景。
Future<void> _teardown({
required bool notifyDevice,
bool playEndTone = true,
}) async {
if (_tearingDown) return;
_tearingDown = true;
try {
await _doTeardown(notifyDevice: notifyDevice, playEndTone: playEndTone);
} finally {
_tearingDown = false;
}
}
Future<void> _doTeardown({
required bool notifyDevice,
bool playEndTone = true,
}) async {
_stopAckTimer?.cancel();
_stopAckTimer = null;
_idleTimer?.cancel();
_idleTimer = null;
_cancelPlayoutTimer();
_micAccum.clear();
if (isActive.value && playEndTone) {
// 先把还没播完的 AI 语音丢掉,不然「咚」要排在几十秒的队尾
await _spk?.discardPending();
await _playCue(CueTone.dong(sampleRate: _deviceRate), waitForPlayout: true);
}
_closeGate();
final wasActive = isActive.value;
isActive.value = false;
_store.dropIfEmpty(_streamingUser);
_streamingUser = null;
_store.dropIfEmpty(_streamingAssistant);
_streamingAssistant = null;
if (wasActive) _store.persist();
await _eventSub?.cancel();
_eventSub = null;
await _audioSub?.cancel();
_audioSub = null;
await _micSub?.cancel();
_micSub = null;
await _spk?.close();
_spk = null;
await _mic?.close();
_mic = null;
await _svc.stop();
final s = _session;
if (wasActive && notifyDevice && s != null) await _sendExit(s);
_session = null;
if (wasActive) {
Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)');
await _resumePhonePage();
}
}
// ---------------- 与手机 EMAI 页互斥 ----------------
void attachPhonePage(EmaiSessionOwner page) => _phonePage = page;
void detachPhonePage(EmaiSessionOwner page) {
if (identical(_phonePage, page)) _phonePage = null;
}
Future<void> _yieldPhonePage() async {
final ctrl = _phonePage;
if (ctrl == null) return;
Logger.i(_tag, '手机 EMAI 页正开着,让它让出会话');
try {
await ctrl.yieldToEarphone();
} catch (e) {
Logger.w(_tag, '通知 EMAI 页让出失败: $e');
}
}
Future<void> _resumePhonePage() async {
final ctrl = _phonePage;
if (ctrl == null) return;
try {
await ctrl.resumeFromEarphone();
} catch (e) {
Logger.w(_tag, '通知 EMAI 页恢复失败: $e');
}
}
String _currentUserId() {
try {
return user_info.User.isLoggedIn()
? user_info.User.instance.uid
: 'eaimar-guest';
} catch (_) {
return 'eaimar-guest';
}
}
}
/// 手机 EMAI 页实现这个接口,让设备会话能在不反向依赖 modules 层的前提下
/// 通知它让出/恢复。EmaiController 在 onInit/onClose 里
/// 调 [DeviceAiSessionService.attachPhonePage] / [DeviceAiSessionService.detachPhonePage]。
abstract class EmaiSessionOwner {
/// 设备接管:停掉本页的播放与会话,并且不要自动重连
Future<void> yieldToEarphone();
/// 设备结束:把本页的会话接回来
Future<void> resumeFromEarphone();
}

296
apps/client/lib/data/services/device_call_recording_service.dart

@ -0,0 +1,296 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import '../../core/utils/audio_foreground_service.dart';
import '../../core/utils/logger.dart';
import '../../devices/device_hub.dart';
import 'recording_archive.dart';
import 'wav_writer.dart';
/// **通话录音**的全局服务——厂商无关,要求设备具备 [DeviceCapability.callAudioTap]。
///
/// 两路音频 `openMic(callLocal)`(本端麦)+ `openMic(callPeer)`(对端声音),
/// 落成**双声道 WAV:左=本端,右=对端**。恒玄是 G.722 mode=0x03 交织上行,
/// 原生按帧标好 mic/spk 后分两路给出,这里按到达顺序配对。
///
/// 只在通话中可用([DeviceHub.inCall]),通话结束自动收尾。
class DeviceCallRecordingService extends GetxService {
static const String _tag = 'DeviceCallRecording';
static DeviceCallRecordingService get to => Get.find();
final RxBool isRecording = false.obs;
final RxInt elapsedMs = 0.obs;
final RxDouble level = 0.0.obs;
final RxList<double> waveform = <double>[].obs;
static const int sampleIntervalMs = 100;
static const int _maxSamples = 600;
StreamSubscription? _hubSub;
Worker? _callWorker;
DeviceSession? _session;
DeviceAudioSource? _local;
DeviceAudioSource? _peer;
StreamSubscription<Uint8List>? _localSub;
StreamSubscription<Uint8List>? _peerSub;
/// 暂存的对端帧,等本端帧到齐后一起交织写盘
Uint8List? _pendingPeer;
int _frameCount = 0;
Timer? _silenceCheck;
WavWriter? _wav;
String? _path;
Timer? _tick;
Timer? _sampleTimer;
final Stopwatch _stopwatch = Stopwatch();
bool _busy = false;
/// 本次录音是不是设备按键发起的。设备侧处于录音态时,停止要额外补一条
/// `record.stop` 让它自己也退出来,只发 `call.stop` 不足以清掉设备端的录音状态。
bool _deviceRecordingActive = false;
@override
void onInit() {
super.onInit();
_hubSub = DeviceHub.to.sessionEvents.listen(_onSessionEvent);
// 通话结束时如果还在录,收尾归档(不能让文件一直挂着)
_callWorker = ever<bool>(DeviceHub.to.inCall, (calling) async {
if (!calling && isRecording.value && !_busy) {
Logger.i(_tag, '通话结束,停止并归档');
await stop(sendCommand: true);
}
});
}
@override
void onClose() {
_hubSub?.cancel();
_callWorker?.dispose();
_localSub?.cancel();
_peerSub?.cancel();
_tick?.cancel();
_sampleTimer?.cancel();
super.onClose();
}
void _onSessionEvent(DeviceSessionEvent e) async {
switch (e.type) {
case DeviceSessionEventType.feature:
if (_busy) return;
final key = e.feature?.key;
// 通话开始**不再自动录音**。入口只有两个:录音页点开始、设备按录音键。
if (key == DeviceFeatureKeys.recordStarted && !isRecording.value) {
if (!DeviceHub.to.inCall.value) return; // 不在通话里的按键归现场录音管
Logger.i(_tag, '设备端按下录音键,开始通话录音');
_deviceRecordingActive = true;
await start(sendCommand: true, deviceId: e.deviceId);
} else if (key == DeviceFeatureKeys.recordStopped && isRecording.value) {
Logger.i(_tag, '设备端结束录音,落盘并归档');
_deviceRecordingActive = false;
await stop(sendCommand: true);
}
break;
case DeviceSessionEventType.connectionStateChanged:
if (e.connectionState == DeviceConnectionState.disconnected &&
isRecording.value &&
_session?.deviceId == e.deviceId) {
Logger.w(_tag, '设备断开,结束当前通话录音');
stop();
}
break;
default:
break;
}
}
Future<bool> start({bool sendCommand = false, String? deviceId}) async {
if (isRecording.value || _busy) return isRecording.value;
_busy = true;
try {
return await _start(sendCommand, deviceId);
} finally {
_busy = false;
}
}
Future<bool> _start(bool sendCommand, String? deviceId) async {
final hub = DeviceHub.to;
final session = (deviceId == null
? null
: hub.sessions.firstWhereOrNull((s) => s.deviceId == deviceId)) ??
hub.sessionWith(DeviceCapability.callAudioTap);
if (session == null) return false;
_session = session;
try {
final docs = await getApplicationDocumentsDirectory();
final dir = Directory('${docs.path}/call_recordings');
if (!await dir.exists()) await dir.create(recursive: true);
final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
_path = '${dir.path}/call_$ts.wav';
_wav = await WavWriter.open(_path!, channels: 2);
_pendingPeer = null;
_frameCount = 0;
_peer = await session.openMic(route: AudioRoute.callPeer);
_local = await session.openMic(route: AudioRoute.callLocal);
_peerSub = _peer!.pcm.listen(_onPeer);
_localSub = _local!.pcm.listen(_onLocal);
// 进通话模式,设备才会双路上行。不管是不是 App 主动发起的都补发一次——
// 只靠设备自己进通话模式时,它未必会把音频回传给 App。
await session.invokeFeature(DeviceFeatures.callStart);
// 2 秒还没收到任何音频帧就再补一次指令,并把情况打进日志
_silenceCheck?.cancel();
_silenceCheck = Timer(const Duration(seconds: 2), () async {
if (!isRecording.value) return;
if (_frameCount == 0) {
Logger.w(_tag, '进通话模式 2s 内没收到任何音频帧,重发 call.start');
try {
await session.invokeFeature(DeviceFeatures.callStart);
} catch (_) {}
} else {
Logger.i(_tag, '通话音频已上行,2s 内收到 $_frameCount 帧');
}
});
// 把进程钉住,否则切后台后录音会被国内 ROM 悄悄掐断
unawaited(AudioForegroundService.start());
isRecording.value = true;
elapsedMs.value = 0;
level.value = 0;
waveform.clear();
_stopwatch
..reset()
..start();
_tick = Timer.periodic(const Duration(milliseconds: 200), (_) {
elapsedMs.value = _stopwatch.elapsedMilliseconds;
});
_sampleTimer = Timer.periodic(
const Duration(milliseconds: sampleIntervalMs),
(_) => _sample(),
);
Logger.i(_tag, '开始录制: $_path');
return true;
} catch (e) {
Logger.e(_tag, '启动本地采集失败: $e');
await _closeFile(save: false);
return false;
}
}
Future<void> stop({bool sendCommand = false}) async {
if (!isRecording.value || _busy) return;
_busy = true;
try {
await _stop(sendCommand);
} finally {
_busy = false;
}
}
Future<void> _stop(bool sendCommand) async {
_silenceCheck?.cancel();
Logger.i(_tag, '停止通话录音: 收到 $_frameCount 帧,写入 ${_wav?.dataBytes} 字节');
_tick?.cancel();
_sampleTimer?.cancel();
_stopwatch.stop();
final int seconds = elapsedMs.value ~/ 1000;
final String? path = _path;
final session = _session;
if (sendCommand &&
session != null &&
session.state == DeviceConnectionState.ready) {
try {
// 设备侧还在录音态时先让它停录,再退出通话模式。顺序不能反:
// 先退通话模式的话,录音停止指令未必还能被正确处理。
if (_deviceRecordingActive) {
await session.invokeFeature(DeviceFeatures.recordStop);
Logger.i(_tag, '已下发 record.stop,通知设备退出录音态');
}
await session.invokeFeature(DeviceFeatures.callStop);
} catch (e) {
Logger.e(_tag, '下发停止指令失败: $e');
}
}
_deviceRecordingActive = false;
final dataBytes = _wav?.dataBytes ?? 0;
await _closeFile(save: true);
unawaited(AudioForegroundService.stop());
isRecording.value = false;
elapsedMs.value = 0;
level.value = 0;
waveform.clear();
_session = null;
if (path != null && dataBytes > 0) {
await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag);
} else {
Logger.w(_tag, '本次没有收到音频数据,删除空文件');
if (path != null) {
try {
final f = File(path);
if (await f.exists()) await f.delete();
} catch (e) {
Logger.e(_tag, '删除空录音文件失败: $e');
}
}
}
_path = null;
}
void _onPeer(Uint8List pcm) {
if (_wav == null || pcm.isEmpty) return;
_frameCount++;
_pendingPeer = pcm;
}
void _onLocal(Uint8List pcm) {
if (_wav == null || pcm.isEmpty) return;
_frameCount++;
if (_frameCount <= 3 || _frameCount % 200 == 0) {
Logger.d(_tag, '收到通话音频帧 #$_frameCount len=${pcm.length}');
}
final peer = _pendingPeer;
_pendingPeer = null;
if (peer == null) return;
// 左声道=本端麦克风,右声道=对端声音
_wav!.write(WavWriter.interleave(pcm, peer), onWritten: () {
level.value = WavWriter.rmsLevel(pcm);
});
}
void _sample() {
final list = waveform.toList();
list.add(level.value);
if (list.length > _maxSamples) {
list.removeRange(0, list.length - _maxSamples);
}
waveform.assignAll(list);
}
Future<void> _closeFile({required bool save}) async {
await _localSub?.cancel();
await _peerSub?.cancel();
_localSub = null;
_peerSub = null;
await _local?.close();
await _peer?.close();
_local = null;
_peer = null;
final w = _wav;
_wav = null;
if (w == null) return;
await w.close(save: save);
}
}

489
apps/client/lib/data/services/device_file_import_service.dart

@ -0,0 +1,489 @@
import 'dart:async';
import 'dart:io';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../core/utils/logger.dart';
import '../../devices/device_hub.dart';
import '../utils/device_file_match.dart';
import '../utils/ogg_opus_duration.dart';
import 'db/sqflite_api.dart';
import 'recording_archive.dart';
/// 把「设备自己录在本机存储里的音频」同步进语音纪要 —— **厂商无关**。
///
/// 适用于任何声明了 [DeviceCapability.fileStore] 的设备。当前只有 EaiRec 支架
/// (TF 卡,通道 B),但录音笔、眼镜都是同一个形态,接进来零改动。
///
/// 触发时机三个,缺一不可:
/// 1. 设备上线(连上后延时几秒,等厂商插件把文件通道握完手);
/// 2. 设备**主动推送**文件列表——多数硬件在「录音停止」后推一次,
/// 这是「刚录完立刻出现在列表里」的唯一及时来源;
/// 3. 用户在语音纪要页手动下拉/点同步([importNow])。
///
/// **导入成功并确认已上传服务端之后,会把设备上的原件删掉**([autoDeleteAfterImport])。
///
/// ⚠️ 这一步是必须的,不是可选的优化:协议里**没有任何自动回收/循环覆盖机制**
/// (EaiRec_protocol.md §5.3 有 `NO_SPACE = 0x08`,§5.10 的 `FILE_DELETE` 是
/// 唯一的单文件清理手段且**只能 App 主动发**)。不删,卡就一直涨到满,
/// 然后设备直接录不进去。
///
/// ⚠️ **删除的门槛是「已经传到服务端」,不是「已经落到手机上」**:
/// 判据是本地库里这条记录的 `audiourl` 非空([MeetingUploadService] 上传成功后写的)。
/// 所以删除通常发生在**后面某一轮**同步,不是导入的那一刻。
/// 断网、上传失败、上传队列还没轮到 —— 这些情况下设备原件一律不动,
/// 这是刻意选的失败方向:宁可卡满要人工清,也不能把用户唯一那份录音销毁掉。
class DeviceFileImportService extends GetxService {
static const String _tag = 'DeviceFileImport';
static DeviceFileImportService get to => Get.find();
/// 已导入的文件指纹(`设备id|文件名|字节数`)→ 语音纪要里的记录 id。
///
/// ⚠️ 指纹带上字节数是有意的:8.3 短文件名会**回绕复用**(`MIC0001.OGG` 删掉后
/// 下次录音可能又叫这个名),只按文件名记会把新录音当成已导入而永远跳过。
///
/// 记 id 而不只是记「导过了」,是因为删除设备原件前要回查这条记录传上去没有。
static const String _doneKey = 'device_files_imported';
/// 导入并确认上传后,删掉设备上的原件。默认开,理由见类文档。
static const String _autoDeleteKey = 'device_files_auto_delete';
bool get autoDeleteAfterImport =>
_storage.read<bool>(_autoDeleteKey) ?? true;
set autoDeleteAfterImport(bool v) => _storage.write(_autoDeleteKey, v);
/// 本轮清理掉的设备文件数,UI 可用来提示「已释放空间」
final RxInt deletedCount = 0.obs;
final GetStorage _storage = GetStorage();
/// 正在导入
final RxBool isImporting = false.obs;
/// 当前正在拉的文件名(没有则空串)
final RxString currentFile = ''.obs;
/// 当前文件的进度 0~1;设备没给总长度时为 -1(不确定态)
final RxDouble progress = 0.0.obs;
/// 本轮还剩几个没拉
final RxInt pending = 0.obs;
/// 最近一次失败原因,成功时清空。UI 可以拿它提示,但**不要弹窗**——
/// 这是后台同步,失败下次连上还会再来。
final RxString lastError = ''.obs;
/// 本进程内累计导入成功的条数,UI 用它做「同步完成」提示
final RxInt importedCount = 0.obs;
Worker? _sessionsWorker;
StreamSubscription<List<DeviceFileEntry>>? _pushSub;
Timer? _debounce;
DeviceFileCancelToken? _cancel;
bool _running = false;
/// 已经挂上推送订阅的那个 session 的 deviceId,避免重复订阅
String _boundDeviceId = '';
@override
void onInit() {
super.onInit();
_sessionsWorker = ever<List<DeviceSession>>(
DeviceHub.to.sessions,
(_) => _onSessionsChanged(),
);
_onSessionsChanged();
}
@override
void onClose() {
_sessionsWorker?.dispose();
_pushSub?.cancel();
_debounce?.cancel();
_cancel?.cancel();
super.onClose();
}
// ---------------- 触发 ----------------
void _onSessionsChanged() {
final s = DeviceHub.to.sessionWith(DeviceCapability.fileStore);
final store = s?.fileStore();
if (s == null || store == null) {
_pushSub?.cancel();
_pushSub = null;
_boundDeviceId = '';
// 设备走了就把在途传输掐掉,不然会在超时里卡好几十秒
_cancel?.cancel();
return;
}
if (_boundDeviceId == s.deviceId) return;
_boundDeviceId = s.deviceId;
_pushSub?.cancel();
_pushSub = store.listPushes.listen(
(_) {
Logger.i(_tag, '设备推送了文件列表,安排一次同步');
_schedule(const Duration(seconds: 1));
},
onError: (Object e) => Logger.w(_tag, '文件列表推送流错误: $e'),
);
// ⚠️ 连上后不能立刻拉:厂商插件那边通道还在握手(支架要对时 + 查状态),
// 这几秒省不掉,抢在前面只会拿到一个「通道不可用」。
_schedule(const Duration(seconds: 5));
}
void _schedule(Duration delay) {
_debounce?.cancel();
_debounce = Timer(delay, () => unawaited(importNow()));
}
/// 「导完了但还没删掉」时的回扫次数上限。
///
/// ⚠️ 刚导完那一轮基本删不掉任何东西:上传还在 [MeetingUploadService] 的队列里,
/// 而删除的门槛是「已上传」。所以要过几分钟再回来收一次。
/// **必须有上限**:上传一直失败(没网/额度用尽)的话,无上限就是每 3 分钟
/// 白拉一次 FILE_LIST,永远停不下来。删掉东西或导入新文件会把计数清零。
static const int _maxCleanupRetries = 5;
static const Duration _cleanupRetryDelay = Duration(minutes: 3);
int _cleanupRetries = 0;
/// 本轮导入成功的条数([importedCount] 是进程累计值,两者别混)
int _importedThisSweep = 0;
/// 设备上还有「导过、但还没删」的文件时,安排一次回扫。
void _scheduleCleanupRetry({required bool madeProgress}) {
if (!autoDeleteAfterImport) return;
if (madeProgress) _cleanupRetries = 0;
if (_doneMap().isEmpty) return; // 设备上没有我们导过的文件了
if (_cleanupRetries >= _maxCleanupRetries) {
Logger.i(_tag, '回扫 $_cleanupRetries 次仍无可删文件(多半是上传没完成),暂停');
return;
}
_cleanupRetries++;
Logger.i(_tag, '安排第 $_cleanupRetries 次清理回扫(${_cleanupRetryDelay.inMinutes} 分钟后)');
_schedule(_cleanupRetryDelay);
}
/// 手动触发一次同步。已在跑就直接返回,不排队。
Future<void> importNow() async {
if (_running) return;
final session = DeviceHub.to.sessionWith(DeviceCapability.fileStore);
final store = session?.fileStore();
if (session == null || store == null) return;
_running = true;
isImporting.value = true;
lastError.value = '';
deletedCount.value = 0;
_importedThisSweep = 0;
final cancel = DeviceFileCancelToken();
_cancel = cancel;
try {
await _sweep(session, store, cancel);
} catch (e) {
lastError.value = '$e';
Logger.w(_tag, '同步失败: $e');
} finally {
_running = false;
isImporting.value = false;
currentFile.value = '';
progress.value = 0;
pending.value = 0;
_cancel = null;
// ⚠️ 这里要用**本轮**的计数。importedCount 是进程累计值,
// 拿它判「有进展」会让重试计数永远清零,退化成无限回扫。
_scheduleCleanupRetry(
madeProgress: deletedCount.value > 0 || _importedThisSweep > 0);
}
}
/// 取消正在进行的同步(用户离开页面 / 主动断开设备)
void cancel() => _cancel?.cancel();
// ---------------- 主流程 ----------------
Future<void> _sweep(
DeviceSession session,
DeviceFileStore store,
DeviceFileCancelToken cancel,
) async {
// 设备正在录音时整轮都别做:通道 B 是单事务的,插一条文件传输进去
// 会和录音抢链路(协议 §7.1)。等它录完自然会推列表,那时再来。
if (store.isDeviceRecording) {
Logger.i(_tag, '设备正在录音,本轮跳过');
return;
}
var files = await store.list();
// 先清理再导入:先腾空间再往卡里写,卡快满时只有这个顺序救得回来。
// 清完重新拉一次列表,免得拿着已删的文件去下载。
final before = files.length;
await _cleanup(session, store, files, cancel);
if (cancel.isCancelled) return;
if (deletedCount.value > 0) {
files = await store.list();
Logger.i(_tag, '清理后设备上剩 ${files.length} 个文件(清理前 $before)');
}
final done = _doneMap();
final todo = <DeviceFileEntry>[];
for (final f in files) {
// 0 字节的多半是刚建还没写的占位,拉下来是个空文件
if (f.size <= 0) continue;
if (done.containsKey(_fingerprint(session.deviceId, f))) continue;
todo.add(f);
}
Logger.i(_tag, '设备上 ${files.length} 个文件,需要导入 ${todo.length} 个');
if (todo.isEmpty) return;
// 旧的先导,用户的时间线才是顺的
todo.sort((a, b) {
final ta = a.modifiedAt;
final tb = b.modifiedAt;
if (ta == null || tb == null) return a.name.compareTo(b.name);
return ta.compareTo(tb);
});
pending.value = todo.length;
final tmpDir = Directory(
'${(await getApplicationDocumentsDirectory()).path}/DeviceImport');
if (!await tmpDir.exists()) await tmpDir.create(recursive: true);
for (final f in todo) {
if (cancel.isCancelled) {
Logger.i(_tag, '同步被取消,剩 ${pending.value} 个未导入');
return;
}
// 每个文件都重新问一次:用户可能中途按了设备上的录音键
if (store.isDeviceRecording) {
Logger.i(_tag, '设备开始录音,本轮中止');
return;
}
await _importOne(session, store, f, tmpDir, cancel);
pending.value = pending.value - 1;
}
}
Future<void> _importOne(
DeviceSession session,
DeviceFileStore store,
DeviceFileEntry f,
Directory tmpDir,
DeviceFileCancelToken cancel,
) async {
currentFile.value = f.name;
progress.value = 0;
final localName = _localName(session, f);
final destPath = '${tmpDir.path}/$localName';
try {
// ⚠️ 同名残留会被 SDK 当成断点续传的半成品接着写。**这正是要的行为**:
// 上一次没拉完的下次接着拉,不用从头来(BLE 上一个 10 分钟的录音要好几分钟)。
await store.download(
f.name,
destPath: destPath,
cancelToken: cancel,
onProgress: (received, total) {
progress.value = total > 0 ? received / total : -1;
},
);
} on DeviceException catch (e) {
if (e.code == DeviceErrorCode.cancelled) return; // 取消不是错误
lastError.value = '${f.name}: ${e.message ?? e.code}';
Logger.w(_tag, '拉取失败 ${f.name}: $e');
return;
}
if (cancel.isCancelled) return;
final seconds = await _probeSeconds(destPath, f.size);
final id = await RecordingArchive.archive(
path: destPath,
seconds: seconds,
tag: _tag,
// 后台批量同步,一条弹一次会把屏幕刷满
notify: false,
// 列表要按「什么时候录的」排,不是「什么时候同步的」
createdAt: f.modifiedAt,
);
if (id == null) {
// 归档失败:文件还在暂存目录,**不记已导入**,下次连上重来。
Logger.w(_tag, '归档失败,保留 $destPath 待下次重试');
lastError.value = '${f.name}: 归档失败';
return;
}
// 记下记录 id:删设备原件前要靠它回查这条传上去没有
_markDone(session.deviceId, f, id);
importedCount.value = importedCount.value + 1;
_importedThisSweep++;
Logger.i(_tag, '已导入 ${f.name} → 语音纪要 id=$id (${seconds}s)');
}
// ---------------- 辅助 ----------------
/// 导入后在语音纪要里显示的名字。
///
/// 设备上是 8.3 短名(`MIC0016.OGG`),直接拿来当标题既看不出是什么时候录的,
/// 又会因为名字回绕而重复。所以拼成 `holder_20260910_203015_MIC0016.ogg`:
/// 前缀沿用 [RecordingArchive] 的约定,中间是设备记的录制时间。
String _localName(DeviceSession session, DeviceFileEntry f) {
final t = f.modifiedAt ?? DateTime.now();
String two(int v) => v.toString().padLeft(2, '0');
final stamp = '${t.year}${two(t.month)}${two(t.day)}_'
'${two(t.hour)}${two(t.minute)}${two(t.second)}';
final base = p.basenameWithoutExtension(f.name);
final ext = p.extension(f.name).toLowerCase();
return '${session.vendor}_${stamp}_$base${ext.isEmpty ? '.ogg' : ext}';
}
/// 读音频时长(秒)。
///
/// 走 Ogg 容器的 granule position,见 [oggOpusSeconds] 里的说明
/// (不解码、不依赖播放器,因为播放器解不了这种裸 Ogg Opus)。
Future<int> _probeSeconds(String path, int sizeHint) async {
try {
final secs = await oggOpusSeconds(File(path));
if (secs != null && secs > 0) return secs.round();
} catch (e) {
Logger.w(_tag, '解析 Ogg 时长失败,退回按码率估: $e');
}
// 兜底:非 Ogg 或文件损坏。16kbps = 2000 B/s,是支架当前固件的规格。
// ⚠️ 这个估值会偏出一倍(真机实测),只是不得已时不至于显示 0。
final est = sizeHint ~/ 2000;
return est > 0 ? est : 1;
}
String _fingerprint(String deviceId, DeviceFileEntry f) =>
'$deviceId|${f.name}|${f.size}';
/// 指纹 → 语音纪要记录 id。
///
/// ⚠️ 兼容旧格式:这个键早先存的是 `List<String>`(只有指纹、没有 id)。
/// 那些条目读成 id=0,含义是「导过了,但不知道是哪条记录」——
/// **id=0 的永远不会被自动删除**,宁可留在卡上让人工清。
Map<String, int> _doneMap() {
final raw = _storage.read(_doneKey);
if (raw is Map) {
return raw.map((k, v) => MapEntry('$k', v is int ? v : 0));
}
if (raw is List) {
return {for (final e in raw.whereType<String>()) e: 0};
}
return <String, int>{};
}
void _writeDone(Map<String, int> m) {
// 无上限地长会让每次启动都读一坨;设备卡上撑死几百个文件,留 2000 条足够。
// 超了从最早的开始丢(丢了最坏情况是重导一次,不会丢数据)。
var out = m;
if (out.length > 2000) {
final keys = out.keys.toList().sublist(out.length - 2000);
out = {for (final k in keys) k: out[k]!};
}
_storage.write(_doneKey, out);
}
void _markDone(String deviceId, DeviceFileEntry f, int meetingId) {
_writeDone(_doneMap()..[_fingerprint(deviceId, f)] = meetingId);
}
/// 已经确认传到服务端的录音:标题(大写)→ 本机音频路径。
///
/// 上传成功时 [MeetingUploadService] 把 audiourl 写回本地库
/// (`SqfliteApi.editMeetingTitle(id, {'audiourl': ...})`),所以读本地库就够了。
///
/// ⚠️ **按标题反查而不是只认那张指纹表**,是为了自愈:指纹表可能是旧格式、
/// 也可能因为超长被截断过,而标题里带着设备原文件名。
///
/// 读不到就返回空表:这一轮谁都不删,失败方向是「不删」。
Future<Map<String, String>> _uploadedRecords() async {
try {
final list = await SqfliteApi.getMeetingList();
final out = <String, String>{};
for (final row in (list as List?) ?? const []) {
if (row is! Map) continue;
if ((row['audiourl'] ?? '').toString().isEmpty) continue;
final title = (row['title'] ?? '').toString();
final path = (row['filepath'] ?? '').toString();
if (title.isEmpty || path.isEmpty) continue;
out[title.toUpperCase()] = path;
}
return out;
} catch (e) {
Logger.w(_tag, '读本地记录失败,本轮不清理设备文件: $e');
return const <String, String>{};
}
}
/// 这个设备文件是不是「已上传,且本机那份与它逐字节等长」。
Future<bool> _safeToDelete(
Map<String, String> uploaded, DeviceFileEntry f) async {
final path = uploadedLocalPathFor(uploaded, f.name);
if (path == null) return false;
try {
final local = File(path);
if (!await local.exists()) return false;
final len = await local.length();
if (len != f.size) {
Logger.w(_tag,
'${f.name} 本机 $len B 与设备 ${f.size} B 不符,不删(可能是同名的新录音)');
return false;
}
return true;
} catch (e) {
Logger.w(_tag, '校验本机文件失败 ${f.name},不删: $e');
return false;
}
}
/// 把「已导入且已上传」的设备原件删掉。
///
/// 在导入之前先跑:先腾空间再往里写,卡快满时这个顺序才救得回来。
Future<void> _cleanup(
DeviceSession session,
DeviceFileStore store,
List<DeviceFileEntry> files,
DeviceFileCancelToken cancel,
) async {
if (!autoDeleteAfterImport) return;
if (files.isEmpty) return;
final uploaded = await _uploadedRecords();
if (uploaded.isEmpty) return;
final done = _doneMap();
var changed = false;
for (final f in files) {
if (cancel.isCancelled) break;
// 设备一开始录音就停手:协议 §5.10 规定录音/传输中删除会回 BUSY,
// 而且正在写的那个文件绝对不能碰。
if (store.isDeviceRecording) {
Logger.i(_tag, '设备开始录音,停止清理');
break;
}
if (!await _safeToDelete(uploaded, f)) continue;
try {
await store.delete(f.name);
// 文件没了,指纹也不用留(名字回绕后同名新文件才不会被当成已导入)
if (done.remove(_fingerprint(session.deviceId, f)) != null) {
changed = true;
}
deletedCount.value = deletedCount.value + 1;
Logger.i(_tag, '设备原件已删除 ${f.name}(${f.size} B,已确认上传)');
} catch (e) {
// 删不掉不是灾难:下一轮还会再试。指纹保留着,别丢。
Logger.w(_tag, '删除设备文件失败 ${f.name}: $e');
}
}
if (changed) _writeDone(done);
if (deletedCount.value > 0) {
Logger.i(_tag, '本轮清理了 ${deletedCount.value} 个设备文件');
}
}
}

284
apps/client/lib/data/services/device_recording_service.dart

@ -0,0 +1,284 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import '../../core/utils/logger.dart';
import '../../devices/device_hub.dart';
import 'recording_archive.dart';
import 'wav_writer.dart';
/// 设备主麦录音(现场录音)的全局采集与归档服务——**厂商无关**。
///
/// 替代原先各写一份的 BesRecordingService / HolderRecordingService:
/// 谁声明了 [DeviceCapability.micRecording] 就能录,
/// 设备按键起停走 `record.started` / `record.stopped` feature 事件,
/// 音频走 `openMic(mic)`(插件负责让设备开始上行、解码成 16k PCM、挑帧)。
///
/// 常驻:设备端按键在任意页面(含页面未打开)都要生效。
class DeviceRecordingService extends GetxService {
static const String _tag = 'DeviceRecording';
static DeviceRecordingService get to => Get.find();
/// 当前是否正在录(由设备端状态驱动)
final RxBool isRecording = false.obs;
/// 已录毫秒数
final RxInt elapsedMs = 0.obs;
/// 当前电平 0~1,供波形使用
final RxDouble level = 0.0.obs;
/// 波形采样序列(固定 [sampleIntervalMs] 一格)
final RxList<double> waveform = <double>[].obs;
static const int sampleIntervalMs = 100;
static const int _maxSamples = 600;
StreamSubscription? _hubSub;
DeviceSession? _session;
DeviceAudioSource? _src;
StreamSubscription<Uint8List>? _audioSub;
WavWriter? _wav;
String? _path;
Timer? _tick;
Timer? _sampleTimer;
final Stopwatch _stopwatch = Stopwatch();
bool _firstPcmLogged = false;
DateTime? _cmdSentAt;
bool _busy = false;
@override
void onInit() {
super.onInit();
_hubSub = DeviceHub.to.sessionEvents.listen(_onSessionEvent);
}
@override
void onClose() {
_hubSub?.cancel();
_audioSub?.cancel();
_tick?.cancel();
_sampleTimer?.cancel();
super.onClose();
}
void _onSessionEvent(DeviceSessionEvent e) async {
switch (e.type) {
case DeviceSessionEventType.feature:
final key = e.feature?.key;
if (_busy) return;
// 通话中的录音键归通话录音管(DeviceCallRecordingService)
if (key == DeviceFeatureKeys.recordStarted &&
!isRecording.value &&
!DeviceHub.to.inCall.value) {
Logger.i(_tag, '设备端开始录音,启动本地采集');
await start(deviceId: e.deviceId);
} else if (key == DeviceFeatureKeys.recordStopped && isRecording.value) {
Logger.i(_tag, '设备端结束录音,落盘并归档');
await stop();
}
break;
case DeviceSessionEventType.connectionStateChanged:
if (e.connectionState == DeviceConnectionState.disconnected &&
isRecording.value &&
_session?.deviceId == e.deviceId) {
Logger.w(_tag, '设备断开,结束当前录音');
stop();
}
break;
default:
break;
}
}
/// 开始本地采集。[sendCommand] = true 时同时下发设备开始指令(App 主动发起)。
Future<bool> start({bool sendCommand = false, String? deviceId}) async {
if (isRecording.value || _busy) return isRecording.value;
_busy = true;
try {
return await _start(sendCommand, deviceId);
} finally {
_busy = false;
}
}
Future<bool> _start(bool sendCommand, String? deviceId) async {
final hub = DeviceHub.to;
final session = (deviceId == null
? null
: hub.sessions.firstWhereOrNull((s) => s.deviceId == deviceId)) ??
hub.sessionWith(DeviceCapability.micRecording);
if (session == null) return false;
_session = session;
try {
final docs = await getApplicationDocumentsDirectory();
final dir = Directory('${docs.path}/field_recordings');
if (!await dir.exists()) await dir.create(recursive: true);
final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
_path = '${dir.path}/${session.vendor}_$ts.wav';
_wav = await WavWriter.open(_path!, channels: 1);
_firstPcmLogged = false;
_cmdSentAt = null;
if (sendCommand) {
// 顺序:先让设备进录音态,再开上行(支架的编码器必须在切状态之后开,
// 否则会被 toggle 顺手关掉;恒玄的 AA 59 与订阅先后无关)。
final t0 = DateTime.now();
final r = await session.invokeFeature(DeviceFeatures.recordStart);
_cmdSentAt = DateTime.now();
if (r['ok'] == false) {
await _closeFile(save: false);
return false;
}
Logger.i(_tag,
'已下发 record.start,写入耗时 ${_cmdSentAt!.difference(t0).inMilliseconds}ms');
}
_src = await session.openMic(route: AudioRoute.mic);
_audioSub = _src!.pcm.listen(_onPcm);
isRecording.value = true;
elapsedMs.value = 0;
level.value = 0;
waveform.clear();
_stopwatch.reset();
// 时长按**已写入的数据量**算,不用墙钟:显示值与文件实际播放长度永远一致
// (恒玄收到 AA 59 后要 1.8~6.2s 才开始上行,还会把等待期缓存的音频补推)。
final bytesPerMs = _src!.format.bytesPerMs;
_tick = Timer.periodic(const Duration(milliseconds: 200), (_) {
elapsedMs.value = _wav == null ? 0 : _wav!.dataBytes ~/ bytesPerMs;
});
_sampleTimer = Timer.periodic(
const Duration(milliseconds: sampleIntervalMs),
(_) => _sample(),
);
Logger.i(_tag, '开始录制: $_path (${session.vendor})');
return true;
} catch (e) {
Logger.e(_tag, '启动本地采集失败: $e');
await _closeFile(save: false);
return false;
}
}
/// 停止。[sendCommand] = true 时同时下发设备停止指令。
Future<void> stop({bool sendCommand = false}) async {
if (!isRecording.value || _busy) return;
_busy = true;
try {
await _stop(sendCommand);
} finally {
_busy = false;
}
}
Future<void> _stop(bool sendCommand) async {
_tick?.cancel();
_sampleTimer?.cancel();
_stopwatch.stop();
await _audioSub?.cancel();
_audioSub = null;
await _src?.close();
_src = null;
// 等队列写完再关文件,否则最后几帧会丢、WAV 头长度也对不上
await _wav?.drain();
final int dataBytes = _wav?.dataBytes ?? 0;
final int seconds = elapsedMs.value ~/ 1000;
final String? path = _path;
final wallMs = _stopwatch.elapsedMilliseconds;
Logger.i(
_tag,
'录音统计: 音频${(dataBytes / 32000).toStringAsFixed(2)}s 数据${dataBytes}B '
'墙钟${(wallMs / 1000).toStringAsFixed(2)}s '
'写入${_wav?.writtenFrames}帧 丢${_wav?.droppedFrames}帧');
final session = _session;
if (sendCommand &&
session != null &&
session.state == DeviceConnectionState.ready) {
try {
await session.invokeFeature(DeviceFeatures.recordStop, {
if (path != null)
'fileName': path.split('/').last.replaceAll('.wav', ''),
});
} catch (e) {
Logger.e(_tag, '下发停止指令失败: $e');
}
}
await _closeFile(save: true);
isRecording.value = false;
elapsedMs.value = 0;
level.value = 0;
waveform.clear();
_session = null;
if (path != null && dataBytes > 0) {
await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag);
} else {
Logger.w(_tag, '本次没有收到音频数据,删除空文件');
if (path != null) {
try {
final f = File(path);
if (await f.exists()) await f.delete();
} catch (e) {
Logger.e(_tag, '删除空录音文件失败: $e');
}
}
}
_path = null;
}
void _onPcm(Uint8List pcm) {
final w = _wav;
if (w == null || pcm.isEmpty) return;
if (!_firstPcmLogged) {
_firstPcmLogged = true;
if (!_stopwatch.isRunning) _stopwatch.start();
final sent = _cmdSentAt;
Logger.i(
_tag,
'首帧音频到达'
'${sent != null ? ",距 record.start ${DateTime.now().difference(sent).inMilliseconds}ms" : ""}'
',计时从此刻开始');
}
// 串行写,绝不在回调里直接 await 文件(见 WavWriter);电平只是显示,不能挡写入
w.write(pcm, onWritten: () {
try {
_updateLevel(pcm);
} catch (_) {}
});
}
void _sample() {
final list = waveform.toList();
list.add(level.value);
if (list.length > _maxSamples) {
list.removeRange(0, list.length - _maxSamples);
}
waveform.assignAll(list);
}
void _updateLevel(Uint8List pcm) {
level.value = WavWriter.rmsLevel(pcm);
}
Future<void> _closeFile({required bool save}) async {
await _audioSub?.cancel();
_audioSub = null;
await _src?.close();
_src = null;
final w = _wav;
_wav = null;
if (w == null) return;
await w.close(save: save);
}
}

103
apps/client/lib/data/services/device_stereo_recorder.dart

@ -0,0 +1,103 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import '../../core/utils/logger.dart';
import '../../devices/device_hub.dart';
import 'wav_writer.dart';
/// 把设备的通话双路(本端 / 对端)录成一个**双声道 WAV** 的小工具,
/// 给「语音纪要 → 通话录音」这类要自己指定文件路径的页面用。
///
/// 与常驻的 DeviceCallRecordingService 的区别:这个是页面持有的一次性对象,
/// 文件放哪、什么时候停都由调用方决定,不归档。
/// 左声道=本端麦克风,右声道=对端声音。
class DeviceStereoRecorder {
DeviceStereoRecorder({this.onWavePcm});
static const String _tag = 'DeviceStereoRecorder';
/// 每写一对帧回调一次本端 PCM(驱动波形)
final void Function(Uint8List pcm)? onWavePcm;
DeviceSession? _session;
DeviceAudioSource? _local;
DeviceAudioSource? _peer;
StreamSubscription? _localSub;
StreamSubscription? _peerSub;
WavWriter? _wav;
Uint8List? _pendingPeer;
bool _paused = false;
bool get isRunning => _wav != null;
/// 开始。没有具备通话双路抓音能力的设备返回 false。
Future<bool> start(String filePath) async {
if (isRunning) return true;
final session = DeviceHub.to.sessionWith(DeviceCapability.callAudioTap);
if (session == null) {
Logger.w(_tag, '没有具备通话双路抓音能力的设备');
return false;
}
try {
_session = session;
_wav = await WavWriter.open(filePath, channels: 2);
_pendingPeer = null;
_paused = false;
_peer = await session.openMic(route: AudioRoute.callPeer);
_local = await session.openMic(route: AudioRoute.callLocal);
_peerSub = _peer!.pcm.listen((pcm) {
if (_paused || pcm.isEmpty) return;
_pendingPeer = pcm;
});
_localSub = _local!.pcm.listen((pcm) {
if (_paused || pcm.isEmpty) return;
final peer = _pendingPeer;
_pendingPeer = null;
if (peer == null) return;
_wav?.write(WavWriter.interleave(pcm, peer),
onWritten: () => onWavePcm?.call(pcm));
});
await session.invokeFeature(DeviceFeatures.callStart);
return true;
} catch (e) {
Logger.e(_tag, '启动失败: $e');
await _shutdown(saveFile: false);
return false;
}
}
void pause() => _paused = true;
void resume() => _paused = false;
Future<bool> stop({required bool save}) async {
if (!isRunning) return true;
await _shutdown(saveFile: save);
return true;
}
Future<void> _shutdown({required bool saveFile}) async {
await _localSub?.cancel();
await _peerSub?.cancel();
_localSub = null;
_peerSub = null;
await _local?.close();
await _peer?.close();
_local = null;
_peer = null;
final s = _session;
_session = null;
if (s != null && s.state == DeviceConnectionState.ready) {
try {
await s.invokeFeature(DeviceFeatures.callStop);
} catch (e) {
Logger.w(_tag, 'call.stop 失败: $e');
}
}
final w = _wav;
_wav = null;
await w?.close(save: saveFile);
}
}

22
apps/client/lib/data/services/network/api.dart

@ -345,15 +345,6 @@ class Api {
);
}
//绑定设备
static bindauthcode([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/user_bindauthcode',
params: params,
);
}
//创建支付订单
static paycreateorder([params]) {
return DioManager().request(
@ -473,6 +464,19 @@ class Api {
);
}
/// 会员与算力状态(`/api/home/user_getcompute`)。
///
/// 一次拿齐:`vip{level, exptime, selfexptime, devices[]}`(自购与设备赋予合并后的有效值)、
/// `compute{deviceleft, userbalance, totalused, monthused}`、`rates{translate, meeting, aichat, gate}`。
/// 客户端**不做任何换算**,展示服务端给的数;`rates` 只用于本地预估文案。
static getCompute([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/user_getcompute',
params: params ?? {},
);
}
static getwakeupvoices([params]) {
return DioManager().request(
NWMethod.post,

48
apps/client/lib/data/services/network/auth_interceptor.dart

@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart' hide Response;
import 'dart:convert';
@ -7,10 +8,16 @@ import 'package:crypto/crypto.dart';
import 'package:get_storage/get_storage.dart';
import '../../../core/utils/logger.dart';
import 'entity/base_entity.dart';
import '../../../routes/app_routes.dart';
class AuthInterceptor extends Interceptor {
final GetStorage _storage = GetStorage();
final String _signKey = '@214%67g15q4*67m17#4l67!'; // 替换为实际的密钥
/// 请求签名密钥。优先读 .env 的 API_SIGN_KEY,没配时用内置默认值
/// (与 Go 端保持一致,改这里要同步服务端)。
static String get _signKey =>
(dotenv.env['API_SIGN_KEY'] ?? '').trim().isNotEmpty
? dotenv.env['API_SIGN_KEY']!.trim()
: '@214%67g15q4*67m17#4l67!';
bool _shouldSuppressErrorSnackbar(DioException err) {
final path = err.requestOptions.path;
@ -84,8 +91,8 @@ class AuthInterceptor extends Interceptor {
String _generateSign(String timestamp) {
// 按照“MD5(私钥 + 时间戳)”生成签名,与 Go 端保持一致
// ⚠️ 别把 signStr 打进日志:里面是明文密钥
final signStr = 'ts=${timestamp}key=${_signKey}';
print("signStr: $signStr");
return md5.convert(utf8.encode(signStr)).toString();
}
@ -107,6 +114,13 @@ class AuthInterceptor extends Interceptor {
if (_isNotLoggedInMessage(rawMessage)) {
return;
}
// 5302 VipRequired:翻译 / 会议纪要在 VIP 过期后受限。这不是「错误」,是引导——
// 弹开通窗而不是红色错误条,并且要同时给「续费」与「绑定带会员的设备」两条路,
// 设备也能带来 VIP,只说充值会漏掉一半用户。
if (entity.code == _codeVipRequired) {
_showVipRequired(rawMessage);
return;
}
final lowerMessage = rawMessage.toLowerCase();
final displayMessage = lowerMessage.contains('record not found')
? 'requestedResourceNotFound'.tr
@ -123,6 +137,36 @@ class AuthInterceptor extends Interceptor {
}
}
/// 服务端 pb.ErrorCode_VipRequired。
static const int _codeVipRequired = 5302;
static bool _vipDialogShowing = false;
void _showVipRequired(String message) {
// 同一屏可能连着几个请求都被拒(同传里 ASR 每句话一次翻译),只弹一次
if (_vipDialogShowing) return;
_vipDialogShowing = true;
Get.dialog(
AlertDialog(
title: Text('vipRequiredTitle'.tr),
content: Text(message.isNotEmpty ? message : 'vipRequiredDesc'.tr),
actions: [
TextButton(
onPressed: () => Get.back(),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () {
Get.back();
Get.toNamed(Routes.goodsvip);
},
child: Text('vipRequiredGo'.tr),
),
],
),
barrierDismissible: true,
).whenComplete(() => _vipDialogShowing = false);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
switch (err.type) {

183
apps/client/lib/data/services/ota_check_service.dart

@ -1,149 +1,100 @@
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../core/utils/logger.dart';
import '../../core/utils/version_compare.dart';
import '../../devices/device_hub.dart';
import '../../routes/app_routes.dart';
import '../../modules/ota_upgrade/controllers/ota_core_contropller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../models/appconfig.dart';
import '../models/appconfig_model.dart';
/// OTA检查服务
/// 负责管理全局OTA检查状态,确保在应用生命周期内只检查一次
/// 连上设备后的固件升级检查(一次会话只提醒一次)——厂商无关。
///
/// 当前版本来自在线会话的 [DeviceInfo.firmwareVersion],最新版本来自
/// `user_getappconfig` 下发的产品配置(按设备名匹配)。
class OtaCheckService extends GetxService {
static const String _tag = 'OtaCheckService';
// 全局状态管理
static bool _hasCheckedInCurrentSession = false;
static bool _isCheckingInProgress = false;
final _otaCoreService = OtaCoreService();
/// 获取服务实例
static OtaCheckService get to => Get.find<OtaCheckService>();
@override
void onInit() {
super.onInit();
Logger.d(_tag, 'OTA检查服务已初始化');
}
/// 重置检查状态(仅在应用重启时调用)
static void resetCheckStatus() {
_hasCheckedInCurrentSession = false;
_isCheckingInProgress = false;
Logger.d(_tag, 'OTA检查状态已重置');
}
/// 检查是否应该进行OTA检查
bool shouldCheckOta() {
return !_hasCheckedInCurrentSession && !_isCheckingInProgress;
}
bool shouldCheckOta() => !_hasCheckedInCurrentSession && !_isCheckingInProgress;
/// 标记为已检查
void _markAsChecked() {
_hasCheckedInCurrentSession = true;
_isCheckingInProgress = false;
Logger.d(_tag, 'OTA检查状态已标记为完成');
}
/// 检查当前是否在Home页面
bool _isCurrentlyOnHomePage() {
final currentRoute = Get.currentRoute;
final isOnHome =
currentRoute == Routes.home || currentRoute == Routes.initial;
Logger.d(_tag, '当前路由: $currentRoute, 是否在Home页面: $isOnHome');
return isOnHome;
return currentRoute == Routes.home || currentRoute == Routes.initial;
}
/// 执行OTA升级检查
/// [isFullyConnected] 设备是否完全连接
/// 返回是否成功执行了检查
DBProduct? _findProduct(String name) {
if (!AppConfig.isInitialized()) return null;
final n = name.trim().toUpperCase();
if (n.isEmpty) return null;
for (final p in AppConfig.config.products) {
final pn = p.devicename.trim().toUpperCase();
if (pn.isEmpty) continue;
if (pn.contains(n) || n.contains(pn)) return p;
}
return null;
}
/// 执行一次检查。[isFullyConnected] 为 false 直接跳过。返回是否真的检查了。
Future<bool> performOtaCheck(bool isFullyConnected) async {
bool isForceUpdate = false;
try {
// 检查是否应该进行OTA检查
if (!shouldCheckOta()) {
Logger.d(_tag, '跳过OTA检查 - 已检查过或正在检查中');
return false;
}
// 检查设备连接状态
if (!isFullyConnected) {
Logger.d(_tag, '设备未完全连接,跳过OTA检查');
return false;
}
// 检查当前页面
if (!_isCurrentlyOnHomePage()) {
Logger.d(_tag, '当前不在Home页面,跳过OTA检查');
return false;
}
if (!shouldCheckOta()) return false;
if (!isFullyConnected) return false;
if (!_isCurrentlyOnHomePage()) return false;
// 标记为检查中
final session = DeviceHub.to.sessionWith(DeviceCapability.ota);
if (session == null) return false;
_isCheckingInProgress = true;
Logger.d(_tag, '开始执行OTA固件升级检查...');
// 获取当前版本信息
final currentInfo = await _otaCoreService.getCurrentVersionInfo();
final currentVersion = currentInfo['currentVersion'] as String;
// 获取最新版本信息
final latestInfo = await _otaCoreService.getLatestVersionInfo();
final latestVersion = latestInfo['latestVersion'] as String;
final currentVersion = session.info.firmwareVersion ?? '';
final product = _findProduct(session.info.name);
final latestVersion = product?.version?.split('+').first.trim() ?? '';
Logger.d(_tag, '当前版本: $currentVersion, 最新版本: $latestVersion');
// 检查是否有更新
final hasUpdate =
_otaCoreService.isNewVersionAvailable(currentVersion, latestVersion);
final hasUpdate = latestVersion.isNotEmpty &&
(currentVersion.isEmpty ||
VersionCompare.isNewer(latestVersion, currentVersion));
if (hasUpdate) {
isForceUpdate = latestInfo['isForceUpdate'] as bool? ?? false;
if (isForceUpdate) {
Logger.d(_tag, '发现强制更新版本,准备显示升级提醒');
} else {
Logger.d(_tag, '发现新版本,准备显示升级提醒');
}
// 再次确认当前在Home页面(防止异步执行期间页面切换)
isForceUpdate = product?.isforceupdate ?? false;
if (_isCurrentlyOnHomePage()) {
_showOtaUpgradeDialog(
latestVersion, latestInfo['updateDescription'] as String?);
} else {
Logger.d(_tag, '检查完成时已不在Home页面,取消显示弹窗');
_showOtaUpgradeDialog(latestVersion);
}
} else {
Logger.d(_tag, '当前已是最新版本');
}
// 标记为已检查(无论是否有更新都标记为已检查)
if (!isForceUpdate) {
_markAsChecked();
}
if (!isForceUpdate) _markAsChecked();
_isCheckingInProgress = false;
return true;
} catch (e) {
Logger.e(_tag, '检查OTA升级失败: $e');
// 即使检查失败,也标记为已检查,避免重复尝试
if (!isForceUpdate) {
_markAsChecked();
}
if (!isForceUpdate) _markAsChecked();
_isCheckingInProgress = false;
return false;
}
}
/// 显示OTA升级提醒对话框
/// [latestVersion] 最新版本号
/// [updateDescription] 更新描述
void _showOtaUpgradeDialog(String latestVersion, String? updateDescription) {
// 最后一次确认当前页面状态
if (!_isCurrentlyOnHomePage()) {
Logger.d(_tag, '显示弹窗时发现不在Home页面,取消显示');
return;
}
void _showOtaUpgradeDialog(String latestVersion) {
if (!_isCurrentlyOnHomePage()) return;
final isDarkMode = Get.isDarkMode;
Logger.d(_tag, '显示OTA升级提醒对话框');
Get.dialog(
AlertDialog(
title: Row(
@ -154,18 +105,16 @@ class OtaCheckService extends GetxService {
size: 24.sp,
),
SizedBox(width: 8.w),
// 使用Flexible确保标题文本能够自适应不同语言长度,避免溢出
Flexible(
child: Text(
'ota_newVersionFound'.tr, // 发现新版本
'ota_newVersionFound'.tr,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.blue[700],
fontWeight: FontWeight.w600,
fontSize: 20.sp, // 设置合适的字体大小
fontSize: 20.sp,
),
softWrap: true, // 启用软换行
overflow: TextOverflow.visible, // 确保文本完全显示
maxLines: 2, // 最多显示两行
softWrap: true,
maxLines: 2,
),
),
],
@ -188,12 +137,8 @@ class OtaCheckService extends GetxService {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 稍后再说 (靠左)
TextButton(
onPressed: () {
Logger.d(_tag, '用户取消OTA升级');
Get.back();
},
onPressed: () => Get.back(),
child: Text(
'talkLater'.tr,
style: TextStyle(
@ -201,12 +146,10 @@ class OtaCheckService extends GetxService {
),
),
),
// 立即升级 (靠右)
ElevatedButton(
onPressed: () {
Logger.d(_tag, '用户确认OTA升级,跳转到升级页面');
Get.back();
Get.toNamed(Routes.OTA_UPGRADE);
Get.toNamed(Routes.otaUpgrade);
},
style: ElevatedButton.styleFrom(
backgroundColor:
@ -219,30 +162,22 @@ class OtaCheckService extends GetxService {
? BorderSide(color: Colors.grey[800]!, width: 1)
: BorderSide.none,
),
child: Text(
'ota_updateNow'.tr,
style: TextStyle(fontSize: 14.sp),
),
child: Text('ota_updateNow'.tr, style: TextStyle(fontSize: 14.sp)),
),
],
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.sp),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.sp)),
backgroundColor: isDarkMode ? const Color(0xFF1A1A1A) : Colors.white,
),
barrierDismissible: true, // 允许点击外部关闭
barrierDismissible: true,
);
}
/// 获取当前检查状态(用于调试)
Map<String, dynamic> getCheckStatus() {
return {
'hasCheckedInCurrentSession': _hasCheckedInCurrentSession,
'isCheckingInProgress': _isCheckingInProgress,
'currentRoute': Get.currentRoute,
'isOnHomePage': _isCurrentlyOnHomePage(),
};
}
Map<String, dynamic> getCheckStatus() => {
'hasCheckedInCurrentSession': _hasCheckedInCurrentSession,
'isCheckingInProgress': _isCheckingInProgress,
'currentRoute': Get.currentRoute,
'isOnHomePage': _isCurrentlyOnHomePage(),
};
}

14
apps/client/lib/data/services/recording_archive.dart

@ -5,7 +5,9 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../core/utils/logger.dart';
import '../utils/recording_time.dart';
import 'db/sqflite_api.dart';
import 'meeting/meeting_task_service.dart';
import 'meeting/meeting_upload_service.dart';
import 'network/api.dart';
@ -29,11 +31,15 @@ class RecordingArchive {
///
/// 返回服务端记录 id;失败返回 null(文件会原地保留,不会丢)。
/// [notify] 为 true 时弹一条「已存入语音记录助手」的提示。
/// [createdAt] 这段录音**实际录制**的时刻。边录边存的四条链路不用传(就是现在),
/// 但从设备存储同步进来的历史录音必须传 —— 否则列表上一条 8 月 15 日录的东西
/// 会显示成「刚刚」,用户按时间根本找不到它。
static Future<int?> archive({
required String path,
required int seconds,
String tag = 'RecordingArchive',
bool notify = true,
DateTime? createdAt,
}) async {
try {
final file = File(path);
@ -70,7 +76,7 @@ class RecordingArchive {
'filepath': newPath,
'audiourl': '',
'tasktype': 0,
'creationtime': DateTime.now().millisecondsSinceEpoch,
'creationtime': resolveRecordingCreationTime(createdAt),
};
final resp = await Api.addRecordEchomeet({
'rtype': 'LOCAL',
@ -87,6 +93,12 @@ class RecordingArchive {
Get.find<MeetingUploadService>()
.addUpload(meetingId, newPath, 'LocalAudio');
}
// 让语音纪要首页立刻重画。**不是可选的**:MeetingHomeController 只在
// `updateListState` 变化时才重查本地库,不捅它就得退出页面再进来才看得到,
// 表现成「录完了/同步完了但列表里没有」。四条归档链路统一在这里捅一次。
if (Get.isRegistered<MeetingTaskService>()) {
Get.find<MeetingTaskService>().updateListState.value++;
}
Logger.i(tag, '已归档到语音记录助手 id=$meetingId title=$fileName');
if (notify) {
Get.snackbar('recordingSavedTitle'.tr, 'recordingSavedDesc'.tr,

136
apps/client/lib/data/services/wav_writer.dart

@ -0,0 +1,136 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import '../../core/utils/logger.dart';
/// 串行写入的 WAV 文件(16k / 16bit)。
///
/// ⚠️ **必须串行写**:Stream.listen 的回调即使标了 async 也不会被等待,
/// 音频每秒上百帧,多次 writeFrom 必然重叠;Dart 的 RandomAccessFile 同一时刻
/// 只允许一个异步操作,重叠调用会抛 "An async operation is currently pending",
/// 那一帧就丢了(实测等效采样率只剩 1587~5331Hz,播放又快又糊)。
/// 用 Future 链把写入排成队列:既不丢帧,也保证顺序。
class WavWriter {
WavWriter._(this._raf, this.path, this.channels, this.sampleRate);
static const String _tag = 'WavWriter';
final RandomAccessFile _raf;
final String path;
final int channels;
final int sampleRate;
int dataBytes = 0;
int writtenFrames = 0;
int droppedFrames = 0;
Future<void> _chain = Future<void>.value();
bool _closed = false;
static Future<WavWriter> open(String path,
{int channels = 1, int sampleRate = 16000}) async {
final raf = await File(path).open(mode: FileMode.write);
final w = WavWriter._(raf, path, channels, sampleRate);
await raf.writeFrom(w._header(0));
return w;
}
/// 入队写一块 PCM。[onWritten] 在这一块真正落盘后回调(电平等只读用途)。
void write(Uint8List pcm, {void Function()? onWritten}) {
if (_closed) return;
_chain = _chain.then((_) async {
if (_closed) return;
try {
await _raf.writeFrom(pcm);
dataBytes += pcm.length;
writtenFrames++;
onWritten?.call();
} catch (e) {
droppedFrames++;
Logger.e(_tag, '写入 PCM 失败(已丢帧 $droppedFrames): $e');
}
});
}
/// 等队列里的帧全部落盘。
Future<void> drain() async {
try {
await _chain;
} catch (_) {}
}
/// 关闭;[save] = true 时回填 WAV 头的长度。
Future<void> close({required bool save}) async {
if (_closed) return;
await drain();
_closed = true;
try {
if (save) {
await _raf.setPosition(0);
await _raf.writeFrom(_header(dataBytes));
}
await _raf.close();
} catch (e) {
Logger.e(_tag, '关闭录音文件失败: $e');
}
}
Uint8List _header(int dataLength) {
const bits = 16;
final byteRate = sampleRate * channels * (bits ~/ 8);
final h = ByteData(44);
void ascii(int off, String s) {
for (var i = 0; i < s.length; i++) {
h.setUint8(off + i, s.codeUnitAt(i));
}
}
ascii(0, 'RIFF');
h.setUint32(4, 36 + dataLength, Endian.little);
ascii(8, 'WAVE');
ascii(12, 'fmt ');
h.setUint32(16, 16, Endian.little);
h.setUint16(20, 1, Endian.little);
h.setUint16(22, channels, Endian.little);
h.setUint32(24, sampleRate, Endian.little);
h.setUint32(28, byteRate, Endian.little);
h.setUint16(32, channels * (bits ~/ 8), Endian.little);
h.setUint16(34, bits, Endian.little);
ascii(36, 'data');
h.setUint32(40, dataLength, Endian.little);
return h.buffer.asUint8List();
}
/// PCM16 小端的 RMS 电平(0~1,开过根让波形更有跳动感)。
///
/// ⚠️ 不能用 `buffer.asInt16List(offsetInBytes, …)`:原生过来的 Uint8List 是
/// 带 offset 的视图,偏移是奇数时直接抛 RangeError。按字节手工拼。
static double rmsLevel(Uint8List pcm) {
final count = pcm.lengthInBytes ~/ 2;
if (count == 0) return 0;
var sum = 0.0;
for (var i = 0; i < count; i++) {
var v = (pcm[i * 2 + 1] << 8) | pcm[i * 2];
if (v >= 0x8000) v -= 0x10000;
final n = v / 32768.0;
sum += n * n;
}
final rms = math.sqrt(sum / count);
return math.sqrt(rms).clamp(0.0, 1.0);
}
/// 把两路单声道按字节交织成双声道(左=[left],右=[right])。
static Uint8List interleave(Uint8List left, Uint8List right) {
final samples = (left.length < right.length ? left.length : right.length) ~/ 2;
final out = Uint8List(samples * 4);
for (var i = 0; i < samples; i++) {
final si = i * 2, di = i * 4;
out[di] = left[si];
out[di + 1] = left[si + 1];
out[di + 2] = right[si];
out[di + 3] = right[si + 1];
}
return out;
}
}

27
apps/client/lib/data/utils/device_file_match.dart

@ -0,0 +1,27 @@
/// 「设备上这个文件,是不是已经安全落袋了」——
/// **这是删除设备原件的唯一判据**,改它等于改一个不可逆操作的闸门。
library;
/// 归档标题形如 `<vendor>_<时间戳>_<设备文件名>`
/// (`holder_20260907_181224_MIC0049.ogg` ← 设备上的 `MIC0049.OGG`)。
///
/// 在「已上传」的记录里按这个后缀找对应的那条,返回它在本机的音频路径;
/// 找不到返回 null。[uploadedByTitle] 的 key 必须已大写。
///
/// ⚠️ **只匹配到名字还不够,调用方必须再比一次字节数**:设备用的是 8.3 短
/// 文件名,删掉后会**回绕复用**(`MIC0001.OGG` 下次录音可能又叫这个名)。
/// 只认名字的话,一条同名的新录音会被当成「已上传」直接删掉 —— 永久丢数据。
/// 字节数的来源刻意选了「本机那份音频的实际长度」而不是数据库里的字段:
/// `meeting` 表**根本没有 size 列**(schema 见 database_helper.dart),
/// 而且比文件长度顺带证明了本机这份没被截断、确实是同一个文件。
String? uploadedLocalPathFor(
Map<String, String> uploadedByTitle,
String deviceFileName,
) {
if (deviceFileName.isEmpty) return null;
final suffix = '_${deviceFileName.toUpperCase()}';
for (final e in uploadedByTitle.entries) {
if (e.key.endsWith(suffix) && e.value.isNotEmpty) return e.value;
}
return null;
}

93
apps/client/lib/data/utils/ogg_opus_duration.dart

@ -0,0 +1,93 @@
/// Ogg Opus 时长解析 —— **不解码、不依赖播放器**。
///
/// Opus 的 granule position 恒以 **48kHz** 计样本(RFC 7845 §4,与音频本身的
/// 采样率无关),所以「最后一个 Ogg page 的 granulepos 减去 OpusHead 里的
/// pre-skip,再除以 48000」就是精确时长。
///
/// 为什么不用现成的播放器:
/// - ⚠️ `just_audio` 的 `setFilePath` 对支架录的这种裸 Ogg Opus **两端都报
/// `Source error`**(Android/iOS 真机实测),拿不到时长;
/// - ⚠️ 按码率估会明显失真:真机那条 38400 字节的录音实际 9.16s,
/// 按 16kbps 估是 19.2s,差一倍,列表上一眼就看得出来。
library;
import 'dart:io';
const List<int> _oggS = [0x4F, 0x67, 0x67, 0x53]; // 'OggS'
const List<int> _opusHead = [
0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64 // 'OpusHead'
];
/// Opus 的 granulepos 单位,**恒为 48000**,不是音频采样率。
/// 写成 16000 会把时长算成 3 倍。
const int _opusGranuleRate = 48000;
/// 从「文件头若干字节」和「文件尾若干字节」解出时长(秒)。
///
/// [head] 至少要覆盖第一个 page(含 OpusHead);[tail] 至少要覆盖最后一个
/// page 的头部。不是 Ogg、或没有可用的 granulepos 时返回 null。
double? oggOpusSecondsFrom(List<int> head, List<int> tail) {
if (head.length < 4 || _indexOf(head, _oggS) != 0) return null; // 不是 'OggS'
final hi = _indexOf(head, _opusHead);
// OpusHead 布局:magic(8) version(1) channels(1) pre-skip(2, LE)
final preSkip =
hi >= 0 && hi + 12 <= head.length ? head[hi + 10] | (head[hi + 11] << 8) : 0;
final ti = _lastIndexOf(tail, _oggS);
if (ti < 0 || ti + 14 > tail.length) return null;
// page 头偏移 6 起的 8 字节小端就是 granulepos
var granule = 0;
for (var b = 7; b >= 0; b--) {
granule = (granule << 8) | tail[ti + 6 + b];
}
if (granule <= preSkip) return null;
return (granule - preSkip) / _opusGranuleRate;
}
/// 读文件解时长。不是 Ogg Opus 返回 null。
///
/// 只读头 4KB 和尾 128KB —— Ogg page 最大 65307 字节,128KB 必然覆盖到
/// 最后一个 page,不用把整个文件读进内存(录音可能几十兆)。
Future<double?> oggOpusSeconds(File file) async {
final raf = await file.open();
try {
final len = await raf.length();
if (len < 47) return null; // 连一个 OpusHead page 都放不下
await raf.setPosition(0);
final head = await raf.read(len < 4096 ? len : 4096);
const tailLen = 128 * 1024;
final from = len > tailLen ? len - tailLen : 0;
await raf.setPosition(from);
final tail = await raf.read(len - from);
return oggOpusSecondsFrom(head, tail);
} finally {
await raf.close();
}
}
int _indexOf(List<int> hay, List<int> needle) {
outer:
for (var i = 0; i + needle.length <= hay.length; i++) {
for (var j = 0; j < needle.length; j++) {
if (hay[i + j] != needle[j]) continue outer;
}
return i;
}
return -1;
}
int _lastIndexOf(List<int> hay, List<int> needle) {
outer:
for (var i = hay.length - needle.length; i >= 0; i--) {
for (var j = 0; j < needle.length; j++) {
if (hay[i + j] != needle[j]) continue outer;
}
return i;
}
return -1;
}

28
apps/client/lib/data/utils/recording_time.dart

@ -0,0 +1,28 @@
/// 一条录音在列表里该显示的「录制时间」。
///
/// [deviceTime] 是设备自己记的时间(如 EaiRec 支架 TF 卡文件的 FAT 时间戳)。
///
/// ⚠️ **设备给的时间不能无条件相信**:支架在 RTC 对时之前写的文件,时间戳是
/// 1980 / 2018 这类垃圾值(2026-09-10 真机那张卡上就有一批 `2018-08-10`)。
/// 直接采信的话,这些记录会沉到列表最底下,用户按时间根本找不到。
///
/// 所以只接受「2020 年之后、且不超过明天」的值,其余退回当前时刻:
/// 显示成「刚导入」是次优但可用,显示成 2018 年是直接丢失。
///
/// ⚠️ **当前这个值在列表上还看不到效果,别以为这段是死代码把它删了。**
/// 服务端 `echomeet_addrecord` 把 `Creationtime` 写死成 `time.Now().Unix()`
/// (`EchomeetAddRecordReq` 里压根没有这个字段),而 `Synchrodata._synchrodata()`
/// 会拿服务端的值**整行 update** 覆盖本地库 —— 于是列表上显示的永远是「导入时刻」。
/// 录制时间目前只活在标题里(`holder_20260907_181224_MIC0049.ogg`)。
/// 要让它真正生效,服务端要做两件事:`EchomeetAddRecordReq` 加 `creationtime`
/// 字段,`AddRecord` 在传了合法值时用它而不是 `time.Now()`。
/// 客户端这一侧已经按「服务端接受」的形状送上去了,接口一改就自动生效。
int resolveRecordingCreationTime(DateTime? deviceTime, {DateTime? now}) {
final n = now ?? DateTime.now();
if (deviceTime == null) return n.millisecondsSinceEpoch;
if (deviceTime.year < 2020) return n.millisecondsSinceEpoch;
if (deviceTime.isAfter(n.add(const Duration(days: 1)))) {
return n.millisecondsSinceEpoch;
}
return deviceTime.millisecondsSinceEpoch;
}

158
apps/client/lib/devices/bes/bes_bluetooth_service.dart

@ -8,9 +8,9 @@ import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../core/utils/logger.dart';
import 'agent_module_defaults.dart';
import 'background_launch_consent.dart';
import '../../data/services/background_launch_consent.dart';
import 'bes_device_auth.dart';
import 'bes_protocol.dart';
/// 恒玄(BES/Bestechnic)芯片经典蓝牙连接服务。
///
@ -28,6 +28,12 @@ class BesBluetoothService extends GetxService {
final _manager = bes.BluetoothManager();
final _storage = GetStorage();
/// 原生插件句柄。只给同目录的 [BesDevicePlugin] 用(AI 通道、下行编码)。
bes.BluetoothManager get manager => _manager;
/// 确权闸门(按 MAC 向服务端登记),由插件注入;null = 不确权。
BesDeviceAuth? auth;
StreamSubscription? _statusSubscription;
StreamSubscription? _cmdEventSubscription;
@ -216,7 +222,8 @@ class BesBluetoothService extends GetxService {
//
// 只有 denied 会中止:unknown(断网/未登录/产品表没下发)一律放行,
// 理由见 BesDeviceAuth 的类注释。
final auth = await BesDeviceAuth.verifyConnected();
final auth = await (this.auth?.verifyConnected() ??
Future.value(BesAuthResult.unknown));
if (auth == BesAuthResult.denied) {
// 断开与提示已由 BesDeviceAuth 就地做完(此时 connectedDeviceId
// 已被清空,别再拿它打日志),这里只是不再往下走
@ -225,13 +232,10 @@ class BesBluetoothService extends GetxService {
}
if (deviceStatus.value != 2) return; // 确权期间掉线了
// 首次连上耳机 → AI 页铺开耳机的默认功能编排
AgentDefaults.onEarphoneConnected();
// 数据通道就绪后再发 0x06 获取固件版本
await Future.delayed(const Duration(milliseconds: 500));
if (deviceStatus.value == 2) {
await sendData([0xAA, 0x06]);
await sendData(BesCmd.frame(BesCmd.firmwareVersion));
await queryBattery();
_scheduleBatteryRetry();
// App 可能是在耳机已经通话中时才连上/重启回连的——0xD1/0xD2/0x02/0x03
@ -368,11 +372,11 @@ class BesBluetoothService extends GetxService {
Future<void> refreshDeviceState() async {
if (!isConnected) return;
try {
await sendData([0xAA, 0x06]);
await sendData(BesCmd.frame(BesCmd.firmwareVersion));
await Future.delayed(const Duration(milliseconds: 120));
await sendData([0xAA, 0x0C]);
await sendData(BesCmd.frame(BesCmd.keyConfig));
await Future.delayed(const Duration(milliseconds: 120));
await sendData([0xAA, 0x10]);
await sendData(BesCmd.frame(BesCmd.eqConfig));
await Future.delayed(const Duration(milliseconds: 120));
// 电量会随时间掉,进功能页时重查一次,别只依赖连接那一刻的值
await queryBattery();
@ -452,7 +456,7 @@ class BesBluetoothService extends GetxService {
Future<void> queryBattery() async {
try {
await sendData([0xAA, 0x09]);
await sendData(BesCmd.frame(BesCmd.status));
} catch (e) {
Logger.e(_tag, '查询电量失败: $e');
}
@ -499,7 +503,7 @@ class BesBluetoothService extends GetxService {
/// 这条查询命令是唯一能补的办法,见连接握手和 [refreshDeviceState]。
Future<void> queryCallState() async {
try {
await sendData([0xAA, 0x0E]);
await sendData(BesCmd.frame(BesCmd.callState));
} catch (e) {
Logger.e(_tag, '查询通话状态失败: $e');
}
@ -568,67 +572,38 @@ class BesBluetoothService extends GetxService {
/// 3. **电量字节最高位是充电指示位**,必须 `& 0x7F` 取百分比。
/// 直接用原字节,充电时会得到 128~228 的假电量。
void _parseBattery(List<int> data) {
var p = 3; // 跳过 SOF + CMD + 1 字节长度
var hit = false;
while (p + 2 < data.length) {
final blockLen = data[p];
if (blockLen < 2 || p + blockLen >= data.length) break;
final attr = data[p + 1];
final raw = data[p + 2];
final level = raw & 0x7F; // 低 7 位是百分比
final charging = (raw & 0x80) != 0; // 最高位是充电指示
if (level <= 100) {
switch (attr) {
case 0x01:
batteryLeft.value = level;
chargingLeft.value = charging;
hit = true;
break;
case 0x02:
batteryRight.value = level;
chargingRight.value = charging;
hit = true;
break;
case 0x03:
batteryCase.value = level;
chargingCase.value = charging;
hit = true;
break;
}
}
// 属性 7 = 耳机蓝牙地址(6 字节)。这是**耳机自己报上来的**真 MAC,
// 不经过系统蓝牙 API,所以 iOS 上也拿得到——iOS 的 connectedDeviceId
// 是 CoreBluetooth 的 peripheral UUID(每台手机看同一只耳机都不一样),
// 设备页显示 MAC 和设备确权都指望这个值。
// 实测(Echo-one 0.0.3)解出 11:11:22:33:33:A4,与 Android 上
// connectedDeviceId 逐字节一致,也正是后台 license 表登记的那个。
// ⚠️ 别改用属性 6:那 12 字节里虽然也嵌着一个 MAC
//(布局 `00 01 01 | <6字节> | 00 00 00`,实测 70:B3:B1:4D:33:DC),
// 但它既不是耳机主地址也不是手机地址,含义至今未知,拿去确权必然对不上。
if (attr == 0x07 && blockLen >= 7 && p + 7 < data.length) {
final mac = data
.sublist(p + 2, p + 8)
.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(':');
if (mac != deviceMac.value) {
deviceMac.value = mac;
Logger.i(_tag, '耳机上报蓝牙地址: $mac');
}
// 按本地设备标识记下来,下次连同一台时同步命中,不用再等一帧 BB 0A
_rememberDeviceMac(mac);
}
p += blockLen + 1;
final st = BesProtocol.parseStatus(data);
if (st.batteryLeft != null) {
batteryLeft.value = st.batteryLeft!;
chargingLeft.value = st.chargingLeft;
}
if (!hit) return;
final levels =
[batteryLeft.value, batteryRight.value].where((v) => v >= 0).toList();
if (levels.isNotEmpty) {
// 左右耳取较低的一侧——用户关心的是「还能用多久」
batteryLevel.value = levels.reduce((a, b) => a < b ? a : b);
} else if (batteryCase.value >= 0) {
// 只有盒子上报时(两只耳机都在盒内)退而显示盒电量
batteryLevel.value = batteryCase.value;
if (st.batteryRight != null) {
batteryRight.value = st.batteryRight!;
chargingRight.value = st.chargingRight;
}
if (st.batteryCase != null) {
batteryCase.value = st.batteryCase!;
chargingCase.value = st.chargingCase;
}
// 属性 7 = 耳机蓝牙地址(6 字节)。这是**耳机自己报上来的**真 MAC,
// 不经过系统蓝牙 API,所以 iOS 上也拿得到——iOS 的 connectedDeviceId
// 是 CoreBluetooth 的 peripheral UUID(每台手机看同一只耳机都不一样),
// 设备页显示 MAC 和设备确权都指望这个值。
// 实测(Echo-one 0.0.3)解出 11:11:22:33:33:A4,与 Android 上
// connectedDeviceId 逐字节一致,也正是后台 license 表登记的那个。
// ⚠️ 别改用属性 6:那 12 字节里虽然也嵌着一个 MAC,含义至今未知。
final mac = st.mac;
if (mac != null) {
if (mac != deviceMac.value) {
deviceMac.value = mac;
Logger.i(_tag, '耳机上报蓝牙地址: $mac');
}
// 按本地设备标识记下来,下次连同一台时同步命中,不用再等一帧 BB 0A
_rememberDeviceMac(mac);
}
if (!st.hasBattery) return;
final summary = st.summaryBattery;
if (summary != null) batteryLevel.value = summary;
Logger.w(
_tag,
'恒玄耳机电量: ${batteryLevel.value}%'
@ -671,30 +646,7 @@ class BesBluetoothService extends GetxService {
/// 固件版本回包:BB CMD len(2) + 若干版本块 [len, 属性, 8字节版本],
/// 属性 0x01左 / 0x02右 / 0x04盒;版本取版本块后 3 字节,优先左耳。
void _parseFirmwareVersion(List<int> data) {
String? left, right, box;
var p = 4; // 跳过 SOF + CMD + 2 字节长度
while (p + 1 < data.length) {
final blockLen = data[p];
if (blockLen < 2 || p + blockLen >= data.length) break;
final attr = data[p + 1];
final version = data.sublist(p + 2, p + 1 + blockLen);
if (version.length >= 8) {
final v = '${version[5]}.${version[6]}.${version[7]}';
switch (attr) {
case 0x01:
left = v;
break;
case 0x02:
right = v;
break;
case 0x04:
box = v;
break;
}
}
p += blockLen + 1;
}
final parsed = left ?? right ?? box;
final parsed = BesProtocol.parseFirmware(data).preferred;
if (parsed != null) {
deviceVersion.value = parsed;
// 固件版本决定这台机器该用哪套命令号(0.0.2 与 0.0.3 的电量命令不同),
@ -756,30 +708,30 @@ class BesBluetoothService extends GetxService {
/// 进入通话模式(`AA 51`):耳机开始双路上行(对端 + 本端),
/// mode=0x03 交织,原生解码后走 [audioStream]。通话录音/通话翻译都用它。
Future<void> startCallMode() => sendData([0xAA, 0x51]);
Future<void> startCallMode() => sendData(BesCmd.frame(BesCmd.callModeStart));
/// 退出通话模式(`AA 52`)
Future<void> stopCallMode() => sendData([0xAA, 0x52]);
Future<void> stopCallMode() => sendData(BesCmd.frame(BesCmd.callModeStop));
/// 通知耳机「ASR 已开启」(`AA 57`),收到 0xD1 之后发
Future<void> notifyAsrStarted() => sendData([0xAA, 0x57]);
Future<void> notifyAsrStarted() => sendData(BesCmd.frame(BesCmd.asrStarted));
/// 通知耳机「ASR 已关闭」(`AA 58`)
Future<void> notifyAsrStopped() => sendData([0xAA, 0x58]);
Future<void> notifyAsrStopped() => sendData(BesCmd.frame(BesCmd.asrStopped));
/// 开始面对面翻译(`AA 66`)。耳机就绪后回 `0xE6`;
/// 耳机按键触发时也会主动上报 `0xE6`。
Future<void> startFaceToFace() => sendData([0xAA, 0x66]);
Future<void> startFaceToFace() => sendData(BesCmd.frame(BesCmd.faceToFaceStart));
/// 结束面对面翻译(`AA 67`),设备回 `0xE7`
Future<void> stopFaceToFace() => sendData([0xAA, 0x67]);
Future<void> stopFaceToFace() => sendData(BesCmd.frame(BesCmd.faceToFaceStop));
/// 开始设备端主麦录音(`AA 59`)。设备就绪后会回 `0xD9`,
/// 音频以 mSBC 帧上行,由原生解码后走 [audioStream]。
Future<void> startMicRecording() => sendData([0xAA, 0x59]);
Future<void> startMicRecording() => sendData(BesCmd.frame(BesCmd.micRecordStart));
/// 停止设备端主麦录音(`AA 5A`),设备回 `0xDA`
Future<void> stopMicRecording() => sendData([0xAA, 0x5A]);
Future<void> stopMicRecording() => sendData(BesCmd.frame(BesCmd.micRecordStop));
/// 系统已配对的经典蓝牙设备列表(不做 BLE 扫描)。
Future<List<Map<String, dynamic>>> getBondedDevices() {

23
apps/client/lib/devices/bes/bes_device_auth.dart

@ -4,9 +4,9 @@ import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../core/utils/logger.dart';
import '../models/user_Info.dart';
import '../../data/models/user_Info.dart';
import '../../data/services/network/api.dart';
import 'bes_bluetooth_service.dart';
import 'network/api.dart';
/// 恒玄耳机的确权结论。
enum BesAuthResult {
@ -62,7 +62,9 @@ enum BesAuthResult {
/// iOS 用耳机在 `BB 0A` 状态帧属性 7 里**自报**的蓝牙地址(不经过系统 API,两端一致;
/// 实测与 Android 拿到的逐字节吻合)。取不到时照旧返回 [BesAuthResult.unknown] 放行。
class BesDeviceAuth {
BesDeviceAuth._();
BesDeviceAuth(this._bes);
final BesBluetoothService _bes;
static const String _tag = 'BesDeviceAuth';
@ -127,9 +129,8 @@ class BesDeviceAuth {
///
/// 判定为 [BesAuthResult.denied] 时会**就地断开连接并提示用户**,调用方只需
/// 据返回值决定要不要继续后续握手。
static Future<BesAuthResult> verifyConnected() async {
if (!Get.isRegistered<BesBluetoothService>()) return BesAuthResult.unknown;
final bes = BesBluetoothService.to;
Future<BesAuthResult> verifyConnected() async {
final bes = _bes;
if (!bes.isConnected) return BesAuthResult.unknown;
// iOS 上 connectedDeviceId 是 peripheral UUID,不能拿去校验;
@ -159,7 +160,7 @@ class BesDeviceAuth {
}
}
static Future<BesAuthResult> _verifyRemote({
Future<BesAuthResult> _verifyRemote({
required String mac,
required String name,
}) async {
@ -235,12 +236,12 @@ class BesDeviceAuth {
///
/// ⚠️ **必须移出 pairedDevices**,否则冷启动的 `tryAutoConnect` 和原生 ACL
/// 广播会立刻把它连回来,变成「连上→弹窗→断开→连上」的死循环。
static Future<void> _reject(String address) async {
Future<void> _reject(String address) async {
try {
// removePairedDevice 内部在地址匹配时会顺带 disconnectDevice
await BesBluetoothService.to.removePairedDevice(address);
if (BesBluetoothService.to.isConnected) {
await BesBluetoothService.to.disconnectDevice();
await _bes.removePairedDevice(address);
if (_bes.isConnected) {
await _bes.disconnectDevice();
}
} catch (e) {
Logger.e(_tag, '断开未确权设备失败: $e');

637
apps/client/lib/devices/bes/bes_device_plugin.dart

@ -0,0 +1,637 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import '../../core/utils/logger.dart';
import '../device_vendors.dart';
import 'bes_bluetooth_service.dart';
import 'bes_ota_port.dart';
import 'bes_protocol.dart';
/// 恒玄(BES) 耳机的厂商插件——把 [BesBluetoothService](协议实现,冻结)
/// 适配成 [DevicePlugin] / [DeviceSession]。
///
/// **这一层只做映射,不碰字节。** feature key → 命令字节的对应表在
/// [BesDeviceSession.invokeFeature],由 `test/devices/bes_session_test.dart`
/// 逐条断言发出的字节。
class BesDevicePlugin implements DevicePlugin {
static const String _tag = 'BesDevicePlugin';
/// 协议实现。仍注册进 GetX 只是为了走 GetxService 的生命周期;
/// 业务层不允许 import 它(见 device_hub_boundary_test)。
final BesBluetoothService link;
BesDevicePlugin({BesBluetoothService? link})
: link = link ?? BesBluetoothService();
final StreamController<DevicePluginEvent> _ctrl =
StreamController<DevicePluginEvent>.broadcast();
BesDeviceSession? _session;
Worker? _statusWorker;
bool _initialized = false;
bool _disposed = false;
@override
String get vendorKey => DeviceVendors.bes;
@override
String get displayName => 'BES (恒玄)';
@override
Set<DeviceCapability> get capabilities => BesDeviceSession.caps;
@override
DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty;
@override
DeviceSession? get activeSession => _session;
@override
Stream<DevicePluginEvent> get eventStream => _ctrl.stream;
@override
Future<void> initialize(DevicePluginConfig config) async {
_checkAlive();
if (_initialized) return;
_initialized = true;
if (!Get.isRegistered<BesBluetoothService>()) {
Get.put<BesBluetoothService>(link, permanent: true);
}
_statusWorker = ever<int>(link.deviceStatus, _onStatus);
if (link.deviceStatus.value == 2) _onStatus(2);
_emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady));
}
void _onStatus(int v) {
switch (v) {
case 2:
if (_session != null) return;
final s = BesDeviceSession._(link);
_session = s;
_emit(DevicePluginEvent(
type: DevicePluginEventType.connectionStateChanged,
deviceId: s.deviceId,
connectionState: DeviceConnectionState.ready,
));
break;
case 1:
_emit(DevicePluginEvent(
type: DevicePluginEventType.connectionStateChanged,
deviceId: link.connectedDeviceId.value,
connectionState: DeviceConnectionState.connecting,
));
break;
default:
final s = _session;
_session = null;
s?._markDisconnected();
_emit(DevicePluginEvent(
type: DevicePluginEventType.connectionStateChanged,
deviceId: s?.deviceId,
connectionState: DeviceConnectionState.disconnected,
));
}
}
void _emit(DevicePluginEvent e) {
if (!_ctrl.isClosed) _ctrl.add(e);
}
// ---------- 扫描(恒玄没有扫描:只从系统配对列表里挑) ----------
@override
Future<void> startScan({DeviceScanFilter? filter, Duration? timeout}) async {
_emit(const DevicePluginEvent(type: DevicePluginEventType.scanStarted));
for (final d in await bondedDevices()) {
_emit(DevicePluginEvent(
type: DevicePluginEventType.deviceDiscovered,
deviceId: d.id,
discovered: d));
}
_emit(const DevicePluginEvent(type: DevicePluginEventType.scanStopped));
}
@override
Future<void> stopScan() async {}
@override
Future<bool> isScanning() async => false;
@override
Future<List<DiscoveredDevice>> bondedDevices() async {
final list = await link.getBondedDevices();
return list
.map((d) => DiscoveredDevice(
id: (d['address'] ?? '') as String,
name: (d['name'] ?? '') as String,
vendor: vendorKey,
))
.where((d) => d.id.isNotEmpty)
.toList(growable: false);
}
@override
Future<bool> ensureReady() async {
try {
return await link.initialize();
} catch (e) {
Logger.e(_tag, '恒玄蓝牙初始化失败: $e');
return false;
}
}
// ---------- 连接 ----------
/// 连接并等到「已连接」。
///
/// 进过「连接中」又掉回 0 就提前判失败(原生 connectBlocking 失败会推 0);
/// 但仍要保留超时兜底:原生若把这次 connect 丢弃了(isConnecting 门禁),
/// 状态会停在 connectToDevice 自己写的 1,等不到 0。
@override
Future<DeviceSession> connect(String deviceId,
{DeviceConnectOptions? options}) async {
_checkAlive();
final name = (options?.extra['name'] as String?) ?? '';
final timeout = options?.timeout ?? const Duration(seconds: 8);
await link.connectToDevice(deviceId, name);
final end = DateTime.now().add(timeout);
var sawConnecting = false;
while (DateTime.now().isBefore(end)) {
final s = link.deviceStatus.value;
if (s == 2) break;
if (s == 1) sawConnecting = true;
if (sawConnecting && s == 0) {
throw DeviceException(DeviceErrorCode.connectFailed, deviceId);
}
await Future.delayed(const Duration(milliseconds: 300));
}
if (link.deviceStatus.value != 2) {
throw DeviceException(DeviceErrorCode.connectTimeout, deviceId);
}
// 状态流回调与这里是同一个事件循环,session 已经建好
_onStatus(2);
return _session!;
}
@override
List<DiscoveredDevice> get rememberedDevices => link
.getPairedDevices()
.map((d) => DiscoveredDevice(
id: (d['address'] ?? '') as String,
name: (d['name'] ?? '') as String,
vendor: vendorKey,
))
.where((d) => d.id.isNotEmpty)
.toList(growable: false);
@override
DiscoveredDevice? get lastKnownDevice {
final n = link.lastDeviceName;
final a = link.lastDeviceAddress;
if (n.isEmpty && a.isEmpty) return null;
return DiscoveredDevice(id: a, name: n, vendor: vendorKey);
}
@override
Future<void> rememberDevice(DiscoveredDevice device) =>
link.savePairedDevice({'address': device.id, 'name': device.name});
@override
Future<void> tryAutoConnect() => link.tryAutoConnect();
@override
Future<void> forgetDevice(String deviceId) =>
link.removePairedDevice(deviceId);
@override
Future<void> dispose() async {
_disposed = true;
_statusWorker?.dispose();
_session?._markDisconnected();
_session = null;
await _ctrl.close();
}
void _checkAlive() {
if (_disposed) throw StateError('BesDevicePlugin disposed');
}
}
/// 恒玄耳机会话。
class BesDeviceSession extends DeviceSession {
BesDeviceSession._(this._link) {
_bind();
}
static const String _tag = 'BesSession';
static const Set<DeviceCapability> caps = {
DeviceCapability.connect,
DeviceCapability.bond,
DeviceCapability.battery,
DeviceCapability.macAddress,
DeviceCapability.micUplink,
DeviceCapability.speakerDownlink,
DeviceCapability.micRecording,
DeviceCapability.callAudioTap,
DeviceCapability.callState,
DeviceCapability.aiWake,
DeviceCapability.translationDownlink,
DeviceCapability.faceToFace,
DeviceCapability.ota,
DeviceCapability.customCommand,
};
final BesBluetoothService _link;
final StreamController<DeviceSessionEvent> _events =
StreamController<DeviceSessionEvent>.broadcast();
final StreamController<DeviceWakeEvent> _wake =
StreamController<DeviceWakeEvent>.broadcast();
final List<Worker> _workers = [];
StreamSubscription? _aiSub;
DeviceConnectionState _state = DeviceConnectionState.ready;
/// 通话翻译下行的引用计数:A/B 两路都关了才 stopCallDownlink
int _callSinks = 0;
BesOtaPort? _ota;
void _bind() {
_workers.add(ever<String>(_link.lastCmdType, (type) {
switch (type) {
case 'startMicRecording':
_feature(DeviceFeatureKeys.recordStarted);
break;
case 'stopMicRecording':
_feature(DeviceFeatureKeys.recordStopped);
break;
}
}));
_workers.add(ever<bool>(_link.isCallOngoing, (on) {
_feature(on ? DeviceFeatureKeys.callStarted : DeviceFeatureKeys.callEnded);
}));
void infoChanged(dynamic _) => _infoUpdated();
_workers.add(ever<int>(_link.batteryLevel, (_) {
_feature(DeviceFeatureKeys.batteryUpdated);
_infoUpdated();
}));
_workers.add(ever<int>(_link.batteryCase, infoChanged));
_workers.add(ever<bool>(_link.chargingLeft, infoChanged));
_workers.add(ever<bool>(_link.chargingRight, infoChanged));
_workers.add(ever<bool>(_link.chargingCase, infoChanged));
_workers.add(ever<String>(_link.deviceVersion, (_) {
_feature(DeviceFeatureKeys.firmwareUpdated);
_infoUpdated();
}));
_workers.add(ever<String>(_link.deviceMac, (_) {
_feature(DeviceFeatureKeys.macUpdated);
_infoUpdated();
}));
_workers.add(ever<String>(_link.connectedDeviceName, infoChanged));
// AI 通道:BB 64 唤醒 / BB E5 结束。应答 AA 6A 由业务层通过 ai.accept 发,
// 与原先 BesAiSessionService 的判断顺序一致(未配置时不应答、发 ai.exit)。
_aiSub = _link.manager.onDeviceAiEvent().listen((event) {
switch (event['type']) {
case 'startAI':
_wake.add(DeviceWakeEvent(deviceId: deviceId, reason: WakeReason.ptt));
break;
case 'stopAI':
_wake.add(
DeviceWakeEvent(deviceId: deviceId, reason: WakeReason.hangup));
break;
}
}, onError: (e) => Logger.e(_tag, 'AI 事件流错误: $e'));
}
void _feature(String key, [Map<String, Object?> data = const {}]) {
if (_events.isClosed) return;
_events.add(DeviceSessionEvent(
type: DeviceSessionEventType.feature,
deviceId: deviceId,
feature: DeviceFeatureEvent(key: key, data: data),
));
}
void _infoUpdated() {
if (_events.isClosed) return;
_events.add(DeviceSessionEvent(
type: DeviceSessionEventType.deviceInfoUpdated,
deviceId: deviceId,
deviceInfo: info,
));
}
void _markDisconnected() {
if (_state == DeviceConnectionState.disconnected) return;
_state = DeviceConnectionState.disconnected;
for (final w in _workers) {
w.dispose();
}
_workers.clear();
_aiSub?.cancel();
_aiSub = null;
_ota?.onSessionGone();
if (!_events.isClosed) {
_events.add(DeviceSessionEvent(
type: DeviceSessionEventType.connectionStateChanged,
deviceId: deviceId,
connectionState: DeviceConnectionState.disconnected,
));
_events.close();
}
if (!_wake.isClosed) _wake.close();
}
void _requireReady() {
if (_state != DeviceConnectionState.ready) {
throw DeviceException(DeviceErrorCode.noActiveSession, 'bes');
}
}
// ---------- DeviceSession ----------
@override
String get deviceId => _link.connectedDeviceId.value;
@override
String get vendor => DeviceVendors.bes;
@override
DeviceConnectionState get state => _state;
@override
Set<DeviceCapability> get capabilities => caps;
@override
Stream<DeviceSessionEvent> get eventStream => _events.stream;
@override
Stream<DeviceWakeEvent> get wakeEvents => _wake.stream;
static int? _nz(int v) => v >= 0 ? v : null;
/// 真实 MAC:Android 系统给的就是;iOS 只能靠耳机自报(`BB 0A` 属性 7)。
String? get macAddress {
if (Platform.isAndroid) {
final id = _link.connectedDeviceId.value;
return id.isEmpty ? null : id.toUpperCase();
}
final m = _link.deviceMac.value;
return m.isEmpty ? null : m;
}
@override
DeviceInfo get info {
final battery = DeviceBattery(
left: _nz(_link.batteryLeft.value),
right: _nz(_link.batteryRight.value),
caseBox: _nz(_link.batteryCase.value),
single: _nz(_link.batteryLevel.value),
chargingLeft: _link.chargingLeft.value,
chargingRight: _link.chargingRight.value,
chargingCase: _link.chargingCase.value,
);
final fw = _link.deviceVersion.value;
return DeviceInfo(
id: deviceId,
name: _link.connectedDeviceName.value,
vendor: vendor,
firmwareVersion: fw.isEmpty ? null : fw,
batteryPercent: battery.summary,
battery: battery,
macAddress: macAddress,
metadata: {
'inCall': _link.isCallOngoing.value,
'localId': _link.connectedDeviceId.value,
},
);
}
@override
Future<int> readRssi() =>
throw DeviceException(DeviceErrorCode.notSupported, 'rssi');
@override
Future<int?> readBattery() async {
_requireReady();
await _link.queryBattery();
return info.battery.summary;
}
@override
Future<DeviceInfo> refreshInfo() async {
_requireReady();
await _link.refreshDeviceState();
return info;
}
/// 取真实 MAC,iOS 上会主动发一次查询并等最多 [timeout]。
Future<String?> resolveMac({Duration timeout = const Duration(seconds: 3)}) =>
_link.resolveDeviceMac(timeout: timeout);
// ---------- 音频 ----------
@override
Future<DeviceAudioSource> openMic({AudioRoute route = AudioRoute.mic}) async {
_requireReady();
switch (route) {
case AudioRoute.mic:
// 现场录音:主麦与前馈麦交替上行,隔帧取主麦
return _BesSource(route, _alternate(_link.audioStream));
case AudioRoute.callLocal:
return _BesSource(route, _link.micPcmStream);
case AudioRoute.callPeer:
return _BesSource(route, _link.spkPcmStream);
case AudioRoute.ai:
return _BesSource(
route,
_link.manager
.onDeviceAiEvent()
.where((e) => e['type'] == 'micData' && e['data'] is Uint8List)
.map((e) => e['data'] as Uint8List)
.where((d) => d.isNotEmpty));
default:
throw DeviceException(DeviceErrorCode.notSupported, 'openMic($route)');
}
}
static Stream<Uint8List> _alternate(Stream<Uint8List> raw) {
var take = true;
return raw.where((_) {
final t = take;
take = !take;
return t;
});
}
@override
Future<DeviceAudioSink> openSpeaker(
{AudioRoute route = AudioRoute.ai}) async {
_requireReady();
switch (route) {
case AudioRoute.ai:
await _link.manager.startAiDownlink();
return _BesAiSink(_link);
case AudioRoute.callLegA:
case AudioRoute.callLegB:
if (_callSinks == 0) await _link.startTranslationDownlink();
_callSinks++;
return _BesCallSink(_link, route, () async {
_callSinks--;
if (_callSinks <= 0) {
_callSinks = 0;
await _link.stopTranslationDownlink();
}
});
default:
throw DeviceException(
DeviceErrorCode.notSupported, 'openSpeaker($route)');
}
}
// ---------- feature ----------
/// feature → 字节的对应表。**冻结**,改动看金测试。
static const Map<String, int> featureCommands = {
DeviceFeatures.recordStart: BesCmd.micRecordStart,
DeviceFeatures.recordStop: BesCmd.micRecordStop,
DeviceFeatures.callStart: BesCmd.callModeStart,
DeviceFeatures.callStop: BesCmd.callModeStop,
DeviceFeatures.callQuery: BesCmd.callState,
DeviceFeatures.asrStarted: BesCmd.asrStarted,
DeviceFeatures.asrStopped: BesCmd.asrStopped,
DeviceFeatures.aiAccept: BesCmd.aiAccept,
DeviceFeatures.aiExit: BesCmd.aiExit,
DeviceFeatures.f2fStart: BesCmd.faceToFaceStart,
DeviceFeatures.f2fStop: BesCmd.faceToFaceStop,
DeviceFeatures.batteryQuery: BesCmd.status,
'bes.firmware.query': BesCmd.firmwareVersion,
};
@override
Future<Map<String, Object?>> invokeFeature(String featureKey,
[Map<String, Object?> args = const {}]) async {
_requireReady();
final cmd = featureCommands[featureKey];
if (cmd != null) {
await _link.sendData(BesCmd.frame(cmd));
return {'sent': BesCmd.frame(cmd)};
}
switch (featureKey) {
case DeviceFeatures.stateRefresh:
await _link.refreshDeviceState();
return const {};
case 'bes.probe':
await _link.probeCommands(
from: (args['from'] as int?) ?? 0x21,
to: (args['to'] as int?) ?? 0x7F,
);
return const {};
case 'bes.send':
final bytes = (args['bytes'] as List?)?.cast<int>() ?? const <int>[];
if (bytes.isEmpty) {
throw DeviceException(DeviceErrorCode.invalidArgument, 'bytes');
}
await _link.sendData(bytes);
return {'sent': bytes};
default:
throw DeviceException(DeviceErrorCode.notSupported, featureKey);
}
}
@override
DeviceOtaPort? otaPort() => _ota ??= BesOtaPort(this, _link);
@override
Future<void> disconnect() => _link.disconnectDevice();
}
class _BesSource implements DeviceAudioSource {
_BesSource(this.route, Stream<Uint8List> src) {
_sub = src.listen(_ctrl.add, onError: _ctrl.addError);
}
@override
final AudioRoute route;
final StreamController<Uint8List> _ctrl = StreamController.broadcast();
StreamSubscription? _sub;
@override
AudioFormat get format => AudioFormat.pcm16kMono;
@override
Stream<Uint8List> get pcm => _ctrl.stream;
@override
Future<void> close() async {
await _sub?.cancel();
_sub = null;
await _ctrl.close();
}
}
/// AI 下行:G.722 编码与 60ms 组包、0xD6/0xE9 调速全在原生 `AiAudioDownlink`。
class _BesAiSink implements DeviceAudioSink {
_BesAiSink(this._link);
final BesBluetoothService _link;
bool _closed = false;
@override
AudioRoute get route => AudioRoute.ai;
@override
AudioFormat get format => AudioFormat.pcm16kMono;
@override
Future<void> write(Uint8List pcm) async {
if (_closed) return;
await _link.manager.pushAiPcm(pcm);
}
@override
Future<void> discardPending() async {
if (_closed) return;
await _link.manager.flushAiDownlink();
}
@override
Future<void> close() async {
if (_closed) return;
_closed = true;
await _link.manager.stopAiDownlink();
}
}
/// 通话翻译下行:`AA 56` 84 字节包,G.722 + 20ms 节流在原生 `CallTranslationDownlink`。
class _BesCallSink implements DeviceAudioSink {
_BesCallSink(this._link, this.route, this._onClose);
final BesBluetoothService _link;
@override
final AudioRoute route;
final Future<void> Function() _onClose;
bool _closed = false;
String get _leg => route == AudioRoute.callLegA ? 'A' : 'B';
@override
AudioFormat get format => AudioFormat.pcm16kMono;
@override
Future<void> write(Uint8List pcm) async {
if (_closed) return;
await _link.pushTranslationTtsPcm(_leg, pcm);
}
@override
Future<void> discardPending() async {}
@override
Future<void> close() async {
if (_closed) return;
_closed = true;
await _onClose();
}
}

203
apps/client/lib/devices/bes/bes_ota_port.dart

@ -0,0 +1,203 @@
import 'dart:async';
import 'package:bes_ota_manager/bes_ota_manager.dart';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import '../../core/utils/logger.dart';
import 'bes_bluetooth_service.dart';
import 'bes_device_plugin.dart';
/// 恒玄 SPP OTA 2.0 端口。
///
/// 恒玄的固件推送由 `bes_ota_manager` 插件**自管**一条 SPP 链路,多数固件同时
/// 只接受一条 SPP,所以:
/// 1. [start] 先断开 app 层连接、留 2s 让耳机拆完旧链路,再让插件连;
/// 2. 升级中 app 层的「已断开」是我们自己断的,**不能**当成 OTA 掉线;
/// 3. [close](退出升级页)先 stopOta 释放插件链路,再延时把 app 层连回来
/// (成功 5s:设备在重启;其余 2s:等耳机拆链路)。
///
/// 看门狗、下载、屏幕常亮这些与厂商无关的事在通用 `OtaUpgradeController` 里。
class BesOtaPort implements DeviceOtaPort {
BesOtaPort(this._session, this._link);
static const String _tag = 'BesOtaPort';
final BesDeviceSession _session;
final BesBluetoothService _link;
final BesOtaManager _ota = BesOtaManager();
final StreamController<DeviceOtaProgress> _progress =
StreamController<DeviceOtaProgress>.broadcast();
StreamSubscription<BesOtaEvent>? _sub;
bool _running = false;
bool _terminal = false;
bool _succeeded = false;
/// 我们自己为了 OTA 断开了 app 层连接
bool _handover = false;
String _address = '';
String _name = '';
int _lastPercent = -1;
@override
bool get isRunning => _running;
@override
Stream<DeviceOtaProgress> get progressStream => _progress.stream;
@override
Future<void> start(DeviceOtaRequest request) async {
if (_running) throw DeviceException(DeviceErrorCode.otaBusy);
if (request is! DeviceOtaFileRequest) {
throw DeviceException(
DeviceErrorCode.notSupported, '${request.runtimeType}');
}
// 断开前先抓住设备标识(disconnect 会清空服务字段)
final id = _link.connectedDeviceId.value;
_address = id.isNotEmpty ? id : _address;
final n = _link.connectedDeviceName.value;
_name = n.isNotEmpty ? n : _name;
if (_address.isEmpty) {
throw DeviceException(DeviceErrorCode.noActiveSession, 'no address');
}
_running = true;
_terminal = false;
_succeeded = false;
_lastPercent = -1;
_emit(DeviceOtaState.entering, 0);
// 1) 释放 app 层 SPP,让插件自管 OTA 连接。
_handover = true;
await _link.disconnectDevice();
// disconnect 返回只代表本端 socket 已关,耳机侧链路拆除还要在空中传播,
// 立刻连 OTA 会被拒;留 2s 缓冲。
await Future.delayed(const Duration(seconds: 2));
if (!_running) return; // 期间被 cancel 了
// 2) 初始化原生 OTA 环境并订阅事件。
await _ota.init();
_sub ??= _ota.events.listen(_onEvent, onError: (Object e) {
_fail(DeviceErrorCode.otaTransferFailed, e.toString());
});
// 3) 启动 SPP OTA 2.0。
_emit(DeviceOtaState.transferring, 0);
await _ota.startOta(
address: _address,
name: _name,
filePath: request.filePath,
protocol: 'SPP',
userFlag: 1,
upgradeType: 1,
user: 1,
);
}
void _onEvent(BesOtaEvent e) {
// 进入终态即锁定:迟到的进度/Canceled/Error 一律忽略,
// 否则成功会被迟到的 Canceled 覆盖成失败。
if (_terminal) return;
switch (e.event) {
case 'onProgress':
_emitPercent(((e.progress ?? 0) * 100).round());
break;
case 'onStatus':
// OTA_SEND_DATA_PROGRESS 状态消息带原始浮点百分比,其余状态文本只记日志
final numeric = double.tryParse(e.message ?? '');
if (numeric != null && numeric > 0) {
_emitPercent(numeric.clamp(0, 100).round());
} else {
Logger.d(_tag, 'OTA 状态: ${e.message}');
}
break;
case 'onSuccess':
_terminal = true;
_succeeded = true;
_running = false;
_emit(DeviceOtaState.done, 100);
_ota.stopOta();
break;
case 'onError':
_fail(DeviceErrorCode.otaTransferFailed,
e.message?.isNotEmpty == true ? e.message! : 'code=${e.code}');
break;
}
}
/// 进度只在单调递增时上报,避免两个来源(整数 onProgress / 浮点 onStatus)
/// 高频交替导致数字来回跳。
void _emitPercent(int p) {
if (p <= _lastPercent) return;
_lastPercent = p;
_emit(DeviceOtaState.transferring, p);
}
void _fail(String code, String message) {
if (_terminal) return;
_terminal = true;
_running = false;
_emit(DeviceOtaState.failed, -1, errorCode: code, errorMessage: message);
// 失败即释放原生会话与 SPP 链路,否则它还占着唯一的 SPP
_ota.stopOta();
}
void _emit(DeviceOtaState st, int percent,
{String? errorCode, String? errorMessage}) {
if (_progress.isClosed) return;
_progress.add(DeviceOtaProgress(
state: st,
sentBytes: 0,
totalBytes: 0,
percent: percent,
tsMs: DateTime.now().millisecondsSinceEpoch,
errorCode: errorCode,
errorMessage: errorMessage,
));
}
@override
Future<void> cancel() async {
if (!_running) return;
_running = false;
_terminal = true;
await _ota.stopOta();
_emit(DeviceOtaState.cancelled, -1);
}
/// 会话没了(app 层断开)。升级中那次断开是我们自己发起的,忽略。
void onSessionGone() {
if (_handover) return;
if (_running) _fail(DeviceErrorCode.disconnectedRemote, '设备断开');
}
@override
Future<void> close() async {
_running = false;
await _sub?.cancel();
_sub = null;
// 先停 OTA 会话释放插件占用的 SPP,再回连 app 层,避免两条连接打架。
try {
await _ota.stopOta();
} catch (_) {}
_ota.dispose();
if (!_progress.isClosed) await _progress.close();
if (!_handover || _address.isEmpty) return;
_handover = false;
final delay = _succeeded
? const Duration(seconds: 5) // 设备正在重启
: const Duration(seconds: 2); // 等耳机拆完 OTA 链路再回连
final addr = _address;
final name = _name;
Future.delayed(delay, () {
// 期间可能已(自动/手动)连上,避免重复发起
if (_link.deviceStatus.value != 2) {
_link.connectToDevice(addr, name);
}
});
}
/// 供插件在会话对象被替换时判断归属。
bool belongsTo(BesDeviceSession s) => identical(s, _session);
}

278
apps/client/lib/devices/bes/bes_protocol.dart

@ -0,0 +1,278 @@
/// 恒玄(BES) 私有协议的**纯函数**编解码。
///
/// ⚠️ **协议冻结**:这里的每一个字节都对应已量产固件(Echo-one 0.0.2 / 0.0.3)
/// 的真实行为,由 `test/devices/bes_protocol_test.dart` 用真机抓的帧做金测试。
/// 改任何一个常量、任何一处偏移,先看测试为什么红。
///
/// 这个文件不依赖 Flutter / GetX / 原生插件,只做 `List<int>` ↔ 结构体。
library;
/// App → 耳机 的命令(`AA xx`)。
///
/// 来源:厂商协议 + 真机实测(见各条注释)。请求码与响应码的关系**没有**
/// 统一规律:多数是 `响应 = 请求 | 0x80`(06→86、0E→8E),但电量是 09→0A。
class BesCmd {
BesCmd._();
static const int sof = 0xAA;
/// 固件版本查询,回 `BB 86`(或主动上报 `BB 07`)
static const int firmwareVersion = 0x06;
/// 耳机状态(电量/位置/连接/MAC),回 `BB 0A` + ack `BB 89`。
/// 实测扫出来的请求码,文档里那个「响应命令 20」两种进制都无回包。
/// ⚠️ 固件 0.0.3 上此命令已不回 `BB 0A`。
static const int status = 0x09;
/// 按键配置查询,回 `BB 8C`
static const int keyConfig = 0x0C;
/// 通话状态查询,回 `BB 8E`(data[6] 0x01=来电 / 0x02=通话中 / 0x03=结束)
static const int callState = 0x0E;
/// EQ 配置查询,回 `BB 90`
static const int eqConfig = 0x10;
/// 进入通话模式:耳机开始 G.722 mode=0x03 双路交织上行
static const int callModeStart = 0x51;
/// 退出通话模式
static const int callModeStop = 0x52;
/// 通话翻译下行 TTS 包(84 字节,由原生 `CallTranslationDownlink` 组包)
static const int translationTts = 0x56;
/// 通知耳机 ASR 已开启
static const int asrStarted = 0x57;
/// 通知耳机 ASR 已关闭
static const int asrStopped = 0x58;
/// 开始设备端主麦录音(mSBC 上行),设备回 `BB D9`
static const int micRecordStart = 0x59;
/// 停止设备端主麦录音,设备回 `BB DA`
static const int micRecordStop = 0x5A;
/// 请求退出 AI 模式,耳机回 `BB E5`
static const int aiExit = 0x65;
/// 开始面对面翻译,耳机回 `BB E6`
static const int faceToFaceStart = 0x66;
/// 结束面对面翻译,耳机回 `BB E7`
static const int faceToFaceStop = 0x67;
/// 应答 AI 唤醒(收到 `BB 64` 后 **3 秒内**必须发)
static const int aiAccept = 0x6A;
/// 这一段会切换耳机工作模式(进通话/录音/AI),探测时一律不碰
static bool switchesMode(int cmd) => cmd >= 0x51 && cmd <= 0x6A;
static List<int> frame(int cmd) => <int>[sof, cmd & 0xFF];
}
/// 耳机 → App 的响应/上报(`BB xx`)。
class BesResp {
BesResp._();
static const int sof = 0xBB;
/// 主动上报固件版本
static const int firmwarePush = 0x07;
/// 响应 `AA 06`
static const int firmwareVersion = 0x86;
/// 耳机状态帧(电量/位置/连接/MAC)
static const int status = 0x0A;
/// `AA 09` 的 ack(`BB 89 04 00`),无数据
static const int statusAck = 0x89;
/// 周期上报的另一种状态帧,`[len,attr,value]×2`,值 0/1,含义未定
static const int unknown18 = 0x18;
}
/// `BB 0A` 状态帧里的属性号。
class BesStatusAttr {
BesStatusAttr._();
static const int batteryLeft = 0x01;
static const int batteryRight = 0x02;
static const int batteryCase = 0x03;
/// 耳机位置(是否在盒内),值常 >100,**不是电量**
static const int position = 0x04;
/// 连接状态,值常 >100
static const int connection = 0x05;
/// 预留 12 字节,里面嵌着一个含义未知的 MAC,**不要拿去确权**
static const int reserved = 0x06;
/// 耳机蓝牙地址 6 字节——耳机自报的真 MAC,iOS 上唯一可靠来源
static const int macAddress = 0x07;
}
/// `BB 0A` 解析结果。没出现的字段为 null。
class BesStatus {
const BesStatus({
this.batteryLeft,
this.batteryRight,
this.batteryCase,
this.chargingLeft = false,
this.chargingRight = false,
this.chargingCase = false,
this.mac,
});
final int? batteryLeft;
final int? batteryRight;
final int? batteryCase;
final bool chargingLeft;
final bool chargingRight;
final bool chargingCase;
/// 大写、冒号分隔,如 `11:11:22:33:33:A4`
final String? mac;
bool get hasBattery =>
batteryLeft != null || batteryRight != null || batteryCase != null;
/// 「只显示一个数」时用:左右取较低一侧;两只都在盒里只有盒电量时退回盒。
int? get summaryBattery {
final lr = [batteryLeft, batteryRight].whereType<int>().toList();
if (lr.isNotEmpty) return lr.reduce((a, b) => a < b ? a : b);
return batteryCase;
}
@override
String toString() =>
'BesStatus(L=$batteryLeft${chargingLeft ? '⚡' : ''} '
'R=$batteryRight${chargingRight ? '⚡' : ''} '
'C=$batteryCase${chargingCase ? '⚡' : ''} mac=$mac)';
}
/// 固件版本帧解析结果。
class BesFirmware {
const BesFirmware({this.left, this.right, this.box});
final String? left;
final String? right;
final String? box;
/// 优先左耳。
String? get preferred => left ?? right ?? box;
}
class BesProtocol {
BesProtocol._();
static String hex(Iterable<int> b) => b
.map((e) => (e & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase())
.join(' ');
static String macOf(Iterable<int> b) => b
.map((e) => (e & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase())
.join(':');
/// 是不是一个 `BB` 开头、至少带命令号的帧。
static bool isResponse(List<int> data) =>
data.length >= 2 && (data[0] & 0xFF) == BesResp.sof;
/// 解析 `BB 0A` 状态帧。
///
/// 帧格式 `BB | 0A | CMD Length(1) | 块… | checksum`,
/// 块是 `[len, 属性, 数据…]`,**len 计的是「属性 + 数据」的字节数**。
///
/// 三个踩过的坑(都会让电量永远显示不出来、且不报错):
/// 1. 起点是 **3** 不是 4:长度字段只有 1 字节,照抄固件版本帧的 `p = 4`
/// 会整体错位一格,第一个块长度读成 0x01 < 2 直接 break;
/// 2. 充电盒是属性 **0x03** 不是 0x04(0x04 是耳机位置,值常 >100);
/// 3. 电量字节最高位是充电指示位,必须 `& 0x7F`。
///
/// 属性 3(盒电量)**可选**:耳机不在盒内时这帧没有它。
static BesStatus parseStatus(List<int> data) {
int? l, r, c;
var cl = false, cr = false, cc = false;
String? mac;
var p = 3;
while (p + 2 < data.length) {
final blockLen = data[p] & 0xFF;
if (blockLen < 2 || p + blockLen >= data.length) break;
final attr = data[p + 1] & 0xFF;
final raw = data[p + 2] & 0xFF;
final level = raw & 0x7F;
final charging = (raw & 0x80) != 0;
if (level <= 100) {
switch (attr) {
case BesStatusAttr.batteryLeft:
l = level;
cl = charging;
break;
case BesStatusAttr.batteryRight:
r = level;
cr = charging;
break;
case BesStatusAttr.batteryCase:
c = level;
cc = charging;
break;
}
}
if (attr == BesStatusAttr.macAddress &&
blockLen >= 7 &&
p + 7 < data.length) {
mac = macOf(data.sublist(p + 2, p + 8));
}
p += blockLen + 1;
}
return BesStatus(
batteryLeft: l,
batteryRight: r,
batteryCase: c,
chargingLeft: cl,
chargingRight: cr,
chargingCase: cc,
mac: mac,
);
}
/// 解析固件版本帧(`BB 86` / `BB 07`):
/// `BB CMD len(2) + 若干版本块 [len, 属性, 8字节版本]`,
/// 属性 0x01左 / 0x02右 / 0x04盒;版本取版本块后 3 字节。
static BesFirmware parseFirmware(List<int> data) {
String? left, right, box;
var p = 4;
while (p + 1 < data.length) {
final blockLen = data[p] & 0xFF;
if (blockLen < 2 || p + blockLen >= data.length) break;
final attr = data[p + 1] & 0xFF;
final version = data.sublist(p + 2, p + 1 + blockLen);
if (version.length >= 8) {
final v = '${version[5]}.${version[6]}.${version[7]}';
switch (attr) {
case 0x01:
left = v;
break;
case 0x02:
right = v;
break;
case 0x04:
box = v;
break;
}
}
p += blockLen + 1;
}
return BesFirmware(left: left, right: right, box: box);
}
/// 通话状态查询 `BB 8E` 的 data[6]:0x01=来电 0x02=通话中 0x03=结束。
/// 原生已经把 0x02/0x03 翻成 startMakeCall/stopMakeCall 事件,这里只作备查。
static int? parseCallState(List<int> data) {
if (data.length < 7 || (data[1] & 0xFF) != 0x8E) return null;
return data[6] & 0xFF;
}
}

71
apps/client/lib/devices/device_auth.dart

@ -0,0 +1,71 @@
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'bes/bes_device_auth.dart';
import 'bes/bes_device_plugin.dart';
import 'device_hub.dart';
import 'smartcar/smartcar_device_auth.dart';
import 'smartcar/smartcar_device_plugin.dart';
/// 确权结论(厂商无关)。
///
/// ⚠️ [unknown] 的语义是**放行**,不是"失败"。判不出来时放行是刻意选的失败方向:
/// 判严的代价是合法用户一断网就用不了自己的设备;判宽只是未登记设备离线时
/// 能用一会儿,联网第一次确权就会被拦下。
enum DeviceAuthResult {
/// 白名单命中 / 服务端确认 / 已在 User.devices 里
authorized,
/// 服务端明确拒绝(这台 MAC 没登记)
denied,
/// 未登录 / 断网 / 拿不到 MAC —— **放行**
unknown,
}
/// 设备确权的业务层入口(登出重置、解绑清白名单、补校当前设备)。
///
/// 确权本身是各厂商插件在连接时就地做的(恒玄见 [BesDeviceAuth]),这里只把
/// 需要从业务层触发的几个动作聚在一起,业务层不用知道是哪家的实现。
class DeviceAuth {
DeviceAuth._();
/// 登出:换账号后同一台设备要重新登记到新账号名下。**白名单不清**。
static void reset() {
BesDeviceAuth.reset();
SmartcarDeviceAuth.reset();
}
/// 解绑:移出白名单,下次连上重新走服务端确权。
///
/// 两家都调:调用方只有一个 MAC,不知道它属于哪个厂商,
/// 而各家白名单是按 MAC 存的、互不相干,多清一次无副作用。
static Future<void> forget(String mac) async {
await BesDeviceAuth.forget(mac);
await SmartcarDeviceAuth.forget(mac);
}
/// 【调试/售后】清空白名单
static Future<void> clearWhitelist() async {
await BesDeviceAuth.clearWhitelist();
await SmartcarDeviceAuth.clearWhitelist();
}
/// 补校当前连着的、需要确权的设备(顺带完成绑定登记)。
/// 「先连耳机后登录」时连接事件里的登记会因未登录跳过,设备管理页进来补一次。
static Future<void> verifyConnected() async {
final hub = DeviceHub.to;
for (final s in hub.sessions.toList()) {
if (!s.supports(DeviceCapability.macAddress)) continue;
final p = hub.plugin(s.vendor);
if (p is BesDevicePlugin) {
await p.link.auth?.verifyConnected();
} else if (p is SmartcarDevicePlugin) {
// Smartcar 的 MAC 来自广播,两端都拿得到(不像恒玄 iOS 只有 UUID)
await SmartcarDeviceAuth.verify(
mac: s.info.macAddress,
name: s.info.name,
);
}
}
}
}

41
apps/client/lib/devices/device_bootstrap.dart

@ -0,0 +1,41 @@
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import '../core/utils/logger.dart';
import 'bes/bes_device_auth.dart';
import 'bes/bes_device_plugin.dart';
import 'device_hub.dart';
import 'holder/holder_device_plugin.dart';
import 'smartcar/smartcar_device_plugin.dart';
/// 装配设备中枢:注册所有厂商插件并初始化。由 InitialBinding 调用一次。
///
/// 新接一种芯片 = 在这里多 `registerPlugin` 一行 + `lib/devices/<vendor>/` 一个目录,
/// 业务层零改动。
///
/// 两个插件的 `initialize` 都是轻量的:恒玄在 iOS 上必须无条件建 CBCentralManager
/// 才能被动接管系统层连接,Android 只在绑过设备时申请权限;支架只在记住过设备时
/// 注册后台扫连。所以这里不做「按绑定记录挑插件」,行为与改造前一致。
void installDeviceHub() {
if (Get.isRegistered<DeviceHub>()) return;
final hub = Get.put<DeviceHub>(DeviceHub(), permanent: true);
final bes = BesDevicePlugin();
bes.link.auth = BesDeviceAuth(bes.link);
_register(hub, bes);
_register(hub, HolderDevicePlugin());
// Smartcar 车载香薰。BLE 走 flutter_blue_plus(纯 Dart,无原生插件)。
// initialize 里不申请权限也不起扫描,冷启动零副作用。
_register(hub, SmartcarDevicePlugin());
}
void _register(DeviceHub hub, DevicePlugin p) {
try {
hub.registerPlugin(p);
p.initialize(const DevicePluginConfig());
} catch (e) {
Logger.e('DeviceBootstrap', '初始化厂商插件 ${p.vendorKey} 失败: $e');
}
}

265
apps/client/lib/devices/device_hub.dart

@ -0,0 +1,265 @@
import 'dart:async';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import '../core/utils/logger.dart';
/// 设备中枢——**业务层唯一的设备入口**。
///
/// 业务层(`lib/modules`、`lib/core`)只能 import 这个文件和
/// `device_plugin_interface`,不能 import 任何 `lib/devices/<vendor>/` 下的东西,
/// `test/devices/device_hub_boundary_test.dart` 守着这条线。
///
/// 职责:
/// - 持有各厂商插件(恒玄 / EaiRec 支架 / 以后接的每一种芯片各一个);
/// - 汇总所有 `ready` 会话成一份可观测状态(连没连、能力集、电量、通话中…);
/// - 合流唤醒事件与会话事件,供常驻服务订阅;
/// - 「最近连过的设备」这类展示用信息也从这里拿。
///
/// 多设备模型:耳机和支架可以同时在线,所以是 `sessions` 列表而不是单个
/// active。要「一台能录音的设备」就问 [sessionWith]。
class DeviceHub extends GetxService {
static const String _tag = 'DeviceHub';
static DeviceHub get to => Get.find<DeviceHub>();
final Map<String, DevicePlugin> _plugins = {};
final Map<String, StreamSubscription> _pluginSubs = {};
final Map<String, StreamSubscription> _sessionSubs = {};
final Map<String, StreamSubscription> _wakeSubs = {};
/// 当前 `ready` 的会话,按连上的先后排。
final RxList<DeviceSession> sessions = <DeviceSession>[].obs;
/// 任一设备在线。
final RxBool anyConnected = false.obs;
/// 所有在线设备能力的并集。UI 显隐一律问它,不问厂商。
final RxSet<DeviceCapability> capabilities = <DeviceCapability>{}.obs;
/// 正在通话中(由声明了 [DeviceCapability.callState] 的设备上报)。
final RxBool inCall = false.obs;
/// 主设备(第一台在线的)的信息快照;没有设备时为 null。
final Rxn<DeviceInfo> primaryInfo = Rxn<DeviceInfo>();
/// 主设备名;没有设备时为空串。
final RxString primaryName = ''.obs;
/// 每当任何会话的 info / 状态变了就 +1,给只想「重画一下」的 UI 用。
final RxInt revision = 0.obs;
final StreamController<DeviceWakeEvent> _wake =
StreamController<DeviceWakeEvent>.broadcast();
final StreamController<DeviceSessionEvent> _events =
StreamController<DeviceSessionEvent>.broadcast();
/// 所有在线设备的唤醒事件合流。
Stream<DeviceWakeEvent> get wakeEvents => _wake.stream;
/// 所有在线设备的会话事件合流。
Stream<DeviceSessionEvent> get sessionEvents => _events.stream;
// ---------------- 插件管理 ----------------
Iterable<DevicePlugin> get plugins => _plugins.values;
DevicePlugin? plugin(String vendorKey) => _plugins[vendorKey];
/// 注册一个厂商插件。重复 key 直接覆盖并报警(正常不该发生)。
void registerPlugin(DevicePlugin p) {
if (_plugins.containsKey(p.vendorKey)) {
Logger.w(_tag, '重复注册厂商插件 ${p.vendorKey},覆盖旧实例');
_pluginSubs.remove(p.vendorKey)?.cancel();
}
_plugins[p.vendorKey] = p;
_pluginSubs[p.vendorKey] = p.eventStream.listen(
(e) => _onPluginEvent(p, e),
onError: (e) => Logger.e(_tag, '${p.vendorKey} 事件流错误: $e'),
);
// 插件可能在注册前就已经连上了(比如原生被动回连),补一次快照
final s = p.activeSession;
if (s != null && s.state == DeviceConnectionState.ready) _attach(s);
Logger.i(_tag, '已注册厂商插件 ${p.vendorKey}(${p.displayName})');
}
// ---------------- 查询 ----------------
bool has(DeviceCapability c) => capabilities.contains(c);
/// 第一台具备该能力的在线设备;没有为 null。
DeviceSession? sessionWith(DeviceCapability c) {
for (final s in sessions) {
if (s.supports(c)) return s;
}
return null;
}
/// 主音频设备:第一台能上行麦克风的。
DeviceSession? get primaryAudio => sessionWith(DeviceCapability.micUplink);
/// 主设备 = **最近上线**的那台([_attach] 把新会话插在最前)。
DeviceSession? get primary => sessions.isEmpty ? null : sessions.first;
DeviceSession? sessionOf(String vendorKey) {
for (final s in sessions) {
if (s.vendor == vendorKey) return s;
}
return null;
}
/// 最近连过(或正连着)的设备名,用于断连后仍显示「我的那台设备」。
/// 从没连过任何设备返回空串。
String get lastKnownName {
if (primaryName.value.isNotEmpty) return primaryName.value;
for (final p in _plugins.values) {
final d = p.lastKnownDevice;
if (d != null && d.name.isNotEmpty) return d.name;
}
return '';
}
bool get hasKnownDevice => lastKnownName.isNotEmpty;
// ---------------- 动作 ----------------
/// 冷启动/前台恢复时让每个插件各自回连记住的设备。
Future<void> tryAutoConnectAll() async {
for (final p in _plugins.values) {
try {
await p.tryAutoConnect();
} catch (e) {
Logger.e(_tag, '${p.vendorKey} 自动回连失败: $e');
}
}
}
Future<void> disconnectAll() async {
for (final s in sessions.toList()) {
try {
await s.disconnect();
} catch (e) {
Logger.e(_tag, '${s.vendor} 断开失败: $e');
}
}
}
/// 进功能页时刷一遍所有在线设备的状态(电量会掉、配置会变)。
Future<void> refreshAll() async {
for (final s in sessions.toList()) {
try {
await s.refreshInfo();
} catch (e) {
Logger.w(_tag, '${s.vendor} 刷新状态失败: $e');
}
}
}
// ---------------- 内部 ----------------
void _onPluginEvent(DevicePlugin p, DevicePluginEvent e) {
switch (e.type) {
case DevicePluginEventType.connectionStateChanged:
final s = p.activeSession;
if (e.connectionState == DeviceConnectionState.ready && s != null) {
_attach(s);
} else if (e.connectionState == DeviceConnectionState.disconnected) {
_detachVendor(p.vendorKey);
}
break;
case DevicePluginEventType.error:
Logger.w(_tag, '${p.vendorKey} 错误: ${e.errorCode} ${e.errorMessage}');
break;
default:
break;
}
}
void _attach(DeviceSession s) {
if (sessions.any((x) => identical(x, s))) return;
// 同一厂商只保留最新的会话
_detachVendor(s.vendor, silent: true);
// ★ 单活动设备:用户从车载香薰切到耳机(反之亦然),旧设备的链路要断掉,
// 不能两台同时在线——设备页只有一张图、一个电量,两台并存时展示和音频路由都会打架。
// 新上线的设备**排在最前**成为主设备,设备页的图/名字/电量跟着它走。
// (2026-09-10 用户反馈:切到耳机后香薰 BLE 没断、设备页图片也没换。)
// ⚠️ 被动回连(系统 ACL 把耳机连回来)同样会走到这里并顶掉当前设备,这是刻意的:
// "最近连上的那台就是用户此刻想用的那台"。
for (final other in sessions.where((x) => x.vendor != s.vendor).toList()) {
Logger.i(_tag, '切换设备:${s.vendor} 上线,断开 ${other.vendor} ${other.info.name}');
_detachVendor(other.vendor, silent: true);
other.disconnect().catchError((Object e) {
Logger.w(_tag, '${other.vendor} 断开失败: $e');
});
}
sessions.insert(0, s);
_sessionSubs[s.vendor] = s.eventStream.listen((e) {
_events.add(e);
_onSessionEvent(s, e);
});
_wakeSubs[s.vendor] = s.wakeEvents.listen(_wake.add);
Logger.i(_tag, '设备上线: ${s.vendor} ${s.info.name} caps=${s.capabilities.map((c) => c.name).join(',')}');
_recompute();
}
void _detachVendor(String vendor, {bool silent = false}) {
_sessionSubs.remove(vendor)?.cancel();
_wakeSubs.remove(vendor)?.cancel();
final before = sessions.length;
sessions.removeWhere((x) => x.vendor == vendor);
if (sessions.length != before && !silent) {
Logger.i(_tag, '设备下线: $vendor');
}
_recompute();
}
void _onSessionEvent(DeviceSession s, DeviceSessionEvent e) {
switch (e.type) {
case DeviceSessionEventType.connectionStateChanged:
if (e.connectionState == DeviceConnectionState.disconnected) {
_detachVendor(s.vendor);
}
break;
case DeviceSessionEventType.deviceInfoUpdated:
_recompute();
break;
case DeviceSessionEventType.feature:
final key = e.feature?.key;
if (key == DeviceFeatureKeys.callStarted) inCall.value = true;
if (key == DeviceFeatureKeys.callEnded) inCall.value = false;
break;
default:
break;
}
}
void _recompute() {
anyConnected.value = sessions.isNotEmpty;
final caps = <DeviceCapability>{};
for (final s in sessions) {
caps.addAll(s.capabilities);
}
capabilities.assignAll(caps);
final p = primary;
primaryInfo.value = p?.info;
primaryName.value = p?.info.name ?? '';
if (p == null) inCall.value = false;
revision.value++;
}
@override
void onClose() {
for (final s in _pluginSubs.values) {
s.cancel();
}
for (final s in _sessionSubs.values) {
s.cancel();
}
for (final s in _wakeSubs.values) {
s.cancel();
}
_wake.close();
_events.close();
super.onClose();
}
}

14
apps/client/lib/devices/device_vendors.dart

@ -0,0 +1,14 @@
/// 厂商插件的 key。业务层只在「选品类 → 进哪个连接页」这类地方用到,
/// 其余判断一律走 [DeviceCapability]。
class DeviceVendors {
DeviceVendors._();
/// 恒玄 BES 耳机(Echo-one),经典蓝牙 SPP / iOS GATT 桥
static const String bes = 'bes';
/// EaiRec 录音支架,BLE + Opus
static const String holder = 'holder';
/// Smartcar 车载香薰(协议待接)
static const String smartcar = 'smartcar';
}

437
apps/client/lib/devices/holder/holder_device_plugin.dart

@ -0,0 +1,437 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:echomeet_device_sdk/echomeet_device_sdk.dart';
import 'package:get/get.dart';
import '../../core/utils/logger.dart';
import '../device_vendors.dart';
import 'holder_device_service.dart';
import 'holder_file_store.dart';
import 'holder_ota_port.dart';
/// EaiRec 录音支架的厂商插件——把 [HolderDeviceService](协议在 `recorder_holder`
/// 插件里,冻结)适配成 [DevicePlugin] / [DeviceSession]。
///
/// 与恒玄的差异全在这一层吸收:BLE 扫描而不是系统配对列表、Opus 而不是 G.722、
/// 录音是「切状态 + 开编码器」两步而不是一条 `AA 59`。业务层看到的是同一套
/// `openMic(mic)` / `record.start`。
class HolderDevicePlugin implements DevicePlugin {
final HolderDeviceService link;
HolderDevicePlugin({HolderDeviceService? link})
: link = link ?? HolderDeviceService();
final StreamController<DevicePluginEvent> _ctrl =
StreamController<DevicePluginEvent>.broadcast();
HolderDeviceSession? _session;
final List<Worker> _workers = [];
bool _initialized = false;
bool _disposed = false;
@override
String get vendorKey => DeviceVendors.holder;
@override
String get displayName => 'EaiRec (录音支架)';
@override
Set<DeviceCapability> get capabilities => HolderDeviceSession.caps;
@override
DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty;
@override
DeviceSession? get activeSession => _session;
@override
Stream<DevicePluginEvent> get eventStream => _ctrl.stream;
@override
Future<void> initialize(DevicePluginConfig config) async {
_checkAlive();
if (_initialized) return;
_initialized = true;
if (!Get.isRegistered<HolderDeviceService>()) {
Get.put<HolderDeviceService>(link, permanent: true);
}
_workers.add(ever<EchoMeetConnectionState>(link.connection, _onConn));
_workers.add(ever<List<EchoMeetScannedDevice>>(link.scanned, (list) {
for (final d in list) {
_emit(DevicePluginEvent(
type: DevicePluginEventType.deviceDiscovered,
deviceId: d.address,
discovered: _discovered(d),
));
}
}));
_workers.add(ever<bool>(link.isScanning, (on) {
_emit(DevicePluginEvent(
type: on
? DevicePluginEventType.scanStarted
: DevicePluginEventType.scanStopped));
}));
if (link.isConnected) _onConn(EchoMeetConnectionState.connected);
_emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady));
}
DiscoveredDevice _discovered(EchoMeetScannedDevice d) => DiscoveredDevice(
id: d.address.isNotEmpty ? d.address : d.id,
name: d.name,
vendor: vendorKey,
metadata: {'advertisementHex': d.advertisementHex, 'sdkId': d.id},
);
void _onConn(EchoMeetConnectionState s) {
switch (s) {
case EchoMeetConnectionState.connected:
if (_session != null) return;
final sess = HolderDeviceSession._(link);
_session = sess;
_emit(DevicePluginEvent(
type: DevicePluginEventType.connectionStateChanged,
deviceId: sess.deviceId,
connectionState: DeviceConnectionState.ready,
));
break;
case EchoMeetConnectionState.connecting:
_emit(DevicePluginEvent(
type: DevicePluginEventType.connectionStateChanged,
deviceId: link.connectedAddress,
connectionState: DeviceConnectionState.connecting,
));
break;
default:
final sess = _session;
_session = null;
sess?._markDisconnected();
_emit(DevicePluginEvent(
type: DevicePluginEventType.connectionStateChanged,
deviceId: sess?.deviceId,
connectionState: DeviceConnectionState.disconnected,
));
}
}
void _emit(DevicePluginEvent e) {
if (!_ctrl.isClosed) _ctrl.add(e);
}
// ---------- 扫描 / 连接 ----------
@override
Future<bool> ensureReady() async {
if (link.isPermissionCoolingDown) return false;
// 用户主动进连接页:允许再申请一次系统蓝牙权限
link.resetPermissionDenied();
return link.requestPermission();
}
/// 用户拒绝过蓝牙权限且仍在冷却期——UI 据此置灰入口
bool get isPermissionCoolingDown => link.isPermissionCoolingDown;
@override
Future<void> startScan({DeviceScanFilter? filter, Duration? timeout}) =>
link.startScan();
@override
Future<void> stopScan() => link.stopScan();
@override
Future<bool> isScanning() async => link.isScanning.value;
@override
Future<List<DiscoveredDevice>> bondedDevices() async =>
link.scanned.map(_discovered).toList(growable: false);
@override
Future<DeviceSession> connect(String deviceId,
{DeviceConnectOptions? options}) async {
_checkAlive();
final d = link.scanned.firstWhereOrNull(
(x) => x.address.toUpperCase() == deviceId.toUpperCase() || x.id == deviceId);
if (d == null) {
throw DeviceException(DeviceErrorCode.connectFailed, '未扫描到 $deviceId');
}
final registered = await link.connect(d);
if (!registered) {
throw DeviceException(DeviceErrorCode.connectFailed, deviceId);
}
final ok = await link.waitConnected(
timeout: options?.timeout ?? const Duration(seconds: 25));
if (!ok) throw DeviceException(DeviceErrorCode.connectTimeout, deviceId);
_onConn(EchoMeetConnectionState.connected);
return _session!;
}
@override
List<DiscoveredDevice> get rememberedDevices => link.pairedDevices
.map((e) => DiscoveredDevice(
id: (e['address'] ?? '').toString(),
name: (e['name'] ?? '').toString(),
vendor: vendorKey,
))
.where((d) => d.id.isNotEmpty)
.toList(growable: false);
@override
DiscoveredDevice? get lastKnownDevice {
final n = link.lastDeviceName;
if (n.isEmpty) return null;
return DiscoveredDevice(id: link.connectedAddress, name: n, vendor: vendorKey);
}
@override
Future<void> rememberDevice(DiscoveredDevice device) async {
// 支架在 connect 成功时自己会记;这里保持幂等
}
@override
Future<void> tryAutoConnect() => link.tryAutoConnect();
@override
Future<void> forgetDevice(String deviceId) async {
link.removePaired(deviceId);
if (link.isConnected &&
link.connectedAddress.toUpperCase() == deviceId.toUpperCase()) {
await link.disconnect();
}
}
@override
Future<void> dispose() async {
_disposed = true;
for (final w in _workers) {
w.dispose();
}
_session?._markDisconnected();
_session = null;
await _ctrl.close();
}
void _checkAlive() {
if (_disposed) throw StateError('HolderDevicePlugin disposed');
}
}
class HolderDeviceSession extends DeviceSession {
HolderDeviceSession._(this._link) {
_bind();
}
static const String _tag = 'HolderSession';
static const Set<DeviceCapability> caps = {
DeviceCapability.scan,
DeviceCapability.connect,
DeviceCapability.battery,
DeviceCapability.macAddress,
DeviceCapability.micUplink,
DeviceCapability.micRecording,
// AB FOT 固件升级(协议实现在 recorder_holder/lib/src/ota/),见 [HolderOtaPort]
DeviceCapability.ota,
// 通道 B:TF 卡录音文件,见 [HolderFileStore]。
// ⚠️ 这台硬件**只有**这条录音路径可用(没有 ae00 实时音频服务)。
DeviceCapability.fileStore,
DeviceCapability.customCommand,
};
final HolderDeviceService _link;
final StreamController<DeviceSessionEvent> _events =
StreamController<DeviceSessionEvent>.broadcast();
final List<Worker> _workers = [];
DeviceConnectionState _state = DeviceConnectionState.ready;
bool _lastRecording = false;
void _bind() {
_lastRecording = _link.recording.value.isRecording;
_workers.add(ever<EchoMeetRecordingState>(_link.recording, (st) {
if (st.isRecording == _lastRecording) return;
_lastRecording = st.isRecording;
_feature(st.isRecording
? DeviceFeatureKeys.recordStarted
: DeviceFeatureKeys.recordStopped);
}));
_workers.add(ever<EchoMeetDeviceInfo?>(_link.deviceInfo, (_) {
_feature(DeviceFeatureKeys.batteryUpdated);
_infoUpdated();
}));
_workers.add(ever<String>(_link.connectedNameRx, (_) => _infoUpdated()));
}
void _feature(String key, [Map<String, Object?> data = const {}]) {
if (_events.isClosed) return;
_events.add(DeviceSessionEvent(
type: DeviceSessionEventType.feature,
deviceId: deviceId,
feature: DeviceFeatureEvent(key: key, data: data),
));
}
void _infoUpdated() {
if (_events.isClosed) return;
_events.add(DeviceSessionEvent(
type: DeviceSessionEventType.deviceInfoUpdated,
deviceId: deviceId,
deviceInfo: info,
));
}
void _markDisconnected() {
if (_state == DeviceConnectionState.disconnected) return;
_state = DeviceConnectionState.disconnected;
for (final w in _workers) {
w.dispose();
}
_workers.clear();
_ota?.onSessionGone();
if (!_events.isClosed) {
_events.add(DeviceSessionEvent(
type: DeviceSessionEventType.connectionStateChanged,
deviceId: deviceId,
connectionState: DeviceConnectionState.disconnected,
));
_events.close();
}
}
void _requireReady() {
if (_state != DeviceConnectionState.ready) {
throw DeviceException(DeviceErrorCode.noActiveSession, 'holder');
}
}
@override
String get deviceId => _link.connectedAddress;
@override
String get vendor => DeviceVendors.holder;
@override
DeviceConnectionState get state => _state;
@override
Set<DeviceCapability> get capabilities => caps;
@override
Stream<DeviceSessionEvent> get eventStream => _events.stream;
static final RegExp _macRe = RegExp(r'^([0-9A-F]{2}:){5}[0-9A-F]{2}$');
@override
DeviceInfo get info {
final i = _link.deviceInfo.value;
final lvl = i?.batteryLevel;
final addr = _link.connectedAddress.toUpperCase();
return DeviceInfo(
id: deviceId,
name: _link.connectedName,
vendor: vendor,
firmwareVersion: i?.firmwareVersion,
batteryPercent: lvl,
battery: DeviceBattery(single: lvl, chargingSingle: i?.isCharging ?? false),
// iOS 上拿到的是 peripheral UUID,只有长得像 MAC 才当 MAC
macAddress: Platform.isAndroid || _macRe.hasMatch(addr) ? addr : null,
metadata: {'productId': i?.productId, 'deviceIsRecording': _link.deviceIsRecording},
);
}
@override
Future<int> readRssi() =>
throw DeviceException(DeviceErrorCode.notSupported, 'rssi');
@override
Future<int?> readBattery() async {
_requireReady();
await _link.refreshBattery();
return info.battery.single;
}
@override
Future<DeviceInfo> refreshInfo() async {
_requireReady();
await _link.refreshBattery();
return info;
}
/// 主麦上行。**必须**打开设备编码器,否则设备不往手机推 Opus 流
/// (设备按键发起的录音只切本地录音态、不开编码器,所以无条件补一条)。
@override
Future<DeviceAudioSource> openMic({AudioRoute route = AudioRoute.mic}) async {
_requireReady();
if (route != AudioRoute.mic) {
throw DeviceException(DeviceErrorCode.notSupported, 'openMic($route)');
}
final ok = await _link.openEncoder();
if (!ok) Logger.w(_tag, 'openEncoder 返回 false,可能收不到音频流');
return _HolderSource(_link.audioFrames.map((f) => f.pcm));
}
@override
Future<Map<String, Object?>> invokeFeature(String featureKey,
[Map<String, Object?> args = const {}]) async {
_requireReady();
switch (featureKey) {
case DeviceFeatures.recordStart:
// toggle 语义:设备已在录时再切一次会把它关掉,先看设备状态
if (_link.deviceIsRecording) return const {'ok': true, 'already': true};
final ok = await _link.startRecording();
return {'ok': ok};
case DeviceFeatures.recordStop:
if (!_link.deviceIsRecording) return const {'ok': true, 'already': true};
final ok = await _link.stopRecording(
fileName: args['fileName'] as String?);
return {'ok': ok};
case 'holder.toggle':
case 'record.toggle':
return {'ok': await _link.toggleRecording()};
case DeviceFeatures.batteryQuery:
return {'ok': await _link.refreshBattery()};
case DeviceFeatures.stateRefresh:
await _link.refreshBattery();
return const {};
default:
throw DeviceException(DeviceErrorCode.notSupported, featureKey);
}
}
/// 主动断开 = 「不要它了」:支架 SDK 的 disconnect 会一并清掉自动回连记忆。
HolderOtaPort? _ota;
HolderFileStore? _files;
@override
DeviceFileStore? fileStore() => _files ??= HolderFileStore(_link);
@override
DeviceOtaPort? otaPort() => _ota ??= HolderOtaPort(_link);
@override
Future<void> disconnect() => _link.disconnect();
}
class _HolderSource implements DeviceAudioSource {
_HolderSource(Stream<Uint8List> src) {
_sub = src.listen(_ctrl.add, onError: _ctrl.addError);
}
final StreamController<Uint8List> _ctrl = StreamController.broadcast();
StreamSubscription? _sub;
@override
AudioRoute get route => AudioRoute.mic;
@override
AudioFormat get format => AudioFormat.pcm16kMono;
@override
Stream<Uint8List> get pcm => _ctrl.stream;
@override
Future<void> close() async {
await _sub?.cancel();
_sub = null;
await _ctrl.close();
}
}

293
apps/client/lib/devices/holder/holder_device_service.dart

@ -4,23 +4,26 @@ import 'dart:io';
import 'package:echomeet_device_sdk/echomeet_device_sdk.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../core/utils/logger.dart';
import '../../core/utils/permission_util.dart';
import 'background_launch_consent.dart';
import 'agent_module_defaults.dart';
import '../models/user_Info.dart';
import 'network/api.dart';
import '../../data/models/user_Info.dart';
import '../../data/services/background_launch_consent.dart';
import '../../data/services/network/api.dart';
/// 蓝讯方案设备(AI手机支架 / AI Pencil)的接入层。
/// EaiRec 录音支架(AI手机支架)的接入层。
///
/// 底层协议实现全部来自 echomeet 项目的 `recorder_holder` 插件,本类只是把
/// [EchoMeetDeviceSdk] 的流封装成 GetX 可观测状态,不重写任何蓝牙/Opus 逻辑。
/// 底层协议在 `recorder_holder` 插件里,本类只是把 [EchoMeetDeviceSdk] 的流
/// 封装成 GetX 可观测状态,不重写任何蓝牙/Opus 逻辑。设备的两条通道:
/// - 通道 A:控制命令 + 实时 Opus 音频流(录音走这条)
/// - 通道 B:TF 卡文件(列表/下载/删除),见本类下半部分
/// 协议规范见 local_plugins/recorder_holder/EaiRec_protocol.md。
///
/// 与项目原有的 [BleManager](杰理耳机链路)是两套独立的设备通路,互不影响:
/// - 杰理耳机 → BleManager
/// - 蓝讯支架 → 本服务
/// - EaiRec 支架 → 本服务
class HolderDeviceService extends GetxService {
static const String _tag = 'HolderDeviceService';
@ -198,6 +201,138 @@ class HolderDeviceService extends GetxService {
/// 录音的 PCM 帧流(16kHz / 16bit / 单声道,Opus 已在原生侧解码)
Stream<EchoMeetAudioFrame> get audioFrames => _sdk.audioFrames;
// ==================== 通道 B:TF 卡文件 ====================
//
// 协议见 local_plugins/recorder_holder/EaiRec_protocol.md §5。
// 这里只做「连上之后对时 + 拉一次状态」和几个供上层调用的入口;
// 帧格式与状态机都在插件的 RecClient 里,本类不碰字节。
/// 设备 TF 卡与录音状态。设备主动上报(录音起停)也会刷新它。
final Rxn<RecInfo> recInfo = Rxn<RecInfo>();
/// 通道 B 是否可用。设备没有 TF 卡能力时为 false,此时通道 A 照常工作。
final RxBool recChannelReady = false.obs;
StreamSubscription<RecInfo>? _recInfoSub;
StreamSubscription<List<RecFileItem>>? _recListSub;
/// 设备端录音停止后会主动推一次完整文件列表,落在这里。
final RxList<RecFileItem> recFiles = <RecFileItem>[].obs;
/// 同一份推送的事件流版本。RxList 只表达「当前是什么」,
/// 而导入服务要的是「又推了一次」这个**事件**(内容可能一模一样)。
final StreamController<List<RecFileItem>> _recFilesPushes =
StreamController<List<RecFileItem>>.broadcast();
Stream<List<RecFileItem>> get recFilesPushes => _recFilesPushes.stream;
/// 连上之后的通道 B 握手。
///
/// 顺序按协议文档 §8.1:先对时再查状态 —— 设备是拿 RTC 生成录音文件的 FAT
/// 时间的,对时晚了这一次录的文件时间就是错的。
/// 通道 B 握手最多试几次。原生的 `connected` 回调在**服务发现之前**就抛出来了
/// (真机实测差约 700ms),第一次探必然探到「没有 0xFDA5」。
/// ⚠️ 这里失败就直接 return 的老写法后果比看上去严重:不只是这次没握上手,
/// 而是**对时没做(录音文件时间全错)、文件列表推送没订阅(录完不会自动同步)、
/// 设备状态没拉**,而 `ensureRecChannel()` 本身是每次重新探的,
/// 于是「列表能拉、但录完不会自动出现」——一个非常难查的半死状态。
static const int _maxRecChannelTries = 6;
Future<void> _initRecChannel() async {
for (var i = 1; i <= _maxRecChannelTries; i++) {
if (!isConnected) return; // 中途断了就别再试
try {
if (await _sdk.ensureRecChannel()) {
// 订阅要在对时之前挂上:设备可能在握手过程中就推东西上来。
// `??=` 保证重试时不会重复订阅。
_recInfoSub ??= _sdk.recorder.onRecInfo.listen((info) {
recInfo.value = info;
Logger.i(_tag, '设备状态: $info');
});
_recListSub ??= _sdk.recorder.onFileListPushed.listen((list) {
Logger.i(_tag, '设备主动推送文件列表,共 ${list.length} 个');
recFiles.assignAll(list);
if (!_recFilesPushes.isClosed) _recFilesPushes.add(list);
});
await _sdk.recorder.syncRtc();
recInfo.value = await _sdk.recorder.queryInfo();
recChannelReady.value = true;
Logger.i(_tag, '通道 B 就绪(第 $i 次): ${recInfo.value}');
return;
}
} catch (e) {
// 通道 B 失败不能影响连接本身,只记不抛
Logger.i(_tag, '通道 B 握手第 $i 次失败,重试: $e');
}
await Future.delayed(const Duration(milliseconds: 800));
}
recChannelReady.value = false;
Logger.w(_tag, '通道 B 握手 $_maxRecChannelTries 次都没成,TF 卡录音无法同步');
}
/// 拉取 TF 卡上的完整录音列表。
Future<List<RecFileItem>> listRecordings() async {
if (!await _sdk.ensureRecChannel()) {
throw RecException('通道 B 不可用');
}
final list = await _sdk.recorder.listFiles();
recFiles.assignAll(list);
return list;
}
/// 把 TF 卡上的一个录音下载到本地。
///
/// 落在 `<documents>/EaiRec/<文件名>`,**同名文件会被当作断点续传的半成品接着写**
/// —— 这正是要的行为(下载中断后重进接着下)。要重下得先删掉本地文件。
Future<RecDownloadResult> downloadRecording(
String name, {
void Function(int received, int total)? onProgress,
RecCancelToken? cancelToken,
}) async {
if (!await _sdk.ensureRecChannel()) {
throw RecException('通道 B 不可用');
}
final docs = await getApplicationDocumentsDirectory();
final sink = FileRecSink(File('${docs.path}/EaiRec/$name'));
await sink.open();
return _sdk.recorder.download(
name: name,
sink: sink,
onProgress: onProgress,
cancelToken: cancelToken,
);
}
/// 把 TF 卡上的一个录音拉到调用方给的 sink 里。
///
/// 与 [downloadRecording] 的区别是**落点由调用方决定**——通用的
/// [DeviceFileStore] 端口要把文件放进自己的暂存目录,不能被这里写死。
Future<RecDownloadResult> downloadTo({
required String name,
required RecFileSink sink,
void Function(int received, int total)? onProgress,
RecCancelToken? cancelToken,
}) async {
if (!await _sdk.ensureRecChannel()) {
throw RecException('通道 B 不可用');
}
return _sdk.recorder.download(
name: name,
sink: sink,
onProgress: onProgress,
cancelToken: cancelToken,
);
}
/// 删除设备上的一个录音。**调用前 UI 必须已二次确认。**
Future<void> deleteRecording(String name) async {
if (!await _sdk.ensureRecChannel()) {
throw RecException('通道 B 不可用');
}
await _sdk.recorder.deleteFile(name);
recFiles.removeWhere((e) => e.name == name);
}
@override
void onInit() {
super.onInit();
@ -217,12 +352,29 @@ class HolderDeviceService extends GetxService {
if (s == EchoMeetConnectionState.connected) {
_autoConnectTimer?.cancel();
_autoAttempt = 0;
_rememberDevice('Echomeet', connectedAddressRx.value);
// 首次连上支架 → AI 页铺开支架的默认功能编排
AgentDefaults.onHolderConnected();
// 名字用本次实际连上的那台。原来这里写死 'Echomeet',换成 EaiRec 之后
// 记下来的名字会是错的,设备页据 lastDeviceName 选主图就会选错。
_rememberDevice(
// 兜底名要跟当前固件的广播名一致,否则设备页按名字取产品图会落空。
// 切换设备名见 EchoMeetDeviceSdk.defaultNameFilters。
connectedNameRx.value.isNotEmpty ? connectedNameRx.value : 'Echomeet',
connectedAddressRx.value,
);
// 协议 §8.1 第 7 步:连上后主动查一次电量。
// ⚠️ 不能只靠 waitConnected() 里那次——**自动回连不走 waitConnected**
// (`tryAutoConnect` 直接调 SDK.connect),于是设备页电量一直空着。
// 设备侧电量是「请求/响应 + 主动上报」双模式,不问就可能永远等不到。
_kickBatteryQuery();
// ⚠️ 绑定登记同样不能只挂在 connect():**自动回连不走 connect()**。
// 设备一旦记住了,之后每次都是原生回连上线,绑定接口一次都不会打,
// 设备管理页就永远空着(换账号/后台补录 MAC 之后尤其明显)。
unawaited(bindConnectedToServer());
// 通道 B(TF 卡文件)握手,失败不影响录音等通道 A 的功能
unawaited(_initRecChannel());
} else if (s == EchoMeetConnectionState.disconnected &&
hasPairedDevice) {
// 设备关机/走远导致的断连:重新挂上后台扫连,等它再开机
_batteryRetry?.cancel();
Logger.i(_tag, '连接断开,重新注册后台扫连等待设备上线');
Future.delayed(const Duration(seconds: 3), () => tryAutoConnect());
}
@ -239,7 +391,11 @@ class HolderDeviceService extends GetxService {
_connSub?.cancel();
_recSub?.cancel();
_infoSub?.cancel();
_recInfoSub?.cancel();
_recListSub?.cancel();
_recFilesPushes.close();
_autoConnectTimer?.cancel();
_batteryRetry?.cancel();
super.onClose();
}
@ -289,7 +445,18 @@ class HolderDeviceService extends GetxService {
_byAddress.clear();
scanned.clear();
isScanning.value = true;
await _sdk.startScan();
// ⚠️ 原生 `startScan` 会返回 false(蓝牙未就绪 / 权限 / 已在扫描)。
// 以前这里不看返回值,UI 就一直转「正在搜索」而其实根本没在扫。
// 现在失败了等蓝牙栈就绪再补一次;两次都不行才如实把 isScanning 放下来。
var ok = await _sdk.startScan();
if (!ok) {
await Future.delayed(const Duration(milliseconds: 800));
ok = await _sdk.startScan();
}
if (!ok) {
Logger.w(_tag, '原生扫描未能启动(蓝牙未就绪/权限/已在扫描),UI 退回未扫描态');
isScanning.value = false;
}
}
Future<void> stopScan() async {
@ -310,28 +477,54 @@ class HolderDeviceService extends GetxService {
/// 把支架登记到服务端的已绑定设备列表。
///
/// 走的是和杰理耳机同一个 user_binddevice 接口。蓝讯广播里没有杰理那套
/// pid/code 授权字段,所以 pid 用广播里的 hex(服务端可据此识别),
/// code 留空——接口失败不阻断连接,只记日志。
Future<void> bindToServer(EchoMeetScannedDevice device) async {
/// 走 `user_binddevice`,与恒玄耳机同一个接口、同一套规则:**只按 MAC 校验**
/// (协议 §2.3;服务端 `api_binddevice.go` 拿 devicemac 在全局 `device_mac`
/// 表里查,产品是从命中行的 productid 列读出来的)。
///
/// ⚠️ **不要传 pid**:服务端 `UserBindDeviceReq.pid` 是 `int32`,而这里唯一能填的
/// `advertisementHex` 是十六进制**字符串**(常常还是空串)。传上去必然
/// `readUint32: unexpected character` → `code:22`,整个请求被拒,
/// 表现为「连上了但设备管理页永远空着」。恒玄那条链路就是不传 pid 的。
/// code 也留空:支架没有杰理那套广播授权码。
///
/// 接口失败不阻断连接和录音,只是设备管理页没有记录。
Future<void> bindToServer(EchoMeetScannedDevice device) =>
_bindMac(device.name, device.address);
/// 用**当前连着的**这台去登记。自动回连路径专用:那条路不经过 [connect],
/// 所以拿不到 `EchoMeetScannedDevice`,只有连接时记下的名字和 MAC。
Future<void> bindConnectedToServer() =>
_bindMac(connectedNameRx.value, connectedAddressRx.value);
/// 本进程内已登记成功的 MAC,避免每次回连都打一次接口。
/// **只记成功的**:失败(如后台还没补录这台 MAC)下次连上要能重试。
final Set<String> _bound = <String>{};
Future<void> _bindMac(String name, String mac) async {
if (mac.isEmpty) return;
if (!User.isLoggedIn()) {
Logger.w(_tag, '未登录,跳过设备绑定登记');
return;
}
final key = mac.toUpperCase();
if (_bound.contains(key)) return;
try {
final resp = await Api.binddevice({
'pid': device.advertisementHex,
'code': '',
'devicename': device.name,
'devicemac': device.address,
'devicename': name,
'devicemac': mac,
});
Logger.i(_tag, '绑定接口返回: $resp');
final deviceJson = resp?['device'];
if (deviceJson is Map<String, dynamic>) {
_bound.add(key);
User.instance.addUserDevice(UserDevice.fromJson(deviceJson));
Logger.i(_tag, '支架已登记到已绑定设备列表');
} else {
Logger.w(_tag, '绑定接口未返回 device 字段,设备管理页可能看不到这台');
// resp == null 是服务端明确拒绝(AuthInterceptor 在业务码 != 0 时
// 把 data 置 null 后 resolve,不抛)——最常见的是这台 MAC 还没导入
// 后台的 device_mac 表,返回 `{"code":2005,"msg":"设备未登记"}`。
Logger.w(_tag, '绑定接口未返回 device 字段(resp=$resp),设备管理页看不到这台');
}
} catch (e) {
// 绑定失败不影响本次连接和录音,只是设备管理页没有记录
@ -360,8 +553,8 @@ class HolderDeviceService extends GetxService {
worker = ever<EchoMeetConnectionState>(connection, (s) {
if (s == EchoMeetConnectionState.connected) {
// 连上后主动拉一次电量,设备页右下角才有数可显示
refreshBattery();
// 电量查询不在这里发:onInit 的 connected 分支已经用 _kickBatteryQuery()
// 带重试地问了。在这儿再补一发只会在服务发现完成前多失败一次。
finish(true);
}
if (s == EchoMeetConnectionState.error) finish(false);
@ -402,4 +595,60 @@ class HolderDeviceService extends GetxService {
Future<bool> toggleRecording() => _sdk.toggleRecording();
Future<bool> refreshBattery() => _sdk.refreshBattery();
// ---------------- 电量:连上后必须重试着问 ----------------
Timer? _batteryRetry;
int _batteryTries = 0;
/// 连上后把电量拿到手。**这里必须重试,问一次是不够的。**
///
/// ⚠️ 原生的 `connected` 回调在**服务发现之前**就抛出来了(真机实测差约
/// 700ms),那一刻写特征还是 null,`sendCommand` 直接打
/// `发送命令失败: 设备未连接` 并返回 false —— 命令整条丢在地上,且没有任何
/// 上层能看见的错。设备侧电量虽然也有主动上报,但不保证会来,
/// 于是表现就是**设备页右下角永远没有电量**(2026-09-10 真机复现)。
///
/// 所以:发不出去就隔 800ms 再发,发出去了给 2s 等回包,直到 deviceInfo
/// 里真的有电量值、或者试满 [_maxBatteryTries] 次为止。
/// 4 次约 3 秒,够覆盖服务发现那 700ms 的窗口了。**不要调大**:
/// 通道 A 缺失时这些重试一次都不会成功,只是白刷日志(见 [_attemptBattery] 的说明)。
static const int _maxBatteryTries = 4;
void _kickBatteryQuery() {
_batteryRetry?.cancel();
_batteryTries = 0;
unawaited(_attemptBattery());
}
Future<void> _attemptBattery() async {
if (!isConnected) return; // 断了就别再问
if (_batteryTries >= _maxBatteryTries) {
// ⚠️ 走到这里最常见的**不是**时序问题,而是设备根本没有通道 A。
// 2026-09-10 真机(Echomeet 00:0D:01:00:00:41)服务发现只有一个服务:
// `0xFDA5`(FDA6/FDA7) —— 通道 B(TF 卡)。协议 §4.3 的电量在通道 A
// (`abc0`/`abc1`/`abc2`) 上,那个服务压根不存在,于是原生 checkConn()
// 因 writeChar==null 恒 false,每一发都打「发送命令失败: 设备未连接」。
// **那条错误信息是误导**:链路好好的,缺的是服务。要电量得固件补通道 A。
Logger.w(
_tag,
'连上后问了 $_batteryTries 次都没拿到电量。'
'若原生一直报「发送命令失败: 设备未连接」,看服务发现清单:'
'这台固件很可能只暴露了 0xFDA5(通道B),没有 abc0(通道A),电量物理上问不到');
return;
}
_batteryTries++;
final sent = await refreshBattery();
_batteryRetry = Timer(
Duration(milliseconds: sent ? 2000 : 800),
() {
if (deviceInfo.value?.batteryLevel != null) {
Logger.i(_tag, '电量已拿到: ${deviceInfo.value?.batteryLevel}%'
'(第 $_batteryTries 次尝试)');
return;
}
unawaited(_attemptBattery());
},
);
}
}

120
apps/client/lib/devices/holder/holder_file_store.dart

@ -0,0 +1,120 @@
import 'dart:async';
import 'dart:io';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:echomeet_device_sdk/echomeet_device_sdk.dart';
import '../../core/utils/logger.dart';
import 'holder_device_service.dart';
/// EaiRec 支架的设备端文件仓库 —— 通道 B(TF 卡,`0xFDA5`)。
///
/// **这台硬件唯一可用的录音路径就是这条**:它没有通道 A' 实时音频上行
/// (真机 GATT 里根本没有 `ae00`,见 CLAUDE.md 支架那节),录音是
/// 「录到 TF 卡、事后拉」。所以本类不是锦上添花,是支架录音能不能用的关键。
///
/// 协议与状态机都在 `recorder_holder` 的 `RecClient` 里,本类只做两件事:
/// 把它接到通用的 [DeviceFileStore] 上,以及把 SDK 的异常翻译成 [DeviceException]。
class HolderFileStore implements DeviceFileStore {
HolderFileStore(this._link);
static const String _tag = 'HolderFileStore';
final HolderDeviceService _link;
@override
bool get isDeviceRecording =>
_link.deviceIsRecording || (_link.recInfo.value?.recording ?? false);
@override
Stream<List<DeviceFileEntry>> get listPushes =>
_link.recFilesPushes.map((list) => list.map(_toEntry).toList());
@override
Future<List<DeviceFileEntry>> list() async {
try {
final files = await _link.listRecordings();
return files.map(_toEntry).toList(growable: false);
} on RecException catch (e) {
throw DeviceException(DeviceErrorCode.fileTransferFailed, e.toString());
}
}
@override
Future<String> download(
String name, {
required String destPath,
void Function(int received, int total)? onProgress,
DeviceFileCancelToken? cancelToken,
}) async {
// ⚠️ 正在录的那个文件不能拉:设备还在往里写,拉下来是个残片,
// 而且 size 一直在变,断点续传的长度判断也会错。
if (isDeviceRecording) {
throw DeviceException(DeviceErrorCode.fileBusy, '设备正在录音');
}
final dest = File(destPath);
await dest.parent.create(recursive: true);
// SDK 的取消令牌与通用令牌是两套类型,这里做个桥接
final tok = RecCancelToken();
if (cancelToken?.isCancelled ?? false) {
tok.cancel();
} else {
unawaited(cancelToken?.whenCancelled.then((_) => tok.cancel()));
}
final sink = FileRecSink(dest);
await sink.open();
try {
final result = await _link.downloadTo(
name: name,
sink: sink,
onProgress: onProgress,
cancelToken: tok,
);
if (!result.complete) {
// 没传完只有两种可能:被取消,或链路断了。前者不是故障。
if (cancelToken?.isCancelled ?? false) {
throw DeviceException(DeviceErrorCode.cancelled, name);
}
throw DeviceException(
DeviceErrorCode.fileTransferFailed, '$name 未传输完整');
}
// ⚠️ 落盘字节数必须与设备报的文件长度逐字节对上。
// 这条是**删除设备原件的前置条件**:传输层虽然逐包校验了 checksum
// (RecClient.verifyChecksum 默认开)且 complete 判的是 bytes==fileSize,
// 但那看的是 sink 的计数,不是磁盘上真实的长度。写盘失败/被截断时
// 两者会不一致,而我们马上要拿这个结果去做不可逆的删除。
final onDisk = await dest.length();
if (onDisk != result.fileSize) {
throw DeviceException(DeviceErrorCode.fileTransferFailed,
'$name 落盘 $onDisk B 与设备报的 ${result.fileSize} B 不符');
}
Logger.i(_tag,
'已拉取 $name → $destPath ($onDisk B,'
'断点续传 ${result.resumes} 次)');
return destPath;
} on RecException catch (e) {
throw DeviceException(DeviceErrorCode.fileTransferFailed, e.toString());
}
}
@override
Future<void> delete(String name) async {
try {
await _link.deleteRecording(name);
} on RecException catch (e) {
throw DeviceException(DeviceErrorCode.featureFailed, e.toString());
}
}
DeviceFileEntry _toEntry(RecFileItem f) => DeviceFileEntry(
name: f.name,
size: f.size,
// RecFileItem.time 解不出合法 FAT 日期时就是 null,原样透传:
// 上层会退回「按导入时刻命名」,好过显示 1980 年
modifiedAt: f.time,
metadata: {'ftime': f.ftime},
);
}

222
apps/client/lib/devices/holder/holder_ota_port.dart

@ -0,0 +1,222 @@
import 'dart:async';
import 'dart:io';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:recorder_holder/recorder_holder.dart';
import 'package:recorder_holder/recorder_holder_ota.dart';
import '../../core/utils/logger.dart';
import 'holder_device_service.dart';
/// EaiRec 支架(中科蓝讯方案)的固件 OTA 端口 —— AB FOT 协议。
///
/// 协议实现全在 `recorder_holder/lib/src/ota/`(帧格式、CRC、TLV、状态机),
/// 本类只做两件事:把它接到通用的 [DeviceOtaPort] 上,以及处理**连接交接**。
///
/// ⚠️ **OTA 期间必须让出常规 BLE 连接**:一部手机对同一个外设只能有一条链路,
/// 而 `FotBleTransport` 自己用 flutter_blue_plus 连 OTA 服务(`0xFF12`,
/// 与常规的 `abc0`/`ae00`/`FDA5` 是不同的 service,设备重启进 OTA 模式后才暴露)。
/// 所以 [start] 先 `setEnterOta(true)` 让原生放手,[close] 再 `setEnterOta(false)`
/// 把设备交还给常规会话。这与恒玄那条 SPP OTA 的交接思路一致。
///
/// ⚠️ 设备进 OTA 模式后**可能换一个蓝牙地址广播**,所以 `knownRemoteId` 只是
/// Android 上的快路径,失败会自动退回按 service UUID 扫描(两端共用那条路径)。
class HolderOtaPort implements DeviceOtaPort {
HolderOtaPort(this._link);
static const String _tag = 'HolderOtaPort';
final HolderDeviceService _link;
/// 原生插件单例。`setEnterOta` 只在插件层暴露(SDK 没转发),直接用它,
/// 少一层转发就少一处会忘记同步的地方。
final RecorderHolder _plugin = RecorderHolder();
final StreamController<DeviceOtaProgress> _progress =
StreamController<DeviceOtaProgress>.broadcast();
FotBleTransport? _transport;
FotOtaSession? _session;
StreamSubscription<FotProgress>? _sub;
bool _running = false;
bool _terminal = false;
bool _succeeded = false;
/// 我们为了 OTA 把常规连接让出去了,[close] 时要还回来
bool _handover = false;
int _lastPercent = -1;
@override
bool get isRunning => _running;
@override
Stream<DeviceOtaProgress> get progressStream => _progress.stream;
@override
Future<void> start(DeviceOtaRequest request) async {
if (_running) throw DeviceException(DeviceErrorCode.otaBusy);
if (request is! DeviceOtaFileRequest) {
throw DeviceException(
DeviceErrorCode.notSupported, '${request.runtimeType}');
}
final file = File(request.filePath);
if (!await file.exists()) {
throw DeviceException(DeviceErrorCode.otaFileInvalid, request.filePath);
}
final bytes = await file.readAsBytes();
if (bytes.isEmpty) {
throw DeviceException(DeviceErrorCode.otaFileInvalid, '固件为空');
}
_running = true;
_terminal = false;
_succeeded = false;
_lastPercent = -1;
_emit(DeviceOtaState.entering, 0);
// 1) 让出常规连接。原生 setEnterOta(true) 会 disconnect 且不自动重连。
final mac = _link.connectedAddress;
_handover = true;
await _plugin.setEnterOta(true);
// 设备要重启进 OTA 模式并重新广播,给它一点时间;连太早只会白等超时。
await Future.delayed(const Duration(seconds: 2));
if (!_running) return; // 期间被 cancel 了
// 2) 建 OTA 链路(自管连接,与原生那条互斥)
final config = FotOtaConfig(
knownRemoteId: Platform.isAndroid && mac.isNotEmpty ? mac : null,
);
final transport = FotBleTransport(config: config);
_transport = transport;
final firmware = FotFirmware.fromBytes(bytes);
final session = FotOtaSession(
transport: transport,
firmware: firmware,
config: config,
);
_session = session;
_sub = session.progress.listen(_onProgress, onError: (Object e) {
_fail(DeviceErrorCode.otaTransferFailed, e.toString());
});
try {
await transport.open();
if (!_running) return;
await session.start();
} catch (e) {
_fail(DeviceErrorCode.otaTransferFailed, e.toString());
}
}
void _onProgress(FotProgress p) {
// 终态锁定:迟到的事件一律忽略,否则成功会被收尾事件覆盖成失败
if (_terminal) return;
switch (p.stage) {
case FotStage.connecting:
_emit(DeviceOtaState.entering, 0);
break;
case FotStage.handshaking:
case FotStage.querying:
_emit(DeviceOtaState.inquiring, 0);
break;
case FotStage.transferring:
_emitPercent(p.percent);
break;
case FotStage.finishing:
case FotStage.verifying:
// 压缩包在这一步解压+烧写,可能要几十秒,UI 别当成卡死
_emit(DeviceOtaState.verifying, 100);
break;
case FotStage.completed:
_terminal = true;
_succeeded = true;
_running = false;
_emit(DeviceOtaState.done, 100);
break;
case FotStage.failed:
_fail(DeviceErrorCode.otaTransferFailed,
p.message ?? p.deviceStatus?.toString() ?? 'failed');
break;
case FotStage.idle:
break;
}
}
/// 进度只在单调递增时上报,避免数字来回跳
void _emitPercent(int p) {
if (p <= _lastPercent) return;
_lastPercent = p;
_emit(DeviceOtaState.transferring, p);
}
void _fail(String code, String message) {
if (_terminal) return;
_terminal = true;
_running = false;
Logger.w(_tag, 'OTA 失败: $message');
_emit(DeviceOtaState.failed, -1, errorCode: code, errorMessage: message);
}
void _emit(DeviceOtaState st, int percent,
{String? errorCode, String? errorMessage}) {
if (_progress.isClosed) return;
_progress.add(DeviceOtaProgress(
state: st,
sentBytes: 0,
totalBytes: 0,
percent: percent,
tsMs: DateTime.now().millisecondsSinceEpoch,
errorCode: errorCode,
errorMessage: errorMessage,
));
}
@override
Future<void> cancel() async {
if (!_running) return;
_running = false;
_terminal = true;
await _teardownLink();
_emit(DeviceOtaState.cancelled, -1);
}
/// 会话没了(常规连接断开)。升级中那次断开是我们自己让出的,忽略。
void onSessionGone() {
if (_handover) return;
if (_running) _fail(DeviceErrorCode.disconnectedRemote, '设备断开');
}
Future<void> _teardownLink() async {
await _sub?.cancel();
_sub = null;
try {
_session?.dispose();
} catch (_) {}
_session = null;
try {
await _transport?.close();
} catch (_) {}
_transport = null;
}
@override
Future<void> close() async {
_running = false;
await _teardownLink();
if (!_progress.isClosed) await _progress.close();
if (!_handover) return;
_handover = false;
// 把设备交还给常规会话。升级成功时设备正在重启,多等几秒。
await _plugin.setEnterOta(false);
final delay = _succeeded
? const Duration(seconds: 5)
: const Duration(seconds: 2);
Future.delayed(delay, () {
if (!_link.isConnected) _link.tryAutoConnect();
});
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save