nixamp 0.27.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +39 -16
  2. package/backtoschool/dist/assets/ar-DK3LI2hP.js +1 -0
  3. package/backtoschool/dist/assets/cs-C9cckHFh.js +1 -0
  4. package/backtoschool/dist/assets/da-BpNIZAZo.js +1 -0
  5. package/backtoschool/dist/assets/de-C1DX_kDk.js +1 -0
  6. package/backtoschool/dist/assets/es-bymdOEnB.js +1 -0
  7. package/backtoschool/dist/assets/fi-p1DAJXf3.js +1 -0
  8. package/backtoschool/dist/assets/fr-w1qH9bbD.js +1 -0
  9. package/backtoschool/dist/assets/hi-DnZJakpY.js +1 -0
  10. package/backtoschool/dist/assets/hu-N7uSyLAk.js +1 -0
  11. package/backtoschool/dist/assets/id-CX3MVU3E.js +1 -0
  12. package/backtoschool/dist/assets/index-4LdMSur6.js +93 -0
  13. package/backtoschool/dist/assets/{index-OE2xWZUH.css → index-D06NfY1L.css} +1 -1
  14. package/backtoschool/dist/assets/it-CQ0H28FI.js +1 -0
  15. package/backtoschool/dist/assets/nl-DJqCLHMq.js +1 -0
  16. package/backtoschool/dist/assets/ru-Rk2yagke.js +1 -0
  17. package/backtoschool/dist/assets/sv-1r9eOl4-.js +1 -0
  18. package/backtoschool/dist/assets/uk-CrRsLNHV.js +1 -0
  19. package/backtoschool/dist/assets/vi-B5kxrjP7.js +1 -0
  20. package/backtoschool/dist/assets/zh-BB_S8QpG.js +1 -0
  21. package/backtoschool/dist/index.html +50 -38
  22. package/dist/admin.js +5 -4
  23. package/dist/event-writer.d.ts +39 -0
  24. package/dist/event-writer.js +139 -0
  25. package/dist/i18n.d.ts +26 -0
  26. package/dist/i18n.js +81 -0
  27. package/dist/live-api.d.ts +2 -0
  28. package/dist/live-api.js +37 -2
  29. package/dist/live-interpreter.d.ts +3 -1
  30. package/dist/live-interpreter.js +109 -83
  31. package/dist/live-recovery.d.ts +6 -0
  32. package/dist/live-recovery.js +11 -0
  33. package/dist/locales/ar.d.ts +201 -0
  34. package/dist/locales/ar.js +200 -0
  35. package/dist/locales/cs.d.ts +201 -0
  36. package/dist/locales/cs.js +200 -0
  37. package/dist/locales/da.d.ts +201 -0
  38. package/dist/locales/da.js +200 -0
  39. package/dist/locales/de.d.ts +201 -0
  40. package/dist/locales/de.js +200 -0
  41. package/dist/locales/en.d.ts +201 -0
  42. package/dist/locales/en.js +201 -0
  43. package/dist/locales/es.d.ts +201 -0
  44. package/dist/locales/es.js +200 -0
  45. package/dist/locales/fi.d.ts +201 -0
  46. package/dist/locales/fi.js +200 -0
  47. package/dist/locales/fr.d.ts +201 -0
  48. package/dist/locales/fr.js +200 -0
  49. package/dist/locales/hi.d.ts +201 -0
  50. package/dist/locales/hi.js +200 -0
  51. package/dist/locales/hu.d.ts +201 -0
  52. package/dist/locales/hu.js +200 -0
  53. package/dist/locales/id.d.ts +201 -0
  54. package/dist/locales/id.js +200 -0
  55. package/dist/locales/it.d.ts +201 -0
  56. package/dist/locales/it.js +200 -0
  57. package/dist/locales/nl.d.ts +201 -0
  58. package/dist/locales/nl.js +200 -0
  59. package/dist/locales/ru.d.ts +201 -0
  60. package/dist/locales/ru.js +200 -0
  61. package/dist/locales/sv.d.ts +201 -0
  62. package/dist/locales/sv.js +200 -0
  63. package/dist/locales/uk.d.ts +201 -0
  64. package/dist/locales/uk.js +200 -0
  65. package/dist/locales/vi.d.ts +201 -0
  66. package/dist/locales/vi.js +200 -0
  67. package/dist/locales/zh.d.ts +201 -0
  68. package/dist/locales/zh.js +200 -0
  69. package/dist/mail-sender.d.ts +9 -0
  70. package/dist/mail-sender.js +11 -0
  71. package/dist/main.js +26 -12
  72. package/dist/notify.d.ts +3 -3
  73. package/dist/notify.js +8 -5
  74. package/dist/password-reset.d.ts +2 -3
  75. package/dist/password-reset.js +4 -2
  76. package/dist/server.d.ts +2 -0
  77. package/dist/server.js +13 -1
  78. package/dist/shared-translation.d.ts +1 -0
  79. package/dist/shared-translation.js +32 -23
  80. package/dist/ui-aliases.d.ts +4 -0
  81. package/dist/ui-aliases.js +41 -0
  82. package/dist/ui-languages.d.ts +62 -0
  83. package/dist/ui-languages.js +32 -0
  84. package/package.json +1 -1
  85. package/src/admin.ts +5 -4
  86. package/src/event-writer.ts +126 -0
  87. package/src/i18n.ts +74 -0
  88. package/src/live-api.ts +26 -2
  89. package/src/live-interpreter.ts +87 -68
  90. package/src/live-recovery.ts +10 -0
  91. package/src/locales/ar.ts +201 -0
  92. package/src/locales/cs.ts +201 -0
  93. package/src/locales/da.ts +201 -0
  94. package/src/locales/de.ts +201 -0
  95. package/src/locales/en.ts +201 -0
  96. package/src/locales/es.ts +201 -0
  97. package/src/locales/fi.ts +201 -0
  98. package/src/locales/fr.ts +201 -0
  99. package/src/locales/hi.ts +201 -0
  100. package/src/locales/hu.ts +201 -0
  101. package/src/locales/id.ts +201 -0
  102. package/src/locales/it.ts +201 -0
  103. package/src/locales/nl.ts +201 -0
  104. package/src/locales/ru.ts +201 -0
  105. package/src/locales/sv.ts +201 -0
  106. package/src/locales/uk.ts +201 -0
  107. package/src/locales/vi.ts +201 -0
  108. package/src/locales/zh.ts +201 -0
  109. package/src/mail-sender.ts +12 -0
  110. package/src/main.ts +25 -12
  111. package/src/notify.ts +10 -7
  112. package/src/password-reset.ts +5 -5
  113. package/src/server.ts +14 -1
  114. package/src/shared-translation.ts +27 -19
  115. package/src/ui-aliases.ts +42 -0
  116. package/src/ui-languages.ts +31 -0
  117. package/web/dist/assets/ar-DK3LI2hP.js +1 -0
  118. package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
  119. package/web/dist/assets/cs-C9cckHFh.js +1 -0
  120. package/web/dist/assets/da-BpNIZAZo.js +1 -0
  121. package/web/dist/assets/de-C1DX_kDk.js +1 -0
  122. package/web/dist/assets/es-bymdOEnB.js +1 -0
  123. package/web/dist/assets/fi-p1DAJXf3.js +1 -0
  124. package/web/dist/assets/fr-w1qH9bbD.js +1 -0
  125. package/web/dist/assets/hi-DnZJakpY.js +1 -0
  126. package/web/dist/assets/{hls-3VKVEQE3-CAL4qBEq.js → hls-3VKVEQE3-DURNoQ5W.js} +1 -1
  127. package/web/dist/assets/hu-N7uSyLAk.js +1 -0
  128. package/web/dist/assets/id-CX3MVU3E.js +1 -0
  129. package/web/dist/assets/index-BXJlt2r6.js +3 -0
  130. package/web/dist/assets/index-Brm9OAsr.css +1 -0
  131. package/web/dist/assets/it-CQ0H28FI.js +1 -0
  132. package/web/dist/assets/{mpegts-H9oC2fAK.js → mpegts-BdyqUrVC.js} +1 -1
  133. package/web/dist/assets/{mpegts-LO6RVLD6-Bkd9Sqr6.js → mpegts-LO6RVLD6-yGogIzG9.js} +1 -1
  134. package/web/dist/assets/nl-DJqCLHMq.js +1 -0
  135. package/web/dist/assets/ru-Rk2yagke.js +1 -0
  136. package/web/dist/assets/sv-1r9eOl4-.js +1 -0
  137. package/web/dist/assets/uk-CrRsLNHV.js +1 -0
  138. package/web/dist/assets/vi-B5kxrjP7.js +1 -0
  139. package/web/dist/assets/zh-BB_S8QpG.js +1 -0
  140. package/web/dist/index.html +132 -131
  141. package/web/dist/sw.js +24 -7
  142. package/backtoschool/dist/assets/index-pQ_uH4bZ.js +0 -93
  143. package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
  144. package/web/dist/assets/index-CLeEWlms.js +0 -3
  145. package/web/dist/assets/index-D34dTY18.css +0 -1
