mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
Merge branch 'dev' into feature/funk
# Conflicts: # frontend/testserver/index.cjs # sky_phone/config/config.lua # sky_phone/source/html/index.html
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import emojiData from 'emoji-picker-element-data/en/emojibase/data.json'
|
||||
import { Search, X } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
type EmojiEntry = {
|
||||
annotation: string
|
||||
emoji: string
|
||||
group: number
|
||||
skins?: Array<{ emoji: string; tone: number }>
|
||||
shortcodes?: string[]
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
pick: [emoji: string]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const query = ref('')
|
||||
const activeGroup = ref(0)
|
||||
const skinTone = ref(0)
|
||||
const groups = ['😀', '👋', '🧑', '🐻', '🍔', '⚽', '🚗', '💡', '❤️', '🏳️']
|
||||
const skinTones = ['✋', '✋🏻', '✋🏼', '✋🏽', '✋🏾', '✋🏿']
|
||||
|
||||
const emojis = computed(() => {
|
||||
const needle = query.value.trim().toLocaleLowerCase(phone.lang)
|
||||
return (emojiData as EmojiEntry[])
|
||||
.filter((entry) => {
|
||||
if (!needle) return entry.group === activeGroup.value
|
||||
return `${entry.annotation} ${entry.shortcodes?.join(' ') ?? ''} ${entry.tags?.join(' ') ?? ''}`
|
||||
.toLocaleLowerCase(phone.lang)
|
||||
.includes(needle)
|
||||
})
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
emoji:
|
||||
skinTone.value === 0
|
||||
? entry.emoji
|
||||
: entry.skins?.find((skin) => skin.tone === skinTone.value)?.emoji ??
|
||||
entry.emoji,
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="messages-full-emoji-picker" aria-label="Emoji picker">
|
||||
<header>
|
||||
<strong>{{ phone.t('Apps.messages.emoji') }}</strong>
|
||||
<button type="button" :aria-label="phone.t('Common.done')" @click="emit('close')">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
<label class="messages-full-emoji-picker__search">
|
||||
<Search :size="15" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="phone.t('Common.search')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<nav v-if="!query" aria-label="Emoji categories">
|
||||
<button
|
||||
v-for="(group, index) in groups"
|
||||
:key="group"
|
||||
type="button"
|
||||
:class="{ active: activeGroup === index }"
|
||||
@click="activeGroup = index"
|
||||
>
|
||||
{{ group }}
|
||||
</button>
|
||||
<i aria-hidden="true" />
|
||||
<button
|
||||
v-for="(tone, index) in skinTones"
|
||||
:key="tone"
|
||||
type="button"
|
||||
:class="{ active: skinTone === index }"
|
||||
:title="`Skin tone ${index}`"
|
||||
@click="skinTone = index"
|
||||
>
|
||||
{{ tone }}
|
||||
</button>
|
||||
</nav>
|
||||
<div class="messages-full-emoji-picker__grid">
|
||||
<button
|
||||
v-for="entry in emojis"
|
||||
:key="`${entry.emoji}-${entry.annotation}`"
|
||||
type="button"
|
||||
:title="entry.annotation"
|
||||
@click="emit('pick', entry.emoji)"
|
||||
>
|
||||
{{ entry.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { Camera, Pause, Play } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { SmsMessage } from '@/types/messages'
|
||||
|
||||
const props = defineProps<{ message: SmsMessage }>()
|
||||
const playing = ref(false)
|
||||
const video = ref<HTMLVideoElement>()
|
||||
|
||||
const imageStyles: Record<string, string> = {
|
||||
'camera-1': 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)',
|
||||
'camera-2': 'linear-gradient(150deg, #00c9a7, #4d8076 46%, #1f3a5f)',
|
||||
'camera-3': 'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)',
|
||||
'city-lights': 'linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)',
|
||||
'desert-road': 'linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)',
|
||||
'ocean-air': 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)',
|
||||
'sunset-drive': 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)',
|
||||
}
|
||||
const videoStyles: Record<string, string> = {
|
||||
'city-loop': 'linear-gradient(135deg, #302b63, #a6c1ee 48%, #fbc2eb)',
|
||||
'ocean-loop': 'linear-gradient(145deg, #0b132b, #26648e 55%, #67d5b5)',
|
||||
'sunset-loop': 'linear-gradient(145deg, #141e30, #5f2c82 55%, #ff9a62)',
|
||||
}
|
||||
const gifContent: Record<string, { emoji: string; label: string }> = {
|
||||
celebrate: { emoji: '🎉', label: 'Celebrate!' },
|
||||
hearts: { emoji: '💖', label: 'Love it' },
|
||||
party: { emoji: '🥳', label: 'Party time' },
|
||||
thumbs_up: { emoji: '👍', label: 'Perfect' },
|
||||
wow: { emoji: '🤯', label: 'WOW' },
|
||||
}
|
||||
|
||||
const background = computed(() =>
|
||||
props.message.message_type === 'video'
|
||||
? videoStyles[props.message.media_asset_id ?? '']
|
||||
: imageStyles[props.message.media_asset_id ?? ''],
|
||||
)
|
||||
const gif = computed(
|
||||
() => gifContent[props.message.media_asset_id ?? ''] ?? gifContent.wow,
|
||||
)
|
||||
const mediaUrl = computed(() =>
|
||||
props.message.media_asset_id?.startsWith('https://')
|
||||
? props.message.media_asset_id
|
||||
: '',
|
||||
)
|
||||
|
||||
async function toggleVideo(): Promise<void> {
|
||||
if (!video.value) {
|
||||
playing.value = !playing.value
|
||||
return
|
||||
}
|
||||
if (video.value.paused) await video.value.play()
|
||||
else video.value.pause()
|
||||
playing.value = !video.value.paused
|
||||
}
|
||||
|
||||
function durationLabel(milliseconds: number | null): string {
|
||||
const seconds = Math.max(0, Math.floor((milliseconds ?? 0) / 1000))
|
||||
return `0:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="message.message_type === 'image'"
|
||||
class="messages-attachment messages-attachment--image"
|
||||
:style="{ background }"
|
||||
>
|
||||
<img v-if="mediaUrl" :src="mediaUrl" alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
<Camera v-else :size="18" />
|
||||
</div>
|
||||
<button
|
||||
v-else-if="message.message_type === 'video'"
|
||||
type="button"
|
||||
class="messages-attachment messages-attachment--video"
|
||||
:class="{ playing }"
|
||||
:style="{ background }"
|
||||
@click="toggleVideo"
|
||||
>
|
||||
<video
|
||||
v-if="mediaUrl"
|
||||
ref="video"
|
||||
:src="mediaUrl"
|
||||
playsinline
|
||||
preload="metadata"
|
||||
@ended="playing = false"
|
||||
/>
|
||||
<span><Pause v-if="playing" :size="22" fill="currentColor" /><Play v-else :size="22" fill="currentColor" /></span>
|
||||
<small>{{ durationLabel(message.media_duration_ms) }}</small>
|
||||
</button>
|
||||
<div v-else class="messages-attachment messages-attachment--gif">
|
||||
<img v-if="mediaUrl" :src="mediaUrl" alt="GIF" loading="lazy" referrerpolicy="no-referrer" />
|
||||
<template v-else>
|
||||
<span>{{ gif.emoji }}</span>
|
||||
<strong>{{ gif.label }}</strong>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { Pause, Play } from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { SmsMessage } from '@/types/messages'
|
||||
|
||||
const props = defineProps<{ message: SmsMessage }>()
|
||||
const phone = usePhoneStore()
|
||||
const messages = useMessagesStore()
|
||||
const audio = ref<HTMLAudioElement>()
|
||||
const currentTime = ref(0)
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const duration = computed(() => (props.message.media_duration_ms ?? 0) / 1000)
|
||||
const progress = computed(() =>
|
||||
duration.value > 0 ? Math.min(1, currentTime.value / duration.value) : 0,
|
||||
)
|
||||
const displayTime = computed(() =>
|
||||
formatDuration(playing.value || currentTime.value > 0 ? currentTime.value : duration.value),
|
||||
)
|
||||
const source = computed(() => messages.mediaSources[props.message.id] ?? '')
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const rounded = Math.max(0, Math.floor(seconds))
|
||||
return `${Math.floor(rounded / 60)}:${String(rounded % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function togglePlayback(): Promise<void> {
|
||||
if (loading.value) return
|
||||
failed.value = false
|
||||
if (playing.value) {
|
||||
audio.value?.pause()
|
||||
return
|
||||
}
|
||||
if (!source.value) {
|
||||
loading.value = true
|
||||
const loaded = await messages.loadMedia(props.message.id)
|
||||
loading.value = false
|
||||
if (!loaded) {
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
}
|
||||
try {
|
||||
await audio.value?.play()
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
console.error(`[Messages] Could not play audio message ${props.message.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress(): void {
|
||||
currentTime.value = audio.value?.currentTime ?? 0
|
||||
}
|
||||
|
||||
function finishPlayback(): void {
|
||||
playing.value = false
|
||||
currentTime.value = 0
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => audio.value?.pause())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-message" :class="{ 'voice-message--failed': failed }">
|
||||
<button
|
||||
type="button"
|
||||
class="voice-message__play"
|
||||
:disabled="loading"
|
||||
:aria-label="phone.t(playing ? 'Apps.messages.pauseAudio' : 'Apps.messages.playAudio')"
|
||||
@click="togglePlayback"
|
||||
>
|
||||
<span v-if="loading" class="voice-message__loader" />
|
||||
<Pause v-else-if="playing" :size="16" fill="currentColor" />
|
||||
<Play v-else :size="16" fill="currentColor" />
|
||||
</button>
|
||||
<div class="voice-message__content">
|
||||
<div class="voice-message__waveform" aria-hidden="true">
|
||||
<i
|
||||
v-for="(sample, index) in message.media_waveform ?? []"
|
||||
:key="index"
|
||||
:class="{ active: index / Math.max(1, (message.media_waveform?.length ?? 1) - 1) <= progress }"
|
||||
:style="{ height: `${Math.max(4, sample * 22)}px` }"
|
||||
/>
|
||||
</div>
|
||||
<span>{{ displayTime }}</span>
|
||||
</div>
|
||||
<audio
|
||||
ref="audio"
|
||||
:src="source"
|
||||
preload="metadata"
|
||||
@play="playing = true"
|
||||
@pause="playing = false"
|
||||
@timeupdate="updateProgress"
|
||||
@ended="finishPlayback"
|
||||
@error="failed = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user