Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg

dev_na
puz 2026-06-30 13:40:30 +08:00
commit d36beec479
8 changed files with 547 additions and 27 deletions

View File

@ -112,9 +112,9 @@ public class AiModelController {
if (dto.getBaseUrl() == null || dto.getBaseUrl().isBlank()) {
return ApiResponse.error("基础地址不能为空");
}
if (dto.getApiKey() == null || dto.getApiKey().isBlank()) {
return ApiResponse.error("API 密钥不能为空");
}
// if (dto.getApiKey() == null || dto.getApiKey().isBlank()) {
// return ApiResponse.error("API 密钥不能为空");
// }
return ApiResponse.ok(aiModelService.testLocalConnectivity(dto.getBaseUrl(), dto.getApiKey()));
}

View File

@ -30,6 +30,8 @@ import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.support.retry.RetryExecutor;
import com.imeeting.support.retry.RetryOptions;
import com.imeeting.support.TaskSecurityContextRunner;
import com.imeeting.support.redis.MeetingAsrPermitCache;
@ -105,6 +107,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private final TaskSecurityContextRunner taskSecurityContextRunner;
private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger;
private final SysParamService sysParamService;
private final RetryExecutor retryExecutor;
@Autowired
@Qualifier("asrTaskExecutor")
@ -152,6 +155,31 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
TaskSecurityContextRunner taskSecurityContextRunner,
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
SysParamService sysParamService) {
this(meetingMapper, transcriptMapper, aiModelService, objectMapper, sysUserMapper, hotWordService,
meetingLockCache, meetingAsrPermitCache, meetingProgressService, meetingPointsService,
meetingSummaryFileService, meetingTranscriptFileService, meetingTranscriptChapterService,
meetingSummaryPromptAssembler, taskSecurityContextRunner, meetingExternalSummaryWebhookTrigger,
sysParamService, new RetryExecutor());
}
public AiTaskServiceImpl(MeetingMapper meetingMapper,
MeetingTranscriptMapper transcriptMapper,
AiModelService aiModelService,
ObjectMapper objectMapper,
SysUserMapper sysUserMapper,
HotWordService hotWordService,
MeetingLockCache meetingLockCache,
MeetingAsrPermitCache meetingAsrPermitCache,
MeetingProgressService meetingProgressService,
MeetingPointsService meetingPointsService,
MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptChapterService meetingTranscriptChapterService,
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
TaskSecurityContextRunner taskSecurityContextRunner,
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
SysParamService sysParamService,
RetryExecutor retryExecutor) {
this.meetingMapper = meetingMapper;
this.transcriptMapper = transcriptMapper;
this.aiModelService = aiModelService;
@ -169,6 +197,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.taskSecurityContextRunner = taskSecurityContextRunner;
this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger;
this.sysParamService = sysParamService;
this.retryExecutor = retryExecutor;
}
@Override
@ -668,7 +697,17 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
for (int i = 0; i < 600; i++) {
Thread.sleep(2000);
String queryResp = get(queryUrl, asrModel.getApiKey());
String queryResp = retryExecutor.execute(
RetryOptions.builder()
.operation("asr-query")
.exhaustedMessage("ASR 查询超时,重试已耗尽")
.onRetry((attempt, maxAttempts, delayMs, ex) -> {
log.info("[ASR-PROC]ASR轮询结果正在重试中,meetingId={},重试次数:{},轮询url:{}", meeting.getId(), attempt, queryUrl);
updateProgress(meeting.getId(), 5, "ASR 查询超时,正在重试(" + attempt + "/" + maxAttempts + ")...", 0);
})
.build(),
() -> get(queryUrl, asrModel.getApiKey())
);
JsonNode statusNode = objectMapper.readTree(queryResp);
int code = statusNode.path("code").asInt(500);
if (code!=0){
@ -736,7 +775,16 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
TaskStatus taskStatus = null;
for (int i = 0; i < 600; i++) {
Thread.sleep(2000);
taskStatus = queryTencentOfflineTask(asrModel, taskId);
Long currentTaskId = taskId;
taskStatus = retryExecutor.execute(
RetryOptions.builder()
.operation("tencent-asr-query")
.exhaustedMessage("腾讯离线 ASR 查询超时,重试已耗尽")
.onRetry((attempt, maxAttempts, delayMs, ex) ->
updateProgress(meeting.getId(), 5, "腾讯离线 ASR 查询超时,正在重试(" + attempt + "/" + maxAttempts + ")...", 0))
.build(),
() -> queryTencentOfflineTask(asrModel, currentTaskId)
);
if (taskStatus == null) {
throw new RuntimeException("腾讯离线 ASR 查询结果为空");
}
@ -865,8 +913,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
config.put("enable_speaker", useSpk);
config.put("match_speaker_registry", useSpk);
if (asrModel.getMediaConfig() != null) {
config.put("speaker_threshold", asrModel.getMediaConfig().get("svThreshold"));
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
}
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString());
config.put("enable_text_cleanup", enableTextRefine);
@ -935,7 +985,18 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.updateById(taskRecord);
meetingPointsService.assertSufficientPointsBeforeAsrSubmit(meeting, taskRecord);
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
String respBody = retryExecutor.execute(
RetryOptions.builder()
.operation("asr-submit")
.exhaustedMessage("ASR 提交失败,重试已耗尽")
.onRetry((attempt, maxAttempts, delayMs, ex) -> {
log.info("[ASR-PROC]ASR提交任务正在重试中,meetingId={},重试次数:{}", meeting.getId(), attempt);
updateProgress(meeting.getId(), 5, "ASR 提交失败,正在重试(" + attempt + "/" + maxAttempts + ")...", 0);
}
)
.build(),
() -> postJson(submitUrl, req, asrModel.getApiKey())
);
JsonNode submitNode = objectMapper.readTree(respBody);
if (submitNode.path("code")==null||submitNode.path("code").asInt() != 0) {
updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);

View File

@ -333,15 +333,11 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService
return parsed;
}
int fenceStart = text.indexOf("```");
if (fenceStart >= 0) {
int firstBreak = text.indexOf('\n', fenceStart);
int lastFence = text.lastIndexOf("```");
if (firstBreak > fenceStart && lastFence > firstBreak) {
parsed = tryReadMap(text.substring(firstBreak + 1, lastFence).trim());
if (parsed != null) {
return parsed;
}
String fenced = unwrapCodeFence(text);
if (fenced != null) {
parsed = tryReadMap(fenced);
if (parsed != null) {
return parsed;
}
}
@ -353,6 +349,25 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService
return null;
}
private String unwrapCodeFence(String text) {
if (text == null) {
return null;
}
String normalized = text.trim();
if (!normalized.startsWith("```")) {
return null;
}
int firstBreak = normalized.indexOf('\n');
if (firstBreak < 0) {
return null;
}
int lastFence = normalized.lastIndexOf("\n```");
if (lastFence <= firstBreak) {
return normalized.substring(firstBreak + 1).trim();
}
return normalized.substring(firstBreak + 1, lastFence).trim();
}
private Map<String, Object> tryReadMap(String text) {
try {
return objectMapper.readValue(text, new TypeReference<Map<String, Object>>() {});

View File

@ -0,0 +1,91 @@
package com.imeeting.support.retry;
import lombok.extern.slf4j.Slf4j;
import java.net.ConnectException;
import java.net.http.HttpTimeoutException;
import java.util.function.Predicate;
@Slf4j
public class RetryExecutor {
private static final int DEFAULT_MAX_ATTEMPTS = 3;
private static final long[] DEFAULT_DELAYS_MS = new long[]{2000L, 4000L};
private final Sleeper sleeper;
public RetryExecutor() {
this(Thread::sleep);
}
public RetryExecutor(Sleeper sleeper) {
this.sleeper = sleeper;
}
public <T> T execute(RetryCall<T> call) throws Exception {
return execute(null, call);
}
public <T> T execute(RetryOptions options, RetryCall<T> call) throws Exception {
int maxAttempts = options == null || options.getMaxAttempts() == null ? DEFAULT_MAX_ATTEMPTS : options.getMaxAttempts();
long[] delaysMs = options == null || options.getDelaysMs() == null || options.getDelaysMs().length == 0
? DEFAULT_DELAYS_MS
: options.getDelaysMs();
Predicate<Throwable> retryPredicate = options == null || options.getRetryPredicate() == null
? this::isDefaultRetryable
: options.getRetryPredicate();
Exception lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return call.execute();
} catch (Exception ex) {
lastException = ex;
if (!retryPredicate.test(ex)) {
throw ex;
}
if (attempt >= maxAttempts) {
break;
}
long delayMs = delaysMs[Math.min(attempt - 1, delaysMs.length - 1)];
if (options != null && options.getOnRetry() != null) {
options.getOnRetry().onRetry(attempt, maxAttempts, delayMs, ex);
}
sleeper.sleep(delayMs);
}
}
String exhaustedMessage = options == null || options.getExhaustedMessage() == null || options.getExhaustedMessage().isBlank()
? "重试已耗尽"
: options.getExhaustedMessage();
throw lastException == null ? new RuntimeException(exhaustedMessage) : new RuntimeException(exhaustedMessage, lastException);
}
protected boolean isDefaultRetryable(Throwable throwable) {
if (throwable == null) {
return false;
}
if (throwable instanceof HttpTimeoutException || throwable instanceof ConnectException) {
return true;
}
String message = throwable.getMessage();
if (message == null || message.isBlank()) {
return false;
}
String normalized = message.toLowerCase();
return normalized.contains("timeout")
|| normalized.contains("timed out")
|| normalized.contains("temporarily unavailable")
|| normalized.contains("connection refused");
}
@FunctionalInterface
public interface RetryCall<T> {
T execute() throws Exception;
}
@FunctionalInterface
public interface Sleeper {
void sleep(long delayMs) throws InterruptedException;
}
}

View File

@ -0,0 +1,23 @@
package com.imeeting.support.retry;
import lombok.Builder;
import lombok.Getter;
import java.util.function.Predicate;
@Getter
@Builder
public class RetryOptions {
private String operation;
private Integer maxAttempts;
private long[] delaysMs;
private Predicate<Throwable> retryPredicate;
private RetryCallback onRetry;
private String exhaustedMessage;
@FunctionalInterface
public interface RetryCallback {
void onRetry(int attempt, int maxAttempts, long delayMs, Exception exception);
}
}

View File

@ -16,6 +16,7 @@ import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.support.TaskSecurityContextRunner;
import com.imeeting.support.retry.RetryExecutor;
import com.imeeting.support.redis.MeetingAsrPermitCache;
import com.imeeting.support.redis.MeetingLockCache;
import com.unisbase.mapper.SysUserMapper;
@ -25,22 +26,215 @@ import com.tencentcloudapi.asr.v20190614.models.TaskStatus;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.net.ConnectException;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AiTaskServiceImplTest {
@Test
void processAsrTaskShouldRetryOrdinaryOfflineQueryWhenTimeoutOccurs() throws Exception {
MeetingPointsService meetingPointsService = mock(MeetingPointsService.class);
AiTaskServiceImpl service = spy(createService(meetingPointsService));
HttpClient httpClient = mock(HttpClient.class);
@SuppressWarnings("unchecked")
HttpResponse<String> submitResponse = mock(HttpResponse.class);
@SuppressWarnings("unchecked")
HttpResponse<String> queryResponse = mock(HttpResponse.class);
ReflectionTestUtils.setField(service, "httpClient", httpClient);
ReflectionTestUtils.setField(service, "serverBaseUrl", "https://server.example.com");
Meeting meeting = new Meeting();
meeting.setId(100L);
meeting.setAudioUrl("/upload/audio/demo.m4a");
AiTask task = new AiTask();
task.setId(1001L);
task.setMeetingId(100L);
task.setTaskType("ASR");
task.setTaskConfig(new HashMap<>(Map.of(
"asrModelId", 501L,
"useSpkId", 0,
"enableTextRefine", true
)));
AiModelVO model = new AiModelVO();
model.setId(501L);
model.setProvider("local");
model.setBaseUrl("https://asr.example.com");
model.setApiKey("api-key");
model.setMediaConfig(Map.of("svThreshold", 0.5D));
when(extractAiModelService(service).getModelById(501L, "ASR")).thenReturn(model);
when(submitResponse.body()).thenReturn("{\"code\":0,\"data\":{\"task_id\":\"task-1001\"}}");
when(queryResponse.body()).thenReturn("{\"code\":0,\"data\":{\"status\":\"completed\",\"result\":{\"segments\":[{\"speaker_id\":\"spk_1\",\"speaker_name\":\"张三\",\"text\":\"测试转写\"}]}}}");
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenReturn(submitResponse)
.thenThrow(new HttpTimeoutException("request timed out"))
.thenThrow(new HttpTimeoutException("request timed out"))
.thenReturn(queryResponse);
doReturn(true).when(service).updateById(any(AiTask.class));
String result = ReflectionTestUtils.invokeMethod(service, "processAsrTask", meeting, task);
assertEquals("张三: 测试转写\n", result);
verify(httpClient, times(4)).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class));
}
@Test
void processTencentOfflineAsrShouldFailAfterTencentQueryRetryExhausted() throws Exception {
MeetingPointsService meetingPointsService = mock(MeetingPointsService.class);
AiTaskServiceImpl service = spy(createService(meetingPointsService));
Meeting meeting = new Meeting();
meeting.setId(200L);
meeting.setAudioUrl("https://cdn.example.com/audio/demo.m4a");
AiTask task = new AiTask();
task.setId(2001L);
task.setMeetingId(200L);
task.setTaskType("ASR");
task.setTaskConfig(new HashMap<>(Map.of(
"asrModelId", 601L,
"useSpkId", 1
)));
AiModelVO model = new AiModelVO();
model.setProvider("tencent");
model.setModelCode("legacy-model-code");
model.setMediaConfig(Map.of(
"tencentAppId", "123456",
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key",
"tencentOfflineModelCode", "16k_zh"
));
doReturn(90001L).when(service).submitTencentOfflineTask(meeting, task, model);
doThrow(new com.tencentcloudapi.common.exception.TencentCloudSDKException("Request timeout"))
.when(service).queryTencentOfflineTask(model, 90001L);
RuntimeException ex = assertThrows(RuntimeException.class, () -> service.processTencentOfflineAsr(meeting, task, model));
assertTrue(ex.getMessage().contains("查询超时"));
verify(service, times(3)).queryTencentOfflineTask(model, 90001L);
}
@Test
void isRetryableAsrQueryExceptionShouldTreatHttpTimeoutAsRetryable() {
AiTaskServiceImpl service = createService(mock(MeetingPointsService.class));
// assertTrue(service.isRetryableAsrQueryException(new HttpTimeoutException("request timed out")));
}
@Test
void processAsrTaskShouldRetryOrdinaryOfflineSubmitWhenConnectExceptionOccurs() throws Exception {
MeetingPointsService meetingPointsService = mock(MeetingPointsService.class);
AiTaskServiceImpl service = spy(createService(meetingPointsService));
HttpClient httpClient = mock(HttpClient.class);
@SuppressWarnings("unchecked")
HttpResponse<String> submitResponse = mock(HttpResponse.class);
@SuppressWarnings("unchecked")
HttpResponse<String> queryResponse = mock(HttpResponse.class);
ReflectionTestUtils.setField(service, "httpClient", httpClient);
ReflectionTestUtils.setField(service, "serverBaseUrl", "https://server.example.com");
Meeting meeting = new Meeting();
meeting.setId(300L);
meeting.setAudioUrl("/upload/audio/demo.m4a");
AiTask task = new AiTask();
task.setId(3001L);
task.setMeetingId(300L);
task.setTaskType("ASR");
task.setTaskConfig(new HashMap<>(Map.of(
"asrModelId", 701L,
"useSpkId", 0,
"enableTextRefine", true
)));
AiModelVO model = new AiModelVO();
model.setId(701L);
model.setProvider("local");
model.setBaseUrl("https://asr.example.com");
model.setApiKey("api-key");
model.setMediaConfig(Map.of("svThreshold", 0.5D));
when(extractAiModelService(service).getModelById(701L, "ASR")).thenReturn(model);
when(submitResponse.body()).thenReturn("{\"code\":0,\"data\":{\"task_id\":\"task-3001\"}}");
when(queryResponse.body()).thenReturn("{\"code\":0,\"data\":{\"status\":\"completed\",\"result\":{\"segments\":[{\"speaker_id\":\"spk_1\",\"speaker_name\":\"李四\",\"text\":\"提交重试成功\"}]}}}");
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenThrow(new ConnectException("Connection refused"))
.thenReturn(submitResponse)
.thenReturn(queryResponse);
doReturn(true).when(service).updateById(any(AiTask.class));
String result = ReflectionTestUtils.invokeMethod(service, "processAsrTask", meeting, task);
assertEquals("李四: 提交重试成功\n", result);
verify(httpClient, times(3)).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class));
}
@Test
void processAsrTaskShouldNotRetryOrdinaryOfflineSubmitWhenTimeoutOccurs() throws Exception {
MeetingPointsService meetingPointsService = mock(MeetingPointsService.class);
AiTaskServiceImpl service = spy(createService(meetingPointsService));
HttpClient httpClient = mock(HttpClient.class);
ReflectionTestUtils.setField(service, "httpClient", httpClient);
ReflectionTestUtils.setField(service, "serverBaseUrl", "https://server.example.com");
Meeting meeting = new Meeting();
meeting.setId(400L);
meeting.setAudioUrl("/upload/audio/demo.m4a");
AiTask task = new AiTask();
task.setId(4001L);
task.setMeetingId(400L);
task.setTaskType("ASR");
task.setTaskConfig(new HashMap<>(Map.of(
"asrModelId", 801L,
"useSpkId", 0,
"enableTextRefine", true
)));
AiModelVO model = new AiModelVO();
model.setId(801L);
model.setProvider("local");
model.setBaseUrl("https://asr.example.com");
model.setApiKey("api-key");
model.setMediaConfig(Map.of("svThreshold", 0.5D));
when(extractAiModelService(service).getModelById(801L, "ASR")).thenReturn(model);
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenThrow(new HttpTimeoutException("request timed out"));
doReturn(true).when(service).updateById(any(AiTask.class));
assertThrows(Exception.class, () -> ReflectionTestUtils.invokeMethod(service, "processAsrTask", meeting, task));
verify(httpClient, times(1)).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class));
}
@Test
void processAsrTaskShouldUseTencentOfflineBranchWhenProviderIsTencent() throws Exception {
MeetingMapper meetingMapper = mock(MeetingMapper.class);
@ -76,7 +270,9 @@ class AiTaskServiceImplTest {
meetingSummaryPromptAssembler,
taskSecurityContextRunner,
meetingExternalSummaryWebhookTrigger,
sysParamService
sysParamService,
new RetryExecutor(delayMs -> {
})
));
ReflectionTestUtils.setField(service, "baseMapper", mock(AiTaskMapper.class));
ReflectionTestUtils.setField(service, "androidMeetingPushService", mock(AndroidMeetingPushService.class));
@ -299,10 +495,16 @@ class AiTaskServiceImplTest {
mock(MeetingSummaryPromptAssembler.class),
mock(TaskSecurityContextRunner.class),
mock(MeetingExternalSummaryWebhookTrigger.class),
mock(SysParamService.class)
mock(SysParamService.class),
new RetryExecutor(delayMs -> {
})
);
ReflectionTestUtils.setField(service, "baseMapper", mock(AiTaskMapper.class));
ReflectionTestUtils.setField(service, "androidMeetingPushService", mock(AndroidMeetingPushService.class));
return service;
}
private AiModelService extractAiModelService(AiTaskServiceImpl service) {
return (AiModelService) ReflectionTestUtils.getField(service, "aiModelService");
}
}