@@ -0,0 +1,201 @@
1
+ import type { Catalogue } from "../i18n.ts";
2
+ export default {
3
+ "Interface language": "界面语言",
4
+ "Language": "语言",
5
+ "Translation language": "翻译语言",
6
+ "Original (auto-detect)": "原文(自动检测)",
7
+ "Play": "播放",
8
+ "Pause": "暂停",
9
+ "Stop": "停止",
10
+ "Next": "下一个",
11
+ "Previous": "上一个",
12
+ "Select": "选择",
13
+ "Next/Prev": "下一个/上一个",
14
+ "Quit": "退出",
15
+ "Now Playing": "正在播放",
16
+ "Spectrum Analyser": "频谱分析仪",
17
+ "Playlist": "播放列表",
18
+ "Files": "文件",
19
+ "Open files": "打开文件",
20
+ "Open folder": "打开文件夹",
21
+ "Add to library": "添加到媒体库",
22
+ "Replace playlist": "替换播放列表",
23
+ "Filter": "筛选",
24
+ "Search": "搜索",
25
+ "Nothing loaded.": "尚未加载内容。",
26
+ "Empty.": "空。",
27
+ "Loading…": "正在加载…",
28
+ "Connecting…": "正在连接…",
29
+ "Connected": "已连接",
30
+ "Not connected": "未连接",
31
+ "No source": "无来源",
32
+ "Playing": "播放中",
33
+ "Stopped": "已停止",
34
+ "Volume": "音量",
35
+ "Seek": "播放位置",
36
+ "Full screen": "全屏",
37
+ "Download": "下载",
38
+ "Share": "分享",
39
+ "Copy": "复制",
40
+ "Copy link": "复制链接",
41
+ "Copied": "已复制",
42
+ "Save": "保存",
43
+ "Cancel": "取消",
44
+ "Close": "关闭",
45
+ "Remove": "移除",
46
+ "Delete": "删除",
47
+ "Edit": "编辑",
48
+ "Add": "添加",
49
+ "Send": "发送",
50
+ "Retry": "重试",
51
+ "Back": "返回",
52
+ "Next page": "下一页",
53
+ "Previous page": "上一页",
54
+ "Show more": "显示更多",
55
+ "Panels": "面板",
56
+ "Reset layout": "重置布局",
57
+ "Show": "显示",
58
+ "Hide": "隐藏",
59
+ "Collapse": "收起",
60
+ "Expand": "展开",
61
+ "Move up": "上移",
62
+ "Move down": "下移",
63
+ "Install": "安装",
64
+ "TV mode": "电视模式",
65
+ "On": "开启",
66
+ "Off": "关闭",
67
+ "Account": "账户",
68
+ "Sign in": "登录",
69
+ "Sign out": "退出登录",
70
+ "Create account": "创建账户",
71
+ "Email": "电子邮箱",
72
+ "Password": "密码",
73
+ "Forgot password?": "忘记密码?",
74
+ "Welcome back": "欢迎回来",
75
+ "Profile": "个人资料",
76
+ "Handle": "用户名",
77
+ "Your servers": "你的服务器",
78
+ "Server": "服务器",
79
+ "Connect": "连接",
80
+ "Disconnect": "断开连接",
81
+ "Directory": "目录",
82
+ "Favourites": "收藏",
83
+ "Follow": "关注",
84
+ "Unfollow": "取消关注",
85
+ "Notifications": "通知",
86
+ "Phone number": "电话号码",
87
+ "Save number": "保存号码",
88
+ "Transcript": "文字稿",
89
+ "Captions": "字幕",
90
+ "Translate audio": "翻译音频",
91
+ "Audio language": "音频语言",
92
+ "Audio options": "音频选项",
93
+ "Keep background sound": "保留背景声音",
94
+ "Background level": "背景音量",
95
+ "Speaker voices": "说话者声音",
96
+ "Any voice": "任意声音",
97
+ "Male voice": "男声",
98
+ "Female voice": "女声",
99
+ "Original audio.": "原始音频。",
100
+ "Buy translated audio": "购买翻译音频",
101
+ "Audio credit": "音频余额",
102
+ "Buy pass": "购买通行证",
103
+ "Pass": "通行证",
104
+ "Pay with": "支付方式",
105
+ "Check payment": "检查付款",
106
+ "Open checkout": "前往付款",
107
+ "Free sessions": "免费会话",
108
+ "Usage rates and expiry": "使用费率与有效期",
109
+ "Catalogs": "内容目录",
110
+ "Add catalog": "添加目录",
111
+ "Catalog name": "目录名称",
112
+ "Link": "链接",
113
+ "Play link": "播放链接",
114
+ "Go live": "开始直播",
115
+ "Publish": "发布",
116
+ "Listen": "收听",
117
+ "Live now": "正在直播",
118
+ "Upcoming": "即将开始",
119
+ "Parties": "一起看",
120
+ "Join party": "加入一起看",
121
+ "Invite code": "邀请码",
122
+ "Invite": "邀请",
123
+ "Chat": "聊天",
124
+ "Chat message": "聊天消息",
125
+ "People": "参与者",
126
+ "Speaker": "发言者",
127
+ "Speakers": "发言者",
128
+ "Listener": "听众",
129
+ "Moderator": "主持人",
130
+ "Raise hand": "举手",
131
+ "Hand raises": "举手列表",
132
+ "Questions": "问题",
133
+ "Resources": "资料",
134
+ "Replay": "回放",
135
+ "Schedule": "日程",
136
+ "Event": "活动",
137
+ "Host": "主办人",
138
+ "About": "关于",
139
+ "Stage": "舞台",
140
+ "Title": "标题",
141
+ "Description": "描述",
142
+ "Topic": "主题",
143
+ "Public": "公开",
144
+ "Anyone with the link": "拥有链接的任何人",
145
+ "Invited people": "受邀者",
146
+ "Who can join": "谁可以加入",
147
+ "Date and time": "日期与时间",
148
+ "Expected length": "预计时长",
149
+ "Repeat": "重复",
150
+ "Does not repeat": "不重复",
151
+ "Every day": "每天",
152
+ "Every week": "每周",
153
+ "Homepage": "网站",
154
+ "Host name": "主办人姓名",
155
+ "Profile photo URL": "头像网址",
156
+ "Broadcast link": "直播链接",
157
+ "Edit event": "编辑活动",
158
+ "Save changes": "保存更改",
159
+ "Create live": "创建直播",
160
+ "Schedule class": "安排课程",
161
+ "Start class": "开始课程",
162
+ "End event": "结束活动",
163
+ "End session": "结束本次会话",
164
+ "Session ended": "会话已结束",
165
+ "Event ended": "活动已结束",
166
+ "Cancelled": "已取消",
167
+ "Replay available": "可观看回放",
168
+ "Join": "加入",
169
+ "Create invite": "创建邀请",
170
+ "Invite as": "邀请身份",
171
+ "Watch now": "立即观看",
172
+ "Start a room": "创建房间",
173
+ "Recent replays": "近期回放",
174
+ "Your account": "你的账户",
175
+ "Write with AI": "用 AI 撰写",
176
+ "Suggested title": "建议标题",
177
+ "Suggested description": "建议描述",
178
+ "Suggested topic": "建议主题",
179
+ "Use these details": "使用这些信息",
180
+ "Learn something live.": "实时学习新知识。",
181
+ "Screen share": "共享屏幕",
182
+ "Upload and broadcast": "上传并直播",
183
+ "Help": "帮助",
184
+ "Reload Player": "重新加载播放器",
185
+ "Copy Bundled CLI Path": "复制内置命令行工具路径",
186
+ "Developer tools": "开发者工具",
187
+ "Undo": "撤销",
188
+ "Redo": "重做",
189
+ "Cut": "剪切",
190
+ "Paste": "粘贴",
191
+ "Select all": "全选",
192
+ "Status": "状态",
193
+ "Activity log": "活动日志",
194
+ "Admin": "管理",
195
+ "Playback controls": "播放控制",
196
+ "Tracks and folders": "曲目与文件夹",
197
+ "Original-language captions.": "原语言字幕。",
198
+ "Skip to playback controls": "跳转到播放控制",
199
+ "Skip to playlist": "跳转到播放列表",
200
+ "Skip to transcript and translated audio": "跳转到文字稿和翻译音频"
201
+ } satisfies Catalogue;
@@ -0,0 +1,12 @@
1
+ export interface MailSender { apiKey: string; from: string }
2
+ export interface BrandedMailSender extends MailSender { backtoschool?: MailSender }
3
+
4
+ /** Only the exact school origins select its verified sender and credentials. */
5
+ export function mailSender(options: BrandedMailSender, link: string): MailSender {
6
+ try {
7
+ const url = new URL(link);
8
+ if (options.backtoschool && url.protocol === "https:" &&
9
+ ["backtoschool.help", "www.backtoschool.help"].includes(url.hostname)) return options.backtoschool;
10
+ } catch { /* A malformed link never changes the sender. */ }
11
+ return options;
12
+ }
package/src/main.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.ts";
1
2
  /**
2
3
  * nixamp — it really whips the terminal's ass.
3
4
  *
@@ -143,6 +144,7 @@ Keys in the player:
143
144
  d detach: hand the music to a daemon and get the terminal back
144
145
  q quit
145
146
 
147
+ --ui-language CODE interface language (default: system language; fallback: English)
146
148
  -v, --version print the version
147
149
  -h, --help print this. \`nixamp help <command>\` says more about one
148
150
  `;
@@ -510,7 +512,18 @@ async function runDaemon(argv: string[]): Promise<number> {
510
512
  * because the flag is false in a module that was imported rather than run.
511
513
  */
