Merge pull request #6 from MertUyanik/codex/add-resolution-selection-feature
Add resolution selection to settings menu
This commit is contained in:
@@ -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<ControlsLayerProps> = ({
|
||||
@@ -27,6 +28,7 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
||||
pictureInPicture = true,
|
||||
subtitles = [],
|
||||
audioTracks = [],
|
||||
qualities = [],
|
||||
}) => {
|
||||
const { videoState, uiState, togglePlay, toggleFullscreen, showControls, hideControls } =
|
||||
usePlayerContext()
|
||||
@@ -223,7 +225,7 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
||||
<div className="controls-right">
|
||||
<div style={{ position: 'relative' }}>
|
||||
<SettingsButton />
|
||||
<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} />
|
||||
<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />
|
||||
</div>
|
||||
{pictureInPicture && <PIPButton />}
|
||||
<FullscreenButton />
|
||||
|
||||
@@ -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<VideoElementProps> = ({
|
||||
@@ -41,10 +42,12 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
onSeeking,
|
||||
onSeeked,
|
||||
onAudioTracksLoaded,
|
||||
onQualityLevelsLoaded,
|
||||
}) => {
|
||||
const { videoRef, setVideoState, toggleFullscreen, settings } = usePlayerContext()
|
||||
const { videoRef, setVideoState, toggleFullscreen, settings, setQuality } = usePlayerContext()
|
||||
const lastClickTimeRef = React.useRef<number>(0)
|
||||
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
|
||||
const [availableQualities, setAvailableQualities] = useState<VideoQuality[]>([])
|
||||
|
||||
// Handle video events
|
||||
const handlePlay = useCallback(() => {
|
||||
@@ -197,6 +200,11 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
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<VideoElementProps> = ({
|
||||
// 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<VideoElementProps> = ({
|
||||
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<VideoElementProps> = ({
|
||||
}
|
||||
}, [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 (
|
||||
<div className="video-container">
|
||||
<video
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
||||
import { VideoElement } from './VideoElement'
|
||||
import { ControlsLayer } from './ControlsLayer'
|
||||
import type { VideoPlayerProps, AudioTrack } from '../types'
|
||||
import type { VideoPlayerProps, AudioTrack, VideoQuality } from '../types'
|
||||
import { initializePolyfills } from '../utils/polyfills'
|
||||
import '../styles/variables.css'
|
||||
import './VideoPlayer.css'
|
||||
@@ -18,6 +18,8 @@ const VideoPlayerContent: React.FC<
|
||||
VideoPlayerProps & {
|
||||
audioTracks: AudioTrack[]
|
||||
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
|
||||
qualities: VideoQuality[]
|
||||
onQualityLevelsLoadedInternal: (qualities: VideoQuality[]) => void
|
||||
}
|
||||
> = ({
|
||||
src,
|
||||
@@ -42,6 +44,8 @@ const VideoPlayerContent: React.FC<
|
||||
onSeeked,
|
||||
audioTracks,
|
||||
onAudioTracksLoadedInternal,
|
||||
qualities,
|
||||
onQualityLevelsLoadedInternal,
|
||||
}) => {
|
||||
const { containerRef } = usePlayerContext()
|
||||
|
||||
@@ -64,6 +68,7 @@ const VideoPlayerContent: React.FC<
|
||||
onSeeking={onSeeking}
|
||||
onSeeked={onSeeked}
|
||||
onAudioTracksLoaded={onAudioTracksLoadedInternal}
|
||||
onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
|
||||
/>
|
||||
{controls && (
|
||||
<ControlsLayer
|
||||
@@ -71,6 +76,7 @@ const VideoPlayerContent: React.FC<
|
||||
pictureInPicture={pictureInPicture}
|
||||
subtitles={subtitles}
|
||||
audioTracks={audioTracks}
|
||||
qualities={qualities}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -101,6 +107,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onSeeked,
|
||||
}) => {
|
||||
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
|
||||
const [qualities, setQualities] = useState<VideoQuality[]>([])
|
||||
|
||||
// Apply theme CSS variables
|
||||
useEffect(() => {
|
||||
@@ -117,6 +124,10 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
setAudioTracks(tracks)
|
||||
}, [])
|
||||
|
||||
const handleQualityLevelsLoaded = useCallback((levels: VideoQuality[]) => {
|
||||
setQualities(levels)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PlayerProvider initialMuted={muted}>
|
||||
<VideoPlayerContent
|
||||
@@ -142,6 +153,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onSeeked={onSeeked}
|
||||
audioTracks={audioTracks}
|
||||
onAudioTracksLoadedInternal={handleAudioTracksLoaded}
|
||||
qualities={qualities}
|
||||
onQualityLevelsLoadedInternal={handleQualityLevelsLoaded}
|
||||
/>
|
||||
</PlayerProvider>
|
||||
)
|
||||
|
||||
@@ -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<SettingsMenuProps> = ({ subtitles = [], audioTracks = [] }) => {
|
||||
const { uiState, videoState, settings, setPlaybackRate, setSubtitle, setAudioTrack, toggleSettings } = usePlayerContext()
|
||||
export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
subtitles = [],
|
||||
audioTracks = [],
|
||||
qualities = [],
|
||||
}) => {
|
||||
const {
|
||||
uiState,
|
||||
videoState,
|
||||
settings,
|
||||
setPlaybackRate,
|
||||
setSubtitle,
|
||||
setAudioTrack,
|
||||
setQuality,
|
||||
toggleSettings,
|
||||
} = usePlayerContext()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [currentView, setCurrentView] = useState<MenuView>('main')
|
||||
|
||||
@@ -55,6 +69,21 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({ subtitles = [], audi
|
||||
<h3>Ayarlar</h3>
|
||||
</div>
|
||||
<div className="settings-main-options">
|
||||
{qualities.length > 0 && (
|
||||
<button className="settings-main-option" onClick={() => setCurrentView('quality')}>
|
||||
<div className="settings-main-option-icon">
|
||||
<QualityIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">Çözünürlük</span>
|
||||
<span className="settings-main-option-value">
|
||||
{settings.quality ? settings.quality.label : 'Otomatik'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-main-option-arrow">›</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="settings-main-option" onClick={() => setCurrentView('speed')}>
|
||||
<div className="settings-main-option-icon">
|
||||
<SpeedIcon size={20} color="var(--player-text)" />
|
||||
@@ -189,13 +218,71 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({ subtitles = [], audi
|
||||
}}
|
||||
>
|
||||
<span>{track.name}</span>
|
||||
{settings.audioTrack?.language === track.language && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||
{settings.audioTrack?.language === track.language && (
|
||||
<CheckIcon size={16} color="var(--player-primary)" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Quality Submenu */}
|
||||
{currentView === 'quality' && (
|
||||
<>
|
||||
<div className="settings-menu-header">
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>Çözünürlük</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
<button
|
||||
className={`settings-option ${!settings.quality ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setQuality(null)
|
||||
setTimeout(() => goBack(), 150)
|
||||
}}
|
||||
>
|
||||
<span>Otomatik</span>
|
||||
{!settings.quality && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||
</button>
|
||||
{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 (
|
||||
<button
|
||||
key={(quality.levelIndex ?? quality.label).toString()}
|
||||
className={`settings-option ${isActive ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setQuality(quality)
|
||||
setTimeout(() => goBack(), 150)
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{quality.label}
|
||||
{bitrateLabel}
|
||||
</span>
|
||||
{isActive && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,6 +165,22 @@ export const SpeedIcon: React.FC<IconProps> = ({ size = 24, className = '', colo
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const QualityIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 12H8v-2H6v2H4.5V9H6v2h2V9h1.5v6zm9 0h-1.5v-2.25H15V15h-1.5V9H15v2.25h3.5V9H20v6z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ForwardIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
|
||||
+4
-1
@@ -17,9 +17,12 @@ export interface AudioTrack {
|
||||
}
|
||||
|
||||
export interface VideoQuality {
|
||||
height: number
|
||||
height?: number
|
||||
label: string
|
||||
url?: string
|
||||
width?: number
|
||||
bitrate?: number
|
||||
levelIndex?: number
|
||||
}
|
||||
|
||||
export interface PlayerTheme {
|
||||
|
||||
+77
-1
@@ -3,7 +3,7 @@
|
||||
* Handles loading hls.js from npm or CDN
|
||||
*/
|
||||
|
||||
import type { AudioTrack } from '../types'
|
||||
import type { AudioTrack, VideoQuality } from '../types'
|
||||
|
||||
const HLS_CDN_URL = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js'
|
||||
|
||||
@@ -104,6 +104,82 @@ export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user