Skip to content

Commit 9c6ce8a

Browse files
committed
Add quality selection
1 parent cbe84ac commit 9c6ce8a

File tree

7 files changed

+436
-10
lines changed

7 files changed

+436
-10
lines changed

example/ActiveLayerLayoutDemo.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,21 @@ export const ActiveLayerLayoutDemo: React.FC<ActiveLayerLayoutDemoProps> = ({ on
190190
}
191191
};
192192

193+
// Example 6: Quality Selection Demo
194+
// For HLS videos, quality levels are automatically detected from the manifest
195+
// No need to specify quality levels manually - they will be dynamically loaded
196+
const qualitySelectionDemo = {
197+
quality: {
198+
enabled: true,
199+
defaultQuality: 'auto',
200+
button: {
201+
position: ButtonPosition.SE,
202+
color: '#9C27B0',
203+
size: 28,
204+
}
205+
}
206+
};
207+
193208
const getVideoLayerProps = () => {
194209
switch (currentExample) {
195210
case 'horizontal':
@@ -208,6 +223,8 @@ export const ActiveLayerLayoutDemo: React.FC<ActiveLayerLayoutDemoProps> = ({ on
208223
return subtitlesButtonDemo;
209224
case 'playbackSpeed':
210225
return playbackSpeedDemo;
226+
case 'quality':
227+
return qualitySelectionDemo;
211228
default:
212229
return {};
213230
}
@@ -218,7 +235,8 @@ export const ActiveLayerLayoutDemo: React.FC<ActiveLayerLayoutDemoProps> = ({ on
218235
{ id: 'vertical', title: 'Vertical Layout', description: 'Stacked button arrangements for enhanced accessibility' },
219236
{ id: 'mixed', title: 'Hybrid Layouts', description: 'Strategic combination of horizontal and vertical groupings' },
220237
{ id: 'subtitles', title: 'Subtitle Controls', description: 'Professional multi-language subtitle management' },
221-
{ id: 'playbackSpeed', title: 'Speed Controls', description: 'Granular playback speed adjustment for optimal viewing' }
238+
{ id: 'playbackSpeed', title: 'Speed Controls', description: 'Granular playback speed adjustment for optimal viewing' },
239+
{ id: 'quality', title: 'Quality Selection', description: 'Adaptive bitrate streaming with manual quality selection' }
222240
];
223241

224242
return (
@@ -288,7 +306,7 @@ export const ActiveLayerLayoutDemo: React.FC<ActiveLayerLayoutDemoProps> = ({ on
288306
<View style={styles.videoWrapper}>
289307
<CLDVideoLayer
290308
cldVideo={createMyVideoObject()}
291-
videoUrl={currentExample === 'subtitles' ? 'https://res.cloudinary.com/demo/video/upload/sp_sd:subtitles_((code_en-US;file_outdoors.vtt);(code_es;file_outdoors-es.vtt))/sea_turtle.m3u8' : undefined}
309+
videoUrl={(currentExample === 'subtitles' || currentExample === 'quality') ? 'https://res.cloudinary.com/demo/video/upload/sp_sd:subtitles_((code_en-US;file_outdoors.vtt);(code_es;file_outdoors-es.vtt))/sea_turtle.m3u8' : undefined}
292310
autoPlay={false}
293311
muted={true}
294312
showCenterPlayButton={true}
@@ -362,7 +380,6 @@ const styles = StyleSheet.create({
362380
borderRadius: 25,
363381
borderWidth: 1,
364382
borderColor: 'rgba(255, 255, 255, 0.2)',
365-
backdropFilter: 'blur(10px)',
366383
},
367384
backButtonText: {
368385
color: '#ffffff',
@@ -458,7 +475,6 @@ const styles = StyleSheet.create({
458475
borderRadius: 12,
459476
borderWidth: 1,
460477
borderColor: 'rgba(255, 255, 255, 0.1)',
461-
backdropFilter: 'blur(10px)',
462478
},
463479
featureTitle: {
464480
fontSize: 18,

src/widgets/video/layer/CLDVideoLayer.tsx

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { View, TouchableOpacity, Text, PanResponder, ActivityIndicator, Animated
33

44
import { Ionicons } from '@expo/vector-icons';
55
import AdvancedVideo from '../../../AdvancedVideo';
6-
import { CLDVideoLayerProps, ButtonPosition, ButtonLayoutDirection, SubtitleOption } from './types';
7-
import { formatTime, handleDefaultShare, isHLSVideo, parseHLSManifest, getVideoUrl } from './utils';
6+
import { CLDVideoLayerProps, ButtonPosition, ButtonLayoutDirection, SubtitleOption, QualityOption } from './types';
7+
import { formatTime, handleDefaultShare, isHLSVideo, parseHLSManifest, parseHLSQualityLevels, getVideoUrl } from './utils';
88
import { SubtitleCue, fetchSubtitleFile, findActiveSubtitle } from './utils/subtitleUtils';
99
import { styles, getResponsiveStyles } from './styles';
1010
import { TopControls, CenterControls, BottomControls, CustomButton, SubtitleDisplay } from './components';
@@ -27,6 +27,9 @@ interface CLDVideoLayerState {
2727
availableSubtitleTracks: SubtitleOption[];
2828
subtitleCues: SubtitleCue[];
2929
activeSubtitleText: string | null;
30+
currentQuality: string;
31+
isQualityMenuVisible: boolean;
32+
availableQualityLevels: QualityOption[];
3033
}
3134

3235
export class CLDVideoLayer extends React.Component<CLDVideoLayerProps, CLDVideoLayerState> {
@@ -65,6 +68,9 @@ export class CLDVideoLayer extends React.Component<CLDVideoLayerProps, CLDVideoL
6568
availableSubtitleTracks: [],
6669
subtitleCues: [],
6770
activeSubtitleText: null,
71+
currentQuality: props.quality?.defaultQuality || 'auto',
72+
isQualityMenuVisible: false,
73+
availableQualityLevels: [],
6874
};
6975

7076
this.panResponder = PanResponder.create({
@@ -149,9 +155,10 @@ export class CLDVideoLayer extends React.Component<CLDVideoLayerProps, CLDVideoL
149155
this.startAutoHideTimer();
150156
}
151157

152-
// Parse HLS manifest for subtitle tracks if video is HLS
158+
// Parse HLS manifest for subtitle tracks and quality levels if video is HLS
153159
setTimeout(() => {
154160
this.parseHLSSubtitlesIfNeeded();
161+
this.parseHLSQualityLevelsIfNeeded();
155162
}, 100);
156163

157164
// Try multiple approaches for orientation detection
@@ -168,9 +175,10 @@ export class CLDVideoLayer extends React.Component<CLDVideoLayerProps, CLDVideoL
168175
}
169176

170177
componentDidUpdate(prevProps: CLDVideoLayerProps) {
171-
// Re-parse subtitles if video URL changed
178+
// Re-parse subtitles and quality levels if video URL changed
172179
if (prevProps.videoUrl !== this.props.videoUrl) {
173180
this.parseHLSSubtitlesIfNeeded();
181+
this.parseHLSQualityLevelsIfNeeded();
174182
}
175183
}
176184

@@ -363,6 +371,50 @@ export class CLDVideoLayer extends React.Component<CLDVideoLayerProps, CLDVideoL
363371
this.setState({ isSubtitlesMenuVisible: !this.state.isSubtitlesMenuVisible });
364372
};
365373

374+
handleQualityChange = async (qualityValue: string) => {
375+
this.setState({ currentQuality: qualityValue });
376+
377+
if (qualityValue === 'auto') {
378+
// Reset to original URL for automatic quality selection
379+
const originalUrl = getVideoUrl(this.props.videoUrl, this.props.cldVideo);
380+
if (this.videoRef.current) {
381+
try {
382+
await this.videoRef.current.setStatusAsync({
383+
uri: originalUrl,
384+
shouldPlay: this.state.status?.shouldPlay || false,
385+
positionMillis: this.state.status?.positionMillis || 0
386+
});
387+
} catch (error) {
388+
console.warn('Failed to switch to auto quality:', error);
389+
}
390+
}
391+
return;
392+
}
393+
394+
// Find the selected quality level
395+
const selectedQuality = this.state.availableQualityLevels.find(
396+
level => level.value === qualityValue
397+
);
398+
399+
if (selectedQuality?.url && this.videoRef.current) {
400+
try {
401+
await this.videoRef.current.setStatusAsync({
402+
uri: selectedQuality.url,
403+
shouldPlay: this.state.status?.shouldPlay || false,
404+
positionMillis: this.state.status?.positionMillis || 0
405+
});
406+
} catch (error) {
407+
console.warn('Failed to switch to quality level:', qualityValue, error);
408+
}
409+
} else {
410+
console.warn('No URL found for quality level:', qualityValue);
411+
}
412+
};
413+
414+
handleToggleQualityMenu = () => {
415+
this.setState({ isQualityMenuVisible: !this.state.isQualityMenuVisible });
416+
};
417+
366418
/**
367419
* Parse HLS manifest to get available subtitle tracks if video is HLS
368420
*/
@@ -387,6 +439,30 @@ export class CLDVideoLayer extends React.Component<CLDVideoLayerProps, CLDVideoL
387439
}
388440
};
389441

442+
/**
443+
* Parse HLS manifest to get available quality levels if video is HLS
444+
*/
445+
parseHLSQualityLevelsIfNeeded = async () => {
446+
const videoUrl = getVideoUrl(this.props.videoUrl, this.props.cldVideo);
447+
448+
if (isHLSVideo(videoUrl)) {
449+
try {
450+
const qualityLevels = await parseHLSQualityLevels(videoUrl);
451+
452+
// Always include "Auto" option
453+
const availableQualityLevels: QualityOption[] = [
454+
{ value: 'auto', label: 'Auto' },
455+
...qualityLevels
456+
];
457+
458+
this.setState({ availableQualityLevels });
459+
} catch (error) {
460+
console.warn('Failed to parse HLS quality levels:', error);
461+
this.setState({ availableQualityLevels: [{ value: 'auto', label: 'Auto' }] });
462+
}
463+
}
464+
};
465+
390466
/**
391467
* Update active subtitle text based on current video time
392468
*/
@@ -470,10 +546,11 @@ export class CLDVideoLayer extends React.Component<CLDVideoLayerProps, CLDVideoL
470546
fullScreen,
471547
playbackSpeed,
472548
subtitles,
549+
quality,
473550
buttonGroups = [],
474551
titleLeftOffset
475552
} = this.props;
476-
const { status, isLandscape, isFullScreen, availableSubtitleTracks } = this.state;
553+
const { status, isLandscape, isFullScreen, availableSubtitleTracks, availableQualityLevels } = this.state;
477554
const progress = this.getProgress();
478555
const currentPosition = this.getCurrentPosition();
479556
const isVideoLoaded = status?.isLoaded === true;
@@ -489,6 +566,14 @@ export class CLDVideoLayer extends React.Component<CLDVideoLayerProps, CLDVideoL
489566
defaultLanguage: subtitles?.defaultLanguage || 'off'
490567
} : subtitles;
491568

569+
// Create dynamic quality config based on HLS availability
570+
const dynamicQuality = isHLSVideo(effectiveVideoUrl) && quality?.enabled ? {
571+
...quality,
572+
enabled: true,
573+
qualities: availableQualityLevels.length > 0 ? availableQualityLevels : [{ value: 'auto', label: 'Auto' }],
574+
defaultQuality: quality?.defaultQuality || 'auto'
575+
} : quality;
576+
492577
// Get responsive styles based on current orientation
493578
const responsiveStyles = getResponsiveStyles(isLandscape);
494579

@@ -579,6 +664,11 @@ export class CLDVideoLayer extends React.Component<CLDVideoLayerProps, CLDVideoL
579664
onSubtitleChange={this.handleSubtitleChange}
580665
isSubtitlesMenuVisible={this.state.isSubtitlesMenuVisible}
581666
onToggleSubtitlesMenu={this.handleToggleSubtitlesMenu}
667+
quality={dynamicQuality}
668+
currentQuality={this.state.currentQuality}
669+
onQualityChange={this.handleQualityChange}
670+
isQualityMenuVisible={this.state.isQualityMenuVisible}
671+
onToggleQualityMenu={this.handleToggleQualityMenu}
582672
buttonGroups={buttonGroups}
583673
/>
584674
</View>

src/widgets/video/layer/components/BottomControls.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ICON_SIZES } from '../constants';
77
import { Seekbar } from './Seekbar';
88
import { PlaybackSpeedButton } from './PlaybackSpeedButton';
99
import { SubtitlesButton } from './SubtitlesButton';
10+
import { QualityButton } from './QualityButton';
1011

1112
export const BottomControls: React.FC<BottomControlsProps> = ({
1213
status,
@@ -31,6 +32,11 @@ export const BottomControls: React.FC<BottomControlsProps> = ({
3132
onSubtitleChange,
3233
isSubtitlesMenuVisible,
3334
onToggleSubtitlesMenu,
35+
quality,
36+
currentQuality,
37+
onQualityChange,
38+
isQualityMenuVisible,
39+
onToggleQualityMenu,
3440
}) => {
3541
const responsiveStyles = getResponsiveStyles(isLandscape);
3642
const progress = getProgress();
@@ -70,6 +76,14 @@ export const BottomControls: React.FC<BottomControlsProps> = ({
7076
</View>
7177

7278
<View style={responsiveStyles.bottomRightControls}>
79+
<QualityButton
80+
quality={quality}
81+
currentQuality={currentQuality}
82+
onQualityChange={onQualityChange}
83+
isLandscape={isLandscape}
84+
isMenuVisible={isQualityMenuVisible}
85+
onToggleMenu={onToggleQualityMenu}
86+
/>
7387
<SubtitlesButton
7488
subtitles={subtitles}
7589
currentSubtitle={currentSubtitle}

0 commit comments

Comments
 (0)