Skip to content

Commit a76e8c2

Browse files
committed
feat(player): HLS session progress polling with media server integration (#209)
* feat(player): implement HLS session progress polling with visual feedback - Add waitForSessionReady to poll session creation progress before playback - Integrate useSessionProgress hook for reusable progress tracking logic - Display deterministic progress bar with percentage and stage information during session initialization - Add SessionService.getSessionProgress API with HTTP 425 handling for in-progress state - Update backend submodule to v64aaf0b with HLS session progress support - Remove excessive debug logging in SubtitleOverlay to reduce console noise Changes: PlayerPage: - Add waitForSessionReady state and sessionProgress state for UI rendering - Implement polling loop with 2s interval to fetch session progress until ready - Display progress bar with stage text and percentage during session creation - Handle HTTP 425 (session not ready) gracefully in progress polling - Fallback to default playlist URL if progress response URL parsing fails useSessionProgress Hook: - Provide reusable session progress polling with configurable interval (default 2s) - Auto-stop polling when session is ready or error occurs - Expose startPolling, stopPolling, reset methods for lifecycle control - Implement progress threshold (5%) to reduce log noise SessionService: - Add getSessionProgress(sessionId) API to fetch session creation progress - Define SessionProgressResponse and AudioProgressInfo interfaces - Treat HTTP 425 as normal in-progress state (not error) This enhancement provides real-time visual feedback during HLS session initialization, improving user experience by showing progress instead of generic "loading..." spinner. The polling mechanism ensures playback starts only after the session is fully ready, preventing premature playlist access. * refactor(PlayerPage): replace progress bar hardcoded values with theme tokens - Import theme constants (SPACING, FONT_SIZES, ANIMATION_DURATION, etc.) - Replace hardcoded width (240px) with SPACING.XXL * 5 - Replace hardcoded heights (4px) with COMPONENT_TOKENS.PROGRESS_BAR.TRACK_HEIGHT_HOVER - Replace hardcoded border-radius (2px) with COMPONENT_TOKENS.PROGRESS_BAR.TRACK_BORDER_RADIUS - Replace hardcoded font-sizes (16px, 14px) with FONT_SIZES.BASE and FONT_SIZES.SM - Replace hardcoded transition timing with ANIMATION_DURATION.SLOW and EASING.STANDARD - Fix: Add setTranscodeStatus('completed') call after HLS session completes Changes ensure theme consistency, maintainability, and proper status tracking across components. Progress bar now respects design system tokens for cross-theme compatibility.
1 parent fa9aa09 commit a76e8c2

4 files changed

Lines changed: 262 additions & 43 deletions

File tree

src/renderer/src/pages/player/PlayerPage.tsx

Lines changed: 198 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import db from '@renderer/databases'
44
import {
55
CodecCompatibilityChecker,
66
type ExtendedErrorType,
7+
SessionError,
78
SessionService,
89
VideoLibraryService
910
} from '@renderer/services'
@@ -16,6 +17,13 @@ import { Layout, Tooltip } from 'antd'
1617

1718
const { Content, Sider } = Layout
1819
import { MediaServerRecommendationPrompt } from '@renderer/components/MediaServerRecommendationPrompt'
20+
import {
21+
ANIMATION_DURATION,
22+
COMPONENT_TOKENS,
23+
EASING,
24+
FONT_SIZES,
25+
SPACING
26+
} from '@renderer/infrastructure/styles/theme'
1927
import { ArrowLeft, PanelRightClose, PanelRightOpen } from 'lucide-react'
2028
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2129
import { useTranslation } from 'react-i18next'
@@ -95,6 +103,12 @@ function PlayerPage() {
95103
originalPath?: string
96104
} | null>(null)
97105
const [showMediaServerPrompt, setShowMediaServerPrompt] = useState(false)
106+
const [waitingForSessionReady, setWaitingForSessionReady] = useState(false)
107+
const [sessionProgress, setSessionProgress] = useState<{
108+
percent: number
109+
stage: string
110+
status: string
111+
} | null>(null)
98112
// const { pokeInteraction } = usePlayerUI()
99113

100114
// 保存转码会话 ID 用于清理
@@ -103,8 +117,64 @@ function PlayerPage() {
103117
// 加载视频数据
104118
useEffect(() => {
105119
let cancelled = false
120+
const pollIntervalMs = 2000
121+
122+
const waitForSessionReady = async (sessionId: string) => {
123+
while (!cancelled) {
124+
try {
125+
const progress = await SessionService.getSessionProgress(sessionId)
126+
if (cancelled) {
127+
break
128+
}
129+
130+
setSessionProgress((prev) => {
131+
const stage = progress.progress_stage?.trim() || prev?.stage || '处理中...'
132+
const rawPercent =
133+
typeof progress.progress_percent === 'number'
134+
? progress.progress_percent
135+
: Number(progress.progress_percent)
136+
const percent = Number.isFinite(rawPercent) ? rawPercent : (prev?.percent ?? 0)
137+
return {
138+
percent,
139+
stage,
140+
status: progress.status
141+
}
142+
})
143+
144+
if (progress.is_ready) {
145+
setSessionProgress((prev) => ({
146+
percent: 100,
147+
stage: progress.progress_stage?.trim() || prev?.stage || '就绪',
148+
status: progress.status
149+
}))
150+
return progress
151+
}
152+
} catch (progressError) {
153+
if (
154+
progressError instanceof SessionError &&
155+
progressError.statusCode &&
156+
progressError.statusCode === 425
157+
) {
158+
// 会话尚未返回进度,等待下一轮
159+
} else {
160+
throw progressError
161+
}
162+
}
163+
164+
if (cancelled) {
165+
break
166+
}
167+
168+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
169+
}
170+
171+
throw new Error('会话进度轮询已取消')
172+
}
173+
106174
const loadData = async () => {
107175
setLoading(true)
176+
setWaitingForSessionReady(false)
177+
setSessionProgress(null)
108178
if (!videoId) {
109179
setError('无效的视频 ID')
110180
setVideoData(null)
@@ -189,36 +259,83 @@ function PlayerPage() {
189259

190260
// 保存会话 ID 用于后续清理
191261
sessionIdRef.current = sessionResult.session_id
192-
193-
// 构建完整的播放列表 URL
194-
const playListUrl = await SessionService.getPlaylistUrl(sessionResult.session_id)
195-
196-
// 更新转码信息和播放源
197-
usePlayerStore.getState().updateTranscodeInfo({
198-
hlsSrc: playListUrl,
199-
windowId: 0, // 会话模式不再使用 windowId
200-
assetHash: sessionResult.asset_hash,
201-
profileHash: sessionResult.profile_hash,
202-
cached: false, // 会话模式由后端管理缓存
203-
sessionId: sessionResult.session_id,
204-
endTime: Date.now()
262+
setWaitingForSessionReady(true)
263+
setSessionProgress({
264+
percent: 0,
265+
stage: '正在创建转码会话...',
266+
status: 'initializing'
205267
})
206268

207-
// 切换到 HLS 播放模式
208-
usePlayerStore.getState().switchToHlsSource(playListUrl, {
209-
windowId: 0,
210-
assetHash: sessionResult.asset_hash,
211-
profileHash: sessionResult.profile_hash,
212-
cached: false,
213-
sessionId: sessionResult.session_id
214-
})
215-
216-
finalSrc = playListUrl
217-
218-
logger.info('预转码流程完成,使用 HLS 播放源', {
219-
originalSrc: fileUrl,
220-
hlsSrc: finalSrc
221-
})
269+
try {
270+
const readyProgress = await waitForSessionReady(sessionResult.session_id)
271+
272+
if (cancelled) {
273+
return
274+
}
275+
276+
const fallbackPlaylistUrl = await SessionService.getPlaylistUrl(
277+
sessionResult.session_id
278+
)
279+
let playListUrl = fallbackPlaylistUrl
280+
281+
if (readyProgress.playlist_url) {
282+
try {
283+
playListUrl = new URL(
284+
readyProgress.playlist_url,
285+
fallbackPlaylistUrl
286+
).toString()
287+
} catch (urlError) {
288+
logger.warn('解析会话进度返回的播放列表 URL 失败,将使用默认值', {
289+
sessionId: sessionResult.session_id,
290+
playlistUrl: readyProgress.playlist_url,
291+
error: urlError instanceof Error ? urlError.message : String(urlError)
292+
})
293+
playListUrl = fallbackPlaylistUrl
294+
}
295+
}
296+
297+
// 更新转码信息和播放源
298+
usePlayerStore.getState().updateTranscodeInfo({
299+
hlsSrc: playListUrl,
300+
windowId: 0, // 会话模式不再使用 windowId
301+
assetHash: sessionResult.asset_hash,
302+
profileHash: sessionResult.profile_hash,
303+
cached: false, // 会话模式由后端管理缓存
304+
sessionId: sessionResult.session_id,
305+
endTime: Date.now()
306+
})
307+
308+
// 切换到 HLS 播放模式
309+
usePlayerStore.getState().switchToHlsSource(playListUrl, {
310+
windowId: 0,
311+
assetHash: sessionResult.asset_hash,
312+
profileHash: sessionResult.profile_hash,
313+
cached: false,
314+
sessionId: sessionResult.session_id
315+
})
316+
if (!cancelled) {
317+
usePlayerStore.getState().setTranscodeStatus('completed')
318+
}
319+
320+
finalSrc = playListUrl
321+
322+
logger.info('预转码流程完成,使用 HLS 播放源', {
323+
originalSrc: fileUrl,
324+
hlsSrc: finalSrc
325+
})
326+
} catch (progressError) {
327+
if (!cancelled) {
328+
const message =
329+
progressError instanceof Error ? progressError.message : '获取会话进度失败'
330+
logger.error('会话进度轮询失败,转码流程终止', {
331+
error: message,
332+
sessionId: sessionResult.session_id
333+
})
334+
setError(message || '获取会话进度失败')
335+
usePlayerStore.getState().setTranscodeStatus('failed')
336+
}
337+
return
338+
}
222339
}
223340
} catch (checkError) {
224341
logger.error('检查 Media Server 状态失败,显示推荐安装弹窗', {
@@ -270,7 +387,10 @@ function PlayerPage() {
270387
logger.error(`加载视频数据失败: ${err}`)
271388
setError(err instanceof Error ? err.message : '加载失败')
272389
} finally {
273-
if (!cancelled) setLoading(false)
390+
if (!cancelled) {
391+
setWaitingForSessionReady(false)
392+
setLoading(false)
393+
}
274394
}
275395
}
276396

@@ -415,13 +535,29 @@ function PlayerPage() {
415535
}, [handleToggleFullscreen])
416536

417537
if (loading) {
538+
const progressPercent = Math.max(0, Math.min(100, Math.round(sessionProgress?.percent ?? 0)))
539+
418540
return (
419541
<Container>
420542
<LoadingContainer>
421-
<LoadingText>加载中...</LoadingText>
422-
<LoadingBarContainer>
423-
<LoadingBarProgress />
424-
</LoadingBarContainer>
543+
{waitingForSessionReady ? (
544+
<>
545+
<ProgressStageText>
546+
{sessionProgress?.stage || '正在创建转码会话...'}
547+
</ProgressStageText>
548+
<DeterminateBarTrack>
549+
<DeterminateBarFill $percent={progressPercent} />
550+
</DeterminateBarTrack>
551+
<ProgressPercentText>{progressPercent}%</ProgressPercentText>
552+
</>
553+
) : (
554+
<>
555+
<LoadingText>加载中...</LoadingText>
556+
<LoadingBarContainer>
557+
<LoadingBarProgress />
558+
</LoadingBarContainer>
559+
</>
560+
)}
425561
</LoadingContainer>
426562
</Container>
427563
)
@@ -609,6 +745,35 @@ const LoadingBarProgress = styled.div`
609745
}
610746
`
611747

748+
const DETERMINATE_BAR_WIDTH = SPACING.XXL * 5
749+
750+
const DeterminateBarTrack = styled.div`
751+
width: ${DETERMINATE_BAR_WIDTH}px;
752+
height: ${COMPONENT_TOKENS.PROGRESS_BAR.TRACK_HEIGHT_HOVER}px;
753+
background: var(--ant-color-fill-quaternary, rgba(255, 255, 255, 0.08));
754+
border-radius: ${COMPONENT_TOKENS.PROGRESS_BAR.TRACK_BORDER_RADIUS}px;
755+
overflow: hidden;
756+
`
757+
758+
const DeterminateBarFill = styled.div<{ $percent: number }>`
759+
height: 100%;
760+
width: ${({ $percent }) => `${$percent}%`};
761+
background: var(--ant-color-primary, #1677ff);
762+
border-radius: ${COMPONENT_TOKENS.PROGRESS_BAR.TRACK_BORDER_RADIUS}px;
763+
transition: width ${ANIMATION_DURATION.SLOW} ${EASING.STANDARD};
764+
`
765+
766+
const ProgressStageText = styled.div`
767+
font-size: ${FONT_SIZES.BASE}px;
768+
color: var(--color-text-1, #ddd);
769+
text-align: center;
770+
`
771+
772+
const ProgressPercentText = styled.div`
773+
font-size: ${FONT_SIZES.SM}px;
774+
color: var(--color-text-2, #bbb);
775+
`
776+
612777
const ErrorContainer = styled.div`
613778
display: flex;
614779
flex-direction: column;

src/renderer/src/pages/player/components/SubtitleOverlay.tsx

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -423,15 +423,6 @@ export const SubtitleOverlay = memo(function SubtitleOverlay({
423423
return null
424424
}
425425

426-
logger.debug('渲染 SubtitleOverlay', {
427-
displayMode,
428-
position,
429-
size,
430-
isDragging,
431-
isResizing,
432-
showBoundaries
433-
})
434-
435426
return (
436427
<OverlayContainer
437428
ref={overlayRef}

src/renderer/src/services/SessionService.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,33 @@ export interface SessionDeleteResponse {
105105
session_id: string
106106
}
107107

108+
/**
109+
* 音频进度信息
110+
*/
111+
export interface AudioProgressInfo {
112+
status: string
113+
progress_percent: number
114+
processed_time: number
115+
total_duration: number
116+
transcode_speed: number
117+
eta_seconds: number
118+
error_message: string | null
119+
}
120+
121+
/**
122+
* 会话进度响应
123+
*/
124+
export interface SessionProgressResponse {
125+
session_id: string
126+
status: string
127+
progress_percent: number
128+
progress_stage: string
129+
error_message: string | null
130+
is_ready: boolean
131+
playlist_url: string | null
132+
audio_progress: AudioProgressInfo | null
133+
}
134+
108135
/**
109136
* 会话错误类型
110137
*/
@@ -518,6 +545,42 @@ export class SessionService {
518545
}
519546
}
520547

548+
/**
549+
* 获取会话创建进度
550+
*
551+
* @param sessionId 会话ID
552+
* @returns 会话进度响应
553+
*/
554+
public static async getSessionProgress(sessionId: string): Promise<SessionProgressResponse> {
555+
logger.debug('获取会话进度', { sessionId })
556+
557+
try {
558+
const response = await this.makeRequest<SessionProgressResponse>(`/${sessionId}/progress`)
559+
560+
logger.debug('会话进度获取成功', {
561+
sessionId,
562+
status: response.status,
563+
progress: response.progress_percent,
564+
stage: response.progress_stage,
565+
isReady: response.is_ready
566+
})
567+
568+
return response
569+
} catch (error) {
570+
// 如果是 HTTP 425 (Too Early),这是正常的进行中状态,不记录为错误
571+
if (error instanceof SessionError && error.statusCode === 425) {
572+
logger.debug('会话尚未就绪 (HTTP 425)', { sessionId })
573+
throw error
574+
}
575+
576+
logger.error('获取会话进度失败', {
577+
sessionId,
578+
error: error instanceof Error ? error.message : String(error)
579+
})
580+
throw error
581+
}
582+
}
583+
521584
/**
522585
* 获取当前活跃的请求数量(用于监控)
523586
*/

0 commit comments

Comments
 (0)