
Unified API for video playback offering streaming, disk caching, partial/full offline downloads, head preloading, queue-based prefetching and native view rendering via a shared UI component.
Kotlin Multiplatform 공통 코드로 Android와 iOS에서 동일한 API로 동영상을 재생합니다.
스트리밍, 디스크 캐시, 앞부분 선로딩(프리로드), 전체 오프라인 다운로드를 지원합니다.
UI는 공통 PlatformMediaPlayer 컴포저블로 플랫폼별 네이티브 플레이어를 렌더링합니다.
캐시, 네트워크, 배터리 제공자들이 전역 Context 에 접근합니다.
앱 시작 시 한 번 초기화하세요.
// app/src/main/java/.../App.kt
class App : Application() {
override fun onCreate() {
super.onCreate()
PlatformMediaPlayer.init(this)
}
}MediaItem: 재생 항목(주소, 제목, 아트워크, 라이브 여부, MIME 타입 힌트 등)MediaPlayerController: 공통 재생 제어 API(expect/actual)PlayerEvent: 상태, 위치, 길이, 버퍼, 오류, 캐시 진행 상황CachePolicy: 디스크 캐시 정책(스트리밍 캐시 / 부분 오프라인 / 전체 오프라인)PreloadPolicy + MediaPreloader: 다음 항목 앞부분 선로딩PlatformMediaPlayer(...): 공통 Compose 컴포저블(플랫폼별 뷰 렌더링)@Composable
fun Sample() {
val controller = remember { MediaPlayerController() }
val item = remember {
MediaItem(
identifier = "sample_hls",
url = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8", // HLS
title = "Onboarding HLS"
)
}
PlatformMediaPlayer(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f),
controller = controller,
mediaItem = item,
autoPlay = true,
loop = true,
mute = true
)
}val controller = MediaPlayerController()
// 대기열 구성
controller.setQueue(
QueueConfig(
items = listOf(
MediaItem("mp4_sample", "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"),
MediaItem("hls_sample", "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8")
),
startIndex = 0
)
)
// 캐시 정책과 함께 준비 + 재생
scope.launch {
controller.prepare(
item = MediaItem(
identifier = "mp4_sample",
url = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
title = "Bunny"
),
cachePolicy = CachePolicy(
mode = CacheMode.StreamCache, // 스트리밍 중 디스크 캐시
diskMaxBytes = 1L * 1024 * 1024 * 1024 // 최대 1GB
)
)
controller.play()
}이벤트 수집으로 UI 갱신:
LaunchedEffect(controller) {
controller.events.collect { event ->
// event.state, event.positionMillis, event.durationMillis, event.bufferedMillis, event.error ...
}
}현재 항목 다음 트랙들의 앞부분 N초를 미리 받아 즉시 재생되도록 합니다.
val batteryProvider = PlatformBatteryInfoProvider()
val networkProvider = PlatformNetworkInfoProvider()
val preloader = MediaPreloader(
controller = controller,
batteryInfoProvider = batteryProvider,
networkInfoProvider = networkProvider
)
preloader.configure(
PreloadPolicy(
aheadCountInQueue = 2, // 다음 2개 항목
preloadHeadMillis = 45_000, // 각 45초 선로딩
wifiOnly = true, // Wi-Fi 에서만
minBatteryPercent = 20 // 배터리 20% 이상일 때만
)
)
// 곡 전환 시 혹은 주기적으로 호출
scope.launch {
val currentIndex = 0 // 현재 인덱스 관리 방식에 맞게 설정
preloader.tick(controller.queue.value, currentIndex)
}Android는 프로그레시브(MP3/MP4)의 “앞부분 바이트 선로딩”을 지원합니다.
HLS/DASH는 세그먼트 기반이므로 정확한 앞부분 시간만큼의 선로딩 대신 전체 오프라인 다운로드를 권장합니다.
iOS는 HLS에 대해AVAssetDownloadURLSession을 사용하여 “일정 시간 다운로드 후 취소”로 프리로드합니다.
네트워크 없이 재생할 수 있도록 전체 저장합니다.
scope.launch {
controller.downloadOffline(
MediaItem(
identifier = "bb_offline",
url = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
title = "Bunny Offline"
)
)
}오프라인 제거:
scope.launch {
controller.removeOffline(itemId = "bb_offline")
}캐시 현황 조회:
scope.launch {
val info = controller.getCacheInfo(itemId = "bb_offline")
// info.offlineReady, info.bytesCached, info.bytesTotal 로 UI 표시
}Android (raw 리소스)
val uri = "android.resource://${'$'}{PlatformMediaPlayer.context.packageName}/${'$'}{R.raw.test}"
val item = MediaItem("test", uri)iOS (앱 번들)
val urlString = NSBundle.mainBundle
.URLForResource("test", "mp4")
?.absoluteString
val item = MediaItem("test", urlString ?: error("리소스 없음"))CommonMain (composeResources 사용)
val uri = Res.getUri("files/test.mp4")
val mediaItem = remember {
MediaItem("test", uri)
}https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd검은 화면
.m3u8)를 재생하는데 media3-exoplayer-hls 의존성이 누락됨aspectRatio 또는 고정 높이 지정PlatformMediaPlayer 의 update 경로에서 attach 재호출java.lang.SecurityException: missing INTERNET permission
AndroidManifest.xml에 <uses-permission android:name="android.permission.INTERNET"/> 추가평문(http://) 주소 재생 불가
https:// 주소 사용iOS에서 DASH 재생 불가
AVPlayer는 DASH를 직접 재생하지 않습니다. HLS 또는 MP4로 테스트하세요.getCacheInfo(itemId) 로 오프라인 유무 확인 → 있으면 오프라인 재생, 없으면 스트리밍으로 준비CachePolicy(mode = CacheMode.StreamCache) 로 재생 중 데이터 자동 캐시MediaPreloader 로 다음 항목 앞부분 선로딩downloadOffline 로 저장val controller = remember { MediaPlayerController() }
val battery = remember { PlatformBatteryInfoProvider() }
val network = remember { PlatformNetworkInfoProvider() }
val preloader = remember { MediaPreloader(controller, battery, network) }
val mediaItems = remember {
listOf(
MediaItem(
identifier = "intro_hls",
url = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
title = "Intro (HLS)",
mimeType = "application/x-mpegURL"
),
MediaItem(
identifier = "bunny_mp4",
url = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
title = "Bunny (MP4)"
)
)
}LaunchedEffect(Unit) {
val first = mediaItems.first()
// 1) 캐시/오프라인 상태 조회
val info = controller.getCacheInfo(first.identifier)
// 2) 오프라인이 준비되어 있다면(프로젝트 정책에 맞게 판단) → 오프라인 URL 사용
// 샘플 구현은 bytesTotal이 없어 단순히 캐시 유무만 참고한다고 가정
val preparedItem = first
// 3) 준비(스트리밍 캐시 모드)
controller.prepare(
item = preparedItem,
cachePolicy = CachePolicy(
mode = CacheMode.StreamCache,
diskMaxBytes = 1L * 1024 * 1024 * 1024
)
)
}PlatformMediaPlayer 로 실제 재생 뷰 렌더링:
PlatformMediaPlayer(
modifier = Modifier.fillMaxWidth().aspectRatio(16f/9f),
controller = controller,
mediaItem = mediaItems.first(),
autoPlay = true,
loop = true,
mute = false
)LaunchedEffect(Unit) {
val mp4 = mediaItems.last()
controller.prepare(
item = mp4,
cachePolicy = CachePolicy(
mode = CacheMode.StreamCache, // ✅ 스트리밍 중 자동 디스크 캐시
diskMaxBytes = 512L * 1024 * 1024
)
)
controller.play()
}// 대기열 세팅
LaunchedEffect(Unit) {
controller.setQueue(QueueConfig(items = mediaItems, startIndex = 0))
}
// 프리로드 정책 구성 (다음 1개, 각 30초, Wi-Fi 전용)
LaunchedEffect(Unit) {
preloader.configure(
PreloadPolicy(
aheadCountInQueue = 1,
preloadHeadMillis = 30_000,
wifiOnly = true,
minBatteryPercent = 20
)
)
}
// 현재 인덱스 0이라고 가정하고 tick 호출 (곡 전환 시마다 다시 호출 권장)
LaunchedEffect(mediaItems) {
preloader.tick(queue = mediaItems, currentIndex = 0)
}// 전체 오프라인 다운로드 (사용 빈도 높은 항목)
LaunchedEffect(Unit) {
val item = mediaItems.last() // Big Buck Bunny (MP4)
controller.downloadOffline(item)
}
// 나중에 제거할 때
LaunchedEffect(Unit) {
controller.removeOffline(itemId = "bunny_mp4")
}val event by controller.events.collectAsState(PlayerEvent(PlaybackState.Idle,0,0,0))
Text("state=${'$'}{event.state} pos=${'$'}{event.positionMillis} / ${'$'}{event.durationMillis}")Kotlin Multiplatform 공통 코드로 Android와 iOS에서 동일한 API로 동영상을 재생합니다.
스트리밍, 디스크 캐시, 앞부분 선로딩(프리로드), 전체 오프라인 다운로드를 지원합니다.
UI는 공통 PlatformMediaPlayer 컴포저블로 플랫폼별 네이티브 플레이어를 렌더링합니다.
캐시, 네트워크, 배터리 제공자들이 전역 Context 에 접근합니다.
앱 시작 시 한 번 초기화하세요.
// app/src/main/java/.../App.kt
class App : Application() {
override fun onCreate() {
super.onCreate()
PlatformMediaPlayer.init(this)
}
}MediaItem: 재생 항목(주소, 제목, 아트워크, 라이브 여부, MIME 타입 힌트 등)MediaPlayerController: 공통 재생 제어 API(expect/actual)PlayerEvent: 상태, 위치, 길이, 버퍼, 오류, 캐시 진행 상황CachePolicy: 디스크 캐시 정책(스트리밍 캐시 / 부분 오프라인 / 전체 오프라인)PreloadPolicy + MediaPreloader: 다음 항목 앞부분 선로딩PlatformMediaPlayer(...): 공통 Compose 컴포저블(플랫폼별 뷰 렌더링)@Composable
fun Sample() {
val controller = remember { MediaPlayerController() }
val item = remember {
MediaItem(
identifier = "sample_hls",
url = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8", // HLS
title = "Onboarding HLS"
)
}
PlatformMediaPlayer(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f),
controller = controller,
mediaItem = item,
autoPlay = true,
loop = true,
mute = true
)
}val controller = MediaPlayerController()
// 대기열 구성
controller.setQueue(
QueueConfig(
items = listOf(
MediaItem("mp4_sample", "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"),
MediaItem("hls_sample", "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8")
),
startIndex = 0
)
)
// 캐시 정책과 함께 준비 + 재생
scope.launch {
controller.prepare(
item = MediaItem(
identifier = "mp4_sample",
url = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
title = "Bunny"
),
cachePolicy = CachePolicy(
mode = CacheMode.StreamCache, // 스트리밍 중 디스크 캐시
diskMaxBytes = 1L * 1024 * 1024 * 1024 // 최대 1GB
)
)
controller.play()
}이벤트 수집으로 UI 갱신:
LaunchedEffect(controller) {
controller.events.collect { event ->
// event.state, event.positionMillis, event.durationMillis, event.bufferedMillis, event.error ...
}
}현재 항목 다음 트랙들의 앞부분 N초를 미리 받아 즉시 재생되도록 합니다.
val batteryProvider = PlatformBatteryInfoProvider()
val networkProvider = PlatformNetworkInfoProvider()
val preloader = MediaPreloader(
controller = controller,
batteryInfoProvider = batteryProvider,
networkInfoProvider = networkProvider
)
preloader.configure(
PreloadPolicy(
aheadCountInQueue = 2, // 다음 2개 항목
preloadHeadMillis = 45_000, // 각 45초 선로딩
wifiOnly = true, // Wi-Fi 에서만
minBatteryPercent = 20 // 배터리 20% 이상일 때만
)
)
// 곡 전환 시 혹은 주기적으로 호출
scope.launch {
val currentIndex = 0 // 현재 인덱스 관리 방식에 맞게 설정
preloader.tick(controller.queue.value, currentIndex)
}Android는 프로그레시브(MP3/MP4)의 “앞부분 바이트 선로딩”을 지원합니다.
HLS/DASH는 세그먼트 기반이므로 정확한 앞부분 시간만큼의 선로딩 대신 전체 오프라인 다운로드를 권장합니다.
iOS는 HLS에 대해AVAssetDownloadURLSession을 사용하여 “일정 시간 다운로드 후 취소”로 프리로드합니다.
네트워크 없이 재생할 수 있도록 전체 저장합니다.
scope.launch {
controller.downloadOffline(
MediaItem(
identifier = "bb_offline",
url = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
title = "Bunny Offline"
)
)
}오프라인 제거:
scope.launch {
controller.removeOffline(itemId = "bb_offline")
}캐시 현황 조회:
scope.launch {
val info = controller.getCacheInfo(itemId = "bb_offline")
// info.offlineReady, info.bytesCached, info.bytesTotal 로 UI 표시
}Android (raw 리소스)
val uri = "android.resource://${'$'}{PlatformMediaPlayer.context.packageName}/${'$'}{R.raw.test}"
val item = MediaItem("test", uri)iOS (앱 번들)
val urlString = NSBundle.mainBundle
.URLForResource("test", "mp4")
?.absoluteString
val item = MediaItem("test", urlString ?: error("리소스 없음"))CommonMain (composeResources 사용)
val uri = Res.getUri("files/test.mp4")
val mediaItem = remember {
MediaItem("test", uri)
}https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd검은 화면
.m3u8)를 재생하는데 media3-exoplayer-hls 의존성이 누락됨aspectRatio 또는 고정 높이 지정PlatformMediaPlayer 의 update 경로에서 attach 재호출java.lang.SecurityException: missing INTERNET permission
AndroidManifest.xml에 <uses-permission android:name="android.permission.INTERNET"/> 추가평문(http://) 주소 재생 불가
https:// 주소 사용iOS에서 DASH 재생 불가
AVPlayer는 DASH를 직접 재생하지 않습니다. HLS 또는 MP4로 테스트하세요.getCacheInfo(itemId) 로 오프라인 유무 확인 → 있으면 오프라인 재생, 없으면 스트리밍으로 준비CachePolicy(mode = CacheMode.StreamCache) 로 재생 중 데이터 자동 캐시MediaPreloader 로 다음 항목 앞부분 선로딩downloadOffline 로 저장val controller = remember { MediaPlayerController() }
val battery = remember { PlatformBatteryInfoProvider() }
val network = remember { PlatformNetworkInfoProvider() }
val preloader = remember { MediaPreloader(controller, battery, network) }
val mediaItems = remember {
listOf(
MediaItem(
identifier = "intro_hls",
url = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
title = "Intro (HLS)",
mimeType = "application/x-mpegURL"
),
MediaItem(
identifier = "bunny_mp4",
url = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
title = "Bunny (MP4)"
)
)
}LaunchedEffect(Unit) {
val first = mediaItems.first()
// 1) 캐시/오프라인 상태 조회
val info = controller.getCacheInfo(first.identifier)
// 2) 오프라인이 준비되어 있다면(프로젝트 정책에 맞게 판단) → 오프라인 URL 사용
// 샘플 구현은 bytesTotal이 없어 단순히 캐시 유무만 참고한다고 가정
val preparedItem = first
// 3) 준비(스트리밍 캐시 모드)
controller.prepare(
item = preparedItem,
cachePolicy = CachePolicy(
mode = CacheMode.StreamCache,
diskMaxBytes = 1L * 1024 * 1024 * 1024
)
)
}PlatformMediaPlayer 로 실제 재생 뷰 렌더링:
PlatformMediaPlayer(
modifier = Modifier.fillMaxWidth().aspectRatio(16f/9f),
controller = controller,
mediaItem = mediaItems.first(),
autoPlay = true,
loop = true,
mute = false
)LaunchedEffect(Unit) {
val mp4 = mediaItems.last()
controller.prepare(
item = mp4,
cachePolicy = CachePolicy(
mode = CacheMode.StreamCache, // ✅ 스트리밍 중 자동 디스크 캐시
diskMaxBytes = 512L * 1024 * 1024
)
)
controller.play()
}// 대기열 세팅
LaunchedEffect(Unit) {
controller.setQueue(QueueConfig(items = mediaItems, startIndex = 0))
}
// 프리로드 정책 구성 (다음 1개, 각 30초, Wi-Fi 전용)
LaunchedEffect(Unit) {
preloader.configure(
PreloadPolicy(
aheadCountInQueue = 1,
preloadHeadMillis = 30_000,
wifiOnly = true,
minBatteryPercent = 20
)
)
}
// 현재 인덱스 0이라고 가정하고 tick 호출 (곡 전환 시마다 다시 호출 권장)
LaunchedEffect(mediaItems) {
preloader.tick(queue = mediaItems, currentIndex = 0)
}// 전체 오프라인 다운로드 (사용 빈도 높은 항목)
LaunchedEffect(Unit) {
val item = mediaItems.last() // Big Buck Bunny (MP4)
controller.downloadOffline(item)
}
// 나중에 제거할 때
LaunchedEffect(Unit) {
controller.removeOffline(itemId = "bunny_mp4")
}val event by controller.events.collectAsState(PlayerEvent(PlaybackState.Idle,0,0,0))
Text("state=${'$'}{event.state} pos=${'$'}{event.positionMillis} / ${'$'}{event.durationMillis}")