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:
Doji
2026-08-08 20:16:10 +08:00
committed by GitHub
co-authored by autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> LemonNeko
parent 5addb1574f
commit 584e4960e9
26 changed files with 680 additions and 415 deletions
@@ -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
@@ -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>
+9 -8
View File
@@ -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:
+9 -8
View File
@@ -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:
+9 -8
View File
@@ -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 laccè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:
+9 -8
View File
@@ -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:
+10 -9
View File
@@ -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:
+9 -8
View File
@@ -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:
+10 -9
View File
@@ -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:
+13 -12
View File
@@ -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"
/>
@@ -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"
@@ -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,
+234 -227
View File
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -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