diff --git a/apps/web/.env.storybook b/apps/web/.env.storybook index dbdbd091d..7a95440d7 100644 --- a/apps/web/.env.storybook +++ b/apps/web/.env.storybook @@ -5,3 +5,4 @@ VITE_API_URL=/api/v1 VITE_IS_STORYBOOK=true VITE_USE_MSW=true +VITE_FEATURE_HLS_STREAMING=true diff --git a/apps/web/src/features/player/components/audio-player/useAudioPlayerLifecycle.ts b/apps/web/src/features/player/components/audio-player/useAudioPlayerLifecycle.ts index dec862859..1c738144e 100644 --- a/apps/web/src/features/player/components/audio-player/useAudioPlayerLifecycle.ts +++ b/apps/web/src/features/player/components/audio-player/useAudioPlayerLifecycle.ts @@ -1,8 +1,11 @@ import { useRef, useEffect, useState } from 'react'; +import Hls from 'hls.js'; import { usePlayer } from '../../hooks/usePlayer'; import { useStreamSync } from '../../hooks/useStreamSync'; import { usePlayerStore } from '../../store/playerStore'; import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'; +import { useHLSPlayer } from '@/features/streaming/hooks/useHLSPlayer'; +import { FEATURES } from '@/config/features'; import type { AudioQuality } from '../QualitySelector'; import type { PlaybackSpeed } from '../PlaybackSpeedControl'; @@ -23,6 +26,14 @@ export function useAudioPlayerLifecycle({ const [playbackSpeed, setPlaybackSpeed] = useState(1); const currentTrack = usePlayerStore((state) => state.currentTrack); + const hlsEnabled = FEATURES.HLS_STREAMING && Hls.isSupported(); + const streamStatus = currentTrack?.url ? 'ready' : undefined; + const hlsPlayer = useHLSPlayer( + audioRef, + hlsEnabled ? (currentTrack?.id ?? null) : null, + hlsEnabled ? streamStatus : undefined, + ); + const sessionId = currentTrack?.id ? `session_${currentTrack.id}` : null; const { isSynced } = useStreamSync({ sessionId, @@ -78,6 +89,13 @@ export function useAudioPlayerLifecycle({ if (audio) audio.playbackRate = playbackSpeed; }, [playbackSpeed]); + const handleSetQuality = (q: AudioQuality) => { + setQuality(q); + if (hlsPlayer.isHLSActive) { + hlsPlayer.setQuality(q); + } + }; + const handlePlayPause = () => { if (player.isPlaying) { player.pause(); @@ -103,7 +121,7 @@ export function useAudioPlayerLifecycle({ error, isLoading, quality, - setQuality, + setQuality: handleSetQuality, playbackSpeed, setPlaybackSpeed, isSynced, @@ -112,5 +130,7 @@ export function useAudioPlayerLifecycle({ handleRetry, canGoNext, canGoPrevious, + isHLSActive: hlsPlayer.isHLSActive, + hlsLevels: hlsPlayer.levels, }; } diff --git a/apps/web/src/mocks/handlers-streaming.ts b/apps/web/src/mocks/handlers-streaming.ts new file mode 100644 index 000000000..9bafcde96 --- /dev/null +++ b/apps/web/src/mocks/handlers-streaming.ts @@ -0,0 +1,69 @@ +import { http, HttpResponse } from 'msw'; + +export const handlersStreaming = [ + http.get('*/api/v1/tracks/:id/hls/info', () => { + return HttpResponse.json({ + success: true, + data: { + track_id: 'mock-track-id', + available: true, + qualities: [ + { bitrate: 128000, codec: 'aac', label: 'low' }, + { bitrate: 256000, codec: 'aac', label: 'medium' }, + { bitrate: 320000, codec: 'aac', label: 'high' }, + ], + duration: 240.5, + format: 'hls', + }, + }); + }), + + http.get('*/api/v1/tracks/:id/hls/status', () => { + return HttpResponse.json({ + success: true, + data: { + track_id: 'mock-track-id', + status: 'ready', + progress: 100, + created_at: new Date().toISOString(), + }, + }); + }), + + http.get('*/api/v1/tracks/:id/hls/master.m3u8', () => { + const playlist = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-STREAM-INF:BANDWIDTH=128000,CODECS="mp4a.40.2"', + '128000/playlist.m3u8', + '#EXT-X-STREAM-INF:BANDWIDTH=256000,CODECS="mp4a.40.2"', + '256000/playlist.m3u8', + '#EXT-X-STREAM-INF:BANDWIDTH=320000,CODECS="mp4a.40.2"', + '320000/playlist.m3u8', + ].join('\n'); + + return new HttpResponse(playlist, { + headers: { + 'Content-Type': 'application/vnd.apple.mpegurl', + }, + }); + }), + + http.get('*/api/v1/tracks/:id/hls/:bitrate/playlist.m3u8', () => { + const playlist = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:10', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXTINF:10.0,', + 'segment_000.ts', + '#EXT-X-ENDLIST', + ].join('\n'); + + return new HttpResponse(playlist, { + headers: { + 'Content-Type': 'application/vnd.apple.mpegurl', + }, + }); + }), +]; diff --git a/apps/web/src/mocks/handlers.ts b/apps/web/src/mocks/handlers.ts index cea289619..a4a9f45ee 100644 --- a/apps/web/src/mocks/handlers.ts +++ b/apps/web/src/mocks/handlers.ts @@ -24,6 +24,7 @@ import { handlersTracks } from './handlers-tracks'; import { handlersPlaylists } from './handlers-playlists'; import { handlersMisc } from './handlers-misc'; import { handlersCloud } from './handlers-cloud'; +import { handlersStreaming } from './handlers-streaming'; export const handlers = [ ...handlersCommon, @@ -35,6 +36,7 @@ export const handlers = [ ...handlersPlaylists, ...handlersMisc, ...handlersCloud, + ...handlersStreaming, // Catch-all for API to prevent network leaks (Phase 1: Stabilization) http.all('*/api/v1/*', ({ request }) => {