diff --git a/src/assets/icons/Icon16Text.svg b/src/assets/icons/Icon16Text.svg new file mode 100644 index 00000000..2a3ddfe8 --- /dev/null +++ b/src/assets/icons/Icon16Text.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/Icon20ChevronUp.svg b/src/assets/icons/Icon20ChevronUp.svg new file mode 100644 index 00000000..7610b4e8 --- /dev/null +++ b/src/assets/icons/Icon20ChevronUp.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.tsx b/src/assets/icons/Icon32PauseCircle.tsx new file mode 100644 index 00000000..4dab7051 --- /dev/null +++ b/src/assets/icons/Icon32PauseCircle.tsx @@ -0,0 +1,24 @@ +import { defineComponent, useId } from 'vue' + +type Props = { + withUnlistenedDot?: boolean +} + +export const Icon32PauseCircle = defineComponent((props) => { + const id = useId() + + return () => ( + + {props.withUnlistenedDot && ( + + + + + )} + + {props.withUnlistenedDot && } + + ) +}, { + props: ['withUnlistenedDot'] +}) diff --git a/src/assets/icons/Icon32PlayCircle.tsx b/src/assets/icons/Icon32PlayCircle.tsx new file mode 100644 index 00000000..d184c0f1 --- /dev/null +++ b/src/assets/icons/Icon32PlayCircle.tsx @@ -0,0 +1,24 @@ +import { defineComponent, useId } from 'vue' + +type Props = { + withUnlistenedDot?: boolean +} + +export const Icon32PlayCircle = defineComponent((props) => { + const id = useId() + + return () => ( + + {props.withUnlistenedDot && ( + + + + + )} + + {props.withUnlistenedDot && } + + ) +}, { + props: ['withUnlistenedDot'] +}) 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 d9e36a7c..603a830b 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -9,10 +9,13 @@ export { default as Icon16Muted } from './Icon16Muted.svg' export { default as Icon16Pin } from './Icon16Pin.svg' export { default as Icon16Repost } from './Icon16Repost.svg' export { default as Icon16Spinner } from './Icon16Spinner.svg' +export { default as Icon16Text } from './Icon16Text.svg' export { default as Icon20BombOutline } from './Icon20BombOutline.svg' +export { default as Icon20ChevronUp } from './Icon20ChevronUp.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 Icon24DoorArrowRightOutline } from './Icon24DoorArrowRightOutline.svg' export { default as Icon24HideOutline } from './Icon24HideOutline.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 { Icon32PauseCircle } from './Icon32PauseCircle' +export { Icon32PlayCircle } from './Icon32PlayCircle' 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..bff733db 100644 --- a/src/converters/AttachConverter.ts +++ b/src/converters/AttachConverter.ts @@ -9,7 +9,10 @@ import * as Peer from 'model/Peer' import { insertPeers } from 'actions' import { isNonEmptyArray } from 'misc/utils' -export function fromApiAttaches(apiAttaches: MessagesMessageAttachment[]): Attach.Attaches { +export function fromApiAttaches( + apiAttaches: MessagesMessageAttachment[], + wasVoiceMessageListened: boolean +): Attach.Attaches { const attaches: Attach.Attaches = {} const addUnknown = (attach: MessagesMessageAttachment) => { @@ -39,6 +42,28 @@ 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, + wasListened: wasVoiceMessageListened, + transcript: apiAttach.audio_message.transcript?.trim() ?? '', + transcriptState: apiAttach.audio_message.transcript_state + } + break + } + case 'photo': { if (!apiAttach.photo?.orig_photo) { addUnknown(apiAttach) @@ -122,7 +147,7 @@ function fromApiAttachWall(apiWall: MessagesMessageAttachmentWall): Attach.Wall accessKey: apiWall.access_key, createdAt: (apiWall.date ?? 0) * 1000, text: apiWall.text ?? '', - attaches: fromApiAttaches(apiWall.attachments ?? []), + attaches: fromApiAttaches(apiWall.attachments ?? [], true), isDeleted: !!apiWall.is_deleted, donutPlaceholder: apiWall.donut?.placeholder?.text, repost: apiWall.copy_history?.[0] diff --git a/src/converters/MessageConverter.ts b/src/converters/MessageConverter.ts index 7663e8a8..d941b5b6 100644 --- a/src/converters/MessageConverter.ts +++ b/src/converters/MessageConverter.ts @@ -37,7 +37,7 @@ export function fromApiMessage(message: MessagesMessage): Message.Message { return { kind: 'Normal', text: message.text, - attaches: fromApiAttaches(message.attachments ?? []), + attaches: fromApiAttaches(message.attachments ?? [], !!message.was_listened), replyMessage: message.reply_message && fromApiForeignMessage( message.reply_message, baseMessage.peerId, @@ -164,7 +164,7 @@ function fromApiForeignMessage( authorId: Peer.resolveOwnerId(foreignMessage.from_id), sentAt: foreignMessage.date * 1000, text: foreignMessage.text, - attaches: fromApiAttaches(foreignMessage.attachments ?? []), + attaches: fromApiAttaches(foreignMessage.attachments ?? [], !!foreignMessage.was_listened), replyMessage: foreignMessage.reply_message && fromApiForeignMessage( foreignMessage.reply_message, rootPeerId, @@ -211,7 +211,7 @@ export function fromApiPinnedMessage(pinnedMessage: MessagesPinnedMessage): Mess authorId: Peer.resolveOwnerId(pinnedMessage.from_id), sentAt: pinnedMessage.date * 1000, text: pinnedMessage.text, - attaches: fromApiAttaches(pinnedMessage.attachments ?? []), + attaches: fromApiAttaches(pinnedMessage.attachments ?? [], true), replyMessage: pinnedMessage.reply_message && fromApiForeignMessage( pinnedMessage.reply_message, peerId, diff --git a/src/lang/ru.ts b/src/lang/ru.ts index 8e67e268..82e0a8e3 100644 --- a/src/lang/ru.ts +++ b/src/lang/ru.ts @@ -111,6 +111,10 @@ export const ru = { me_chat_leaved_status: 'Вы вышли из чата', me_chat_kicked_status: 'Вы были исключены из чата', + me_voice_transcription_error: 'Ошибка составления транскрипции', + me_voice_transcription_in_progress: 'Расшифровка...', + me_voice_transcript_empty: 'Слова не распознаны', + me_chat_members_count: { one: '{count} участник', few: '{count} участника', @@ -241,7 +245,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..c5d62972 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,20 @@ 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[] + wasListened: boolean + transcript: string + transcriptState: 'in_progress' | 'done' | 'error' +} + export type Wall = { kind: 'Wall' id: number @@ -111,7 +126,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 +148,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/MessagesForeignMessage.ts b/src/model/api-types/objects/MessagesForeignMessage.ts index 78baad49..122bdacc 100644 --- a/src/model/api-types/objects/MessagesForeignMessage.ts +++ b/src/model/api-types/objects/MessagesForeignMessage.ts @@ -11,11 +11,11 @@ export type MessagesForeignMessage = { fwd_messages?: MessagesForeignMessage[] geo?: MessagesMessageAttachmentGeo id?: number + was_listened?: boolean peer_id?: number reply_message?: MessagesForeignMessage text: string update_time?: number - was_listened?: boolean was_played?: boolean payload?: string is_expired?: boolean 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..ac2a4345 --- /dev/null +++ b/src/ui/messenger/attaches/AttachVoice/AttachVoice.css @@ -0,0 +1,99 @@ +.AttachVoice__player { + display: flex; + gap: 8px; +} + +.AttachVoice__playButton { + color: var(--vkui--color_text_accent_themed); +} + +.AttachVoice__track { + flex-grow: 1; +} + +.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__toggleTranscription { + align-self: flex-start; + height: 20px; + width: 30px; + color: var(--vkui--color_text_accent_themed); + border-radius: 8px; + margin-top: 2px; + padding: 2px 6px; +} + +.AttachVoice__toggleTranscription::before { + content: ''; + border-radius: inherit; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: var(--vkui--color_background_accent); + opacity: 0.1; + transition: opacity var(--fastTransition); +} + +.AttachVoice__toggleTranscription:hover::before { + opacity: 0.2; +} + +.AttachVoice__time { + font: var(--messageDateFontSize) / var(--messageDateLineHeight) var(--fontFamily); + color: var(--vkui--color_text_accent_themed); +} + +.AttachVoice__transcript { + position: relative; + display: flex; + margin-top: 8px; + padding-left: 12px; +} + +.AttachVoice__transcript--notReady { + opacity: 0.4; +} + +.AttachVoice__transcript::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 2px; + height: 100%; + border-radius: 2px; + background: var(--vkui--color_stroke_accent); +} + +.AttachVoice__transcript:not(.AttachVoice__transcript--notReady)::before { + opacity: 0.4; +} diff --git a/src/ui/messenger/attaches/AttachVoice/AttachVoice.tsx b/src/ui/messenger/attaches/AttachVoice/AttachVoice.tsx new file mode 100644 index 00000000..7c779dc9 --- /dev/null +++ b/src/ui/messenger/attaches/AttachVoice/AttachVoice.tsx @@ -0,0 +1,130 @@ +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 { Icon16Text, Icon20ChevronUp, 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 showTranscript = shallowRef(false) + + const transcriptNotReadyStatus = computed(() => { + if (props.voice.transcriptState === 'error') { + return lang.use('me_voice_transcription_error') + } + + if (props.voice.transcriptState === 'in_progress') { + return lang.use('me_voice_transcription_in_progress') + } + + if (!props.voice.transcript) { + return lang.use('me_voice_transcript_empty') + } + }) + + 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={() => (showTranscript.value = !showTranscript.value)} + /> +
+ {showTranscript.value && ( +
+ {transcriptNotReadyStatus.value ?? props.voice.transcript} +
+ )} +
+ ) +}, { + 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/ui/ui/ButtonIcon/ButtonIcon.css b/src/ui/ui/ButtonIcon/ButtonIcon.css index 1287c94a..d954cb7f 100644 --- a/src/ui/ui/ButtonIcon/ButtonIcon.css +++ b/src/ui/ui/ButtonIcon/ButtonIcon.css @@ -5,6 +5,7 @@ flex: none; position: relative; border-radius: 6px; + line-height: 0; transition: background-color var(--fastTransition), opacity var(--fastTransition); } 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 }