feat(release): close handler and microphone gaps
This commit is contained in:
@@ -137,7 +137,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
import type {
|
||||
AsrResponse,
|
||||
InterviewFinishResponse,
|
||||
@@ -501,7 +501,7 @@ onShow(() => {
|
||||
|
||||
onHide(() => {
|
||||
speechPlayback.stop();
|
||||
if (!isPlatformFileSelectionActive()) cancelQuestionRecording();
|
||||
if (!isPlatformNativeActivityActive()) cancelQuestionRecording();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
|
||||
@@ -177,6 +177,7 @@ import { resetPageScroll } from '@/services/navigation';
|
||||
import { createPageRequestScope } from '@/services/request-scope';
|
||||
import { startSpeechCapture, type SpeechCapture, type SpeechCaptureSession } from '@/services/speech-capture';
|
||||
import { transcribeSpeechCapture } from '@/services/speech';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
|
||||
const path = '/pages/user/direct/index';
|
||||
const pageSize = 20;
|
||||
@@ -485,7 +486,7 @@ onShow(async () => {
|
||||
});
|
||||
onHide(() => {
|
||||
requests.invalidate();
|
||||
teardownVoice();
|
||||
if (!isPlatformNativeActivityActive()) teardownVoice();
|
||||
});
|
||||
onUnload(() => {
|
||||
requests.invalidate();
|
||||
|
||||
@@ -503,7 +503,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||
import RealtimePracticeAppBridge from '@/components/practice/RealtimePracticeAppBridge.vue';
|
||||
import type {
|
||||
@@ -1852,7 +1852,7 @@ onHide(() => {
|
||||
stopRealtime();
|
||||
invalidateRequests();
|
||||
speechPlayback.stop();
|
||||
if (!isPlatformFileSelectionActive()) cancelRecording();
|
||||
if (!isPlatformNativeActivityActive()) cancelRecording();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { chooseKnowledgeMedia } from '@/services/knowledge';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
@@ -784,7 +784,7 @@ const teardown = (preservePlatformFileSelection = false) => {
|
||||
activeUploadTask?.abort();
|
||||
activeUploadTask = null;
|
||||
speechPlayback.stop();
|
||||
composerRef.value?.teardown();
|
||||
if (!preservePlatformFileSelection) composerRef.value?.teardown();
|
||||
organizing.value = false;
|
||||
uploading.value = false;
|
||||
submitting.value = false;
|
||||
@@ -838,7 +838,7 @@ onShow(() => {
|
||||
}
|
||||
});
|
||||
|
||||
onHide(() => teardown(isPlatformFileSelectionActive()));
|
||||
onHide(() => teardown(isPlatformNativeActivityActive()));
|
||||
onUnload(() => {
|
||||
teardown();
|
||||
speechPlayback.destroy();
|
||||
|
||||
@@ -424,7 +424,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
BroadcastKnowledgeContext,
|
||||
@@ -1565,8 +1565,10 @@ onHide(() => {
|
||||
candidateRefreshGeneration += 1;
|
||||
cancelPendingBroadcastConsultation();
|
||||
speechPlayback.stop();
|
||||
composerRef.value?.teardown();
|
||||
if (!isPlatformFileSelectionActive()) cancelVoiceTranscription();
|
||||
if (!isPlatformNativeActivityActive()) {
|
||||
composerRef.value?.teardown();
|
||||
cancelVoiceTranscription();
|
||||
}
|
||||
cancelPendingMediaSearch();
|
||||
});
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ import { ensureEmployeePosition } from '@/services/position';
|
||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||
import { getBroadcastUnreadCount } from '@/services/broadcast';
|
||||
import { isFileSelectionCancelled } from '@/utils/file-selection';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
|
||||
const home = ref<RoleHome | null>(null);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
@@ -578,8 +579,10 @@ onShow(() => {
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
holdGeneration += 1;
|
||||
cancelHold();
|
||||
if (!isPlatformNativeActivityActive()) {
|
||||
holdGeneration += 1;
|
||||
cancelHold();
|
||||
}
|
||||
requests.invalidate();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
let activePlatformFileSelections = 0;
|
||||
let activePlatformPermissionActivities = 0;
|
||||
|
||||
export const isPlatformFileSelectionActive = () => activePlatformFileSelections > 0;
|
||||
export const isPlatformNativeActivityActive = () =>
|
||||
activePlatformFileSelections > 0 || activePlatformPermissionActivities > 0;
|
||||
|
||||
export const withPlatformFileSelection = async <T>(select: () => Promise<T>): Promise<T> => {
|
||||
activePlatformFileSelections += 1;
|
||||
@@ -10,3 +13,12 @@ export const withPlatformFileSelection = async <T>(select: () => Promise<T>): Pr
|
||||
activePlatformFileSelections = Math.max(0, activePlatformFileSelections - 1);
|
||||
}
|
||||
};
|
||||
|
||||
export const withPlatformPermissionActivity = async <T>(request: () => Promise<T>): Promise<T> => {
|
||||
activePlatformPermissionActivities += 1;
|
||||
try {
|
||||
return await request();
|
||||
} finally {
|
||||
activePlatformPermissionActivities = Math.max(0, activePlatformPermissionActivities - 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,14 +8,25 @@
|
||||
* Android 运行时授权框。已授权设备上本调用为无害空转;若确实不弹,则它是唯一的授权入口。
|
||||
*/
|
||||
|
||||
import { withPlatformPermissionActivity } from '../services/file-selection-lifecycle';
|
||||
|
||||
const RECORD_AUDIO = 'android.permission.RECORD_AUDIO';
|
||||
|
||||
/** 授权申请超时:回调未触发时不能无限挂起,放行交由 getUserMedia 兜底。 */
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
/** Android 权限 Activity 返回后,必须等应用窗口重新获得焦点再启动录音或文件选择。 */
|
||||
const ACTIVITY_FOCUS_POLL_MS = 50;
|
||||
const ACTIVITY_FOCUS_TIMEOUT_MS = 1500;
|
||||
|
||||
interface AndroidActivityLike {
|
||||
hasWindowFocus?: () => boolean;
|
||||
}
|
||||
|
||||
export type MicPermissionResult = 'granted' | 'denied' | 'denied-always' | 'unsupported';
|
||||
|
||||
interface PlusAndroidLike {
|
||||
importClass?: (target: unknown) => unknown;
|
||||
runtimeMainActivity?: () => AndroidActivityLike;
|
||||
requestPermissions?: (
|
||||
permissions: string[],
|
||||
success: (event: { granted?: string[]; deniedPresent?: string[]; deniedAlways?: string[] }) => void,
|
||||
@@ -28,6 +39,42 @@ const androidBridge = (): PlusAndroidLike | null => {
|
||||
return plusApi?.android ?? null;
|
||||
};
|
||||
|
||||
const settleAfterPermissionActivity = (
|
||||
android: PlusAndroidLike,
|
||||
settle: (result: MicPermissionResult) => void,
|
||||
result: MicPermissionResult
|
||||
) => {
|
||||
let activity: AndroidActivityLike | undefined;
|
||||
try {
|
||||
activity = android.runtimeMainActivity?.();
|
||||
if (activity) android.importClass?.(activity);
|
||||
} catch {
|
||||
// 某些旧基座不暴露主 Activity;仍至少跨过当前权限回调任务。
|
||||
}
|
||||
if (!activity?.hasWindowFocus) {
|
||||
setTimeout(() => settle(result), ACTIVITY_FOCUS_POLL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + ACTIVITY_FOCUS_TIMEOUT_MS;
|
||||
const poll = () => {
|
||||
let focused = false;
|
||||
try {
|
||||
focused = Boolean(activity?.hasWindowFocus?.());
|
||||
} catch {
|
||||
settle(result);
|
||||
return;
|
||||
}
|
||||
if (focused || Date.now() >= deadline) {
|
||||
settle(result);
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, ACTIVITY_FOCUS_POLL_MS);
|
||||
};
|
||||
// 即使回调时已经恢复焦点,也跨过一次事件循环,避免和权限 Activity 的 finish 竞争。
|
||||
setTimeout(poll, ACTIVITY_FOCUS_POLL_MS);
|
||||
};
|
||||
|
||||
/**
|
||||
* 主动申请麦克风权限。
|
||||
* 非 Android App 运行时(H5、iOS)返回 unsupported,由各自平台的原生授权流程处理。
|
||||
@@ -36,7 +83,7 @@ export const requestMicrophonePermission = (): Promise<MicPermissionResult> => {
|
||||
const android = androidBridge();
|
||||
if (!android?.requestPermissions) return Promise.resolve('unsupported');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return withPlatformPermissionActivity(() => new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result: MicPermissionResult) => {
|
||||
if (settled) return;
|
||||
@@ -50,17 +97,19 @@ export const requestMicrophonePermission = (): Promise<MicPermissionResult> => {
|
||||
android.requestPermissions!(
|
||||
[RECORD_AUDIO],
|
||||
(event) => {
|
||||
if (event?.granted?.length) settle('granted');
|
||||
else if (event?.deniedAlways?.length) settle('denied-always');
|
||||
else if (event?.deniedPresent?.length) settle('denied');
|
||||
else settle('denied');
|
||||
const result: MicPermissionResult = event?.granted?.length
|
||||
? 'granted'
|
||||
: event?.deniedAlways?.length
|
||||
? 'denied-always'
|
||||
: 'denied';
|
||||
settleAfterPermissionActivity(android, settle, result);
|
||||
},
|
||||
() => settle('unsupported')
|
||||
);
|
||||
} catch {
|
||||
settle('unsupported');
|
||||
}
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
/** 权限被拒时的中文引导文案。 */
|
||||
|
||||
Reference in New Issue
Block a user