mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
feat(stage-pocket): improve permission management (#2184)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: LemonNeko <17664845+LemonNekoGH@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
LemonNeko
parent
5addb1574f
commit
584e4960e9
@@ -27,6 +27,7 @@ class MainActivity : BridgeActivity() {
|
||||
private var webSocketBridge: HostWebSocketBridge? = null
|
||||
|
||||
override fun load() {
|
||||
registerPlugin(MicrophonePermissionPlugin::class.java)
|
||||
super.load()
|
||||
|
||||
val bridge = bridge ?: return
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package ai.moeru.airi_pocket
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
|
||||
@CapacitorPlugin(name = "MicrophonePermission")
|
||||
class MicrophonePermissionPlugin : Plugin() {
|
||||
/** Reports native permission state without opening Android's permission dialog. */
|
||||
@PluginMethod
|
||||
fun checkPermission(call: PluginCall) {
|
||||
val permission = Manifest.permission.RECORD_AUDIO
|
||||
val result = JSObject().apply {
|
||||
put("granted", context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
call.resolve(result)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { AndroidSettings, IOSSettings, NativeSettings } from 'capacitor-native-settings'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type PermissionState = boolean | undefined
|
||||
import PermissionsPanel from '../permissions/permissions-panel.vue'
|
||||
|
||||
interface Props {
|
||||
onNext: () => Promise<void> | void
|
||||
@@ -15,31 +11,6 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isNativePlatform = Capacitor.isNativePlatform()
|
||||
|
||||
const notificationPermissionGranted = ref<PermissionState>(undefined)
|
||||
|
||||
async function requestNotificationPermission() {
|
||||
const beforeRequest = await LocalNotifications.checkPermissions()
|
||||
if (beforeRequest.display === 'granted') {
|
||||
notificationPermissionGranted.value = true
|
||||
return
|
||||
}
|
||||
|
||||
const requested = await LocalNotifications.requestPermissions()
|
||||
if (requested.display === 'granted') {
|
||||
notificationPermissionGranted.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (isNativePlatform) {
|
||||
NativeSettings.open({
|
||||
optionAndroid: AndroidSettings.AppNotification,
|
||||
optionIOS: IOSSettings.AppNotification,
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -59,25 +30,9 @@ async function requestNotificationPermission() {
|
||||
{{ t('settings.dialogs.onboarding.permissions.description') }}
|
||||
</p>
|
||||
|
||||
<section class="border border-neutral-200 rounded-xl bg-neutral-50 p-4 dark:border-neutral-700 dark:bg-neutral-800/50">
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm text-neutral-800 font-semibold dark:text-neutral-100">
|
||||
{{ t('settings.dialogs.onboarding.permissions.notificationsTitle') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{{ t('settings.dialogs.onboarding.permissions.notificationsDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="notificationPermissionGranted" class="i-solar:check-circle-linear h-5 w-5 text-green-700 dark:text-green-400" />
|
||||
</div>
|
||||
<Button
|
||||
:label="t('settings.dialogs.onboarding.permissions.notificationsAction')"
|
||||
@click="requestNotificationPermission"
|
||||
/>
|
||||
</section>
|
||||
<PermissionsPanel />
|
||||
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p :class="['text-xs', 'text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.dialogs.onboarding.permissions.optionalHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
description: string
|
||||
actionLabel: string
|
||||
granted: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
request: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const statusLabel = computed(() => props.granted
|
||||
? t('settings.dialogs.onboarding.permissions.stateGranted')
|
||||
: t('settings.dialogs.onboarding.permissions.stateNotGranted'),
|
||||
)
|
||||
|
||||
const statusIcon = computed(() => props.granted
|
||||
? 'i-solar:check-circle-linear text-green-700 dark:text-green-400'
|
||||
: 'i-solar:close-circle-linear text-red-600 dark:text-red-400',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="[
|
||||
'rounded-xl p-4',
|
||||
'border border-neutral-200 bg-neutral-50',
|
||||
'dark:border-neutral-700 dark:bg-neutral-800/50',
|
||||
]"
|
||||
>
|
||||
<div :class="['flex items-start justify-between gap-3', { 'mb-3': !props.granted }]">
|
||||
<div>
|
||||
<h3 :class="['text-sm font-semibold', 'text-neutral-800 dark:text-neutral-100']">
|
||||
{{ props.title }}
|
||||
</h3>
|
||||
<p :class="['mt-1 text-xs', 'text-neutral-600 dark:text-neutral-300']">
|
||||
{{ props.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
:class="['flex shrink-0 items-center gap-1.5 text-xs', 'text-neutral-500 dark:text-neutral-400']"
|
||||
role="status"
|
||||
>
|
||||
<span :class="[statusIcon, 'h-5 w-5']" />
|
||||
<span>{{ statusLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!props.granted"
|
||||
:label="props.actionLabel"
|
||||
:disabled="props.disabled"
|
||||
@click="emit('request')"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import type { PluginListenerHandle } from '@capacitor/core'
|
||||
|
||||
import { App } from '@capacitor/app'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { AndroidSettings, IOSSettings, NativeSettings } from 'capacitor-native-settings'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, onUnmounted, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PermissionCard from './permission-card.vue'
|
||||
|
||||
import { MicrophonePermission } from '../../modules/microphone-permission'
|
||||
|
||||
const { t } = useI18n()
|
||||
const audioDeviceStore = useSettingsAudioDevice()
|
||||
const { permissionGranted: webMicrophonePermissionGranted } = storeToRefs(audioDeviceStore)
|
||||
|
||||
const isNativePlatform = Capacitor.isNativePlatform()
|
||||
const isAndroid = Capacitor.getPlatform() === 'android'
|
||||
|
||||
const notificationPermissionGranted = shallowRef(false)
|
||||
const platformMicrophonePermissionGranted = shallowRef(false)
|
||||
const requestingNotificationPermission = shallowRef(false)
|
||||
const requestingMicrophonePermission = shallowRef(false)
|
||||
const microphonePermissionRequested = useLocalStorage('permissions/microphone/requested', false)
|
||||
|
||||
let appStateListener: Promise<PluginListenerHandle> | undefined
|
||||
|
||||
async function refreshNotificationPermission() {
|
||||
const permission = await LocalNotifications.checkPermissions()
|
||||
notificationPermissionGranted.value = permission.display === 'granted'
|
||||
}
|
||||
|
||||
async function refreshMicrophonePermission() {
|
||||
if (isAndroid) {
|
||||
const permission = await MicrophonePermission.checkPermission()
|
||||
platformMicrophonePermissionGranted.value = permission.granted
|
||||
return
|
||||
}
|
||||
|
||||
const permission = await navigator.permissions?.query({ name: 'microphone' }).catch(() => undefined)
|
||||
platformMicrophonePermissionGranted.value = permission
|
||||
? permission.state === 'granted'
|
||||
: webMicrophonePermissionGranted.value
|
||||
}
|
||||
|
||||
async function refreshPermissionStates() {
|
||||
await Promise.all([
|
||||
refreshNotificationPermission().catch(error => console.error('Unable to refresh notification permission:', error)),
|
||||
refreshMicrophonePermission().catch(error => console.error('Unable to refresh microphone permission:', error)),
|
||||
])
|
||||
}
|
||||
|
||||
async function requestNotificationPermission() {
|
||||
requestingNotificationPermission.value = true
|
||||
try {
|
||||
const beforeRequest = await LocalNotifications.checkPermissions()
|
||||
if (beforeRequest.display === 'granted') {
|
||||
notificationPermissionGranted.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (beforeRequest.display === 'denied') {
|
||||
notificationPermissionGranted.value = false
|
||||
if (isNativePlatform) {
|
||||
await NativeSettings.open({
|
||||
optionAndroid: AndroidSettings.AppNotification,
|
||||
optionIOS: IOSSettings.AppNotification,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const requested = await LocalNotifications.requestPermissions()
|
||||
notificationPermissionGranted.value = requested.display === 'granted'
|
||||
}
|
||||
finally {
|
||||
requestingNotificationPermission.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function requestMicrophonePermission() {
|
||||
requestingMicrophonePermission.value = true
|
||||
try {
|
||||
await refreshMicrophonePermission()
|
||||
if (platformMicrophonePermissionGranted.value)
|
||||
return
|
||||
|
||||
if (microphonePermissionRequested.value && isNativePlatform) {
|
||||
await NativeSettings.open({
|
||||
optionAndroid: AndroidSettings.ApplicationDetails,
|
||||
optionIOS: IOSSettings.App,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Persist before requesting so later native clicks take the settings route, including after denial.
|
||||
microphonePermissionRequested.value = true
|
||||
await audioDeviceStore.askPermission()
|
||||
await refreshMicrophonePermission()
|
||||
}
|
||||
finally {
|
||||
requestingMicrophonePermission.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshPermissionStates()
|
||||
|
||||
if (isNativePlatform) {
|
||||
appStateListener = App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive)
|
||||
void refreshPermissionStates()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
void appStateListener?.then(listener => listener.remove())
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['flex flex-col gap-4']">
|
||||
<PermissionCard
|
||||
:title="t('settings.dialogs.onboarding.permissions.notificationsTitle')"
|
||||
:description="t('settings.dialogs.onboarding.permissions.notificationsDescription')"
|
||||
:action-label="t('settings.dialogs.onboarding.permissions.requestAction')"
|
||||
:granted="notificationPermissionGranted"
|
||||
:disabled="requestingNotificationPermission"
|
||||
@request="requestNotificationPermission"
|
||||
/>
|
||||
|
||||
<PermissionCard
|
||||
:title="t('settings.dialogs.onboarding.permissions.microphoneTitle')"
|
||||
:description="t('settings.dialogs.onboarding.permissions.microphoneDescription')"
|
||||
:action-label="t('settings.dialogs.onboarding.permissions.requestAction')"
|
||||
:granted="platformMicrophonePermissionGranted"
|
||||
:disabled="requestingMicrophonePermission"
|
||||
@request="requestMicrophonePermission"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { registerPlugin } from '@capacitor/core'
|
||||
|
||||
interface MicrophonePermissionState {
|
||||
granted: boolean
|
||||
}
|
||||
|
||||
interface MicrophonePermissionPlugin {
|
||||
checkPermission: () => Promise<MicrophonePermissionState>
|
||||
}
|
||||
|
||||
/** Reads Android's native microphone permission state without triggering a permission request. */
|
||||
export const MicrophonePermission = registerPlugin<MicrophonePermissionPlugin>('MicrophonePermission')
|
||||
@@ -20,6 +20,12 @@ const settings = computed(() => [
|
||||
icon: 'i-solar:pallete-2-bold-duotone',
|
||||
to: '/settings/system/color-scheme',
|
||||
},
|
||||
{
|
||||
title: t('settings.pages.system.permissions.title'),
|
||||
description: t('settings.pages.system.permissions.description'),
|
||||
icon: 'i-solar:shield-check-bold-duotone',
|
||||
to: '/settings/system/permissions',
|
||||
},
|
||||
{
|
||||
title: t('settings.pages.system.developer.title'),
|
||||
description: t('settings.pages.system.developer.description'),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import PermissionsPanel from '../../../components/permissions/permissions-panel.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['flex flex-col gap-4 pb-12']">
|
||||
<p :class="['text-sm text-neutral-600', 'md:text-base dark:text-neutral-300']">
|
||||
{{ $t('settings.pages.system.permissions.description') }}
|
||||
</p>
|
||||
|
||||
<PermissionsPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
titleKey: settings.pages.system.permissions.title
|
||||
subtitleKey: settings.title
|
||||
stageTransition:
|
||||
name: slide
|
||||
</route>
|
||||
@@ -58,16 +58,14 @@ dialogs:
|
||||
Please return to the previous step and check your API key, or check the
|
||||
network connection.
|
||||
permissions:
|
||||
title: Notifications
|
||||
description: You can allow notifications now or continue and set it up later.
|
||||
title: Permission management
|
||||
description: You can grant the required permissions now or continue and configure them later.
|
||||
notificationsTitle: Notifications
|
||||
notificationsDescription: Allow reminders and important status updates.
|
||||
notificationsAction: Request notification permission
|
||||
notificationsNotGrantedHint: If denied, you can enable notifications later from iOS Settings.
|
||||
openSettings: Open iOS Settings
|
||||
optionalHint: You can continue even if notification permission is not granted.
|
||||
stateUnknown: Not requested
|
||||
stateRequesting: Requesting...
|
||||
microphoneTitle: Microphone
|
||||
microphoneDescription: Allow AIRI to use your microphone for voice input and transcription.
|
||||
requestAction: Request access
|
||||
optionalHint: You can continue even if these permissions are not granted.
|
||||
stateGranted: Granted
|
||||
stateNotGranted: Not granted
|
||||
bug-report:
|
||||
@@ -1512,6 +1510,9 @@ pages:
|
||||
general:
|
||||
description: Dark theme, languages, etc.
|
||||
title: General
|
||||
permissions:
|
||||
description: View current permission status.
|
||||
title: Permission Management
|
||||
description: Customize your stage!
|
||||
sections:
|
||||
section:
|
||||
|
||||
@@ -51,16 +51,14 @@ dialogs:
|
||||
no-models-help: >-
|
||||
Por favor, vuelva al paso anterior y compruebe su clave API, o compruebe la conexión de red.
|
||||
permissions:
|
||||
title: Notificaciones
|
||||
description: Puedes permitir notificaciones ahora o continuar y configurarlas más tarde.
|
||||
title: Gestión de permisos
|
||||
description: Puedes conceder los permisos necesarios ahora o continuar y configurarlos más tarde.
|
||||
notificationsTitle: Notificaciones
|
||||
notificationsDescription: Permitir recordatorios y actualizaciones importantes de estado.
|
||||
notificationsAction: 'Solicitar Permiso de Notificación'
|
||||
notificationsNotGrantedHint: Si se deniega, puede habilitar las notificaciones más tarde desde la configuración de iOS.
|
||||
openSettings: Abrir ajustes de iOS
|
||||
optionalHint: Puedes continuar incluso si no se concede permiso de notificación.
|
||||
stateUnknown: No solicitado
|
||||
stateRequesting: Solicitando...
|
||||
microphoneTitle: Micrófono
|
||||
microphoneDescription: Permite que AIRI use el micrófono para la entrada de voz y la transcripción.
|
||||
requestAction: Solicitar acceso
|
||||
optionalHint: Puedes continuar aunque no se concedan estos permisos.
|
||||
stateGranted: Concedido
|
||||
stateNotGranted: No concedido
|
||||
bug-report:
|
||||
@@ -1452,6 +1450,9 @@ pages:
|
||||
general:
|
||||
description: Tema oscuro, idiomas, etc.
|
||||
title: General
|
||||
permissions:
|
||||
description: Consulta el estado actual de los permisos.
|
||||
title: Gestión de permisos
|
||||
description: '¡Personaliza tu escenario!'
|
||||
sections:
|
||||
section:
|
||||
|
||||
@@ -51,16 +51,14 @@ dialogs:
|
||||
no-models-help: >-
|
||||
Veuillez revenir à l’étape précédente et vérifier votre clé API, ou bien vérifier la connexion réseau.
|
||||
permissions:
|
||||
title: Notifications
|
||||
description: Vous pouvez autoriser les notifications maintenant ou continuer et les configurer plus tard.
|
||||
title: Gestion des autorisations
|
||||
description: Vous pouvez accorder les autorisations nécessaires maintenant ou les configurer plus tard.
|
||||
notificationsTitle: Notifications
|
||||
notificationsDescription: Autorisez les rappels et les mises à jour de statut importantes.
|
||||
notificationsAction: Demander l'autorisation de notification
|
||||
notificationsNotGrantedHint: En cas de refus, vous pourrez activer les notifications plus tard dans les Réglages iOS.
|
||||
openSettings: Ouvrir les Réglages iOS
|
||||
optionalHint: Vous pouvez continuer même si l'autorisation de notification n'est pas accordée.
|
||||
stateUnknown: Non demandé
|
||||
stateRequesting: Demande en cours...
|
||||
microphoneTitle: Microphone
|
||||
microphoneDescription: Autorisez AIRI à utiliser le microphone pour la saisie vocale et la transcription.
|
||||
requestAction: Demander l’accès
|
||||
optionalHint: Vous pouvez continuer même si ces autorisations ne sont pas accordées.
|
||||
stateGranted: Accordée
|
||||
stateNotGranted: Non accordée
|
||||
bug-report:
|
||||
@@ -1452,6 +1450,9 @@ pages:
|
||||
general:
|
||||
description: Thème sombre, langues, etc.
|
||||
title: Général
|
||||
permissions:
|
||||
description: Consultez l’état actuel des autorisations.
|
||||
title: Gestion des autorisations
|
||||
description: Personnalisez votre scène !
|
||||
sections:
|
||||
section:
|
||||
|
||||
@@ -51,16 +51,14 @@ dialogs:
|
||||
no-models-help: >-
|
||||
前のステップに戻ってAPIキーを確認するか、ネットワーク接続を確認してください。
|
||||
permissions:
|
||||
title: 通知
|
||||
description: 今すぐ通知を許可するか、続行して後で設定できます。
|
||||
title: 権限管理
|
||||
description: 今すぐ必要な権限を許可するか、続行して後で設定できます。
|
||||
notificationsTitle: 通知
|
||||
notificationsDescription: リマインダーと重要なステータス更新を許可する。
|
||||
notificationsAction: 通知権限を要求
|
||||
notificationsNotGrantedHint: 通知を拒否した場合は、iOS の設定から後で通知を有効にできます。
|
||||
openSettings: iOSの設定を開く
|
||||
optionalHint: 通知権限を許可しない場合でも続行できます。
|
||||
stateUnknown: リクエストされていません
|
||||
stateRequesting: リクエスト中…
|
||||
microphoneTitle: マイク
|
||||
microphoneDescription: AIRI が音声入力と文字起こしにマイクを使用することを許可します。
|
||||
requestAction: アクセスをリクエスト
|
||||
optionalHint: これらの権限を許可しない場合でも続行できます。
|
||||
stateGranted: 許可済み
|
||||
stateNotGranted: 許可されていません
|
||||
bug-report:
|
||||
@@ -1452,6 +1450,9 @@ pages:
|
||||
general:
|
||||
description: ダークテーマ、言語など。
|
||||
title: 一般
|
||||
permissions:
|
||||
description: 現在の権限状態を確認します。
|
||||
title: 権限管理
|
||||
description: ステージをカスタマイズしよう!
|
||||
sections:
|
||||
section:
|
||||
|
||||
@@ -51,16 +51,14 @@ dialogs:
|
||||
no-models-help: >-
|
||||
이전 단계로 돌아가서 API 키를 확인하거나 네트워크 연결을 확인해 주세요.
|
||||
permissions:
|
||||
title: 알림
|
||||
description: 지금 알림을 받도록 허용할 수 있고 나중에 허용할 수 있어요.
|
||||
title: 권한 관리
|
||||
description: 지금 필요한 권한을 허용하거나 계속 진행한 뒤 나중에 설정할 수 있어요.
|
||||
notificationsTitle: 알림
|
||||
notificationsDescription: Allow reminders and important status updates.
|
||||
notificationsAction: Request notification permission
|
||||
notificationsNotGrantedHint: If denied, you can enable notifications later from iOS Settings.
|
||||
openSettings: iOS 설정 열기
|
||||
optionalHint: You can continue even if notification permission is not granted.
|
||||
stateUnknown: 요청 전
|
||||
stateRequesting: 요청 중...
|
||||
notificationsDescription: 미리 알림과 중요한 상태 업데이트를 허용합니다.
|
||||
microphoneTitle: 마이크
|
||||
microphoneDescription: AIRI가 음성 입력 및 텍스트 변환에 마이크를 사용하도록 허용합니다.
|
||||
requestAction: 액세스 요청
|
||||
optionalHint: 이 권한을 허용하지 않아도 계속할 수 있어요.
|
||||
stateGranted: 허용됨
|
||||
stateNotGranted: 허용되지 않음
|
||||
bug-report:
|
||||
@@ -1452,6 +1450,9 @@ pages:
|
||||
general:
|
||||
description: 어두운 테마, 언어 등.
|
||||
title: 일반
|
||||
permissions:
|
||||
description: 현재 권한 상태를 확인합니다.
|
||||
title: 권한 관리
|
||||
description: 무대를 커스터마이징하세요!
|
||||
sections:
|
||||
section:
|
||||
|
||||
@@ -51,16 +51,14 @@ dialogs:
|
||||
no-models-help: >-
|
||||
Вернитесь на предыдущий шаг и проверьте API-ключ или подключение к сети.
|
||||
permissions:
|
||||
title: Оповещения
|
||||
description: Вы можете разрешить уведомления сейчас или продолжить и настроить их позже.
|
||||
title: Управление разрешениями
|
||||
description: Вы можете предоставить необходимые разрешения сейчас или настроить их позже.
|
||||
notificationsTitle: Оповещения
|
||||
notificationsDescription: Разрешить напоминания и важные обновления статуса.
|
||||
notificationsAction: Запросить разрешение на уведомление
|
||||
notificationsNotGrantedHint: Если отказано, вы можете включить уведомления позже в настройках iOS.
|
||||
openSettings: Открыть настройки iOS
|
||||
optionalHint: Вы можете продолжить, даже если разрешение на уведомление не предоставлено.
|
||||
stateUnknown: Не запрошены
|
||||
stateRequesting: Запрашивается...
|
||||
microphoneTitle: Микрофон
|
||||
microphoneDescription: Разрешить AIRI использовать микрофон для голосового ввода и расшифровки речи.
|
||||
requestAction: Запросить доступ
|
||||
optionalHint: Вы можете продолжить, даже если эти разрешения не предоставлены.
|
||||
stateGranted: Предоставлено
|
||||
stateNotGranted: Не предоставлено
|
||||
bug-report:
|
||||
@@ -1452,6 +1450,9 @@ pages:
|
||||
general:
|
||||
description: Тема, языки и др.
|
||||
title: Общие
|
||||
permissions:
|
||||
description: Просмотр текущего состояния разрешений.
|
||||
title: Управление разрешениями
|
||||
description: Настройте вашу сцену!
|
||||
sections:
|
||||
section:
|
||||
|
||||
@@ -51,18 +51,16 @@ dialogs:
|
||||
no-models-help: >-
|
||||
Vui lòng quay lại bước trước và kiểm tra khóa API, hoặc kiểm tra kết nối mạng.
|
||||
permissions:
|
||||
title: Thông báo
|
||||
description: Bạn có thể cho phép thông báo ngay bây giờ hoặc tiếp tục và chỉnh lại sau.
|
||||
title: Quản lý quyền
|
||||
description: Bạn có thể cấp các quyền cần thiết ngay bây giờ hoặc tiếp tục và thiết lập sau.
|
||||
notificationsTitle: Thông báo
|
||||
notificationsDescription: Cho phép nhắc nhở và các cập nhật trạng thái quan trọng.
|
||||
notificationsAction: Yêu cầu quyền thông báo
|
||||
notificationsNotGrantedHint: Nếu từ chối, bạn có thể bật thông báo ở Cài đặt iOS.
|
||||
openSettings: Mở Cài đặt iOS
|
||||
optionalHint: Bạn có thể tiếp tục kể cả khi quyền thông báo không được cấp quyền.
|
||||
stateUnknown: Không được yêu cầu
|
||||
stateRequesting: Đang yêu cầu...
|
||||
microphoneTitle: Micrô
|
||||
microphoneDescription: Cho phép AIRI sử dụng micrô để nhập giọng nói và phiên âm.
|
||||
requestAction: Yêu cầu quyền truy cập
|
||||
optionalHint: Bạn vẫn có thể tiếp tục ngay cả khi chưa cấp các quyền này.
|
||||
stateGranted: Đã cấp quyền
|
||||
stateNotGranted: Khônh được cấp quyền
|
||||
stateNotGranted: Không được cấp quyền
|
||||
bug-report:
|
||||
title: Báo lỗi (´;ω;`)ヾ(・∀・`)
|
||||
subtitle: Úi, xin lỗi nếu chúng tôi đã làm gì sai. Nếu không phiền thì bạn có thể kể cho chúng tôi biết chuyện gì đã xảy ra không?
|
||||
@@ -1452,6 +1450,9 @@ pages:
|
||||
general:
|
||||
description: Chế độ tối, ngôn ngữ, v.v.
|
||||
title: Chung
|
||||
permissions:
|
||||
description: Xem trạng thái quyền hiện tại.
|
||||
title: Quản lý quyền
|
||||
description: Tùy chỉnh thiết lập bối cảnh của bạn!
|
||||
sections:
|
||||
section:
|
||||
|
||||
@@ -51,16 +51,14 @@ dialogs:
|
||||
no-models-help: >-
|
||||
请返回上一步并检查您的 API Key,或检查网络连接。
|
||||
permissions:
|
||||
title: 通知
|
||||
description: 您现在可以允许通知或者继续并稍后设置。
|
||||
title: 权限管理
|
||||
description: 您现在可以授予所需权限,或继续并稍后在系统设置中配置。
|
||||
notificationsTitle: 通知
|
||||
notificationsDescription: 允许提醒以及重要的状态更新。
|
||||
notificationsAction: 需要获取消息通知权限
|
||||
notificationsNotGrantedHint: 如果拒绝,您可以稍后从 iOS 设置中启用通知。
|
||||
openSettings: 打开系统/偏好设置
|
||||
optionalHint: 即使没有授予通知权限,您也可以继续。
|
||||
stateUnknown: 未请求
|
||||
stateRequesting: 正在获取...
|
||||
microphoneTitle: 麦克风
|
||||
microphoneDescription: 允许 AIRI 使用麦克风进行语音输入和转写。
|
||||
requestAction: 请求获取
|
||||
optionalHint: 即使没有授予这些权限,您也可以继续。
|
||||
stateGranted: 已授权
|
||||
stateNotGranted: 未授权
|
||||
bug-report:
|
||||
@@ -1452,6 +1450,9 @@ pages:
|
||||
general:
|
||||
description: 深色主题、语言等选项
|
||||
title: 通用
|
||||
permissions:
|
||||
description: 查看当前权限状态
|
||||
title: 权限管理
|
||||
description: 自定义你的舞台外观!
|
||||
sections:
|
||||
section:
|
||||
|
||||
@@ -51,18 +51,16 @@ dialogs:
|
||||
no-models-help: >-
|
||||
請返回上一步並檢查您的 API Key,或檢查網路連線。
|
||||
permissions:
|
||||
title: Notifications
|
||||
description: You can allow notifications now or continue and set it up later.
|
||||
notificationsTitle: Notifications
|
||||
notificationsDescription: Allow reminders and important status updates.
|
||||
notificationsAction: Request notification permission
|
||||
notificationsNotGrantedHint: If denied, you can enable notifications later from iOS Settings.
|
||||
openSettings: Open iOS Settings
|
||||
optionalHint: You can continue even if notification permission is not granted.
|
||||
stateUnknown: Not requested
|
||||
stateRequesting: Requesting...
|
||||
stateGranted: Granted
|
||||
stateNotGranted: Not granted
|
||||
title: 權限管理
|
||||
description: 您現在可以授予所需權限,或繼續並稍後在系統設定中配置。
|
||||
notificationsTitle: 通知
|
||||
notificationsDescription: 允許提醒以及重要的狀態更新。
|
||||
microphoneTitle: 麥克風
|
||||
microphoneDescription: 允許 AIRI 使用麥克風進行語音輸入和轉錄。
|
||||
requestAction: 請求取得
|
||||
optionalHint: 即使沒有授予這些權限,您也可以繼續。
|
||||
stateGranted: 已授權
|
||||
stateNotGranted: 未授權
|
||||
bug-report:
|
||||
title: Bug report (´;ω;`)ヾ(・∀・`)
|
||||
subtitle: Oops, sorry we made something wrong. Would you mind telling us what happened?
|
||||
@@ -1452,6 +1450,9 @@ pages:
|
||||
general:
|
||||
description: 深色主題、語言等選項
|
||||
title: 通用
|
||||
permissions:
|
||||
description: 查看目前權限狀態
|
||||
title: 權限管理
|
||||
description: 自訂你的舞台外觀!
|
||||
sections:
|
||||
section:
|
||||
|
||||
@@ -38,8 +38,9 @@ const providerStore = useProviderConfigStore()
|
||||
const { configuredTranscriptionProvidersMetadata } = storeToRefs(providersStore)
|
||||
|
||||
const { trackProviderClick } = useAnalytics()
|
||||
const { stopStream, startStream } = useSettingsAudioDevice()
|
||||
const { audioInputs, selectedAudioInput, stream } = storeToRefs(useSettingsAudioDevice())
|
||||
const settingsAudioDeviceStore = useSettingsAudioDevice()
|
||||
const { askPermission, stopStream, startStream } = settingsAudioDeviceStore
|
||||
const { audioInputOptions, selectedAudioInput, stream } = storeToRefs(settingsAudioDeviceStore)
|
||||
const { startRecord, stopRecord, onStopRecord } = useAudioRecorder(stream)
|
||||
const { startAnalyzer, stopAnalyzer, onAnalyzerUpdate, volumeLevel } = useAudioAnalyzer()
|
||||
const { audioContext } = storeToRefs(useAudioContext())
|
||||
@@ -525,8 +526,8 @@ watch(activeTranscriptionProvider, async (provider) => {
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
// Audio devices are loaded on demand when user requests them
|
||||
syncOpenAICompatibleSettings()
|
||||
await askPermission()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -556,10 +557,7 @@ onUnmounted(() => {
|
||||
v-model="selectedAudioInput"
|
||||
label="Audio Input Device"
|
||||
description="Select the audio input device for your hearing module."
|
||||
:options="audioInputs.map(input => ({
|
||||
label: input.label || input.deviceId,
|
||||
value: input.deviceId,
|
||||
}))"
|
||||
:options="audioInputOptions"
|
||||
placeholder="Select an audio input device"
|
||||
layout="vertical"
|
||||
/>
|
||||
|
||||
+6
-9
@@ -113,15 +113,15 @@ const isWebSpeechAPIAvailable = computed(() => {
|
||||
&& ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)
|
||||
})
|
||||
|
||||
const settingsAudioDeviceStore = useSettingsAudioDevice()
|
||||
const { askPermission, stopStream, startStream } = settingsAudioDeviceStore
|
||||
const { audioInputOptions, selectedAudioInput, stream } = storeToRefs(settingsAudioDeviceStore)
|
||||
|
||||
onMounted(async () => {
|
||||
ensureProviderSettings()
|
||||
// Audio devices are loaded on demand when user requests them
|
||||
await askPermission()
|
||||
})
|
||||
|
||||
// Speech-to-Text test state (always uses Web Speech API)
|
||||
const { stopStream, startStream } = useSettingsAudioDevice()
|
||||
const { audioInputs, selectedAudioInput, stream } = storeToRefs(useSettingsAudioDevice())
|
||||
|
||||
const isTestingSTT = ref(false)
|
||||
const testTranscriptionText = ref<string>('')
|
||||
const testTranscriptionError = ref<string>('')
|
||||
@@ -405,10 +405,7 @@ onUnmounted(() => {
|
||||
v-model="selectedAudioInput"
|
||||
label="Audio Input Device"
|
||||
description="Select the audio input device for testing"
|
||||
:options="audioInputs.map(input => ({
|
||||
label: input.label || input.deviceId,
|
||||
value: input.deviceId,
|
||||
}))"
|
||||
:options="audioInputOptions"
|
||||
placeholder="Select an audio input device"
|
||||
layout="vertical"
|
||||
class="flex-1"
|
||||
|
||||
+3
-2
@@ -1,5 +1,3 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createPinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
@@ -32,6 +30,9 @@ vi.mock('../../../../composables/audio', async () => {
|
||||
return {
|
||||
useAudioDevice: () => ({
|
||||
audioInputs: ref([createAudioInput('store-microphone', 'Store microphone')]),
|
||||
audioInputOptions: computed(() => [
|
||||
{ label: 'Store microphone', value: 'store-microphone' },
|
||||
]),
|
||||
selectedAudioInput: ref('store-microphone'),
|
||||
stream: shallowRef<MediaStream>(),
|
||||
deviceConstraints: computed(() => ({ audio: true })),
|
||||
|
||||
@@ -14,7 +14,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const deviceStore = useSettingsAudioDevice()
|
||||
const { askPermission } = deviceStore
|
||||
const { audioInputs, enabled, permissionGranted, selectedAudioInput } = storeToRefs(deviceStore)
|
||||
const { audioInputOptions, enabled, permissionGranted, selectedAudioInput } = storeToRefs(deviceStore)
|
||||
const { volumeLevel } = useAudioAnalyzer()
|
||||
|
||||
const autoSend = defineModel<boolean | undefined>('autoSend')
|
||||
@@ -119,7 +119,7 @@ function toggleHearingEnabled() {
|
||||
v-model="selectedAudioInput"
|
||||
label="Input device"
|
||||
description="Select the microphone you want to use."
|
||||
:options="audioInputs.map(device => ({ label: device.label || 'Unknown Device', value: device.deviceId }))"
|
||||
:options="audioInputOptions"
|
||||
placeholder="Select microphone"
|
||||
layout="vertical"
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { audioInputs, selectedAudioInput, stream, stopStream, startStream } = useAudioDevice()
|
||||
const { audioInputs, audioInputOptions, selectedAudioInput, stream, stopStream, startStream } = useAudioDevice()
|
||||
const { volumeLevel, stopAnalyzer, startAnalyzer } = useAudioAnalyzer()
|
||||
const { startRecord, stopRecord, onStopRecord } = useAudioRecorder(stream)
|
||||
|
||||
@@ -178,10 +178,7 @@ onUnmounted(() => {
|
||||
v-model="selectedAudioInput"
|
||||
label="Audio Input Device"
|
||||
description="Select the audio input device for your hearing module."
|
||||
:options="audioInputs.map(input => ({
|
||||
label: input.label || input.deviceId,
|
||||
value: input.deviceId,
|
||||
}))"
|
||||
:options="audioInputOptions"
|
||||
placeholder="Select an audio input device"
|
||||
layout="vertical"
|
||||
h-fit w-full
|
||||
|
||||
@@ -45,7 +45,21 @@ export function useAudioDevice(requestPermission: boolean = false) {
|
||||
trackMicrophonePermissionDenied,
|
||||
trackMicrophonePermissionRequested,
|
||||
} = useAnalytics()
|
||||
const { audioInputs, permissionGranted, ensurePermissions } = useDevicesList({ constraints: { audio: true }, requestPermissions: requestPermission })
|
||||
const {
|
||||
devices,
|
||||
audioInputs,
|
||||
permissionGranted,
|
||||
ensurePermissions,
|
||||
} = useDevicesList({
|
||||
constraints: { audio: true },
|
||||
requestPermissions: requestPermission,
|
||||
})
|
||||
const audioInputOptions = computed(() => audioInputs.value
|
||||
.filter(device => device.deviceId)
|
||||
.map(device => ({
|
||||
label: device.label || device.deviceId,
|
||||
value: device.deviceId,
|
||||
})))
|
||||
const selectedAudioInput = ref<string>(audioInputs.value.find(device => device.deviceId === 'default')?.deviceId || '')
|
||||
/**
|
||||
* Keeps the selected microphone aligned with the currently available device list.
|
||||
@@ -79,37 +93,47 @@ export function useAudioDevice(requestPermission: boolean = false) {
|
||||
selectAvailableAudioInput()
|
||||
})
|
||||
|
||||
function askPermission() {
|
||||
trackMicrophonePermissionRequested({ stt_provider_id: UNKNOWN_STT_PROVIDER_ID })
|
||||
async function askPermission() {
|
||||
if (!permissionGranted.value)
|
||||
trackMicrophonePermissionRequested({ stt_provider_id: UNKNOWN_STT_PROVIDER_ID })
|
||||
|
||||
return ensurePermissions()
|
||||
.then(() => nextTick())
|
||||
.then(() => {
|
||||
selectAvailableAudioInput()
|
||||
if (audioInputs.value.length <= 0) {
|
||||
trackAudioDeviceUnavailable({
|
||||
stt_provider_id: UNKNOWN_STT_PROVIDER_ID,
|
||||
error_code: 'device_unavailable',
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorCode = audioDeviceErrorCode(error)
|
||||
if (errorCode === 'permission_denied') {
|
||||
trackMicrophonePermissionDenied({
|
||||
stt_provider_id: UNKNOWN_STT_PROVIDER_ID,
|
||||
error_code: errorCode,
|
||||
})
|
||||
}
|
||||
else {
|
||||
trackAudioDeviceUnavailable({
|
||||
stt_provider_id: UNKNOWN_STT_PROVIDER_ID,
|
||||
error_code: errorCode,
|
||||
})
|
||||
}
|
||||
console.error('Error ensuring permissions:', error)
|
||||
throw error // Re-throw so callers can handle the error
|
||||
})
|
||||
try {
|
||||
const granted = await ensurePermissions()
|
||||
|
||||
if (granted) {
|
||||
// NOTICE:
|
||||
// VueUse starts its post-permission device refresh without awaiting it, so callers can
|
||||
// otherwise observe the anonymous pre-permission list after askPermission() resolves.
|
||||
// Source: `@vueuse/core` 14.2.1 `useDevicesList.ensurePermissions()`.
|
||||
// Remove this refresh when VueUse exposes or awaits its internal device-list update.
|
||||
devices.value = await navigator.mediaDevices.enumerateDevices()
|
||||
}
|
||||
|
||||
selectAvailableAudioInput()
|
||||
if (audioInputs.value.length <= 0) {
|
||||
trackAudioDeviceUnavailable({
|
||||
stt_provider_id: UNKNOWN_STT_PROVIDER_ID,
|
||||
error_code: 'device_unavailable',
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const errorCode = audioDeviceErrorCode(error)
|
||||
if (errorCode === 'permission_denied') {
|
||||
trackMicrophonePermissionDenied({
|
||||
stt_provider_id: UNKNOWN_STT_PROVIDER_ID,
|
||||
error_code: errorCode,
|
||||
})
|
||||
}
|
||||
else {
|
||||
trackAudioDeviceUnavailable({
|
||||
stt_provider_id: UNKNOWN_STT_PROVIDER_ID,
|
||||
error_code: errorCode,
|
||||
})
|
||||
}
|
||||
console.error('Error ensuring permissions:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function startStream() {
|
||||
@@ -138,6 +162,7 @@ export function useAudioDevice(requestPermission: boolean = false) {
|
||||
|
||||
return {
|
||||
audioInputs,
|
||||
audioInputOptions,
|
||||
selectedAudioInput,
|
||||
stream,
|
||||
deviceConstraints,
|
||||
|
||||
@@ -9,6 +9,7 @@ let microphonePermissionStatus: PermissionStatus
|
||||
export const useSettingsAudioDevice = defineStore('settings-audio-devices', () => {
|
||||
const {
|
||||
audioInputs,
|
||||
audioInputOptions,
|
||||
deviceConstraints,
|
||||
permissionGranted,
|
||||
selectedAudioInput: selectedAudioInputNonPersist,
|
||||
@@ -146,6 +147,7 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
|
||||
|
||||
return {
|
||||
audioInputs,
|
||||
audioInputOptions,
|
||||
deviceConstraints,
|
||||
permissionGranted,
|
||||
selectedAudioInput: selectedAudioInputPersist,
|
||||
|
||||
Generated
+234
-227
File diff suppressed because it is too large
Load Diff
+5
-6
@@ -13,7 +13,6 @@ packages:
|
||||
- '!**/dist/**'
|
||||
|
||||
overrides:
|
||||
'@better-auth/oauth-provider>@better-auth/core': 1.6.5
|
||||
array-flatten: npm:@nolyfill/array-flatten@^1.0.44
|
||||
axios: npm:feaxios@^0.0.23
|
||||
is-core-module: npm:@nolyfill/is-core-module@^1.0.39
|
||||
@@ -37,10 +36,10 @@ catalog:
|
||||
'@anthropic-ai/claude-code': ^2.1.204
|
||||
'@arethetypeswrong/core': ^0.18.2
|
||||
'@ax-llm/ax': ^19.0.43
|
||||
'@better-auth/cli': ^1.4.21
|
||||
'@better-auth/drizzle-adapter': ^1.6.5
|
||||
'@better-auth/oauth-provider': 1.5.6
|
||||
'@better-fetch/fetch': ^1.1.21
|
||||
'@better-auth/cli': ^1.4.22
|
||||
'@better-auth/drizzle-adapter': ^1.6.25
|
||||
'@better-auth/oauth-provider': 1.6.25
|
||||
'@better-fetch/fetch': ^1.3.1
|
||||
'@capacitor/android': ^8.3.1
|
||||
'@capacitor/app': ^8.1.0
|
||||
'@capacitor/barcode-scanner': ^3.0.2
|
||||
@@ -231,7 +230,7 @@ catalog:
|
||||
async-mutex: 0.5.0
|
||||
awilix: ^13.0.3
|
||||
best-effort-json-parser: ^1.4.0
|
||||
better-auth: ^1.6.5
|
||||
better-auth: ^1.6.25
|
||||
builder-util-runtime: ^9.5.1
|
||||
bumpp: ^11.0.1
|
||||
cac: ^7.0.0
|
||||
|
||||
Reference in New Issue
Block a user