koishi-plugin-chat-patch 3.0.2 → 5.0.1

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 (237) hide show
  1. package/client/vue/index.vue +60 -634
  2. package/client/web/dist/assets/AppIcon-DIku6-h0.png +0 -0
  3. package/client/web/dist/assets/AppIconDark-BYymN71t.png +0 -0
  4. package/client/web/dist/assets/CapacitorAppIcon-CSZwNhvi.png +0 -0
  5. package/client/web/dist/assets/CapacitorAppIconDark-BMvMOzsg.png +0 -0
  6. package/client/web/dist/assets/Chat-CTdyeaX2.css +1 -0
  7. package/client/web/dist/assets/Chat-DkZvD7d8.js +28 -0
  8. package/client/web/dist/assets/MealHungryPan-Beos3lRI.js +1 -0
  9. package/client/web/dist/assets/MsgBody-aZ4_aija.js +1 -0
  10. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-CPZjRLp6.js +67 -0
  11. package/client/web/dist/assets/NapcatAppIcon-C7VOAkW9.png +0 -0
  12. package/client/web/dist/assets/Vue-lEhHouxB.png +0 -0
  13. package/client/web/dist/assets/append_darwin-CqBNU62J.css +1 -0
  14. package/client/web/dist/assets/append_full_vibrancy-j0D2LM9m.css +1 -0
  15. package/client/web/dist/assets/append_linux-uhxeBqeC.css +1 -0
  16. package/client/web/dist/assets/append_linux_vibrancy-COiZq8MS.css +1 -0
  17. package/client/web/dist/assets/append_napcat-DV8CAyAd.css +1 -0
  18. package/client/web/dist/assets/append_new-407Lo5_V.css +1 -0
  19. package/client/web/dist/assets/append_vibrancy-6FHctkOY.css +1 -0
  20. package/client/web/dist/assets/append_win32-srmD-78y.css +1 -0
  21. package/client/web/dist/assets/cakes-CxTVKdnK.svg +189 -0
  22. package/client/web/dist/assets/core-DhEqZVGG.js +1 -0
  23. package/client/web/dist/assets/event-Cadp6m6J.js +1 -0
  24. package/client/web/dist/assets/index-CPTXiemn.js +4045 -0
  25. package/client/web/dist/assets/index-Cg_esKB0.js +1 -0
  26. package/client/web/dist/assets/index-Cln012sP.css +1 -0
  27. package/client/web/dist/assets//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223-CJKIKBn3.ttf +0 -0
  28. package/client/web/dist/assets//346/234/237/345/276/205-C90_OY60.png +0 -0
  29. package/client/web/dist/bcui/css/color-dark.css +35 -0
  30. package/client/web/dist/bcui/css/color-light.css +35 -0
  31. package/client/web/dist/bcui/css/style.css +468 -0
  32. package/client/web/dist/bcui/js/main.js +193 -0
  33. package/client/web/dist/css/append-dark.css +13 -0
  34. package/client/web/dist/css/append-light.css +13 -0
  35. package/client/web/dist/css/append-napcat-dark.css +14 -0
  36. package/client/web/dist/css/append-napcat-light.css +14 -0
  37. package/client/web/dist/favicon.ico +0 -0
  38. package/client/web/dist/img/icons/icon-for-fuck-apple.png +0 -0
  39. package/client/web/dist/img/icons/icon-maskable.png +0 -0
  40. package/client/web/dist/img/icons/icon.png +0 -0
  41. package/client/web/dist/img/icons/icon.svg +10 -0
  42. package/client/web/dist/index.html +22 -0
  43. package/client/web/dist/robots.txt +2 -0
  44. package/client/web/dist/sw.js +58 -0
  45. package/client/web/env.d.ts +56 -0
  46. package/client/web/index.html +21 -0
  47. package/client/web/package-lock.json +3419 -0
  48. package/client/web/package.json +62 -0
  49. package/client/web/public/bcui/css/color-dark.css +35 -0
  50. package/client/web/public/bcui/css/color-light.css +35 -0
  51. package/client/web/public/bcui/css/style.css +468 -0
  52. package/client/web/public/bcui/js/main.js +193 -0
  53. package/client/web/public/css/append-dark.css +13 -0
  54. package/client/web/public/css/append-light.css +13 -0
  55. package/client/web/public/css/append-napcat-dark.css +14 -0
  56. package/client/web/public/css/append-napcat-light.css +14 -0
  57. package/client/web/public/favicon.ico +0 -0
  58. package/client/web/public/img/icons/icon-for-fuck-apple.png +0 -0
  59. package/client/web/public/img/icons/icon-maskable.png +0 -0
  60. package/client/web/public/img/icons/icon.png +0 -0
  61. package/client/web/public/img/icons/icon.svg +10 -0
  62. package/client/web/public/robots.txt +2 -0
  63. package/client/web/public/sw.js +58 -0
  64. package/client/web/scripts/copy-bcui.mjs +17 -0
  65. package/client/web/src/App.vue +1127 -0
  66. package/client/web/src/assets/css/append/append_darwin.css +68 -0
  67. package/client/web/src/assets/css/append/append_full_vibrancy.css +290 -0
  68. package/client/web/src/assets/css/append/append_linux.css +125 -0
  69. package/client/web/src/assets/css/append/append_linux_vibrancy.css +22 -0
  70. package/client/web/src/assets/css/append/append_napcat.css +35 -0
  71. package/client/web/src/assets/css/append/append_new.css +843 -0
  72. package/client/web/src/assets/css/append/append_vibrancy.css +113 -0
  73. package/client/web/src/assets/css/append/append_win32.css +109 -0
  74. package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +349 -0
  75. package/client/web/src/assets/css/append/mobile/append_mobile_vertical.css +96 -0
  76. package/client/web/src/assets/css/chat.css +2104 -0
  77. package/client/web/src/assets/css/msg.css +941 -0
  78. package/client/web/src/assets/css/options.css +811 -0
  79. package/client/web/src/assets/css/sys_notice.css +62 -0
  80. package/client/web/src/assets/css/view.css +2105 -0
  81. package/client/web/src/assets/font//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223.ttf +0 -0
  82. package/client/web/src/assets/img/Vue.png +0 -0
  83. package/client/web/src/assets/img/cakes.svg +189 -0
  84. package/client/web/src/assets/img/icons/AppIcon.png +0 -0
  85. package/client/web/src/assets/img/icons/AppIconDark.png +0 -0
  86. package/client/web/src/assets/img/icons/CapacitorAppIcon.png +0 -0
  87. package/client/web/src/assets/img/icons/CapacitorAppIconDark.png +0 -0
  88. package/client/web/src/assets/img/icons/NapcatAppIcon.png +0 -0
  89. package/client/web/src/assets/img/icons/NapcatAppIconDark.png +0 -0
  90. package/client/web/src/assets/img/icons/exit.png +0 -0
  91. package/client/web/src/assets/img/icons/pen.png +0 -0
  92. package/client/web/src/assets/img/stars.svg +89 -0
  93. package/client/web/src/assets/img//346/234/237/345/276/205.png +0 -0
  94. package/client/web/src/assets/l10n/_About I10n.md +35 -0
  95. package/client/web/src/assets/l10n/_l10nconfig.json +7 -0
  96. package/client/web/src/assets/l10n/zh-CN.po +1941 -0
  97. package/client/web/src/assets/logo.png +0 -0
  98. package/client/web/src/assets/pathMap/LLOneBot.yaml +2 -0
  99. package/client/web/src/assets/pathMap/Lagrange.OneBot.yaml +180 -0
  100. package/client/web/src/assets/pathMap/NapCat.Onebot.yaml +224 -0
  101. package/client/web/src/assets/pathMap/SnowLuma.yaml +80 -0
  102. package/client/web/src/assets/qed.txt +1 -0
  103. package/client/web/src/components/AboutPan.vue +197 -0
  104. package/client/web/src/components/BulletinBody.vue +129 -0
  105. package/client/web/src/components/DepPan.vue +310 -0
  106. package/client/web/src/components/EmojiFace.vue +32 -0
  107. package/client/web/src/components/FacePan.vue +461 -0
  108. package/client/web/src/components/FileBody.vue +129 -0
  109. package/client/web/src/components/FileManager.vue +532 -0
  110. package/client/web/src/components/FriendBody.vue +54 -0
  111. package/client/web/src/components/GlobalSessionSearchBar.vue +301 -0
  112. package/client/web/src/components/History.vue +49 -0
  113. package/client/web/src/components/LazyLottie.vue +82 -0
  114. package/client/web/src/components/MergePan.vue +127 -0
  115. package/client/web/src/components/MsgBody.vue +1478 -0
  116. package/client/web/src/components/MusicPlayer.vue +549 -0
  117. package/client/web/src/components/NoticeBody.vue +168 -0
  118. package/client/web/src/components/QzonePermissionPan.vue +150 -0
  119. package/client/web/src/components/RawMsgRenderPreviewPan.vue +190 -0
  120. package/client/web/src/components/ThemeColorPickerPan.vue +420 -0
  121. package/client/web/src/components/TinySessionBody.vue +51 -0
  122. package/client/web/src/components/UmamiInfoPan.vue +1549 -0
  123. package/client/web/src/components/UpdatePan.vue +263 -0
  124. package/client/web/src/components/ViewerCom.vue +1360 -0
  125. package/client/web/src/components/VoiceMsg.vue +626 -0
  126. package/client/web/src/components/WelPan.vue +894 -0
  127. package/client/web/src/components/msg-body/.gitkeep +0 -0
  128. package/client/web/src/components/msg-component/JsonSegComp.vue +138 -0
  129. package/client/web/src/components/msg-component/XmlSegComp.vue +132 -0
  130. package/client/web/src/components/msg-component/jsonComp/AutoReply.vue +97 -0
  131. package/client/web/src/components/msg-component/jsonComp/Contact.lua.vue +129 -0
  132. package/client/web/src/components/msg-component/jsonComp/Feed.lua.vue +94 -0
  133. package/client/web/src/components/msg-component/jsonComp/Forum.vue +84 -0
  134. package/client/web/src/components/msg-component/jsonComp/Mannounce.vue +111 -0
  135. package/client/web/src/components/msg-component/jsonComp/Map.vue +52 -0
  136. package/client/web/src/components/msg-component/jsonComp/Miniapp.lua.vue +53 -0
  137. package/client/web/src/components/msg-component/jsonComp/Miniapp.vue +69 -0
  138. package/client/web/src/components/msg-component/jsonComp/Music.lua.vue +135 -0
  139. package/client/web/src/components/msg-component/jsonComp/Tuwen.lua.vue +56 -0
  140. package/client/web/src/components/notice-component/.gitkeep +0 -0
  141. package/client/web/src/components/notice-component/MealHungryPan.vue +119 -0
  142. package/client/web/src/components/tooltip/CustomFaceTooltip.vue +22 -0
  143. package/client/web/src/components/tooltip/Tooltip.vue +50 -0
  144. package/client/web/src/components/tooltip/Tooltips.vue +14 -0
  145. package/client/web/src/components/tooltip/UserInfoTooltip.vue +69 -0
  146. package/client/web/src/function/base.ts +238 -0
  147. package/client/web/src/function/connect.ts +2042 -0
  148. package/client/web/src/function/debug.ts +10 -0
  149. package/client/web/src/function/elements/information.ts +141 -0
  150. package/client/web/src/function/elements/system.ts +101 -0
  151. package/client/web/src/function/elements/vueComp.ts +62 -0
  152. package/client/web/src/function/favicon.ts +76 -0
  153. package/client/web/src/function/input.ts +69 -0
  154. package/client/web/src/function/model/emoji.ts +127 -0
  155. package/client/web/src/function/model/img.ts +150 -0
  156. package/client/web/src/function/model/msg-body.ts +51 -0
  157. package/client/web/src/function/msg.ts +2674 -0
  158. package/client/web/src/function/notify.ts +225 -0
  159. package/client/web/src/function/option.ts +749 -0
  160. package/client/web/src/function/satori-model.ts +818 -0
  161. package/client/web/src/function/satori.ts +325 -0
  162. package/client/web/src/function/sender.ts +178 -0
  163. package/client/web/src/function/tooltip.ts +49 -0
  164. package/client/web/src/function/utils/appUtil.ts +2288 -0
  165. package/client/web/src/function/utils/avatarUtil.ts +31 -0
  166. package/client/web/src/function/utils/backgroundUtil.ts +110 -0
  167. package/client/web/src/function/utils/linkViewUtil.ts +101 -0
  168. package/client/web/src/function/utils/localHistoryUtil.ts +517 -0
  169. package/client/web/src/function/utils/msgUtil.ts +1242 -0
  170. package/client/web/src/function/utils/pinyin.ts +141 -0
  171. package/client/web/src/function/utils/recordUtil.ts +187 -0
  172. package/client/web/src/function/utils/sessionUtil.ts +114 -0
  173. package/client/web/src/function/utils/systemUtil.ts +664 -0
  174. package/client/web/src/main.ts +91 -0
  175. package/client/web/src/pages/Chat.vue +3453 -0
  176. package/client/web/src/pages/Friends.vue +636 -0
  177. package/client/web/src/pages/Info.vue +717 -0
  178. package/client/web/src/pages/Messages.vue +875 -0
  179. package/client/web/src/pages/Options.vue +82 -0
  180. package/client/web/src/pages/Qzone.vue +1178 -0
  181. package/client/web/src/pages/chat-view/ChatDanmaku.vue +759 -0
  182. package/client/web/src/pages/chat-view/ChatGlagame.vue +642 -0
  183. package/client/web/src/pages/chat-view/ChatTerminal.vue +1044 -0
  184. package/client/web/src/pages/chat-view/SystemNotice.vue +171 -0
  185. package/client/web/src/pages/options/OptAccount.vue +107 -0
  186. package/client/web/src/pages/options/OptAddon.vue +185 -0
  187. package/client/web/src/pages/options/OptDev.vue +704 -0
  188. package/client/web/src/pages/options/OptFunction.vue +403 -0
  189. package/client/web/src/pages/options/OptInfo.vue +165 -0
  190. package/client/web/src/pages/options/OptView.vue +846 -0
  191. package/client/web/src/runtime/backend.ts +310 -0
  192. package/client/web/src/state/auth.ts +14 -0
  193. package/client/web/src/state/chat.ts +36 -0
  194. package/client/web/src/state/connection.ts +20 -0
  195. package/client/web/src/state/contact.ts +37 -0
  196. package/client/web/src/state/qzone.ts +18 -0
  197. package/client/web/src/state/sessionHistory.ts +25 -0
  198. package/client/web/src/state/settings.ts +18 -0
  199. package/client/web/src/state/sticker.ts +10 -0
  200. package/client/web/src/state/ui.ts +56 -0
  201. package/client/web/tsconfig.json +27 -0
  202. package/client/web/tsconfig.tsbuildinfo +1 -0
  203. package/client/web/vite.config.ts +33 -0
  204. package/dist/index.js +1 -12
  205. package/dist/style.css +1 -1
  206. package/lib/index.js +2070 -2077
  207. package/package.json +15 -2
  208. package/readme.md +0 -22
  209. package/src/bootstrap.ts +71 -0
  210. package/src/cache.ts +319 -0
  211. package/src/config.ts +23 -35
  212. package/src/database.ts +650 -0
  213. package/src/index.ts +27 -59
  214. package/src/media.ts +302 -0
  215. package/src/recorder.ts +175 -0
  216. package/src/self-message.ts +456 -0
  217. package/src/server.d.ts +26 -0
  218. package/src/types.ts +108 -48
  219. package/src/web.ts +712 -0
  220. package/client/vue/chat-logic.ts +0 -683
  221. package/client/vue/composables/useChatActions.ts +0 -61
  222. package/client/vue/composables/useChatData.ts +0 -185
  223. package/client/vue/composables/useImageCache.ts +0 -281
  224. package/client/vue/composables/useVideoCache.ts +0 -125
  225. package/client/vue/types.ts +0 -72
  226. package/lib/api-handlers.d.ts +0 -25
  227. package/lib/config.d.ts +0 -14
  228. package/lib/file-manager.d.ts +0 -124
  229. package/lib/index.d.ts +0 -17
  230. package/lib/logger.d.ts +0 -9
  231. package/lib/message-handler.d.ts +0 -35
  232. package/lib/types.d.ts +0 -57
  233. package/lib/utils.d.ts +0 -18
  234. package/src/api-handlers.ts +0 -646
  235. package/src/file-manager.ts +0 -1436
  236. package/src/message-handler.ts +0 -507
  237. package/src/utils.ts +0 -189
