diff --git a/src/components/ControlsLayer.tsx b/src/components/ControlsLayer.tsx index 965bb6c..4743bb5 100644 --- a/src/components/ControlsLayer.tsx +++ b/src/components/ControlsLayer.tsx @@ -12,7 +12,7 @@ import { CenterPlayButton } from './controls/CenterPlayButton' import { SettingsMenu } from './menus/SettingsMenu' import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts' import { useTouchGestures } from '../hooks/useTouchGestures' -import type { SubtitleTrack, AudioTrack } from '../types' +import type { SubtitleTrack, AudioTrack, VideoQuality } from '../types' import './ControlsLayer.css' interface ControlsLayerProps { @@ -20,6 +20,7 @@ interface ControlsLayerProps { pictureInPicture?: boolean subtitles?: SubtitleTrack[] audioTracks?: AudioTrack[] + qualities?: VideoQuality[] } export const ControlsLayer: React.FC = ({ @@ -27,6 +28,7 @@ export const ControlsLayer: React.FC = ({ pictureInPicture = true, subtitles = [], audioTracks = [], + qualities = [], }) => { const { videoState, uiState, togglePlay, toggleFullscreen, showControls, hideControls } = usePlayerContext() @@ -223,7 +225,7 @@ export const ControlsLayer: React.FC = ({
- +
{pictureInPicture && } diff --git a/src/components/VideoElement.tsx b/src/components/VideoElement.tsx index 2916a7e..69188a8 100644 --- a/src/components/VideoElement.tsx +++ b/src/components/VideoElement.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useCallback, useState } from 'react' import { usePlayerContext } from '../contexts/PlayerContext' -import type { SubtitleTrack, AudioTrack } from '../types' +import type { SubtitleTrack, AudioTrack, VideoQuality } from '../types' import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper' -import { getHlsAudioTracks, setHlsAudioTrack } from '../utils/hlsLoader' +import { getHlsAudioTracks, getHlsQualities, setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsLoader' import './VideoElement.css' interface VideoElementProps { @@ -22,6 +22,7 @@ interface VideoElementProps { onSeeking?: () => void onSeeked?: () => void onAudioTracksLoaded?: (tracks: AudioTrack[]) => void + onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void } export const VideoElement: React.FC = ({ @@ -41,10 +42,12 @@ export const VideoElement: React.FC = ({ onSeeking, onSeeked, onAudioTracksLoaded, + onQualityLevelsLoaded, }) => { - const { videoRef, setVideoState, toggleFullscreen, settings } = usePlayerContext() + const { videoRef, setVideoState, toggleFullscreen, settings, setQuality } = usePlayerContext() const lastClickTimeRef = React.useRef(0) const [availableAudioTracks, setAvailableAudioTracks] = useState([]) + const [availableQualities, setAvailableQualities] = useState([]) // Handle video events const handlePlay = useCallback(() => { @@ -197,6 +200,11 @@ export const VideoElement: React.FC = ({ const video = videoRef.current if (!video) return + setAvailableAudioTracks([]) + onAudioTracksLoaded?.([]) + setAvailableQualities([]) + onQualityLevelsLoaded?.([]) + // Validate video URL first const validation = validateVideoURL(src) if (!validation.valid) { @@ -234,11 +242,15 @@ export const VideoElement: React.FC = ({ // Sometimes audio tracks are not immediately available, so we try with a small delay setTimeout(() => { const tracks = getHlsAudioTracks(hls) + const qualities = getHlsQualities(hls) if (tracks.length > 0) { setAvailableAudioTracks(tracks) onAudioTracksLoaded?.(tracks) } + + setAvailableQualities(qualities) + onQualityLevelsLoaded?.(qualities) }, 100) if (autoplay) { @@ -321,7 +333,16 @@ export const VideoElement: React.FC = ({ delete (video as any).__hlsInstance } } - }, [src, autoplay, videoRef, handleError, setVideoState, onError, onAudioTracksLoaded]) + }, [ + src, + autoplay, + videoRef, + handleError, + setVideoState, + onError, + onAudioTracksLoaded, + onQualityLevelsLoaded, + ]) // Handle audio track changes useEffect(() => { @@ -341,6 +362,79 @@ export const VideoElement: React.FC = ({ } }, [settings.audioTrack, availableAudioTracks, videoRef]) + // Reset selected quality if it no longer exists + useEffect(() => { + if (!settings.quality) return + if (availableQualities.length === 0) return + + const hasQuality = availableQualities.some((quality) => { + if ( + typeof settings.quality?.levelIndex === 'number' && + typeof quality.levelIndex === 'number' && + quality.levelIndex === settings.quality.levelIndex + ) { + return true + } + + if ( + typeof settings.quality?.height === 'number' && + typeof quality.height === 'number' && + quality.height === settings.quality.height + ) { + return true + } + + return quality.label === settings.quality?.label + }) + + if (!hasQuality) { + setQuality(null) + } + }, [availableQualities, settings.quality, setQuality]) + + // Apply selected quality to HLS instance + useEffect(() => { + const video = videoRef.current + if (!video) return + + const hlsInstance = (video as any).__hlsInstance + if (!hlsInstance) return + + if (!settings.quality) { + setHlsQualityLevel(hlsInstance, null) + return + } + + let targetLevelIndex = + typeof settings.quality.levelIndex === 'number' ? settings.quality.levelIndex : undefined + + if (typeof targetLevelIndex !== 'number') { + const matchingQuality = availableQualities.find((quality) => { + if ( + typeof settings.quality?.levelIndex === 'number' && + typeof quality.levelIndex === 'number' + ) { + return quality.levelIndex === settings.quality.levelIndex + } + + if ( + typeof settings.quality?.height === 'number' && + typeof quality.height === 'number' + ) { + return quality.height === settings.quality.height + } + + return quality.label === settings.quality?.label + }) + + if (matchingQuality && typeof matchingQuality.levelIndex === 'number') { + targetLevelIndex = matchingQuality.levelIndex + } + } + + setHlsQualityLevel(hlsInstance, targetLevelIndex) + }, [settings.quality, availableQualities, videoRef]) + return (
@@ -101,6 +107,7 @@ export const VideoPlayer: React.FC = ({ onSeeked, }) => { const [audioTracks, setAudioTracks] = useState([]) + const [qualities, setQualities] = useState([]) // Apply theme CSS variables useEffect(() => { @@ -117,6 +124,10 @@ export const VideoPlayer: React.FC = ({ setAudioTracks(tracks) }, []) + const handleQualityLevelsLoaded = useCallback((levels: VideoQuality[]) => { + setQualities(levels) + }, []) + return ( = ({ onSeeked={onSeeked} audioTracks={audioTracks} onAudioTracksLoadedInternal={handleAudioTracksLoaded} + qualities={qualities} + onQualityLevelsLoadedInternal={handleQualityLevelsLoaded} /> ) diff --git a/src/components/menus/SettingsMenu.tsx b/src/components/menus/SettingsMenu.tsx index 88fa4b4..63afca5 100644 --- a/src/components/menus/SettingsMenu.tsx +++ b/src/components/menus/SettingsMenu.tsx @@ -1,18 +1,32 @@ import React, { useEffect, useRef, useState } from 'react' import { usePlayerContext } from '../../contexts/PlayerContext' -import { SpeedIcon, SubtitlesIcon, CheckIcon, AudioIcon } from '../../icons' -import type { AudioTrack } from '../../types' +import { SpeedIcon, SubtitlesIcon, CheckIcon, AudioIcon, QualityIcon } from '../../icons' +import type { AudioTrack, VideoQuality } from '../../types' import './SettingsMenu.css' interface SettingsMenuProps { subtitles?: Array<{ src: string; lang: string; label: string }> audioTracks?: AudioTrack[] + qualities?: VideoQuality[] } -type MenuView = 'main' | 'speed' | 'subtitles' | 'audio' +type MenuView = 'main' | 'speed' | 'subtitles' | 'audio' | 'quality' -export const SettingsMenu: React.FC = ({ subtitles = [], audioTracks = [] }) => { - const { uiState, videoState, settings, setPlaybackRate, setSubtitle, setAudioTrack, toggleSettings } = usePlayerContext() +export const SettingsMenu: React.FC = ({ + subtitles = [], + audioTracks = [], + qualities = [], +}) => { + const { + uiState, + videoState, + settings, + setPlaybackRate, + setSubtitle, + setAudioTrack, + setQuality, + toggleSettings, + } = usePlayerContext() const menuRef = useRef(null) const [currentView, setCurrentView] = useState('main') @@ -55,6 +69,21 @@ export const SettingsMenu: React.FC = ({ subtitles = [], audi

Ayarlar

+ {qualities.length > 0 && ( + + )} + ))}
)} + {/* Quality Submenu */} + {currentView === 'quality' && ( + <> +
+ +

Çözünürlük

+
+
+ + {qualities.map((quality) => { + const isActive = (() => { + if (typeof settings.quality?.levelIndex === 'number') { + return settings.quality.levelIndex === quality.levelIndex + } + if (typeof settings.quality?.height === 'number' && typeof quality.height === 'number') { + return settings.quality.height === quality.height + } + return settings.quality?.label === quality.label + })() + + const bitrateLabel = + typeof quality.bitrate === 'number' && quality.bitrate > 0 + ? ` • ${Math.round(quality.bitrate / 1000)} kbps` + : '' + + return ( + + ) + })} +
+ + )} ) } diff --git a/src/icons/index.tsx b/src/icons/index.tsx index 242e014..12c23d9 100644 --- a/src/icons/index.tsx +++ b/src/icons/index.tsx @@ -165,6 +165,22 @@ export const SpeedIcon: React.FC = ({ size = 24, className = '', colo ) +export const QualityIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + export const ForwardIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( { } } +/** + * Extract available quality levels from HLS instance + */ +export const getHlsQualities = (hls: any): VideoQuality[] => { + try { + if (!hls || !Array.isArray(hls.levels)) { + return [] + } + + const qualities: VideoQuality[] = hls.levels.map((level: any, index: number) => { + const resolution = typeof level.attrs?.RESOLUTION === 'string' ? level.attrs.RESOLUTION : undefined + const [widthFromResolution, heightFromResolution] = resolution + ? resolution.split('x').map((value: string) => parseInt(value, 10)) + : [undefined, undefined] + + const width = level.width || widthFromResolution + const height = level.height || heightFromResolution + const bitrate = typeof level.bitrate === 'number' ? level.bitrate : level.attrs?.BANDWIDTH + + let label: string + if (typeof level.name === 'string' && level.name.trim().length > 0) { + label = level.name + } else if (typeof height === 'number' && !Number.isNaN(height) && height > 0) { + label = `${height}p` + } else if (typeof bitrate === 'number' && bitrate > 0) { + label = `${Math.round(bitrate / 1000)} kbps` + } else { + label = `Seviye ${index + 1}` + } + + return { + height, + width, + bitrate: typeof bitrate === 'number' ? bitrate : undefined, + label, + levelIndex: index, + url: level.url || level.uri || undefined, + } + }) + + return qualities.sort((a, b) => { + const heightDifference = (b.height || 0) - (a.height || 0) + if (heightDifference !== 0) { + return heightDifference + } + return (b.bitrate || 0) - (a.bitrate || 0) + }) + } catch { + return [] + } +} + +/** + * Update active quality level in HLS instance. Passing null re-enables auto. + */ +export const setHlsQualityLevel = (hls: any, levelIndex: number | null | undefined): void => { + if (!hls || !Array.isArray(hls.levels)) { + return + } + + if (levelIndex === null || typeof levelIndex === 'undefined' || levelIndex < 0) { + if (hls.currentLevel !== -1) { + hls.currentLevel = -1 + } + return + } + + if (levelIndex >= hls.levels.length) { + return + } + + if (hls.currentLevel !== levelIndex) { + hls.currentLevel = levelIndex + } +} + /** * Set active audio track in HLS instance */