512
514
  export async function main(): Promise<void> {
513
- const [first, ...rest] = process.argv.slice(2);
515
+ const args = process.argv.slice(2);
516
+ const languageAt = args.findIndex(arg => arg === "--ui-language" || arg.startsWith("--ui-language="));
517
+ let language = terminalUiLanguage(process.env);
518
+ if (languageAt >= 0) {
519
+ const flag = args[languageAt]!;
520
+ const value = flag.includes("=") ? flag.slice(flag.indexOf("=") + 1) : args[languageAt + 1];
521
+ if (!value || value.startsWith("-")) throw new Error("nixamp: --ui-language needs a language code, e.g. de");
522
+ language = uiLanguage(value);
523
+ args.splice(languageAt, flag.includes("=") ? 1 : 2);
524
+ }
525
+ await i18n.setLanguage(language);
526
+ const [first, ...rest] = args;
514
527
 
515
528
  // Asked for however anybody asks for it. `nixamp help serve` and
516
529
  // `nixamp serve --help` are the same question, so they get the same answer.
@@ -842,15 +855,15 @@ export function view(
842
855
 
843
856
  ui.row({ size: 1 }, (header) => {
844
857
  header.text(" ⣿ NIXAMP", { fg: theme.title, bold: true, size: 11 });
845
- header.text(state.playing ? "▶ PLAYING" : "■ STOPPED", {
858
+ header.text(state.playing ? uiMessage("▶ PLAYING") : uiMessage("■ STOPPED"), {
846
859
  fg: state.playing ? theme.success : theme.muted,
847
860
  size: 12,
848
861
  });
849
862
  header.text(`${state.tracks.length} tracks ${state.root} `, { fg: theme.muted, align: "right" });
850
863
  });
851
864
 
852
- ui.panel({ title: "Now Playing", size: 6 }, (p) => {
853
- if (!track) { p.label("Nothing loaded."); return; }
865
+ ui.panel({ title: uiMessage("Now Playing"), size: 6 }, (p) => {
866
+ if (!track) { p.label(uiMessage("Nothing loaded.")); return; }
854
867
  p.text(displayName(track), { fg: theme.accent, bold: true, size: 1 });
855
868
  p.text(track.album || "—", { fg: theme.muted, size: 1 });
856
869
  p.row({ size: 1 }, (r) => {
@@ -863,7 +876,7 @@ export function view(
863
876
  });
864
877
 
865
878
  ui.row({ size: height - 10, gap: 1 }, (row) => {
866
- row.panel({ title: "Spectrum Analyser", width: "1.3fr" }, (p) => {
879
+ row.panel({ title: uiMessage("Spectrum Analyser"), width: "1.3fr" }, (p) => {
867
880
  // Braille gives four vertical pixels per cell, so the bars move smoothly
868
881
  // rather than stepping through eight block glyphs.
869
882
  p.canvas((canvas) => {
@@ -879,8 +892,8 @@ export function view(
879
892
  });
880
893
  });
881
894
 
882
- row.panel({ title: `Playlist (${state.tracks.length})`, width: "1fr" }, (p) => {
883
- if (state.tracks.length === 0) { p.label("Empty."); return; }
895
+ row.panel({ title: `${uiMessage("Playlist")} (${i18n.number(state.tracks.length)})`, width: "1fr" }, (p) => {
896
+ if (state.tracks.length === 0) { p.label(uiMessage("Empty.")); return; }
884
897
  p.table({
885
898
  rows: state.tracks.map((t, i) => ({
886
899
  n: String(i + 1).padStart(2, " "),
@@ -910,11 +923,11 @@ export function view(
910
923
 
911
924
  ui.statusBar({
912
925
  items: [
913
- { key: "Space", label: state.playing ? "Stop" : "Play", active: state.playing },
914
- { key: "↑↓", label: "Select" },
915
- { key: "n/p", label: "Next/Prev" },
916
- { key: "Enter", label: "Play" },
917
- { key: "q", label: "Quit" },
926
+ { key: "Space", label: state.playing ? uiMessage("Stop") : uiMessage("Play"), active: state.playing },
927
+ { key: "↑↓", label: uiMessage("Select") },
928
+ { key: "n/p", label: uiMessage("Next/Prev") },
929
+ { key: "Enter", label: uiMessage("Play") },
930
+ { key: "q", label: uiMessage("Quit") },
918
931
  ],
919
932
  });
920
933
  }
package/src/notify.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { mailSender, type BrandedMailSender } from "./mail-sender.ts";
2
+
1
3
  /**
2
4
  * Telling followers a broadcaster went live, wherever they are.
3
5
  *
@@ -119,27 +121,28 @@ export async function notifyAll(
119
121
  * blocked, and no dependency: a fetch is the whole client.
120
122
  */
121
123
  export function resendEmail(
122
- { apiKey, from, fetch = globalThis.fetch, onEvent }: {
123
- apiKey: string;
124
- from: string;
124
+ { apiKey, from, backtoschool, reason = "You are getting this because you follow them on nixamp.", fetch = globalThis.fetch, onEvent }: BrandedMailSender & {
125
+ reason?: string;
125
126
  fetch?: typeof globalThis.fetch;
126
127
  onEvent?: (message: string) => void;
127
128
  },
128
129
  ): (to: string, note: Notification) => Promise<boolean> {
129
130
  return async (to, note) => {
130
131
  try {
132
+ const sender = mailSender({apiKey, from, backtoschool}, note.url);
131
133
  const response = await fetch("https://api.resend.com/emails", {
132
134
  method: "POST",
133
- headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
135
+ headers: { authorization: `Bearer ${sender.apiKey}`, "content-type": "application/json" },
136
+ signal: AbortSignal.timeout(15_000),
134
137
  body: JSON.stringify({
135
- from,
138
+ from: sender.from,
136
139
  to: [to],
137
140
  subject: note.title,
138
- text: `${note.body}\n\n${note.url}\n\nYou are getting this because you follow them on nixamp.`,
141
+ text: `${note.body}\n\n${note.url}\n\n${reason}`,
139
142
  html:
140
143
  `<p>${escapeHtml(note.body)}</p>` +
141
144
  `<p><a href="${escapeHtml(note.url)}">${escapeHtml(note.url)}</a></p>` +
142
- `<p style="color:#666;font-size:12px">You are getting this because you follow them on nixamp.</p>`,
145
+ `<p style="color:#666;font-size:12px">${escapeHtml(reason)}</p>`,
143
146
  }),
144
147
  });
145
148
  if (!response.ok) {
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import type { Queryable } from "./follows.ts";
3
3
  import type { Tokens } from "./tokens.ts";
4
+ import { mailSender, type BrandedMailSender } from "./mail-sender.ts";
4
5
 
5
6
  export const RESET_MESSAGE = "If an account exists for that email, you’ll receive a password reset link shortly.";
6
7
  export const RESET_INVALID = "This reset link is invalid, expired, or already used. Request a new link.";
@@ -115,17 +116,16 @@ export class PasswordResets {
115
116
  }
116
117
 
117
118
  /** Transactional recovery mail has no follower footer and never logs a token. */
118
- export function resendPasswordReset(options: {
119
- apiKey: string; from: string; fetch?: typeof globalThis.fetch;
120
- }): ResetMail {
119
+ export function resendPasswordReset(options: BrandedMailSender & { fetch?: typeof globalThis.fetch }): ResetMail {
121
120
  return async (email, link) => {
122
121
  const host = new URL(link).hostname;
122
+ const sender = mailSender(options, link);
123
123
  const response = await (options.fetch ?? globalThis.fetch)("https://api.resend.com/emails", {
124
124
  method: "POST",
125
- headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
125
+ headers: { authorization: `Bearer ${sender.apiKey}`, "content-type": "application/json" },
126
126
  signal: AbortSignal.timeout(15_000),
127
127
  body: JSON.stringify({
128
- from: options.from, to: [email], subject: `Reset your ${host} password`,
128
+ from: sender.from, to: [email], subject: `Reset your ${host} password`,
129
129
  text: `Someone requested a password reset for your account on ${host}.\n\nChoose a new password using this link:\n${link}\n\nThis link expires in ${LIFETIME_MS / 60_000} minutes and works once. Resetting your password signs out existing sessions.\n\nIf you did not request this, you can ignore this email. Your password will stay the same.`,
130
130
  }),
131
131
  });
package/src/server.ts CHANGED
@@ -87,6 +87,7 @@ import { createThrottle, presentedCredential, type Throttle } from "@profullstac
87
87
  import { Durable } from "./durable.ts";
88
88
  import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
89
89
  import { inviteSubject, inviteText, isEmail, isPhone, watchLink } from "./invite.ts";
90
+ import { EventWriter } from "./event-writer.ts";
90
91
  import { handleLiveApi } from "./live-api.ts";
91
92
  import { LiveEvents, eventStructuredData, isTicketed, type LiveEvent } from "./live-events.ts";
92
93
  import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.ts";
@@ -1759,6 +1760,7 @@ export interface HandlerOptions {
1759
1760
  settingsSync?: SettingsSync;
1760
1761
  /** Scheduled and live sessions, kept by NixAmp and shared by branded clients. */
1761
1762
  events?: LiveEvents;
1763
+ eventWriter?: EventWriter;
1762
1764
  /** Versioned panel layouts, including event and user overrides. */
1763
1765
  layouts?: Layouts;
1764
1766
  /** Persistent participation state that must not be coupled to live audio. */
@@ -1944,6 +1946,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1944
1946
 
1945
1947
  if (options.events && await handleLiveApi(request, response, url, {
1946
1948
  events: options.events,
1949
+ ...(options.eventWriter ? {eventWriter: options.eventWriter} : {}),
1947
1950
  ...(options.accounts ? { accounts: options.accounts } : {}),
1948
1951
  ...(options.layouts ? { layouts: options.layouts } : {}),
1949
1952
  ...(options.rooms ? { rooms: options.rooms } : {}),
@@ -6123,6 +6126,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6123
6126
  const favorites = pool ? new Favorites(pool) : undefined;
6124
6127
  const settingsSync = pool ? new SettingsSync(pool) : undefined;
6125
6128
  const nixampSite = (process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, "");
6129
+ const schoolMailKey = process.env["BACKTOSCHOOL_RESEND_API_KEY"] ?? process.env["RESEND_API_KEY"];
6130
+ const schoolMail = schoolMailKey && process.env["BACKTOSCHOOL_MAIL_FROM"] ? {
6131
+ apiKey: schoolMailKey, from: process.env["BACKTOSCHOOL_MAIL_FROM"],
6132
+ } : undefined;
6126
6133
  const events = pool ? new LiveEvents(pool) : undefined;
6127
6134
  const layouts = pool ? new Layouts(pool) : undefined;
6128
6135
  const rooms = pool ? new Rooms(pool) : undefined;
@@ -6651,7 +6658,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6651
6658
  ...(follows ? { follows, vapidPublicKey } : {}),
6652
6659
  ...(favorites ? { favorites } : {}),
6653
6660
  ...(settingsSync ? { settingsSync } : {}),
6654
- ...(events ? { events } : {}),
6661
+ ...(events ? { events, eventWriter: new EventWriter({
6662
+ openaiKey: process.env["OPENAI_API_KEY"], anthropicKey: process.env["ANTHROPIC_API_KEY"],
6663
+ openaiModel: process.env["NIXAMP_WRITER_OPENAI_MODEL"], claudeModel: process.env["NIXAMP_WRITER_CLAUDE_MODEL"],
6664
+ }) } : {}),
6655
6665
  ...(layouts ? { layouts } : {}),
6656
6666
  ...(rooms ? { rooms } : {}),
6657
6667
  ...(trollbox ? { trollbox } : {}),
@@ -6668,6 +6678,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6668
6678
  resetMail: resendPasswordReset({
6669
6679
  apiKey: process.env["RESEND_API_KEY"],
6670
6680
  from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
6681
+ backtoschool: schoolMail,
6671
6682
  }),
6672
6683
  } : {}),
6673
6684
  // Other people's OpenProfiles, read for the voice a line is spoken in,
@@ -6692,6 +6703,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6692
6703
  email: resendEmail({
6693
6704
  apiKey: process.env["RESEND_API_KEY"],
6694
6705
  from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
6706
+ backtoschool: schoolMail,
6707
+ reason: "You received this email because a host invited you to join a live session.",
6695
6708
  onEvent: (message) => console.log(message),
6696
6709
  }),
6697
6710
  }
@@ -1,5 +1,6 @@
1
1
  /** One live source/language pipeline, paid access for every listening account.
2
2
  * Browsers receive the same PCM, never upload a replacement for a public feed. */
3
+ import { transientAudioError } from "./live-recovery.ts";
3
4
  import { createHash, randomUUID } from "node:crypto";
4
5
  import { lookup } from "node:dns/promises";
5
6
  import { BlockList, isIP } from "node:net";
@@ -58,9 +59,9 @@ export function liveInput(ffmpeg: string[] = ["ffmpeg"]): OpenLiveInput {
58
59
  };
59
60
  }
60
61
 
61
- export type SharedEvent = { type: "status"; listeners: number } | { type: "line"; id: string; line: Caption } | { type: "audio"; id: string; data: string } | { type: "end"; id: string } | { type: "error"; error: string };
62
+ export type SharedEvent = { type: "status"; listeners: number } | { type: "line"; id: string; line: Caption } | { type: "audio"; id: string; data: string } | { type: "end"; id: string } | { type: "error"; error: string; retryable?: boolean };
62
63
  type Member = { by: string; send: (event: SharedEvent) => void; close: () => void };
63
- interface Feed { id: string; source: URL; language: string; members: Set<Member>; controller: AbortController; input?: LiveInput; interpreter: Interpreter; meter: TranslationMeter; reservations: Map<string, string[]>; queue: Caption[]; speaking: boolean; previous: Buffer; pending: Buffer; until: number; heartbeat?: ReturnType<typeof setInterval>; }
64
+ interface Feed { id: string; source: URL; language: string; members: Set<Member>; controller: AbortController; input?: LiveInput; interpreter: Interpreter; meter: TranslationMeter; reservations: Map<string, string[]>; queue: Caption[]; speaking: boolean; voiceFailures: number; previous: Buffer; pending: Buffer; until: number; heartbeat?: ReturnType<typeof setInterval>; }
64
65
 
65
66
  export class SharedTranslations {
66
67
  private readonly feeds = new Map<string, Feed>();
@@ -123,12 +124,12 @@ export class SharedTranslations {
123
124
  try { member.send(event); } catch { this.leave(feed, member); member.close(); }
124
125
  }
125
126
  }
126
- private stop(feed: Feed, error: string): void {
127
- this.broadcast(feed, { type: "error", error });
127
+ private stop(feed: Feed, error: string, retryable = false): void {
128
+ this.broadcast(feed, { type: "error", error, retryable });
128
129
  for (const member of [...feed.members]) { this.leave(feed, member); member.close(); }
129
130
  }
130
131
  private make(id: string, source: URL, language: string): Feed {
131
- const feed = { id, source, language, members: new Set<Member>(), controller: new AbortController(), reservations: new Map<string, string[]>(), queue: [], speaking: false, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() } as unknown as Feed;
132
+ const feed = { id, source, language, members: new Set<Member>(), controller: new AbortController(), reservations: new Map<string, string[]>(), queue: [], speaking: false, voiceFailures: 0, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() } as unknown as Feed;
132
133
  // Each account uses free access first, then the same published credit rate.
133
134
  // Additional listeners do not trigger recognition or synthesis.
134
135
  feed.meter = {
@@ -163,10 +164,10 @@ export class SharedTranslations {
163
164
  feed.interpreter = new Interpreter({
164
165
  language: () => language, speakers: () => true, voices: () => voices, channel: () => id,
165
166
  status: text => { if (!feed.controller.signal.aborted && !feed.members.size) this.stop(feed, text); },
166
- failed: () => this.stop(feed, "Live translation stopped. Enable it again to retry."),
167
+ failed: message => this.stop(feed, message),
167
168
  lines: lines => {
168
169
  feed.queue.push(...lines);
169
- if (feed.queue.length > 24) { this.stop(feed, "Live translation fell behind. Enable it again to retry."); return; }
170
+ feed.queue = feed.queue.filter(line => Date.now() - line.until <= 12_000).slice(-24);
170
171
  if (!feed.speaking) void this.speak(feed);
171
172
  },
172
173
  fetcher: (async (url, init) => {
@@ -196,7 +197,7 @@ export class SharedTranslations {
196
197
  for (let index = 0; index < samples.length; index++) samples[index] = pcm.readInt16LE(index * 2) / 32768;
197
198
  feed.interpreter.push({ samples, at: feed.until - samples.length / 16, until: feed.until, freshAt: feed.until - 2000 });
198
199
  }
199
- }, () => this.stop(feed, "The live source stopped. Enable translated audio again when playback resumes."), feed.controller.signal);
200
+ }, () => this.stop(feed, "The live source disconnected.", true), feed.controller.signal);
200
201
  if (feed.controller.signal.aborted) input.stop(); else feed.input = input;
201
202
  } catch (error) { this.stop(feed, error instanceof SpeechError ? error.message : "The live source could not be opened."); }
202
203
  }
@@ -205,18 +206,25 @@ export class SharedTranslations {
205
206
  try {
206
207
  while (feed.queue.length && !feed.controller.signal.aborted) {
207
208
  const line = feed.queue.shift()!;
208
- if (Date.now() - line.until > 12_000) throw new SpeechError("Live translation fell behind. Enable it again to retry.", 503);
209
- const response = await this.options.voice.stream({ text: line.text, language: feed.language, channel: feed.id, speaker: line.speaker, voice: feed.interpreter.tracker.speakers.get(line.speaker ?? "")?.voice }, feed.id, feed.controller.signal, feed.meter);
210
- const id = randomUUID();
211
- this.broadcast(feed, { type: "line", id, line });
212
- const reader = response.body!.getReader();
209
+ if (Date.now() - line.until > 12_000) continue;
213
210
  try {
214
- while (true) {
215
- const { done, value } = await reader.read(); if (done) break;
216
- for (let at = 0; at < value.length; at += 16_384) this.broadcast(feed, { type: "audio", id, data: Buffer.from(value.subarray(at, at + 16_384)).toString("base64") });
217
- }
218
- } finally { reader.releaseLock(); }
219
- this.broadcast(feed, { type: "end", id });
211
+ const response = await this.options.voice.stream({ text: line.text, language: feed.language, channel: feed.id, speaker: line.speaker, voice: feed.interpreter.tracker.speakers.get(line.speaker ?? "")?.voice }, feed.id, feed.controller.signal, feed.meter);
212
+ const id = randomUUID();
213
+ this.broadcast(feed, { type: "line", id, line });
214
+ const reader = response.body!.getReader();
215
+ try {
216
+ while (true) {
217
+ const { done, value } = await reader.read(); if (done) break;
218
+ for (let at = 0; at < value.length; at += 16_384) this.broadcast(feed, { type: "audio", id, data: Buffer.from(value.subarray(at, at + 16_384)).toString("base64") });
219
+ }
220
+ } finally { reader.releaseLock(); this.broadcast(feed, { type: "end", id }); }
221
+ feed.voiceFailures = 0;
222
+ } catch (error) {
223
+ if (feed.controller.signal.aborted) return;
224
+ if (!transientAudioError(error) || ++feed.voiceFailures >= 3) throw error;
225
+ // A provider failure skips this phrase; the source and other listeners
226
+ // remain connected. Never replay partial speech or retry a charge.
227
+ }
220
228
  }
221
229
  } catch (error) { if (!feed.controller.signal.aborted) this.stop(feed, error instanceof SpeechError ? error.message : "Translated audio was interrupted."); }
222
230
  finally { feed.speaking = false; }
@@ -0,0 +1,42 @@
1
+ import type { Message } from './i18n.ts';
2
+ /** Existing copy can use a concise shared label in translated interfaces.
3
+ * English remains verbatim. Never use these aliases for user-authored content. */
4
+ export const aliases: Record<string, Message> = {
5
+ '■ STOPPED': 'Stopped', '▶ PLAYING': 'Playing', 'LOADING': 'Loading…',
6
+ 'no source': 'No source', 'not connected': 'Not connected', 'Now playing': 'Now Playing',
7
+ '● GO LIVE': 'Go live', '● Go live': 'Go live', '⤓ Download': 'Download',
8
+ 'A man\'s voice': 'Male voice', 'A woman\'s voice': 'Female voice',
9
+ 'Catalogs on this server': 'Catalogs', 'Catalog folders': 'Catalogs',
10
+ 'Parties on this server': 'Parties', 'Servers streaming now': 'Directory',
11
+ 'Playlist (0)': 'Playlist', 'Playlist folders': 'Playlist', 'Playlist pages': 'Playlist',
12
+ 'Filter these files': 'Filter', 'Filter these entries': 'Filter',
13
+ 'Filter the entries': 'Filter', 'Filter the playlist': 'Filter',
14
+ 'Create a free account': 'Create account', 'Create one': 'Create account',
15
+ 'Create your account': 'Create account', 'New here? Create an account': 'Create account',
16
+ 'Already have an account? Sign in': 'Sign in', 'Your NixAmp account': 'Your account',
17
+ 'Your public handle': 'Handle', 'handle': 'Handle', 'password': 'Password',
18
+ 'Phone number for texts': 'Phone number', 'Back to the player': 'Back',
19
+ 'Share what is playing': 'Share', 'Share this stream': 'Share',
20
+ 'Link to this stream': 'Link', 'Copy this stream\'s URL': 'Copy link',
21
+ 'A link to play': 'Link', 'A server\'s link': 'Server', 'Close translated audio purchase': 'Close',
22
+ 'Open secure checkout ↗ (new tab)': 'Open checkout', 'Checking…': 'Loading…',
23
+ 'Browse the directory': 'Directory', 'See what is live': 'Live now',
24
+ 'Join live': 'Join', 'Raise Hand': 'Raise hand', 'Hand Raises': 'Hand raises',
25
+ 'Hide this': 'Hide', 'Log': 'Activity log', 'Status:': 'Status',
26
+ 'Nixamp player': 'Playback controls', 'Nixamp audio and video player': 'Playback controls',
27
+ 'What is being said': 'Transcript', 'The room\'s chat': 'Chat', 'Trollbox': 'Chat',
28
+ 'A line for the trollbox': 'Chat message', 'Say something to the room…': 'Chat message',
29
+ 'Send it': 'Send', 'Something to add': 'Chat message', 'What to call it': 'Catalog name',
30
+ 'call it something': 'Catalog name', 'An m3u catalog to add': 'Catalogs',
31
+ 'On this device': 'Play', 'Play on this device': 'Play', 'What this is': 'About',
32
+ 'Looking for servers…': 'Loading…', 'Finding today’s conversations…': 'Loading…',
33
+ 'Coming up': 'Upcoming', 'Listen after class': 'Replay', 'Schedule live': 'Schedule class',
34
+ 'Schedule a class': 'Schedule class', 'Plan a conversation': 'Schedule class',
35
+ 'Your classroom': 'Event', 'Join in': 'Join', 'Open a room →': 'Start a room',
36
+ 'Chat message': 'Chat message', 'Add to the conversation': 'Chat message',
37
+ 'Create live': 'Create live', 'Host stage': 'Stage', 'Your host': 'Host',
38
+ 'Class host': 'Host', 'Hand raised ✓': 'Raise hand', 'Copied ✓': 'Copied',
39
+ 'Screen share with Pairux ↗': 'Screen share', 'Upload & broadcast in Nixamp ↗': 'Upload and broadcast',
40
+ 'Open Music Folder…': 'Open folder', 'Choose a music folder': 'Open folder',
41
+ 'Main navigation': 'Panels', 'Skip to a section': 'Panels',
42
+ };
@@ -0,0 +1,31 @@
1
+ /** The UI and audio-translation menus share the same supported targets. */
2
+ export const UI_LANGUAGES = [
3
+ { code: 'en', label: 'English' }, { code: 'de', label: 'Deutsch' },
4
+ { code: 'sv', label: 'Svenska' }, { code: 'es', label: 'Español' },
5
+ { code: 'fr', label: 'Français' }, { code: 'it', label: 'Italiano' },
6
+ { code: 'nl', label: 'Nederlands' }, { code: 'da', label: 'Dansk' },
7
+ { code: 'fi', label: 'Suomi' }, { code: 'ru', label: 'Русский' },
8
+ { code: 'uk', label: 'Українська' }, { code: 'cs', label: 'Čeština' },
9
+ { code: 'hu', label: 'Magyar' }, { code: 'zh', label: '中文' },
10
+ { code: 'ar', label: 'العربية' }, { code: 'hi', label: 'हिन्दी' },
11
+ { code: 'vi', label: 'Tiếng Việt' }, { code: 'id', label: 'Bahasa Indonesia' },
12
+ ] as const;
13
+ export type UiLanguage = typeof UI_LANGUAGES[number]['code'];
14
+ export const UI_LANGUAGE_KEY = 'nixamp.ui.language';
15
+ export function uiLanguage(value: unknown): UiLanguage {
16
+ const code = typeof value === 'string' ? value.trim().toLowerCase().split(/[-_.@]/)[0] : '';
17
+ return UI_LANGUAGES.find(language => language.code === code)?.code ?? 'en';
18
+ }
19
+ /** A saved choice wins; otherwise use the first supported system preference. */
20
+ export function preferredUiLanguage(saved: unknown, system: readonly unknown[] = []): UiLanguage {
21
+ const match = (value: unknown): UiLanguage | undefined => {
22
+ const code = typeof value === 'string' ? value.trim().toLowerCase().split(/[-_.@]/)[0] : '';
23
+ return UI_LANGUAGES.find(language => language.code === code)?.code;
24
+ };
25
+ return match(saved) ?? system.map(match).find(Boolean) ?? 'en';
26
+ }
27
+ export function terminalUiLanguage(env: Record<string, string | undefined>): UiLanguage {
28
+ if (env['NIXAMP_UI_LANGUAGE']) return uiLanguage(env['NIXAMP_UI_LANGUAGE']);
29
+ if (env['LC_ALL']) return preferredUiLanguage(null, [env['LC_ALL']]);
30
+ return preferredUiLanguage(null, env['LANGUAGE']?.split(':') ?? [env['LC_MESSAGES'] || env['LANG']]);
31
+ }
@@ -0,0 +1 @@
1
+ var e={"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{e as default};