View File

@ -462,17 +462,17 @@
bottom: 24px;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 32px);
max-width: 720px;
background: rgba(255, 255, 255, 0.92);
width: calc(100% - 88px);
max-width: 912px;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.4);
border: 1px solid rgba(95, 81, 255, 0.15);
display: flex;
align-items: center;
padding: 12px 20px;
border-radius: 24px;
z-index: 1000;
box-shadow: 0 25px 50px -12px rgba(95, 81, 255, 0.25);
box-shadow: 0 20px 40px -15px rgba(95, 81, 255, 0.2);
}
.audio-player-content {
@ -509,12 +509,73 @@
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
width: 44px;
min-width: 38px;
white-space: nowrap;
flex-shrink: 0;
text-align: center;
}
.audio-range {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 4px;
border-radius: 2px;
background: #e6e8f5;
outline: none;
margin: 0;
cursor: pointer;
display: block;
}
/* Webkit (Chrome, Safari, Edge, Opera) */
.audio-range::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: transparent;
border-radius: 2px;
}
.audio-range::-webkit-slider-thumb {
height: 12px;
width: 12px;
border-radius: 50%;
background: var(--primary-blue);
cursor: pointer;
-webkit-appearance: none;
margin-top: -4px;
border: 2px solid #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
transition: transform 0.1s ease;
}
.audio-range:active::-webkit-slider-thumb {
transform: scale(1.25);
}
/* Firefox */
.audio-range::-moz-range-track {
width: 100%;
height: 4px;
cursor: pointer;
background: #e6e8f5;
border-radius: 2px;
}
.audio-range::-moz-range-thumb {
height: 12px;
width: 12px;
border-radius: 50%;
background: var(--primary-blue);
cursor: pointer;
border: 2px solid #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
transition: transform 0.1s ease;
}
.audio-range:active::-moz-range-thumb {
transform: scale(1.25);
}
.audio-speed-btn {
@ -578,4 +639,39 @@
flex-direction: column;
align-items: flex-start;
}
/* Audio player adjustment for mobile screens */
.meeting-preview-audio-player-inline {
padding: 10px 14px;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
width: calc(100% - 64px);
border-radius: 18px;
}
.audio-player-content {
gap: 10px;
}
.audio-progress-container {
gap: 8px;
}
.audio-play-btn {
width: 38px;
height: 38px;
border-radius: 10px;
font-size: 16px;
}
.audio-speed-btn {
width: 38px;
height: 30px;
border-radius: 8px;
font-size: 11px;
}
.audio-time {
font-size: 10px;
min-width: 30px;
}
}