@@ -0,0 +1,626 @@
1
+ <!--
2
+ * @FileDescription: 语音消息组件
3
+ * @Description: 渲染 QQ 语音消息,支持播放/暂停、进度显示和时长展示。
4
+ * 语音消息通过 recordUtil 调用 OneBot get_record API 加载。
5
+ * 如果协议端不支持格式转换,显示"不支持的消息"。
6
+ -->
7
+
8
+ <template>
9
+ <div class="voice-msg"
10
+ :class="{ me: isMe, loading: isLoading, playing: isPlaying }"
11
+ @click.stop="handleClick">
12
+ <!-- 播放/加载图标 -->
13
+ <div class="voice-icon">
14
+ <font-awesome-icon v-if="isLoading" :icon="['fas', 'spinner']" spin />
15
+ <font-awesome-icon v-else-if="isError" :icon="['fas', 'exclamation-circle']" />
16
+ <font-awesome-icon v-else-if="isPlaying" :icon="['fas', 'pause']" />
17
+ <font-awesome-icon v-else :icon="['fas', 'play']" />
18
+ </div>
19
+
20
+ <!-- 频谱区域 -->
21
+ <div class="voice-progress">
22
+ <template v-if="isUnsupported">
23
+ <span class="voice-unsupported">{{ $t('不支持的消息') }}</span>
24
+ </template>
25
+ <template v-else>
26
+ <div ref="seekWrapRef"
27
+ class="voice-spectrum-wrap"
28
+ :class="{ seeking, disabled: !loaded || duration <= 0 }"
29
+ @pointerdown.stop="startSeek"
30
+ @pointermove.stop="moveSeek"
31
+ @pointerup.stop="endSeek"
32
+ @pointercancel.stop="endSeek"
33
+ @click.stop>
34
+ <div class="voice-spectrum">
35
+ <div
36
+ v-for="(height, index) in displaySpectrumBars"
37
+ :key="index"
38
+ class="voice-spectrum-bar"
39
+ :class="{ active: index < activeBarCount }"
40
+ :style="{ height: `${height}%` }" />
41
+ </div>
42
+ <div class="voice-seek-line">
43
+ <div class="voice-seek-fill"
44
+ :style="{ width: `${progressPercent}%` }" />
45
+ <div class="voice-seek-thumb"
46
+ :style="{ left: `${progressPercent}%` }" />
47
+ </div>
48
+ </div>
49
+ <span class="voice-duration">{{ isError ? $t('加载失败') : formatSeconds }}</span>
50
+ </template>
51
+ </div>
52
+
53
+ <!-- 音频元素,仅在有可用 src 时渲染 -->
54
+ <audio v-if="audioSrc"
55
+ ref="audioRef"
56
+ :src="audioSrc"
57
+ preload="auto"
58
+ @loadedmetadata="onLoaded"
59
+ @error="onError"
60
+ @timeupdate="onTimeUpdate"
61
+ @ended="onEnded"
62
+ @play="isPlaying = true"
63
+ @pause="isPlaying = false" />
64
+ </div>
65
+ </template>
66
+
67
+ <script setup lang="ts">
68
+ import { ref, computed, onBeforeUnmount, onMounted, watch } from 'vue'
69
+ import { loadRecord, revokeRecord, type RecordMsgData } from '../function/utils/recordUtil'
70
+ import { Logger } from '../function/base'
71
+
72
+ const logger = new Logger()
73
+
74
+ // ============================================================================
75
+ // Props
76
+ // ============================================================================
77
+
78
+ const props = defineProps<{
79
+ /** 语音消息数据 */
80
+ item: RecordMsgData
81
+ /** 消息 ID(用于缓存) */
82
+ messageId?: string
83
+ /** 是否为本人发送的消息 */
84
+ isMe?: boolean
85
+ /** 多选模式下点击只负责选中消息,不播放语音 */
86
+ selecting?: boolean
87
+ }>()
88
+
89
+ const emit = defineEmits<{
90
+ select: [event: MouseEvent]
91
+ }>()
92
+
93
+ // ============================================================================
94
+ // 响应式状态
95
+ // ============================================================================
96
+
97
+ const audioRef = ref<HTMLAudioElement | null>(null)
98
+ const audioSrc = ref<string>('')
99
+ const seekWrapRef = ref<HTMLDivElement | null>(null)
100
+ const isLoading = ref(false)
101
+ const isError = ref(false)
102
+ const isUnsupported = ref(false)
103
+ const isPlaying = ref(false)
104
+ const seeking = ref(false)
105
+ const currentTime = ref(0)
106
+ const duration = ref(props.item.duration || 0)
107
+ const loaded = ref(false)
108
+ const spectrumBars = ref<number[]>([])
109
+ const hasSpectrumData = ref(false)
110
+
111
+ let spectrumBuildToken = 0
112
+
113
+ const SPECTRUM_MIN_BAR_HEIGHT = 14
114
+ const SPECTRUM_FFT_SIZE = 256
115
+ const SPECTRUM_BAR_COUNT = 32
116
+
117
+ // ============================================================================
118
+ // 计算属性
119
+ // ============================================================================
120
+
121
+ const progressPercent = computed(() => {
122
+ if (duration.value <= 0) return 0
123
+ return Math.min(100, (currentTime.value / duration.value) * 100)
124
+ })
125
+
126
+ const spectrumBarCount = computed(() => {
127
+ return SPECTRUM_BAR_COUNT
128
+ })
129
+
130
+ const placeholderSpectrumBars = computed(() => createPlaceholderSpectrumBars(spectrumBarCount.value))
131
+
132
+ const displaySpectrumBars = computed(() => {
133
+ return hasSpectrumData.value && spectrumBars.value.length > 0 ? spectrumBars.value : placeholderSpectrumBars.value
134
+ })
135
+
136
+ const activeBarCount = computed(() => {
137
+ return Math.round((displaySpectrumBars.value.length * progressPercent.value) / 100)
138
+ })
139
+
140
+ function formatSecondsLabel(value: number) {
141
+ return `${Math.max(1, Math.ceil(value))}″`
142
+ }
143
+
144
+ const formatSeconds = computed(() => {
145
+ if (isLoading.value) return '--″'
146
+ if (isError.value) return '⚠'
147
+ if (isUnsupported.value) return ''
148
+ if (!loaded.value && duration.value <= 0) return '--″'
149
+ const seconds = isPlaying.value || currentTime.value > 0 ? currentTime.value : duration.value
150
+ return formatSecondsLabel(seconds)
151
+ })
152
+
153
+ // ============================================================================
154
+ // 方法
155
+ // ============================================================================
156
+
157
+ function createPlaceholderSpectrumBars(count: number) {
158
+ return Array.from({ length: count }, (_, index) => {
159
+ const t = count <= 1 ? 0 : index / (count - 1)
160
+ const curve = Math.sin(t * Math.PI)
161
+ return Math.round(SPECTRUM_MIN_BAR_HEIGHT + curve * 34)
162
+ })
163
+ }
164
+
165
+ function normalizeSpectrumBars(samples: number[], count: number) {
166
+ if (!samples.length) return createPlaceholderSpectrumBars(count)
167
+
168
+ const merged = Array.from({ length: count }, (_, index) => {
169
+ const start = Math.floor((index * samples.length) / count)
170
+ const end = Math.max(start + 1, Math.floor(((index + 1) * samples.length) / count))
171
+ const slice = samples.slice(start, end)
172
+
173
+ if (!slice.length) return 0
174
+
175
+ const peak = Math.max(...slice)
176
+ const average = slice.reduce((sum, value) => sum + value, 0) / slice.length
177
+ return peak * 0.65 + average * 0.35
178
+ })
179
+
180
+ const maxValue = Math.max(...merged, 0.001)
181
+
182
+ return merged.map((value, index) => {
183
+ const normalized = Math.min(1, value / maxValue)
184
+ const eased = Math.pow(normalized, 0.82)
185
+ const smoothed = (() => {
186
+ const prev = merged[Math.max(0, index - 1)] / maxValue
187
+ const next = merged[Math.min(merged.length - 1, index + 1)] / maxValue
188
+ return (prev * 0.2) + (eased * 0.6) + (next * 0.2)
189
+ })()
190
+
191
+ return Math.round(SPECTRUM_MIN_BAR_HEIGHT + Math.min(1, smoothed) * (100 - SPECTRUM_MIN_BAR_HEIGHT))
192
+ })
193
+ }
194
+
195
+ function buildWaveFallback(buffer: AudioBuffer, count: number) {
196
+ const channelData = buffer.getChannelData(0)
197
+ const samples = Array.from({ length: count }, (_, index) => {
198
+ const start = Math.floor((index * channelData.length) / count)
199
+ const end = Math.max(start + 1, Math.floor(((index + 1) * channelData.length) / count))
200
+ let energy = 0
201
+
202
+ for (let i = start; i < end; i += 1) {
203
+ energy += Math.abs(channelData[i])
204
+ }
205
+
206
+ return energy / Math.max(1, end - start)
207
+ })
208
+
209
+ return normalizeSpectrumBars(samples, count)
210
+ }
211
+
212
+ async function analyzeBufferSpectrum(buffer: AudioBuffer, count: number) {
213
+ const offlineContext = new OfflineAudioContext(1, buffer.length, buffer.sampleRate)
214
+ const source = offlineContext.createBufferSource()
215
+ const analyser = offlineContext.createAnalyser()
216
+ const processor = offlineContext.createScriptProcessor(2048, 1, 1)
217
+ const frequencyData = new Uint8Array(analyser.frequencyBinCount)
218
+ const collected: number[] = []
219
+
220
+ analyser.fftSize = SPECTRUM_FFT_SIZE
221
+ analyser.smoothingTimeConstant = 0.35
222
+ source.buffer = buffer
223
+
224
+ processor.onaudioprocess = () => {
225
+ analyser.getByteFrequencyData(frequencyData)
226
+ const average = frequencyData.reduce((sum, value) => sum + value, 0) / (frequencyData.length * 255)
227
+ collected.push(average)
228
+ }
229
+
230
+ source.connect(analyser)
231
+ analyser.connect(processor)
232
+ processor.connect(offlineContext.destination)
233
+ source.start(0)
234
+
235
+ await offlineContext.startRendering()
236
+
237
+ source.disconnect()
238
+ analyser.disconnect()
239
+ processor.disconnect()
240
+
241
+ return normalizeSpectrumBars(collected, count)
242
+ }
243
+
244
+ async function buildSpectrumBars() {
245
+ if (!audioSrc.value) return
246
+
247
+ const currentToken = ++spectrumBuildToken
248
+ const count = spectrumBarCount.value
249
+
250
+ hasSpectrumData.value = false
251
+ spectrumBars.value = createPlaceholderSpectrumBars(count)
252
+
253
+ try {
254
+ const response = await fetch(audioSrc.value)
255
+ const arrayBuffer = await response.arrayBuffer()
256
+ const audioContext = new AudioContext()
257
+ let decodedBuffer: AudioBuffer
258
+
259
+ try {
260
+ decodedBuffer = await audioContext.decodeAudioData(arrayBuffer.slice(0))
261
+ } finally {
262
+ await audioContext.close()
263
+ }
264
+
265
+ if (currentToken !== spectrumBuildToken) return
266
+
267
+ try {
268
+ spectrumBars.value = await analyzeBufferSpectrum(decodedBuffer, count)
269
+ } catch (error) {
270
+ spectrumBars.value = buildWaveFallback(decodedBuffer, count)
271
+ }
272
+
273
+ hasSpectrumData.value = true
274
+ } catch (error) {
275
+ if (currentToken === spectrumBuildToken) {
276
+ spectrumBars.value = createPlaceholderSpectrumBars(count)
277
+ hasSpectrumData.value = false
278
+ }
279
+ }
280
+ }
281
+
282
+ /**
283
+ * 加载语音资源
284
+ */
285
+ async function loadVoice() {
286
+ if (loaded.value || isLoading.value) return
287
+
288
+ isLoading.value = true
289
+ isError.value = false
290
+ isUnsupported.value = false
291
+
292
+ try {
293
+ const result = await loadRecord(props.item, props.messageId)
294
+ audioSrc.value = result.src
295
+ duration.value = result.duration || props.item.duration || 0
296
+ loaded.value = true
297
+ void buildSpectrumBars()
298
+
299
+ logger.debug(`语音加载完成: 时长=${duration.value}s`)
300
+ } catch (e) {
301
+ logger.error(e as Error, '语音加载失败')
302
+ // 根据错误信息判断是否因为 OneBot 不支持
303
+ const msg = (e as Error).message || ''
304
+ if (msg.includes('不支持') || msg.includes('缺少 file')) {
305
+ isUnsupported.value = true
306
+ } else {
307
+ isError.value = true
308
+ }
309
+ } finally {
310
+ isLoading.value = false
311
+ }
312
+ }
313
+
314
+ /**
315
+ * 切换播放/暂停
316
+ */
317
+ function togglePlay() {
318
+ if (isUnsupported.value) return
319
+
320
+ if (isError.value) {
321
+ // 点击重试
322
+ isError.value = false
323
+ loaded.value = false
324
+ audioSrc.value = ''
325
+ loadVoice()
326
+ return
327
+ }
328
+
329
+ if (!loaded.value) {
330
+ loadVoice()
331
+ return
332
+ }
333
+
334
+ const audio = audioRef.value
335
+ if (!audio) return
336
+
337
+ if (audio.paused) {
338
+ audio.play().catch((e) => {
339
+ logger.error(e, '语音播放失败')
340
+ isError.value = true
341
+ })
342
+ } else {
343
+ audio.pause()
344
+ }
345
+ }
346
+
347
+ function handleClick(event: MouseEvent) {
348
+ if (props.selecting) {
349
+ emit('select', event)
350
+ return
351
+ }
352
+ togglePlay()
353
+ }
354
+
355
+ function seekPosition(clientX: number) {
356
+ const wrap = seekWrapRef.value
357
+ const audio = audioRef.value
358
+ if (!wrap || !audio || duration.value <= 0) return
359
+ const rect = wrap.getBoundingClientRect()
360
+ if (rect.width <= 0) return
361
+ const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width))
362
+ const nextTime = ratio * duration.value
363
+ audio.currentTime = nextTime
364
+ currentTime.value = nextTime
365
+ }
366
+
367
+ function startSeek(event: PointerEvent) {
368
+ if (!loaded.value || duration.value <= 0) return
369
+ seeking.value = true
370
+ const target = event.currentTarget as HTMLElement | null
371
+ target?.setPointerCapture?.(event.pointerId)
372
+ seekPosition(event.clientX)
373
+ }
374
+
375
+ function moveSeek(event: PointerEvent) {
376
+ if (!seeking.value) return
377
+ seekPosition(event.clientX)
378
+ }
379
+
380
+ function endSeek(event: PointerEvent) {
381
+ if (!seeking.value) return
382
+ seeking.value = false
383
+ const target = event.currentTarget as HTMLElement | null
384
+ if (target?.hasPointerCapture?.(event.pointerId)) {
385
+ target.releasePointerCapture(event.pointerId)
386
+ }
387
+ }
388
+
389
+ /**
390
+ * 音频加载完成
391
+ */
392
+ function onLoaded() {
393
+ const audio = audioRef.value
394
+ if (audio && audio.duration && !duration.value) {
395
+ duration.value = audio.duration
396
+ void buildSpectrumBars()
397
+ }
398
+ logger.debug(`音频元数据加载完成: ${duration.value}s`)
399
+ }
400
+
401
+ /**
402
+ * 音频加载失败
403
+ */
404
+ function onError() {
405
+ logger.error(null, '音频播放错误')
406
+ isError.value = true
407
+ isPlaying.value = false
408
+ }
409
+
410
+ /**
411
+ * 播放时间更新
412
+ */
413
+ function onTimeUpdate() {
414
+ const audio = audioRef.value
415
+ if (audio) {
416
+ currentTime.value = audio.currentTime
417
+ }
418
+ }
419
+
420
+ /**
421
+ * 播放结束
422
+ */
423
+ function onEnded() {
424
+ isPlaying.value = false
425
+ currentTime.value = 0
426
+ }
427
+
428
+ // ============================================================================
429
+ // 生命周期
430
+ // ============================================================================
431
+
432
+ onMounted(() => {
433
+ loadVoice()
434
+ })
435
+
436
+ watch(spectrumBarCount, (count) => {
437
+ if (!hasSpectrumData.value) {
438
+ spectrumBars.value = createPlaceholderSpectrumBars(count)
439
+ return
440
+ }
441
+
442
+ if (loaded.value && audioSrc.value && spectrumBars.value.length !== count) {
443
+ void buildSpectrumBars()
444
+ }
445
+ }, { immediate: true })
446
+
447
+ onBeforeUnmount(() => {
448
+ spectrumBuildToken += 1
449
+ // 停止播放
450
+ const audio = audioRef.value
451
+ if (audio) {
452
+ audio.pause()
453
+ audio.src = ''
454
+ }
455
+ // 释放缓存的 blob URL
456
+ revokeRecord(props.messageId)
457
+ })
458
+ </script>
459
+
460
+ <style scoped>
461
+ .voice-msg {
462
+ display: flex;
463
+ align-items: center;
464
+ gap: 10px;
465
+ max-width: 100%;
466
+ cursor: pointer;
467
+ border-radius: 10px;
468
+ transition: all 0.2s ease;
469
+ }
470
+
471
+ .voice-msg.loading {
472
+ opacity: 0.7;
473
+ pointer-events: none;
474
+ }
475
+
476
+ .voice-msg.playing .voice-icon {
477
+ color: var(--color-main);
478
+ }
479
+
480
+ /* 播放图标 */
481
+ .voice-icon {
482
+ flex-shrink: 0;
483
+ width: 22px;
484
+ height: 23px;
485
+ display: flex;
486
+ align-items: center;
487
+ justify-content: center;
488
+ border-radius: 50%;
489
+ background: rgba(var(--color-card-rgb), 0.5);
490
+ color: var(--color-font-2);
491
+ font-size: 11px;
492
+ transition: all 0.2s ease;
493
+ padding-left: 2px;
494
+ }
495
+
496
+ .voice-msg.me .voice-icon {
497
+ background: var(--color-card-2);
498
+ color: var(--color-main);
499
+ }
500
+
501
+ .voice-msg.playing .voice-icon {
502
+ background: var(--color-main);
503
+ color: var(--color-font-r);
504
+ }
505
+ .voice-msg.me.playing .voice-icon {
506
+ background: rgba(var(--color-card-2-rgb), 0.5);
507
+ }
508
+
509
+ /* 频谱区域 */
510
+ .voice-progress {
511
+ flex: 1;
512
+ display: flex;
513
+ align-items: center;
514
+ gap: 8px;
515
+ min-width: 0;
516
+ max-width: 100%;
517
+ }
518
+
519
+ .voice-spectrum-wrap {
520
+ position: relative;
521
+ flex: 1 1 auto;
522
+ min-width: 0;
523
+ height: 22px;
524
+ cursor: pointer;
525
+ touch-action: none;
526
+ }
527
+
528
+ .voice-spectrum-wrap.disabled {
529
+ cursor: default;
530
+ }
531
+
532
+ .voice-spectrum-wrap.seeking {
533
+ cursor: grabbing;
534
+ }
535
+
536
+ .voice-spectrum {
537
+ position: relative;
538
+ flex: 1;
539
+ min-width: 0;
540
+ height: 100%;
541
+ display: flex;
542
+ align-items: center;
543
+ gap: 2px;
544
+ pointer-events: none;
545
+ }
546
+
547
+ .voice-spectrum-bar {
548
+ flex: 1 1 0;
549
+ min-width: 2px;
550
+ border-radius: 999px;
551
+ background: var(--color-font);
552
+ opacity: 0.5;
553
+ transition: height 0.2s ease, background-color 0.12s linear, opacity 0.12s linear;
554
+ }
555
+
556
+ .voice-spectrum-bar.active {
557
+ background: var(--color-font);
558
+ opacity: 1;
559
+ }
560
+
561
+ .voice-seek-line {
562
+ position: absolute;
563
+ top: 50%;
564
+ right: 0;
565
+ left: 0;
566
+ height: 3px;
567
+ pointer-events: none;
568
+ border-radius: 999px;
569
+ background: rgba(0, 0, 0, 0.16);
570
+ transform: translateY(-50%);
571
+ }
572
+
573
+ .voice-seek-fill {
574
+ height: 100%;
575
+ border-radius: 999px;
576
+ background: var(--color-main);
577
+ }
578
+
579
+ .voice-seek-thumb {
580
+ position: absolute;
581
+ top: 50%;
582
+ width: 10px;
583
+ height: 10px;
584
+ border-radius: 50%;
585
+ background: var(--color-main);
586
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.24);
587
+ transform: translate(-50%, -50%);
588
+ }
589
+
590
+ .voice-msg.me .voice-spectrum-bar {
591
+ background: var(--color-card-2);
592
+ }
593
+
594
+ .voice-msg.me .voice-spectrum-bar.active {
595
+ background: var(--color-card-2);
596
+ }
597
+
598
+ .voice-msg.me .voice-seek-fill {
599
+ background: var(--color-card-2);
600
+ }
601
+
602
+ .voice-msg.me .voice-seek-thumb {
603
+ background: var(--color-card-2);
604
+ }
605
+
606
+ .voice-duration {
607
+ font-size: 0.8rem;
608
+ color: var(--color-font-2);
609
+ white-space: nowrap;
610
+ flex-shrink: 0;
611
+ }
612
+ .voice-msg.me span.voice-duration {
613
+ color: var(--color-font-r);
614
+ }
615
+
616
+ .voice-unsupported {
617
+ font-size: 0.8rem;
618
+ color: var(--color-font-2);
619
+ opacity: 0.6;
620
+ font-style: italic;
621
+ }
622
+
623
+ .voice-msg.me .voice-duration {
624
+ color: var(--color-font-1);
625
+ }
626
+ </style>