diff --git a/.gitignore b/.gitignore index 9d7c7e7..ca458ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ -# OSX +example/android/caches +example/android/wrapper +example/android/daemon +example/android/native +example/android/android # .DS_Store @@ -74,4 +78,4 @@ android/keystores/debug.keystore # generated by bob lib/ -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo diff --git a/android/build.gradle b/android/build.gradle index d052a8d..c79a154 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -143,7 +143,7 @@ dependencies { // Add a dependency on NitroModules implementation project(":react-native-nitro-modules") - implementation("dev.jdtech.mpv:libmpv:1.0.0") + implementation("org.videolan.android:libvlc-all:3.7.0") implementation("androidx.media3:media3-common:1.4.1") implementation("androidx.media3:media3-session:1.4.1") implementation("androidx.media3:media3-ui:1.4.1") diff --git a/android/src/main/java/dev/zoriya/omni/MpvPlayer.kt b/android/src/main/java/dev/zoriya/omni/MpvPlayer.kt deleted file mode 100644 index 3897cfd..0000000 --- a/android/src/main/java/dev/zoriya/omni/MpvPlayer.kt +++ /dev/null @@ -1,890 +0,0 @@ -package dev.zoriya.omni - -import android.annotation.SuppressLint -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.view.Surface -import android.view.SurfaceHolder -import android.view.SurfaceView -import android.view.TextureView -import androidx.media3.common.AudioAttributes -import androidx.media3.common.BasePlayer -import androidx.media3.common.C.FORMAT_HANDLED -import androidx.media3.common.C.INDEX_UNSET -import androidx.media3.common.C.SELECTION_FLAG_DEFAULT -import androidx.media3.common.C.SELECTION_FLAG_FORCED -import androidx.media3.common.C.TIME_UNSET -import androidx.media3.common.C.TRACK_TYPE_AUDIO -import androidx.media3.common.C.TRACK_TYPE_TEXT -import androidx.media3.common.C.TRACK_TYPE_VIDEO -import androidx.media3.common.DeviceInfo -import androidx.media3.common.Format -import androidx.media3.common.Format.NO_VALUE -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.PlaybackException -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player -import androidx.media3.common.Timeline -import androidx.media3.common.TrackGroup -import androidx.media3.common.TrackSelectionParameters -import androidx.media3.common.Tracks -import androidx.media3.common.Tracks.Group -import androidx.media3.common.VideoSize -import androidx.media3.common.text.CueGroup -import androidx.media3.common.util.Clock -import androidx.media3.common.util.ListenerSet -import androidx.media3.common.util.Size -import dev.jdtech.mpv.MPVLib - -@SuppressLint("UnsafeOptInUsageError") -class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { - private val applicationLooper: Looper = Looper.getMainLooper() - private val applicationHandler = Handler(applicationLooper) - private val listeners = ListenerSet( - applicationLooper, - Clock.DEFAULT - ) { listener, flags -> - listener.onEvents(this, Player.Events(flags)) - } - - private fun notifyListeners(eventFlag: Int, callback: (Player.Listener) -> Unit) = - notifyListeners(arrayOf(eventFlag), callback) - - private fun notifyListeners(eventFlag: Array, callback: (Player.Listener) -> Unit) { - val notifyAction = { - for (event in eventFlag) { - listeners.queueEvent(event, callback) - } - listeners.flushEvents() - } - if (Looper.myLooper() == applicationLooper) { - notifyAction() - } else { - applicationHandler.post(notifyAction) - } - } - - internal val mpv = (MPVLib.create(ctx) ?: throw Error("Failed to initialize MPVLib")).apply { - setOptionString("vo", "gpu-next") - setOptionString("force-window", "no") - setOptionString("gpu-context", "android") - setOptionString("opengl-es", "yes") - setOptionString("hwdec", "mediacodec-copy") - setOptionString("profile", "fast") - setOptionString("keep-open", "always") - - setOptionString("cache", "yes") - setOptionString("cache-pause-initial", "yes") - setOptionString("demuxer-max-bytes", "150MiB") - setOptionString("demuxer-max-back-bytes", "75MiB") - setOptionString("demuxer-readahead-secs", "20") - - setOptionString("save-position-on-quit", "no") - setOptionString("ytdl", "no") - setOptionString("hr-seek", "no") - - init() - addObserver(this@MpvPlayer) - observeProperty("core-idle", MPVLib.MpvFormat.MPV_FORMAT_FLAG) - observeProperty("paused-for-cache", MPVLib.MpvFormat.MPV_FORMAT_FLAG) - observeProperty("pause", MPVLib.MpvFormat.MPV_FORMAT_FLAG) - observeProperty("eof-reached", MPVLib.MpvFormat.MPV_FORMAT_FLAG) - observeProperty("duration", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE) - observeProperty("speed", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE) - observeProperty("volume", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE) - observeProperty("vid", MPVLib.MpvFormat.MPV_FORMAT_INT64) - observeProperty("aid", MPVLib.MpvFormat.MPV_FORMAT_INT64) - observeProperty("sid", MPVLib.MpvFormat.MPV_FORMAT_INT64) - observeProperty("width", MPVLib.MpvFormat.MPV_FORMAT_INT64) - observeProperty("height", MPVLib.MpvFormat.MPV_FORMAT_INT64) - } - - private var mediaItems: List = emptyList() - private var currentMediaItemIndex: Int = INDEX_UNSET - private var currentTrackSelectionParameters = TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT - private var playerError: PlaybackException? = null - private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY - private var userInitiatedTransition: Boolean = false - - private val availableCommands: Player.Commands = Player.Commands.Builder() - .add(COMMAND_PLAY_PAUSE) - .add(COMMAND_PREPARE) - .add(COMMAND_STOP) - .add(COMMAND_RELEASE) - .add(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) - .add(COMMAND_SEEK_TO_DEFAULT_POSITION) - .add(COMMAND_SEEK_BACK) - .add(COMMAND_SEEK_FORWARD) - .add(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) - .add(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) - .add(COMMAND_SET_SPEED_AND_PITCH) - .add(COMMAND_GET_VOLUME) - .add(COMMAND_SET_VOLUME) - .add(COMMAND_SET_VIDEO_SURFACE) - .add(COMMAND_SET_MEDIA_ITEM) - .add(COMMAND_GET_CURRENT_MEDIA_ITEM) - .add(COMMAND_GET_METADATA) - .add(COMMAND_GET_TIMELINE) - .add(COMMAND_GET_TRACKS) - .add(COMMAND_SET_TRACK_SELECTION_PARAMETERS) - .build() - - override fun getApplicationLooper(): Looper = applicationLooper - - override fun addListener(listener: Player.Listener) { - listeners.add(listener) - } - - override fun removeListener(listener: Player.Listener) { - listeners.remove(listener) - } - - override fun setMediaItems(mediaItems: List, resetPosition: Boolean) { - setMediaItems( - mediaItems, - if (resetPosition) 0 else INDEX_UNSET, - if (resetPosition) 0L else TIME_UNSET - ) - } - - override fun setMediaItems( - mediaItems: List, - startIndex: Int, - startPositionMs: Long - ) { - val prev = currentMediaItemIndex - val targetIndex = when { - mediaItems.isEmpty() -> INDEX_UNSET - startIndex in mediaItems.indices -> startIndex - else -> 0 - } - - this.mediaItems = mediaItems - currentMediaItemIndex = targetIndex - val target = if (targetIndex != INDEX_UNSET) mediaItems[targetIndex] else null - playlistMetadata = target?.mediaMetadata ?: MediaMetadata.EMPTY - playerError = null - - mpv.command(arrayOf("stop")) - - if (target != null) { - val targetMs = if (startPositionMs == TIME_UNSET) 0L else startPositionMs.coerceAtLeast(0L) - mpv.setPropertyDouble("start", targetMs / 1000.0) - - val uri = target.localConfiguration?.uri?.toString() - if (!uri.isNullOrEmpty()) { - mpv.command(arrayOf("loadfile", uri, "replace")) - target.localConfiguration?.subtitleConfigurations?.forEach { subtitle -> - mpv.command(arrayOf("sub-add", subtitle.uri.toString(), "cached")) - } - } - } - - val events = arrayListOf( - EVENT_TIMELINE_CHANGED, - EVENT_MEDIA_METADATA_CHANGED, - EVENT_PLAYLIST_METADATA_CHANGED - ) - if (prev != currentMediaItemIndex) { - events.add(EVENT_MEDIA_ITEM_TRANSITION) - } - val transitionReason = if (userInitiatedTransition) { - MEDIA_ITEM_TRANSITION_REASON_SEEK - } else { - MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED - } - userInitiatedTransition = false - notifyListeners(events.toTypedArray()) { - it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) - it.onMediaMetadataChanged(mediaMetadata) - it.onPlaylistMetadataChanged(playlistMetadata) - if (prev != currentMediaItemIndex) { - it.onMediaItemTransition( - currentMediaItem, - transitionReason - ) - } - } - } - - override fun addMediaItems( - p0: Int, - p1: List - ) = Unit - - override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) = Unit - - override fun replaceMediaItems(fromIndex: Int, toIndex: Int, mediaItems: List) { - setMediaItems(mediaItems) - } - - override fun removeMediaItems(fromIndex: Int, toIndex: Int) { - val prev = currentMediaItemIndex - mediaItems = emptyList() - currentMediaItemIndex = INDEX_UNSET - playlistMetadata = MediaMetadata.EMPTY - mpv.command(arrayOf("stop")) - - var events = arrayListOf( - EVENT_TIMELINE_CHANGED, - EVENT_MEDIA_METADATA_CHANGED, - EVENT_PLAYLIST_METADATA_CHANGED - ) - if (prev != INDEX_UNSET) { - events.add(EVENT_MEDIA_ITEM_TRANSITION) - } - notifyListeners(events.toTypedArray()) { - it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) - it.onMediaMetadataChanged(mediaMetadata) - it.onPlaylistMetadataChanged(playlistMetadata) - if (prev != INDEX_UNSET) { - it.onMediaItemTransition(null, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) - } - } - } - - override fun getAvailableCommands(): Player.Commands = availableCommands - - override fun prepare() = Unit - - override fun getPlaybackState(): Int = - when (true) { - (currentMediaItemIndex == INDEX_UNSET) -> STATE_IDLE - mpv.getPropertyBoolean("paused-for-cache") -> STATE_BUFFERING - mpv.getPropertyBoolean("eof-reached") -> STATE_ENDED - else -> STATE_READY - } - - override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE - - override fun getPlayerError(): PlaybackException? = playerError - - override fun setPlayWhenReady(playWhenReady: Boolean) { - mpv.setPropertyBoolean("pause", !playWhenReady) - } - - override fun getPlayWhenReady(): Boolean = !(mpv.getPropertyBoolean("pause") ?: true) - - override fun setRepeatMode(repeatMode: Int) = Unit - - override fun getRepeatMode(): Int = REPEAT_MODE_OFF - - override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) = Unit - - override fun getShuffleModeEnabled(): Boolean = false - - override fun isLoading(): Boolean = mpv.getPropertyBoolean("paused-for-cache") ?: false - - override fun seekTo( - mediaItemIndex: Int, - positionMs: Long, - seekCommand: Int, - isRepeatingCurrentItem: Boolean - ) { - val targetIndex = if (mediaItemIndex == INDEX_UNSET) currentMediaItemIndex else mediaItemIndex - if (targetIndex == INDEX_UNSET || targetIndex !in mediaItems.indices) return - - if (targetIndex != currentMediaItemIndex) { - userInitiatedTransition = true - setMediaItems(mediaItems, targetIndex, positionMs) - return - } - - val targetMs = if (positionMs == TIME_UNSET) 0L else positionMs.coerceAtLeast(0L) - mpv.command(arrayOf("seek", (targetMs / 1000.0).toString(), "absolute")) - } - - override fun getSeekBackIncrement(): Long = 5_000L - - override fun getSeekForwardIncrement(): Long = 15_000L - - override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { - mpv.setPropertyDouble("speed", playbackParameters.speed.coerceAtLeast(0f).toDouble()) - } - - override fun getPlaybackParameters(): PlaybackParameters { - return PlaybackParameters( - (mpv.getPropertyDouble("speed") ?: 1.0).toFloat().coerceAtLeast(0f) - ) - } - - override fun stop() { - mpv.command(arrayOf("stop")) - } - - override fun release() { - mpv.removeObserver(this) - clearVideoSurface() - mpv.destroy() - } - - override fun getCurrentTracks(): Tracks { - val count = mpv.getPropertyInt("track-list/count") ?: 0 - if (count <= 0) return Tracks.EMPTY - - val selectedVideo = mpv.getPropertyInt("vid") - val selectedAudio = mpv.getPropertyInt("aid") - val selectedSubtitle = mpv.getPropertyInt("sid") - - val groupedVideoTracks = LinkedHashMap>>() - val result = ArrayList() - - for (i in 0 until count) { - val base = "track-list/$i" - val type = mpv.getPropertyString("$base/type") ?: continue - val id = mpv.getPropertyInt("$base/id") ?: continue - val bitrate = mpv.getPropertyInt("$base/hls-bitrate") - ?: mpv.getPropertyInt("$base/demux-bitrate") - val isDefault = mpv.getPropertyBoolean("$base/default") ?: false - val isForced = mpv.getPropertyBoolean("$base/forced") ?: false - - val format = Format.Builder() - .setId(id.toString()) - .setLabel(mpv.getPropertyString("$base/title")) - .setLanguage(mpv.getPropertyString("$base/lang")) - .setCodecs(mpv.getPropertyString("$base/codec")) - .setAverageBitrate(bitrate ?: NO_VALUE) - .setSelectionFlags( - when { - isDefault && isForced -> SELECTION_FLAG_DEFAULT or SELECTION_FLAG_FORCED - isDefault -> SELECTION_FLAG_DEFAULT - isForced -> SELECTION_FLAG_FORCED - else -> 0 - } - ) - .setSampleMimeType(when(type) { - "video" -> "video/x-unknown" - "audio" -> "audio/x-unknown" - "sub" -> "text/x-unknown" - else -> null - }) - .apply { - if (type == "video") { - val width = mpv.getPropertyInt("$base/demux-w") - val height = mpv.getPropertyInt("$base/demux-h") - if (width != null && width > 0 && height != null && height > 0) { - setWidth(width).setHeight(height) - } - } - } - .build() - - - when (type) { - "video" -> { - val programIds = mutableListOf() - val programIdsCount = mpv.getPropertyInt("$base/program-ids/count") ?: 0 - for (programIndex in -1 until programIdsCount) { - val programId = mpv.getPropertyInt("$base/program-ids/$programIndex") - if (programId != null) { - programIds.add(programId) - } - } - programIds.sort() - val programKey = - if (programIds.isEmpty()) "none" else programIds.joinToString(",") - val key = - "programs=$programKey|title=${mpv.getPropertyString("$base/title").orEmpty()}|lang=${ - mpv.getPropertyString( - "$base/lang" - ).orEmpty()}|codec=${mpv.getPropertyString("$base/codec").orEmpty()}" - groupedVideoTracks.getOrPut(key) { mutableListOf() }.add(id to format) - } - - "audio" -> { - val group = TrackGroup("mpv-audio-$id", format) - val selected = booleanArrayOf(selectedAudio == id) - val support = intArrayOf(FORMAT_HANDLED) - result.add(Group(group, false, support, selected)) - } - - "sub" -> { - val group = TrackGroup("mpv-sub-$id", format) - val selected = booleanArrayOf(selectedSubtitle == id) - val support = intArrayOf(FORMAT_HANDLED) - result.add(Group(group, false, support, selected)) - } - } - } - - var videoGroupIndex = 0 - for ((_, entries) in groupedVideoTracks) { - val formats = entries.map { it.second }.toTypedArray() - val group = TrackGroup("mpv-video-$videoGroupIndex", *formats) - val selected = BooleanArray(entries.size) { idx -> - selectedVideo == entries[idx].first - } - val support = IntArray(entries.size) { FORMAT_HANDLED } - result.add(Group(group, entries.size > 1, support, selected)) - videoGroupIndex += 1 - } - - return Tracks(result) - } - - override fun getTrackSelectionParameters(): TrackSelectionParameters = - currentTrackSelectionParameters - - override fun setTrackSelectionParameters(trackSelectionParameters: TrackSelectionParameters) { - currentTrackSelectionParameters = trackSelectionParameters - val videoDisabled = trackSelectionParameters.disabledTrackTypes.contains(TRACK_TYPE_VIDEO) - val audioDisabled = trackSelectionParameters.disabledTrackTypes.contains(TRACK_TYPE_AUDIO) - val textDisabled = trackSelectionParameters.disabledTrackTypes.contains(TRACK_TYPE_TEXT) - - if (videoDisabled) { - mpv.setPropertyString("vid", "no") - } - if (audioDisabled) { - mpv.setPropertyString("aid", "no") - } - if (textDisabled) { - mpv.setPropertyString("sid", "no") - } - - var hasVideoOverride = false - var hasAudioOverride = false - var hasTextOverride = false - for (override in trackSelectionParameters.overrides.values) { - val selectedIndex = override.trackIndices.firstOrNull() ?: continue - if (selectedIndex !in 0 until override.mediaTrackGroup.length) continue - val trackId = - override.mediaTrackGroup.getFormat(selectedIndex).id?.toIntOrNull() ?: continue - - when (override.type) { - TRACK_TYPE_VIDEO -> { - hasVideoOverride = true - mpv.setPropertyInt("vid", trackId) - } - TRACK_TYPE_AUDIO -> { - hasAudioOverride = true - mpv.setPropertyInt("aid", trackId) - } - TRACK_TYPE_TEXT -> { - hasTextOverride = true - mpv.setPropertyInt("sid", trackId) - } - } - } - - val tracks = getCurrentTracks() - val videoPreference = selectTrackByPreference( - tracks, TRACK_TYPE_VIDEO, - trackSelectionParameters.preferredVideoLanguages, - trackSelectionParameters.preferredVideoLabels, - ) - if (!videoDisabled && !hasVideoOverride) { - videoPreference?.let { mpv.setPropertyInt("vid", it) } - ?: mpv.setPropertyString("vid", "auto") - } - val audioPreference = selectTrackByPreference( - tracks, TRACK_TYPE_AUDIO, - trackSelectionParameters.preferredAudioLanguages, - trackSelectionParameters.preferredAudioLabels, - ) - if (!audioDisabled && !hasAudioOverride) { - audioPreference?.let { mpv.setPropertyInt("aid", it) } - ?: mpv.setPropertyString("aid", "auto") - } - val textPreference = selectTrackByPreference( - tracks, TRACK_TYPE_TEXT, - trackSelectionParameters.preferredTextLanguages, - trackSelectionParameters.preferredTextLabels, - ) - if (!textDisabled && !hasTextOverride) { - textPreference?.let { mpv.setPropertyInt("sid", it) } - ?: mpv.setPropertyString("sid", "auto") - } - } - - private fun selectTrackByPreference( - tracks: Tracks, - trackType: Int, - languages: List, - labels: List, - ): Int? { - val preferredLanguages = languages.filter { it.isNotEmpty() } - val preferredLabels = labels.filter { it.isNotEmpty() } - - if (preferredLanguages.isEmpty() && preferredLabels.isEmpty()) return null - - data class Candidate(val id: Int, val langIndex: Int, val labelIndex: Int, val hasDefaultFlag: Boolean) - - val candidates = mutableListOf() - - for (group in tracks.groups) { - if (group.type != trackType) continue - for (i in 0 until group.length) { - val format = group.getTrackFormat(i) - val id = format.id?.toIntOrNull() ?: continue - - val langIndex = format.language?.let { preferredLanguages.indexOf(it) } ?: -1 - val labelIndex = format.label?.let { preferredLabels.indexOf(it) } ?: -1 - - val matchesLang = preferredLanguages.isEmpty() || langIndex >= 0 - val matchesLabel = preferredLabels.isEmpty() || labelIndex >= 0 - - if (matchesLang && matchesLabel) { - candidates.add( - Candidate( - id, - if (langIndex >= 0) langIndex else Int.MAX_VALUE, - if (labelIndex >= 0) labelIndex else Int.MAX_VALUE, - (format.selectionFlags and SELECTION_FLAG_DEFAULT) != 0 - ) - ) - } - } - } - - return candidates.minWithOrNull( - compareBy { it.langIndex } - .thenBy { it.labelIndex } - .thenByDescending { it.hasDefaultFlag } - )?.id - } - - override fun getMediaMetadata(): MediaMetadata = - getCurrentMediaItem()?.mediaMetadata ?: MediaMetadata.EMPTY - - override fun getPlaylistMetadata(): MediaMetadata = playlistMetadata - - override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) { - playlistMetadata = mediaMetadata - } - - override fun getCurrentTimeline(): Timeline { - if (mediaItems.isEmpty()) return Timeline.EMPTY - return object : Timeline() { - override fun getWindowCount(): Int = mediaItems.size - - override fun getWindow( - windowIndex: Int, - window: Window, - defaultPositionProjectionUs: Long - ): Window { - check(windowIndex in mediaItems.indices) - val item = mediaItems[windowIndex] - val durationUs = if (windowIndex == currentMediaItemIndex) { - val dur = duration - if (dur == TIME_UNSET) TIME_UNSET else dur * 1000L - } else TIME_UNSET - return window.set( - windowIndex, - item, - null, - TIME_UNSET, - TIME_UNSET, - TIME_UNSET, - true, - false, - null, - 0L, - durationUs, - 0, - mediaItems.size-1, - 0L - ) - } - - override fun getPeriodCount(): Int = mediaItems.size - - override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { - check(periodIndex in mediaItems.indices) - val durationUs = if (periodIndex == currentMediaItemIndex) { - val dur = duration - if (dur == TIME_UNSET) TIME_UNSET else dur * 1000L - } else TIME_UNSET - return period.set(periodIndex, periodIndex, 0, durationUs, 0L) - } - - override fun getIndexOfPeriod(uid: Any): Int { - return if (uid is Int && uid in mediaItems.indices) uid else INDEX_UNSET - } - - override fun getUidOfPeriod(periodIndex: Int): Any { - check(periodIndex in mediaItems.indices) - return periodIndex - } - } - } - - override fun getCurrentPeriodIndex() = if (currentMediaItemIndex == INDEX_UNSET) INDEX_UNSET else currentMediaItemIndex - - override fun getCurrentMediaItemIndex() = currentMediaItemIndex - - override fun getDuration(): Long { - val duration = mpv.getPropertyDouble("duration") ?: return TIME_UNSET - if (!duration.isFinite() || duration <= 0.0) return TIME_UNSET - return (duration * 1000.0).toLong() - } - - override fun getCurrentPosition() = - ((mpv.getPropertyDouble("time-pos") ?: 0.0).coerceAtLeast(0.0) * 1000.0).toLong() - - override fun getBufferedPosition() = - ((mpv.getPropertyDouble("demuxer-cache-time") ?: 0.0).coerceAtLeast(0.0) * 1000.0).toLong() - .coerceAtLeast(0L) - - override fun getTotalBufferedDuration() = bufferedPosition - - override fun isPlayingAd(): Boolean = false - - override fun getCurrentAdGroupIndex(): Int = INDEX_UNSET - - override fun getCurrentAdIndexInAdGroup(): Int = INDEX_UNSET - - override fun getMaxSeekToPreviousPosition() = 3_000L - - override fun getContentPosition() = getCurrentPosition() - - override fun getContentBufferedPosition() = getBufferedPosition() - - override fun getAudioAttributes(): AudioAttributes = AudioAttributes.DEFAULT - - override fun setVolume(volume: Float) { - mpv.setPropertyDouble("volume", volume.coerceIn(0f, 1f) * 100.0) - } - - override fun getVolume(): Float { - return ((mpv.getPropertyDouble("volume") ?: 100.0).coerceIn(0.0, 100.0) / 100.0).toFloat() - } - - override fun mute() { - mpv.setPropertyBoolean("mute", true) - } - - override fun unmute() { - mpv.setPropertyBoolean("mute", false) - } - - override fun clearVideoSurface() { - mpv.setOptionString("vo", "null") - mpv.setOptionString("force-window", "no") - mpv.detachSurface() - } - - override fun clearVideoSurface(surface: Surface?) { - clearVideoSurface() - } - - override fun setVideoSurface(surface: Surface?) { - mpv.attachSurface(surface ?: return clearVideoSurface()) - mpv.setOptionString("force-window", "yes") - mpv.setOptionString("vo", "gpu-next") - } - - fun updateSurfaceSize(width: Int, height: Int) { - if (width > 0 && height > 0) { - mpv.setPropertyString("android-surface-size", "${width}x${height}") - } - } - - override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { - val holder = surfaceHolder ?: return clearVideoSurface() - val surface = holder.surface - if (surface != null && surface.isValid) { - setVideoSurface(surface) - val frame = holder.surfaceFrame - val width = frame?.width() ?: 0 - val height = frame?.height() ?: 0 - updateSurfaceSize(width, height) - } - } - - override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { - clearVideoSurface() - } - - override fun setVideoSurfaceView(surfaceView: SurfaceView?) { - val view = surfaceView ?: return clearVideoSurface() - setVideoSurfaceHolder(view.holder) - } - - override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { - val view = surfaceView ?: return clearVideoSurface() - clearVideoSurfaceHolder(view.holder) - } - - override fun setVideoTextureView(textureView: TextureView?) = Unit - - override fun clearVideoTextureView(textureView: TextureView?) = Unit - - override fun getVideoSize(): VideoSize { - val width = mpv.getPropertyInt("width") ?: 0 - val height = mpv.getPropertyInt("height") ?: 0 - if (width <= 0 || height <= 0) return VideoSize.UNKNOWN - return VideoSize(width, height) - } - - override fun getSurfaceSize(): Size = Size.UNKNOWN - - override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO - - override fun getDeviceInfo(): DeviceInfo = DeviceInfo.UNKNOWN - - override fun getDeviceVolume(): Int = 0 - - override fun isDeviceMuted(): Boolean = false - - @Deprecated("Deprecated in Java") - override fun setDeviceVolume(volume: Int) = Unit - - override fun setDeviceVolume(volume: Int, flags: Int) = Unit - - @Deprecated("Deprecated in Java") - override fun increaseDeviceVolume() = Unit - - override fun increaseDeviceVolume(flags: Int) = Unit - - @Deprecated("Deprecated in Java") - override fun decreaseDeviceVolume() = Unit - - override fun decreaseDeviceVolume(flags: Int) = Unit - - @Deprecated("Deprecated in Java") - override fun setDeviceMuted(muted: Boolean) = Unit - - override fun setDeviceMuted(muted: Boolean, flags: Int) = Unit - - override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = - Unit - - override fun event(eventId: Int) { - when (eventId) { - MPVLib.MpvEvent.MPV_EVENT_START_FILE -> - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { - it.onPlaybackStateChanged(STATE_BUFFERING) - } - - MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED -> - notifyListeners( - arrayOf( - EVENT_TIMELINE_CHANGED, - EVENT_MEDIA_METADATA_CHANGED, - EVENT_PLAYBACK_STATE_CHANGED, - EVENT_IS_PLAYING_CHANGED, - EVENT_TRACKS_CHANGED - ) - ) { - it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_SOURCE_UPDATE) - it.onMediaMetadataChanged(mediaMetadata) - it.onPlaybackStateChanged(STATE_READY) - it.onIsPlayingChanged(playWhenReady) - it.onTracksChanged(getCurrentTracks()) - } - - MPVLib.MpvEvent.MPV_EVENT_SEEK, - MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART -> { - val positionMs = getCurrentPosition() - val position = Player.PositionInfo( - currentMediaItemIndex, - getCurrentMediaItemIndex(), - getCurrentMediaItem(), - currentMediaItemIndex, - getCurrentPeriodIndex(), - positionMs, - positionMs, - INDEX_UNSET, - INDEX_UNSET - ) - notifyListeners(EVENT_POSITION_DISCONTINUITY) { - it.onPositionDiscontinuity(position, position, DISCONTINUITY_REASON_SEEK) - } - } - - MPVLib.MpvEvent.MPV_EVENT_END_FILE -> - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { - it.onPlaybackStateChanged(STATE_ENDED) - } - - MPVLib.MpvEvent.MPV_EVENT_QUEUE_OVERFLOW -> { - playerError = PlaybackException( - "mpv event queue overflow", - null, - PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK - ) - notifyListeners(EVENT_PLAYER_ERROR) { - it.onPlayerErrorChanged(playerError) - it.onPlayerError( - playerError ?: PlaybackException( - "unknown", - null, - PlaybackException.ERROR_CODE_UNSPECIFIED - ) - ) - } - } - } - } - - override fun eventProperty(property: String) = Unit - - override fun eventProperty(property: String, value: Long) { - when (property) { - "vid", "aid", "sid" -> { - notifyListeners(EVENT_TRACKS_CHANGED) { it.onTracksChanged(getCurrentTracks()) } - } - - "width", "height" -> { - notifyListeners(EVENT_VIDEO_SIZE_CHANGED) { - it.onVideoSizeChanged(videoSize) - } - } - } - } - - override fun eventProperty(property: String, value: Double) { - when (property) { - "duration" -> - notifyListeners(arrayOf(EVENT_TIMELINE_CHANGED, EVENT_MEDIA_METADATA_CHANGED)) { - it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_SOURCE_UPDATE) - it.onMediaMetadataChanged(mediaMetadata) - } - - "speed" -> - notifyListeners(EVENT_PLAYBACK_PARAMETERS_CHANGED) { - it.onPlaybackParametersChanged( - PlaybackParameters( - value.toFloat().coerceAtLeast(0f) - ) - ) - } - - "volume" -> - notifyListeners(EVENT_VOLUME_CHANGED) { - it.onVolumeChanged((value.coerceIn(0.0, 100.0) / 100.0).toFloat()) - } - } - } - - override fun eventProperty(property: String, value: Boolean) { - when (property) { - "pause" -> - notifyListeners(arrayOf(EVENT_PLAY_WHEN_READY_CHANGED, EVENT_IS_PLAYING_CHANGED)) { - it.onPlayWhenReadyChanged( - !value, - PLAY_WHEN_READY_CHANGE_REASON_REMOTE - ) - it.onIsPlayingChanged(!value) - } - - "core-idle", "eof-reached" -> - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { - it.onPlaybackStateChanged(getPlaybackState()) - } - - "paused-for-cache" -> { - notifyListeners(arrayOf(EVENT_IS_LOADING_CHANGED, EVENT_PLAYBACK_STATE_CHANGED)) { - it.onIsLoadingChanged(value) - it.onPlaybackStateChanged(getPlaybackState()) - } - } - } - } - - override fun eventProperty(property: String, value: String) = Unit - - -} diff --git a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt index 25cc191..c79132d 100644 --- a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt @@ -32,7 +32,7 @@ class OmniPlayer : HybridOmniPlayerSpec() { private val ctx = NitroModules.applicationContext ?: throw Error("No Context available!") val player: Player = runOnMainThreadSync { // ExoPlayer.Builder(ctx).build() - MpvPlayer(ctx) + VlcPlayer(ctx) } override val eventMap = EventMap(player) @@ -103,15 +103,9 @@ class OmniPlayer : HybridOmniPlayerSpec() { ): MediaItem { // val headers = Bundle().apply { // putStringArrayList( - // MpvPlayer.REQUEST_HEADER_NAMES_KEY, - // ArrayList(src.headers.keys) - // ) - // putStringArrayList( - // MpvPlayer.REQUEST_HEADER_VALUES_KEY, - // ArrayList(src.headers.values) - // ) - // source.startTime?.let { - // putLong(MpvPlayer.REQUEST_START_MS_KEY, (it.coerceAtLeast(0.0) * 1000.0).toLong()) + // VlcPlayer.REQUEST_HEADER_NAMES_KEY, + // VlcPlayer.REQUEST_HEADER_VALUES_KEY, + // putLong(VlcPlayer.REQUEST_START_MS_KEY, (it.coerceAtLeast(0.0) * 1000.0).toLong()) // } // } return MediaItem.Builder() diff --git a/android/src/main/java/dev/zoriya/omni/VlcPlayer.kt b/android/src/main/java/dev/zoriya/omni/VlcPlayer.kt new file mode 100644 index 0000000..4d10b52 --- /dev/null +++ b/android/src/main/java/dev/zoriya/omni/VlcPlayer.kt @@ -0,0 +1,752 @@ +package dev.zoriya.omni + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.TextureView +import androidx.core.net.toUri +import androidx.media3.common.AudioAttributes +import androidx.media3.common.BasePlayer +import androidx.media3.common.C.FORMAT_HANDLED +import androidx.media3.common.C.INDEX_UNSET +import androidx.media3.common.C.SELECTION_FLAG_DEFAULT +import androidx.media3.common.C.TIME_UNSET +import androidx.media3.common.C.TRACK_TYPE_AUDIO +import androidx.media3.common.C.TRACK_TYPE_TEXT +import androidx.media3.common.C.TRACK_TYPE_VIDEO +import androidx.media3.common.DeviceInfo +import androidx.media3.common.Format +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.TrackGroup +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks +import androidx.media3.common.Tracks.Group +import androidx.media3.common.VideoSize +import androidx.media3.common.text.CueGroup +import androidx.media3.common.util.Clock +import androidx.media3.common.util.ListenerSet +import androidx.media3.common.util.Size +import org.videolan.libvlc.LibVLC +import org.videolan.libvlc.Media +import org.videolan.libvlc.MediaPlayer +import org.videolan.libvlc.interfaces.IMedia +import org.videolan.libvlc.interfaces.IVLCVout + +@SuppressLint("UnsafeOptInUsageError") +class VlcPlayer(ctx: Context) : BasePlayer(), MediaPlayer.EventListener { + private val applicationLooper: Looper = Looper.getMainLooper() + private val applicationHandler = Handler(applicationLooper) + private val listeners = ListenerSet( + applicationLooper, + Clock.DEFAULT + ) { listener, flags -> + listener.onEvents(this, Player.Events(flags)) + } + + private fun notifyListeners(eventFlag: Int, callback: (Player.Listener) -> Unit) = + notifyListeners(arrayOf(eventFlag), callback) + + private fun notifyListeners(eventFlags: Array, callback: (Player.Listener) -> Unit) { + val flush = { + eventFlags.forEach { listeners.queueEvent(it, callback) } + listeners.flushEvents() + } + if (Looper.myLooper() == Looper.getMainLooper()) flush() else applicationHandler.post(flush) + } + + private val libVLC = LibVLC(ctx, arrayListOf( + "--network-caching=1500", + "--file-caching=1500", + "--no-save-config", + "--no-stats", + "--no-osd", + "--quiet" + )) + + private val player = MediaPlayer(libVLC) + + private val vlcVout: IVLCVout = player.vlcVout + + private var mediaItems: List = emptyList() + private var currentMediaItemIndex: Int = INDEX_UNSET + private var currentTrackSelectionParameters = TrackSelectionParameters.Builder().build() + private var playerError: PlaybackException? = null + private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY + private var userInitiatedTransition: Boolean = false + private var cachedBufferedPosition: Long = 0L + + private val availableCommands: Player.Commands = Player.Commands.Builder() + .add(COMMAND_PLAY_PAUSE) + .add(COMMAND_PREPARE) + .add(COMMAND_STOP) + .add(COMMAND_RELEASE) + .add(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) + .add(COMMAND_SEEK_TO_DEFAULT_POSITION) + .add(COMMAND_SEEK_BACK) + .add(COMMAND_SEEK_FORWARD) + .add(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .add(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .add(COMMAND_SET_SPEED_AND_PITCH) + .add(COMMAND_GET_VOLUME) + .add(COMMAND_SET_VOLUME) + .add(COMMAND_SET_VIDEO_SURFACE) + .add(COMMAND_SET_MEDIA_ITEM) + .add(COMMAND_GET_CURRENT_MEDIA_ITEM) + .add(COMMAND_GET_METADATA) + .add(COMMAND_GET_TIMELINE) + .add(COMMAND_GET_TRACKS) + .add(COMMAND_SET_TRACK_SELECTION_PARAMETERS) + .build() + + init { + player.setEventListener(this) + } + + override fun onEvent(event: MediaPlayer.Event) { + when (event.type) { + MediaPlayer.Event.Opening -> { + notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { + it.onPlaybackStateChanged(STATE_BUFFERING) + } + } + + MediaPlayer.Event.Playing -> { + notifyListeners( + arrayOf( + EVENT_TIMELINE_CHANGED, + EVENT_MEDIA_METADATA_CHANGED, + EVENT_PLAYBACK_STATE_CHANGED, + EVENT_IS_PLAYING_CHANGED, + EVENT_TRACKS_CHANGED + ) + ) { + it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + it.onMediaMetadataChanged(mediaMetadata) + it.onPlaybackStateChanged(STATE_READY) + it.onIsPlayingChanged(true) + it.onTracksChanged(getCurrentTracks()) + } + } + + MediaPlayer.Event.Paused -> { + notifyListeners( + arrayOf(EVENT_PLAY_WHEN_READY_CHANGED, EVENT_IS_PLAYING_CHANGED) + ) { + it.onPlayWhenReadyChanged(false, PLAY_WHEN_READY_CHANGE_REASON_REMOTE) + it.onIsPlayingChanged(false) + } + } + + MediaPlayer.Event.Stopped -> { + notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { + it.onPlaybackStateChanged(STATE_IDLE) + } + } + + MediaPlayer.Event.EndReached -> { + notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { + it.onPlaybackStateChanged(STATE_ENDED) + } + } + + MediaPlayer.Event.Buffering -> { + cachedBufferedPosition = (event.buffering * getDuration()).toLong() + notifyListeners(arrayOf(EVENT_IS_LOADING_CHANGED, EVENT_PLAYBACK_STATE_CHANGED)) { + it.onIsLoadingChanged(true) + it.onPlaybackStateChanged(STATE_BUFFERING) + } + } + + MediaPlayer.Event.EncounteredError -> { + val error = PlaybackException( + "VLC playback error", + null, + PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK + ) + playerError = error + notifyListeners(EVENT_PLAYER_ERROR) { + it.onPlayerErrorChanged(error) + it.onPlayerError(error) + } + } + + MediaPlayer.Event.TimeChanged -> { + notifyListeners(EVENT_POSITION_DISCONTINUITY) { + val positionMs = getCurrentPosition() + val position = Player.PositionInfo( + currentMediaItemIndex, + currentMediaItemIndex, + getCurrentMediaItem(), + currentMediaItemIndex, + currentMediaItemIndex, + positionMs, + positionMs, + INDEX_UNSET, + INDEX_UNSET + ) + it.onPositionDiscontinuity(position, position, DISCONTINUITY_REASON_SEEK) + } + } + + MediaPlayer.Event.ESAdded, + MediaPlayer.Event.ESDeleted, + MediaPlayer.Event.ESSelected -> { + notifyListeners(EVENT_TRACKS_CHANGED) { + it.onTracksChanged(getCurrentTracks()) + } + } + + MediaPlayer.Event.LengthChanged -> { + notifyListeners(EVENT_TIMELINE_CHANGED) { + it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + } + } + } + } + + override fun getApplicationLooper(): Looper = applicationLooper + + override fun addListener(listener: Player.Listener) { + listeners.add(listener) + } + + override fun removeListener(listener: Player.Listener) { + listeners.remove(listener) + } + + override fun setMediaItems(mediaItems: List, resetPosition: Boolean) { + setMediaItems( + mediaItems, + if (resetPosition) 0 else INDEX_UNSET, + if (resetPosition) 0L else TIME_UNSET + ) + } + + override fun setMediaItems( + mediaItems: List, + startIndex: Int, + startPositionMs: Long + ) { + val prev = currentMediaItemIndex + val targetIndex = when { + mediaItems.isEmpty() -> INDEX_UNSET + startIndex in mediaItems.indices -> startIndex + else -> 0 + } + + this.mediaItems = mediaItems + currentMediaItemIndex = targetIndex + playlistMetadata = mediaItems.getOrNull(targetIndex)?.mediaMetadata ?: MediaMetadata.EMPTY + playerError = null + + player.stop() + + mediaItems.getOrNull(targetIndex)?.let { item -> + val uri = item.localConfiguration?.uri?.toString() + if (!uri.isNullOrEmpty()) { + val media = Media(libVLC, uri.toUri()) + media.setHWDecoderEnabled(true, false) + + item.localConfiguration?.subtitleConfigurations?.forEach { subtitle -> + media.addSlave( + IMedia.Slave(IMedia.Slave.Type.Subtitle, 0, subtitle.uri.toString()) + ) + } + + val targetMs = startPositionMs.coerceAtLeast(0L).takeIf { it != TIME_UNSET } ?: 0L + media.addOption(":start-time=${targetMs / 1000.0}") + + player.setMedia(media) + } + } + + val events = mutableListOf( + EVENT_TIMELINE_CHANGED, + EVENT_MEDIA_METADATA_CHANGED, + EVENT_PLAYLIST_METADATA_CHANGED + ) + if (prev != currentMediaItemIndex) events += EVENT_MEDIA_ITEM_TRANSITION + val reason = if (userInitiatedTransition) MEDIA_ITEM_TRANSITION_REASON_SEEK else MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED + userInitiatedTransition = false + notifyListeners(events.toTypedArray()) { + it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) + it.onMediaMetadataChanged(mediaMetadata) + it.onPlaylistMetadataChanged(playlistMetadata) + if (prev != currentMediaItemIndex) { + it.onMediaItemTransition(currentMediaItem, reason) + } + } + } + + override fun addMediaItems( + p0: Int, + p1: List + ) = Unit + + override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) = Unit + + override fun replaceMediaItems(fromIndex: Int, toIndex: Int, mediaItems: List) { + setMediaItems(mediaItems) + } + + override fun removeMediaItems(fromIndex: Int, toIndex: Int) { + val prev = currentMediaItemIndex + mediaItems = emptyList() + currentMediaItemIndex = INDEX_UNSET + playlistMetadata = MediaMetadata.EMPTY + player.stop() + + val events = mutableListOf( + EVENT_TIMELINE_CHANGED, + EVENT_MEDIA_METADATA_CHANGED, + EVENT_PLAYLIST_METADATA_CHANGED + ) + if (prev != INDEX_UNSET) events += EVENT_MEDIA_ITEM_TRANSITION + notifyListeners(events.toTypedArray()) { + it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) + it.onMediaMetadataChanged(mediaMetadata) + it.onPlaylistMetadataChanged(playlistMetadata) + if (prev != INDEX_UNSET) { + it.onMediaItemTransition(null, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) + } + } + } + + override fun getAvailableCommands(): Player.Commands = availableCommands + + override fun prepare() { + player.play() + } + + override fun getPlaybackState(): Int = + when { + currentMediaItemIndex == INDEX_UNSET -> STATE_IDLE + player.media == null -> STATE_IDLE + player.isPlaying -> STATE_READY + player.isSeekable && player.time >= player.length && player.length > 0 -> STATE_ENDED + else -> STATE_READY + } + + override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE + + override fun getPlayerError(): PlaybackException? = playerError + + override fun setPlayWhenReady(playWhenReady: Boolean) { + if (playWhenReady) player.play() else player.pause() + } + + override fun getPlayWhenReady(): Boolean = player.isPlaying + + override fun setRepeatMode(repeatMode: Int) = Unit + + override fun getRepeatMode(): Int = REPEAT_MODE_OFF + + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) = Unit + + override fun getShuffleModeEnabled(): Boolean = false + + override fun isLoading() = player.media?.state == IMedia.State.Opening + + override fun seekTo( + mediaItemIndex: Int, + positionMs: Long, + seekCommand: Int, + isRepeatingCurrentItem: Boolean + ) { + val targetIndex = if (mediaItemIndex == INDEX_UNSET) currentMediaItemIndex else mediaItemIndex + if (targetIndex == INDEX_UNSET || targetIndex !in mediaItems.indices) return + + if (targetIndex != currentMediaItemIndex) { + userInitiatedTransition = true + setMediaItems(mediaItems, targetIndex, positionMs) + return + } + + player.time = positionMs.coerceAtLeast(0L).takeIf { it != TIME_UNSET } ?: 0L + } + + override fun getSeekBackIncrement(): Long = 5_000L + + override fun getSeekForwardIncrement(): Long = 15_000L + + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { + player.rate = playbackParameters.speed.coerceAtLeast(0f) + } + + override fun getPlaybackParameters(): PlaybackParameters = + PlaybackParameters(player.rate.coerceAtLeast(0f)) + + override fun stop() { + player.stop() + } + + override fun release() { + player.setEventListener(null) + player.stop() + clearVideoSurface() + player.release() + libVLC.release() + } + + override fun getCurrentTracks(): Tracks { + val result = ArrayList() + val selectedVideo = player.videoTrack + val selectedAudio = player.audioTrack + val selectedSubtitle = player.spuTrack + + val videoTracks = player.videoTracks + if (!videoTracks.isNullOrEmpty()) { + val videoFormats = videoTracks.map { track -> + Format.Builder() + .setId(track.id.toString()) + .setLabel(track.name) + .setSampleMimeType("video/x-unknown") + .build() + } + val group = TrackGroup("vlc-video", *videoFormats.toTypedArray()) + val selected = BooleanArray(videoFormats.size) { idx -> + selectedVideo == videoTracks[idx].id + } + val support = IntArray(videoFormats.size) { FORMAT_HANDLED } + result.add(Group(group, videoFormats.size > 1, support, selected)) + } + + player.audioTracks?.forEach { track -> + val format = Format.Builder() + .setId(track.id.toString()) + .setLabel(track.name) + .setSampleMimeType("audio/x-unknown") + .build() + val group = TrackGroup("vlc-audio-${track.id}", format) + val selected = booleanArrayOf(selectedAudio == track.id) + val support = intArrayOf(FORMAT_HANDLED) + result.add(Group(group, false, support, selected)) + } + + player.spuTracks?.forEach { track -> + if (track.id < 0) return@forEach + val format = Format.Builder() + .setId(track.id.toString()) + .setLabel(track.name) + .setSampleMimeType("text/x-unknown") + .build() + val group = TrackGroup("vlc-sub-${track.id}", format) + val selected = booleanArrayOf(selectedSubtitle == track.id) + val support = intArrayOf(FORMAT_HANDLED) + result.add(Group(group, false, support, selected)) + } + + return if (result.isEmpty()) Tracks.EMPTY else Tracks(result) + } + + override fun getTrackSelectionParameters(): TrackSelectionParameters = + currentTrackSelectionParameters + + override fun setTrackSelectionParameters(trackSelectionParameters: TrackSelectionParameters) { + currentTrackSelectionParameters = trackSelectionParameters + val videoDisabled = trackSelectionParameters.disabledTrackTypes.contains(TRACK_TYPE_VIDEO) + val audioDisabled = trackSelectionParameters.disabledTrackTypes.contains(TRACK_TYPE_AUDIO) + val textDisabled = trackSelectionParameters.disabledTrackTypes.contains(TRACK_TYPE_TEXT) + + if (videoDisabled) player.setVideoTrackEnabled(false) + if (audioDisabled) player.setAudioTrack(-1) + if (textDisabled) player.setSpuTrack(-1) + + val tracks = getCurrentTracks() + + var hasVideoOverride = false + var hasAudioOverride = false + var hasTextOverride = false + + for (override in trackSelectionParameters.overrides.values) { + val selectedIndex = override.trackIndices.firstOrNull() ?: continue + if (selectedIndex !in 0 until override.mediaTrackGroup.length) continue + val trackId = override.mediaTrackGroup.getFormat(selectedIndex).id?.toIntOrNull() ?: continue + + when (override.type) { + TRACK_TYPE_VIDEO -> { + hasVideoOverride = true + player.setVideoTrackEnabled(true) + player.setVideoTrack(trackId) + } + TRACK_TYPE_AUDIO -> { + hasAudioOverride = true + player.setAudioTrack(trackId) + } + TRACK_TYPE_TEXT -> { + hasTextOverride = true + player.setSpuTrack(trackId) + } + } + } + + if (!videoDisabled && !hasVideoOverride) { + val videoPreference = selectTrackByPreference( + tracks, TRACK_TYPE_VIDEO, + trackSelectionParameters.preferredVideoLanguages, + trackSelectionParameters.preferredVideoLabels, + ) + player.setVideoTrackEnabled(true) + videoPreference?.let { player.setVideoTrack(it) } + } + if (!audioDisabled && !hasAudioOverride) { + val audioPreference = selectTrackByPreference( + tracks, TRACK_TYPE_AUDIO, + trackSelectionParameters.preferredAudioLanguages, + trackSelectionParameters.preferredAudioLabels, + ) + audioPreference?.let { player.setAudioTrack(it) } + } + if (!textDisabled && !hasTextOverride) { + val textPreference = selectTrackByPreference( + tracks, TRACK_TYPE_TEXT, + trackSelectionParameters.preferredTextLanguages, + trackSelectionParameters.preferredTextLabels, + ) + textPreference?.let { player.setSpuTrack(it) } + } + } + + private fun selectTrackByPreference( + tracks: Tracks, + trackType: Int, + languages: List, + labels: List, + ): Int? { + val preferredLanguages = languages.filter { it.isNotEmpty() } + val preferredLabels = labels.filter { it.isNotEmpty() } + if (preferredLanguages.isEmpty() && preferredLabels.isEmpty()) return null + + data class Candidate(val id: Int, val langIndex: Int, val labelIndex: Int, val hasDefaultFlag: Boolean) + + val candidates = tracks.groups + .filter { it.type == trackType } + .flatMap { group -> + (0 until group.length).mapNotNull { i -> + val format = group.getTrackFormat(i) + val id = format.id?.toIntOrNull() ?: return@mapNotNull null + + val langIndex = format.language?.let { lang -> + preferredLanguages.indexOfFirst { it.equals(lang, ignoreCase = true) } + } ?: -1 + val labelIndex = format.label?.let { label -> + preferredLabels.indexOfFirst { it.equals(label, ignoreCase = true) } + } ?: -1 + + val matchesLang = preferredLanguages.isEmpty() || langIndex >= 0 + val matchesLabel = preferredLabels.isEmpty() || labelIndex >= 0 + if (matchesLang && matchesLabel) { + Candidate( + id, + if (langIndex >= 0) langIndex else Int.MAX_VALUE, + if (labelIndex >= 0) labelIndex else Int.MAX_VALUE, + (format.selectionFlags and SELECTION_FLAG_DEFAULT) != 0 + ) + } else null + } + } + + return candidates.minWithOrNull( + compareBy { it.langIndex } + .thenBy { it.labelIndex } + .thenByDescending { it.hasDefaultFlag } + )?.id + } + + override fun getMediaMetadata(): MediaMetadata = + getCurrentMediaItem()?.mediaMetadata ?: MediaMetadata.EMPTY + + override fun getPlaylistMetadata(): MediaMetadata = playlistMetadata + + override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) { + playlistMetadata = mediaMetadata + } + + override fun getCurrentTimeline(): Timeline { + if (mediaItems.isEmpty()) return Timeline.EMPTY + return object : Timeline() { + private fun durationUsForIndex(index: Int): Long { + if (index != currentMediaItemIndex) return TIME_UNSET + val dur = duration + return if (dur == TIME_UNSET) TIME_UNSET else dur * 1000L + } + + override fun getWindowCount(): Int = mediaItems.size + + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long + ): Window { + check(windowIndex in mediaItems.indices) + return window.set( + windowIndex, + mediaItems[windowIndex], + null, + TIME_UNSET, + TIME_UNSET, + TIME_UNSET, + true, + false, + null, + 0L, + durationUsForIndex(windowIndex), + 0, + mediaItems.size - 1, + 0L + ) + } + + override fun getPeriodCount(): Int = mediaItems.size + + override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { + check(periodIndex in mediaItems.indices) + return period.set(periodIndex, periodIndex, 0, durationUsForIndex(periodIndex), 0L) + } + + override fun getIndexOfPeriod(uid: Any): Int { + return if (uid is Int && uid in mediaItems.indices) uid else INDEX_UNSET + } + + override fun getUidOfPeriod(periodIndex: Int): Any { + check(periodIndex in mediaItems.indices) + return periodIndex + } + } + } + + override fun getCurrentPeriodIndex() = currentMediaItemIndex + + override fun getCurrentMediaItemIndex() = currentMediaItemIndex + + override fun getDuration(): Long = player.length.takeIf { it > 0 } ?: TIME_UNSET + + override fun getCurrentPosition() = player.time.coerceAtLeast(0L) + + override fun getBufferedPosition(): Long { + val duration = getDuration() + if (duration == TIME_UNSET) return getCurrentPosition() + return cachedBufferedPosition.coerceAtMost(duration) + } + + override fun getTotalBufferedDuration() = bufferedPosition + + override fun isPlayingAd(): Boolean = false + + override fun getCurrentAdGroupIndex(): Int = INDEX_UNSET + + override fun getCurrentAdIndexInAdGroup(): Int = INDEX_UNSET + + override fun getMaxSeekToPreviousPosition() = 3_000L + + override fun getContentPosition() = getCurrentPosition() + + override fun getContentBufferedPosition() = getBufferedPosition() + + override fun getAudioAttributes(): AudioAttributes = AudioAttributes.DEFAULT + + override fun setVolume(volume: Float) { + player.volume = (volume * 100).toInt() + } + + override fun getVolume(): Float = player.volume / 100f + + override fun mute() { + player.volume = 0 + } + + override fun unmute() { + player.volume = 100 + } + + override fun clearVideoSurface() { + vlcVout.detachViews() + } + + override fun clearVideoSurface(surface: Surface?) { + clearVideoSurface() + } + + override fun setVideoSurface(surface: Surface?) { + if (surface == null) { + clearVideoSurface() + return + } + vlcVout.setVideoSurface(surface, null) + vlcVout.attachViews() + } + + override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + if (surfaceHolder == null) return clearVideoSurface() + val surface = surfaceHolder.surface + if (surface != null && surface.isValid && !vlcVout.areViewsAttached()) { + vlcVout.setVideoSurface(surface, surfaceHolder) + vlcVout.attachViews() + } + } + + override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + clearVideoSurface() + } + + override fun setVideoSurfaceView(surfaceView: SurfaceView?) { + vlcVout.setVideoView(surfaceView ?: return clearVideoSurface()) + vlcVout.attachViews() + } + + override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { + clearVideoSurface() + } + + override fun setVideoTextureView(textureView: TextureView?) = Unit + + override fun clearVideoTextureView(textureView: TextureView?) = Unit + + override fun getVideoSize(): VideoSize { + val videoTrack = player.currentVideoTrack ?: return VideoSize.UNKNOWN + if (videoTrack.width <= 0 || videoTrack.height <= 0) return VideoSize.UNKNOWN + return VideoSize(videoTrack.width, videoTrack.height) + } + + override fun getSurfaceSize(): Size = Size.UNKNOWN + + override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO + + override fun getDeviceInfo(): DeviceInfo = DeviceInfo.UNKNOWN + + override fun getDeviceVolume(): Int = 0 + + override fun isDeviceMuted(): Boolean = false + + @Deprecated("Deprecated in Java") + override fun setDeviceVolume(volume: Int) = Unit + + override fun setDeviceVolume(volume: Int, flags: Int) = Unit + + @Deprecated("Deprecated in Java") + override fun increaseDeviceVolume() = Unit + + override fun increaseDeviceVolume(flags: Int) = Unit + + @Deprecated("Deprecated in Java") + override fun decreaseDeviceVolume() = Unit + + override fun decreaseDeviceVolume(flags: Int) = Unit + + @Deprecated("Deprecated in Java") + override fun setDeviceMuted(muted: Boolean) = Unit + + override fun setDeviceMuted(muted: Boolean, flags: Int) = Unit + + override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = + Unit +}