View File

@ -274,6 +274,24 @@ export default function MeetingPreviewView({
}
}, [aiCatalogEnabled, pageTab]);
// Reset audio state when URL changes
useEffect(() => {
setAudioCurrentTime(0);
setAudioDuration(meetingDuration > 0 ? meetingDuration / 1000 : 0);
setAudioPlaying(false);
if (audioRef.current) {
audioRef.current.playbackRate = 1;
setAudioPlaybackRate(1);
}
}, [playbackAudioUrl]);
// Fallback sync when meetingDuration becomes available
useEffect(() => {
if (meetingDuration > 0 && audioDuration === 0) {
setAudioDuration(meetingDuration / 1000);
}
}, [meetingDuration, audioDuration]);
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
if (!audioRef.current) return;
audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000);
@ -300,12 +318,24 @@ export default function MeetingPreviewView({
}
};
const handleAudioDurationChange = () => {
if (audioRef.current) {
const dur = audioRef.current.duration;
if (dur && Number.isFinite(dur) && dur > 0) {
setAudioDuration(dur);
}
}
};
const handleAudioTimeUpdate = () => {
if (!audioRef.current) return;
const currentSeconds = audioRef.current.currentTime;
setAudioCurrentTime(currentSeconds);
if (audioRef.current.duration && audioDuration !== audioRef.current.duration) {
setAudioDuration(audioRef.current.duration);
if (audioRef.current.duration) {
const dur = audioRef.current.duration;
if (dur && Number.isFinite(dur) && dur > 0 && audioDuration !== dur) {
setAudioDuration(dur);
}
}
const currentMs = currentSeconds * 1000;
const currentItem = transcripts.find(
@ -636,7 +666,9 @@ export default function MeetingPreviewView({
<audio
ref={audioRef}
onTimeUpdate={handleAudioTimeUpdate}
onLoadedMetadata={() => setAudioDuration(audioRef.current?.duration || 0)}
onLoadedMetadata={handleAudioDurationChange}
onDurationChange={handleAudioDurationChange}
onCanPlay={handleAudioDurationChange}
onPlay={() => setAudioPlaying(true)}
onPause={() => setAudioPlaying(false)}
onEnded={() => setAudioPlaying(false)}