diff --git a/src/assets/icons/Icon20ChevronUp.svg b/src/assets/icons/Icon20ChevronUp.svg new file mode 100644 index 00000000..363e703d --- /dev/null +++ b/src/assets/icons/Icon20ChevronUp.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/Icon20TextOutline.svg b/src/assets/icons/Icon20TextOutline.svg new file mode 100644 index 00000000..cdcc039f --- /dev/null +++ b/src/assets/icons/Icon20TextOutline.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/Icon24ChevronDown.svg b/src/assets/icons/Icon24ChevronDown.svg new file mode 100644 index 00000000..0017649b --- /dev/null +++ b/src/assets/icons/Icon24ChevronDown.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/Icon32PauseCircle.svg b/src/assets/icons/Icon32PauseCircle.svg new file mode 100644 index 00000000..d19c9adb --- /dev/null +++ b/src/assets/icons/Icon32PauseCircle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/Icon32PlayCircle.svg b/src/assets/icons/Icon32PlayCircle.svg new file mode 100644 index 00000000..0fcdf3d4 --- /dev/null +++ b/src/assets/icons/Icon32PlayCircle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/Icon32Spinner.svg b/src/assets/icons/Icon32Spinner.svg index b2612d01..d087aff5 100644 --- a/src/assets/icons/Icon32Spinner.svg +++ b/src/assets/icons/Icon32Spinner.svg @@ -1 +1,3 @@ - + + + diff --git a/src/assets/icons/Icon44Spinner.svg b/src/assets/icons/Icon44Spinner.svg index 6ca3d24c..809ed8b5 100644 --- a/src/assets/icons/Icon44Spinner.svg +++ b/src/assets/icons/Icon44Spinner.svg @@ -1 +1,3 @@ - + + + diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index b736b3b2..518a1cc4 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -9,9 +9,12 @@ export { default as Icon16Pin } from './Icon16Pin.svg' export { default as Icon16Repost } from './Icon16Repost.svg' export { default as Icon16Spinner } from './Icon16Spinner.svg' export { default as Icon20BombOutline } from './Icon20BombOutline.svg' +export { default as Icon20ChevronUp } from './Icon20ChevronUp.svg' +export { default as Icon20TextOutline } from './Icon20TextOutline.svg' export { default as Icon20TrashOutline } from './Icon20TrashOutline.svg' export { default as Icon24Cancel } from './Icon24Cancel.svg' export { default as Icon24ChevronCompactLeft } from './Icon24ChevronCompactLeft.svg' +// export { default as Icon24ChevronDown } from './Icon24ChevronDown.svg' // export { default as Icon24ChevronRight } from './Icon24ChevronRight.svg' // export { default as Icon24CopyOutline } from './Icon24CopyOutline.svg' export { default as Icon24DoorArrowRightOutline } from './Icon24DoorArrowRightOutline.svg' @@ -25,5 +28,7 @@ export { default as Icon24ViewOutline } from './Icon24ViewOutline.svg' export { default as Icon24VolumeOutline } from './Icon24VolumeOutline.svg' export { default as Icon28DeleteOutline } from './Icon28DeleteOutline.svg' export { default as Icon32DonutCircleFillYellow } from './Icon32DonutCircleFillYellow.svg' +export { default as Icon32PauseCircle } from './Icon32PauseCircle.svg' +export { default as Icon32PlayCircle } from './Icon32PlayCircle.svg' export { default as Icon32Spinner } from './Icon32Spinner.svg' export { default as Icon44Spinner } from './Icon44Spinner.svg' diff --git a/src/converters/AttachConverter.ts b/src/converters/AttachConverter.ts index 3cc68155..907ccbbe 100644 --- a/src/converters/AttachConverter.ts +++ b/src/converters/AttachConverter.ts @@ -39,6 +39,27 @@ export function fromApiAttaches(apiAttaches: MessagesMessageAttachment[]): Attac break } + case 'audio_message': { + if (!apiAttach.audio_message) { + addUnknown(apiAttach) + break + } + + attaches.voice = { + kind: 'Voice', + id: apiAttach.audio_message.id, + ownerId: Peer.resolveOwnerId(apiAttach.audio_message.owner_id), + accessKey: apiAttach.audio_message.access_key, + linkMp3: apiAttach.audio_message.link_mp3, + linkOgg: apiAttach.audio_message.link_ogg, + duration: apiAttach.audio_message.duration, + waveform: apiAttach.audio_message.waveform, + transcript: apiAttach.audio_message.transcript, + transcriptState: apiAttach.audio_message.transcript_state + } + break + } + case 'photo': { if (!apiAttach.photo?.orig_photo) { addUnknown(apiAttach) diff --git a/src/lang/ru.ts b/src/lang/ru.ts index e302b386..a5a13ca6 100644 --- a/src/lang/ru.ts +++ b/src/lang/ru.ts @@ -111,6 +111,9 @@ export const ru = { me_chat_leaved_status: 'Вы вышли из чата', me_chat_kicked_status: 'Вы были исключены из чата', + me_voice_transcription_empty: 'Слова не распознаны', + me_voice_transcription_in_progress: 'Расшифровка...', + me_chat_members_count: { one: '{count} участник', few: '{count} участника', @@ -241,7 +244,7 @@ export const ru = { me_message_attach_wall: 'Запись', me_message_attach_wall_reply: 'Комментарий', me_message_attach_event: 'Мероприятие', - me_message_attach_audio_message: 'Аудиосообщение', + me_message_attach_voice: 'Аудиосообщение', me_message_attach_audio_playlist: 'Плейлист', me_message_attach_artist: 'Исполнитель', me_message_attach_curator: 'Куратор', diff --git a/src/model/Attach.ts b/src/model/Attach.ts index f05ba679..63d5fac5 100644 --- a/src/model/Attach.ts +++ b/src/model/Attach.ts @@ -7,6 +7,7 @@ export type Attaches = { sticker?: Sticker photos?: NonEmptyArray links?: NonEmptyArray + voice?: Voice wall?: Wall unknown?: NonEmptyArray } @@ -37,6 +38,19 @@ export type Link = { imageSizes?: ImageSizes } +export type Voice = { + kind: 'Voice' + id: number + ownerId: Peer.OwnerId + accessKey: string + linkMp3: string + linkOgg: string + duration: number + waveform: number[] + transcript?: string + transcriptState: 'in_progress' | 'done' | 'error' +} + export type Wall = { kind: 'Wall' id: number @@ -111,7 +125,8 @@ export function preview(attach: Attach, lang: ILang.Lang): string { switch (attach.kind) { case 'Sticker': - case 'Wall': { + case 'Wall': + case 'Voice': { const lowerCaseName = attach.kind.toLowerCase() as Lowercase return lang.use(`me_message_attach_${lowerCaseName}`) } @@ -132,13 +147,15 @@ function previewUnknown(unknown: NonNullable, lang: ILang.L unknown.filter(({ type }) => type === firstType).length ) + case 'audio_message': + return lang.use('me_message_attach_voice') + case 'gift': case 'sticker': case 'ugc_sticker': case 'wall': case 'wall_reply': case 'event': - case 'audio_message': case 'audio_playlist': case 'artist': case 'curator': diff --git a/src/model/api-types/objects/MessagesMessageAttachment.ts b/src/model/api-types/objects/MessagesMessageAttachment.ts index c21ddac5..b8328753 100644 --- a/src/model/api-types/objects/MessagesMessageAttachment.ts +++ b/src/model/api-types/objects/MessagesMessageAttachment.ts @@ -34,7 +34,7 @@ export type MessagesMessageAttachment = { market_album?: unknown call?: unknown graffiti?: unknown - audio_message?: unknown + audio_message?: MessagesMessageAttachmentAudioMessage artist?: unknown event?: unknown mini_app?: unknown @@ -144,6 +144,18 @@ type MessagesMessageAttachmentLink = { photo?: PhotosPhoto } +type MessagesMessageAttachmentAudioMessage = { + id: number + owner_id: number + access_key: string + link_mp3: string + link_ogg: string + duration: number + waveform: number[] + transcript?: string + transcript_state: 'in_progress' | 'done' | 'error' +} + export type MessagesMessageAttachmentWall = { inner_type: 'wall_wallpost' id?: number diff --git a/src/ui/messenger/attaches/AttachVoice/AttachVoice.css b/src/ui/messenger/attaches/AttachVoice/AttachVoice.css new file mode 100644 index 00000000..3509b886 --- /dev/null +++ b/src/ui/messenger/attaches/AttachVoice/AttachVoice.css @@ -0,0 +1,117 @@ +.AttachVoice { + overflow: hidden; +} + +.AttachVoice__player { + display: flex; + gap: 8px; + color: var(--vkui--color_background_accent_themed); +} + +.AttachVoice__track { + display: flex; + align-items: center; + flex-grow: 1; +} + +.AttachVoice__trackContent { + padding-top: 2px; + flex: 1 1 auto; +} + +.AttachVoice__range { + position: relative; + width: 100%; + appearance: none; + cursor: pointer; + height: 3px; + background: none; + overflow: hidden; +} + +.AttachVoice__range::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: var(--vkui--color_text_secondary); + opacity: 0.2; +} + +.AttachVoice__range::-webkit-slider-thumb { + appearance: none; + height: 3px; + width: 2px; + cursor: pointer; + box-shadow: -300px 0 0 300px var(--vkui--color_background_accent_themed); +} + +.AttachVoice__button { + position: relative; + align-self: flex-start; + border: none; + border-radius: 8px; + background: none; + margin-top: 2px; + margin-left: 12px; + padding: 2px 6px; + color: var(--vkui--color_icon_accent); +} + +.AttachVoice__button::before { + content: ''; + border-radius: inherit; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: var(--vkui--color_background_accent); + opacity: 0.12; + transition: opacity 0.14s; +} + +.AttachVoice__time { + display: block; + padding: 2px 0; + font: var(--messageDateFontSize) / var(--messageDateLineHeight) var(--fontFamily); +} + +.AttachVoice__transcript { + display: flex; + padding-top: 4px; +} + +.AttachVoice__collapse { + position: relative; + overflow: hidden; + padding-left: 10px; +} + +.AttachVoice__collapse::before { + content: ''; + position: absolute; + top: 1px; + bottom: 0; + left: 0; + width: 2px; + height: 100%; + border-radius: 2px; + background: var(--vkui--color_stroke_accent); +} + +.AttachVoice__collapse--open { + height: auto; +} + +.AttachVoice__collapse--close { + height: 0; + white-space: pre; +} + +.AttachVoice__collapse--faded { + opacity: 0.4; + font-size: 15px; +} diff --git a/src/ui/messenger/attaches/AttachVoice/AttachVoice.tsx b/src/ui/messenger/attaches/AttachVoice/AttachVoice.tsx new file mode 100644 index 00000000..d59c1efd --- /dev/null +++ b/src/ui/messenger/attaches/AttachVoice/AttachVoice.tsx @@ -0,0 +1,135 @@ +import { computed, defineComponent, InputEvent, shallowRef } from 'vue' +import * as Attach from 'model/Attach' +import { useEnv } from 'hooks' +import { ButtonIcon } from 'ui/ui/ButtonIcon/ButtonIcon' +import { Icon20ChevronUp, Icon20TextOutline, Icon32PauseCircle, Icon32PlayCircle } from 'assets/icons' +import './AttachVoice.css' + +type Props = { + voice: Attach.Voice +} + +export const AttachVoice = defineComponent((props) => { + const { lang } = useEnv() + + const audio = new Audio(props.voice.linkMp3) + const isPause = shallowRef(false) + const range = shallowRef(0) + const isRange = shallowRef(false) + const requestId = shallowRef(-1) + const isHiddenCollapse = shallowRef(true) + + const transcriptNotReady = computed(() => { + return !props.voice.transcript || + props.voice.transcript.trim() === '' || + props.voice.transcriptState === 'error' || + props.voice.transcriptState === 'in_progress' + }) + + const text = computed(() => { + if (props.voice.transcript && props.voice.transcript.trim() === '') { + return lang.use('me_voice_transcription_empty') + } + + if (props.voice.transcriptState === 'error') { + return lang.use('me_voice_transcription_empty') + } + + if (props.voice.transcriptState === 'in_progress') { + return lang.use('me_voice_transcription_in_progress') + } + + return props.voice.transcript + }) + + const getCurrentTime = () => { + const currentTime = audio.currentTime === 0 + ? props.voice.duration + : audio.currentTime + + const date = new Date() + date.setMinutes(0, currentTime) + + return date.getMinutes() + ':' + String(date.getSeconds()).padStart(2, '0') + } + + const moveRange = (event: InputEvent) => { + audio.currentTime = (+event.target.value / 100) * props.voice.duration + requestId.value = requestAnimationFrame(updateRange) + } + + const updateRange = () => { + if (isRange.value) { + return + } + + range.value = (audio.currentTime / props.voice.duration) * 100 + requestId.value = requestAnimationFrame(updateRange) + } + + const toggleAudio = () => { + if (!isPause.value) { + requestId.value = requestAnimationFrame(updateRange) + isPause.value = true + audio.play() + return + } + + cancelAnimationFrame(requestId.value) + audio.pause() + isPause.value = false + } + + audio.onended = () => { + cancelAnimationFrame(requestId.value) + isPause.value = false + range.value = 0 + } + + return () => ( +
+
+ : } + /> +
+
+ moveRange(event)} + onTouchstart={() => (isRange.value = true)} + onTouchend={() => (isRange.value = false)} + onMousedown={() => (isRange.value = true)} + onMouseup={() => (isRange.value = false)} + /> + {getCurrentTime()} +
+ : } + onClick={() => (isHiddenCollapse.value = !isHiddenCollapse.value)} + /> +
+
+
+
+ {text.value} +
+
+
+ ) +}, { + props: ['voice'] +}) diff --git a/src/ui/messenger/attaches/Attaches.tsx b/src/ui/messenger/attaches/Attaches.tsx index 89899256..49be565f 100644 --- a/src/ui/messenger/attaches/Attaches.tsx +++ b/src/ui/messenger/attaches/Attaches.tsx @@ -2,6 +2,7 @@ import { defineComponent } from 'vue' import * as Attach from 'model/Attach' import { useEnv } from 'hooks' import { ClassName } from 'misc/utils' +import { AttachVoice } from './AttachVoice/AttachVoice' import { AttachLink } from 'ui/messenger/attaches/AttachLink/AttachLink' import { AttachPhotos } from 'ui/messenger/attaches/AttachPhotos/AttachPhotos' import { AttachSticker } from 'ui/messenger/attaches/AttachSticker/AttachSticker' @@ -22,6 +23,7 @@ export const Attaches = defineComponent((props) => { {props.attaches.photos && } {props.attaches.links?.map((link) => )} {props.attaches.wall && } + {props.attaches.voice && } {props.attaches.unknown?.map((unknown) => (
{lang.use('me_unknown_attach')} ({unknown.type}) diff --git a/src/vue-jsx-events.d.ts b/src/vue-jsx-events.d.ts index 4b81c564..dca408e3 100644 --- a/src/vue-jsx-events.d.ts +++ b/src/vue-jsx-events.d.ts @@ -35,6 +35,7 @@ declare module 'vue' { interface InputHTMLAttributes { onKeydown?: (event: KeyboardEvent) => void + onChange?: (event: InputEvent) => void onInput?: (event: InputEvent) => void }