diff --git a/src/components/VideoPlayer.test.tsx b/src/components/VideoPlayer.test.tsx
index 6633ec1..701104c 100644
--- a/src/components/VideoPlayer.test.tsx
+++ b/src/components/VideoPlayer.test.tsx
@@ -125,6 +125,20 @@ describe('VideoPlayer', () => {
// Error handling is tested separately in integration tests
})
+ it('renders animated images with img element in auto mode', () => {
+ const { container } = render(
)
+ const image = container.querySelector('.sp-animated-image-element')
+ const video = container.querySelector('video')
+
+ expect(image).toBeInTheDocument()
+ expect(video).not.toBeInTheDocument()
+ })
+
+ it('hides controls for animated image media', () => {
+ const { container } = render(
)
+ expect(container.querySelector('.sp-controls-layer')).not.toBeInTheDocument()
+ })
+
it('hides controls when controls prop is false', () => {
const { container } = render(
)
const controls = container.querySelector('.controls')
diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx
index 890112e..f011421 100644
--- a/src/components/VideoPlayer.tsx
+++ b/src/components/VideoPlayer.tsx
@@ -9,8 +9,10 @@ import type {
AudioTrack,
VideoQuality,
SubtitleTrack,
+ VideoMediaType,
} from '../types'
import { initializePolyfills } from '../utils/polyfills'
+import { detectPlayerMediaType } from '../utils/mediaSource'
import '../styles/variables.css'
import './VideoPlayer.css'
@@ -65,6 +67,7 @@ const resolveSubtitleStyleEditorConfig = (
}
interface VideoPlayerContentProps extends VideoPlayerProps {
+ mediaType: VideoMediaType
audioTracks: AudioTrack[]
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
qualities: VideoQuality[]
@@ -77,6 +80,7 @@ const VideoPlayerContent = forwardRef
(() => {
const cssVariables: Record = {}
@@ -238,12 +244,13 @@ const VideoPlayerContent = forwardRef
- {controls && (
+ {effectiveControls && (
(
(
{
src,
+ mediaType = 'auto',
poster,
protocol = 'auto',
autoplay = false,
@@ -379,6 +387,10 @@ export const VideoPlayer = forwardRef(
},
ref
) => {
+ const resolvedMediaType = useMemo(() => {
+ const detectedMediaType = detectPlayerMediaType(src, mediaType)
+ return detectedMediaType === 'animated-image' ? 'animated-image' : 'video'
+ }, [src, mediaType])
const [audioTracks, setAudioTracks] = useState([])
const [qualities, setQualities] = useState([])
const [hlsSubtitles, setHlsSubtitles] = useState([])
@@ -419,6 +431,7 @@ export const VideoPlayer = forwardRef(
void
}
+export interface AudioPlayerHandle {
+ /** Audio HTML elementi */
+ audio: HTMLAudioElement | null
+ /** Player container elementi */
+ container: HTMLDivElement | null
+ play: () => void
+ pause: () => void
+ seek: (time: number) => void
+ setVolume: (volume: number) => void
+ toggleMute: () => void
+ setPlaybackRate: (rate: number) => void
+}
+
+export interface AudioPlayerProps {
+ src: string
+ artwork?: string
+ title?: string
+ subtitle?: string
+ autoplay?: boolean
+ loop?: boolean
+ muted?: boolean
+ volume?: number
+ playbackRate?: number
+ currentTime?: number
+ crossOrigin?: '' | 'anonymous' | 'use-credentials'
+ preload?: 'none' | 'metadata' | 'auto'
+ controls?: boolean
+ keyboardShortcuts?: boolean
+ theme?: PlayerTheme
+ language?: string
+ className?: string
+ style?: CSSProperties
+ /** Oynatma hızı seçenekleri (varsayılan: [0.5, 0.75, 1, 1.25, 1.5, 2]) */
+ playbackRates?: number[]
+ /** Özel çeviri metinleri */
+ translations?: Partial
+
+ // Slot prop'ları
+ /** Player üzerine yerleştirilecek overlay içeriği */
+ children?: ReactNode
+ /** Kontrol çubuğu sol tarafına eklenecek butonlar */
+ controlsLeftExtra?: ReactNode
+ /** Kontrol çubuğu sağ tarafına eklenecek butonlar */
+ controlsRightExtra?: ReactNode
+
+ // Event callbacks
+ onPlay?: () => void
+ onPause?: () => void
+ onEnded?: () => void
+ onTimeUpdate?: (currentTime: number) => void
+ onVolumeChange?: (volume: number) => void
+ onError?: (error: Error) => void
+ onLoadedMetadata?: () => void
+ onSeeking?: () => void
+ onSeeked?: () => void
+ onProgress?: (buffered: number) => void
+ onDurationChange?: (duration: number) => void
+ onRateChange?: (playbackRate: number) => void
+ onWaiting?: () => void
+ onCanPlay?: () => void
+}
+
export interface VideoState {
playing: boolean
currentTime: number
diff --git a/src/utils/mediaSource.test.ts b/src/utils/mediaSource.test.ts
new file mode 100644
index 0000000..c52c73b
--- /dev/null
+++ b/src/utils/mediaSource.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest'
+import { detectPlayerMediaType, isAnimatedImageSource, isAudioSource } from './mediaSource'
+
+describe('mediaSource', () => {
+ describe('isAnimatedImageSource', () => {
+ it('detects GIF by extension', () => {
+ expect(isAnimatedImageSource('https://example.com/loop.gif')).toBe(true)
+ })
+
+ it('detects animated WEBP with query string', () => {
+ expect(isAnimatedImageSource('/assets/anim.webp?size=large')).toBe(true)
+ })
+
+ it('detects data URI animated image', () => {
+ expect(isAnimatedImageSource('data:image/gif;base64,R0lGODlhAQABAIAAAAUEBA==')).toBe(true)
+ })
+
+ it('returns false for non-animated image formats', () => {
+ expect(isAnimatedImageSource('https://example.com/poster.jpg')).toBe(false)
+ })
+ })
+
+ describe('isAudioSource', () => {
+ it('detects MP3 by extension', () => {
+ expect(isAudioSource('https://example.com/song.mp3')).toBe(true)
+ })
+
+ it('detects WAV with hash', () => {
+ expect(isAudioSource('/audio/test.wav#preview')).toBe(true)
+ })
+
+ it('detects audio data URI', () => {
+ expect(isAudioSource('data:audio/wav;base64,UklGRpQAAABXQVZFZm10')).toBe(true)
+ })
+
+ it('returns false for mp4', () => {
+ expect(isAudioSource('https://example.com/video.mp4')).toBe(false)
+ })
+ })
+
+ describe('detectPlayerMediaType', () => {
+ it('returns requested type when explicitly set', () => {
+ expect(detectPlayerMediaType('https://example.com/video.mp4', 'animated-image')).toBe(
+ 'animated-image'
+ )
+ })
+
+ it('detects animated-image automatically', () => {
+ expect(detectPlayerMediaType('https://example.com/a.gif')).toBe('animated-image')
+ })
+
+ it('detects audio automatically', () => {
+ expect(detectPlayerMediaType('https://example.com/track.flac')).toBe('audio')
+ })
+
+ it('defaults to video', () => {
+ expect(detectPlayerMediaType('https://example.com/video.mp4')).toBe('video')
+ })
+ })
+})
diff --git a/src/utils/mediaSource.ts b/src/utils/mediaSource.ts
new file mode 100644
index 0000000..957985e
--- /dev/null
+++ b/src/utils/mediaSource.ts
@@ -0,0 +1,86 @@
+export type PlayerMediaType = 'video' | 'audio' | 'animated-image'
+
+export type PlayerMediaTypeInput = PlayerMediaType | 'auto'
+
+const ANIMATED_IMAGE_EXTENSIONS = new Set(['gif', 'apng', 'webp', 'avif'])
+const AUDIO_EXTENSIONS = new Set([
+ 'mp3',
+ 'wav',
+ 'ogg',
+ 'oga',
+ 'm4a',
+ 'aac',
+ 'flac',
+ 'opus',
+ 'weba',
+])
+
+const getNormalizedPath = (src: string): string => {
+ if (!src) return ''
+
+ try {
+ if (typeof window !== 'undefined') {
+ return new URL(src, window.location.href).pathname.toLowerCase()
+ }
+ return new URL(src).pathname.toLowerCase()
+ } catch {
+ const normalized = src.split('?')[0]?.split('#')[0] ?? src
+ return normalized.toLowerCase()
+ }
+}
+
+const getExtension = (src: string): string => {
+ const path = getNormalizedPath(src)
+ const lastDot = path.lastIndexOf('.')
+ if (lastDot < 0 || lastDot === path.length - 1) return ''
+ return path.slice(lastDot + 1)
+}
+
+const startsWithDataMime = (src: string, mimePrefix: string): boolean =>
+ src.toLowerCase().startsWith(`data:${mimePrefix}`)
+
+export const isAnimatedImageSource = (src: string): boolean => {
+ if (!src) return false
+
+ if (
+ startsWithDataMime(src, 'image/gif') ||
+ startsWithDataMime(src, 'image/apng') ||
+ startsWithDataMime(src, 'image/webp') ||
+ startsWithDataMime(src, 'image/avif')
+ ) {
+ return true
+ }
+
+ const extension = getExtension(src)
+ return ANIMATED_IMAGE_EXTENSIONS.has(extension)
+}
+
+export const isAudioSource = (src: string): boolean => {
+ if (!src) return false
+
+ if (startsWithDataMime(src, 'audio/')) {
+ return true
+ }
+
+ const extension = getExtension(src)
+ return AUDIO_EXTENSIONS.has(extension)
+}
+
+export const detectPlayerMediaType = (
+ src: string,
+ requestedType: PlayerMediaTypeInput = 'auto'
+): PlayerMediaType => {
+ if (requestedType !== 'auto') {
+ return requestedType
+ }
+
+ if (isAnimatedImageSource(src)) {
+ return 'animated-image'
+ }
+
+ if (isAudioSource(src)) {
+ return 'audio'
+ }
+
+ return 'video'
+}