diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc68d2b67..18f1b2abe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -78,6 +78,13 @@ android { } } + /*splits { + abi { + isEnable = true + isUniversalApk = true + } + }*/ + @Suppress("UnstableApiUsage") buildFeatures { viewBinding = true @@ -89,6 +96,9 @@ android { compileOptions { isCoreLibraryDesugaringEnabled = true } + packagingOptions { + jniLibs.keepDebugSymbols += "**/*.so" + } lint { isAbortOnError = false sarifReport = true @@ -163,6 +173,11 @@ dependencies { testImplementation(libs.bundles.kotest) testImplementation(libs.mockk) androidTestImplementation(libs.bundles.androidx.test) + + // TODO: Decide how to build / publish this.. + implementation(files("libmpv\\app-release.aar")) + //implementation(files("libmpv\\app-sources.jar")) + //implementation(files("libmpv\\app-javadoc.jar")) } tasks { diff --git a/app/libmpv/app-javadoc.jar b/app/libmpv/app-javadoc.jar new file mode 100644 index 000000000..ce760e758 Binary files /dev/null and b/app/libmpv/app-javadoc.jar differ diff --git a/app/libmpv/app-release.aar b/app/libmpv/app-release.aar new file mode 100644 index 000000000..f58b6d85e Binary files /dev/null and b/app/libmpv/app-release.aar differ diff --git a/app/libmpv/app-sources.jar b/app/libmpv/app-sources.jar new file mode 100644 index 000000000..d5b588daf Binary files /dev/null and b/app/libmpv/app-sources.jar differ diff --git a/app/src/main/assets/mpv.conf b/app/src/main/assets/mpv.conf new file mode 100644 index 000000000..70a08601a --- /dev/null +++ b/app/src/main/assets/mpv.conf @@ -0,0 +1,13 @@ +# hwdec: try to use hardware decoding +hwdec=mediacodec-copy +hwdec-codecs="h264,hevc,mpeg4,mpeg2video,vp8,vp9" +gpu-dumb-mode=auto +# tls: allow self signed certificate +tls-verify=no +tls-ca-file="" +# demuxer: limit cache to 32 MiB, the default is too high for mobile devices +demuxer-max-bytes=32MiB +demuxer-max-back-bytes=32MiB +# sub: scale subtitles with video +sub-scale-with-window=no +sub-use-margins=no diff --git a/app/src/main/assets/native/ExoPlayerPlugin.js b/app/src/main/assets/native/NativePlayerPlugin.js similarity index 97% rename from app/src/main/assets/native/ExoPlayerPlugin.js rename to app/src/main/assets/native/NativePlayerPlugin.js index 14df51595..aeb45a93c 100644 --- a/app/src/main/assets/native/ExoPlayerPlugin.js +++ b/app/src/main/assets/native/NativePlayerPlugin.js @@ -1,4 +1,4 @@ -export class ExoPlayerPlugin { +export class NativePlayerPlugin { constructor({ events, playbackManager, loading }) { window['ExoPlayer'] = this; @@ -6,9 +6,9 @@ export class ExoPlayerPlugin { this.playbackManager = playbackManager; this.loading = loading; - this.name = 'ExoPlayer'; + this.name = 'NativePlayer'; this.type = 'mediaplayer'; - this.id = 'exoplayer'; + this.id = 'nativeplayer'; // Prioritize first this.priority = -1; diff --git a/app/src/main/assets/native/nativeshell.js b/app/src/main/assets/native/nativeshell.js index ea2b0ce90..58c6dde86 100644 --- a/app/src/main/assets/native/nativeshell.js +++ b/app/src/main/assets/native/nativeshell.js @@ -17,7 +17,7 @@ const features = [ const plugins = [ 'NavigationPlugin', - 'ExoPlayerPlugin', + 'NativePlayerPlugin', 'ExternalPlayerPlugin' ]; diff --git a/app/src/main/assets/skip-intro.lua b/app/src/main/assets/skip-intro.lua new file mode 100644 index 000000000..a9e7a2b53 --- /dev/null +++ b/app/src/main/assets/skip-intro.lua @@ -0,0 +1,159 @@ +MAX_SPEED = 100 +ONE_SECOND = 1 +skip = false +ov = mp.create_osd_overlay("ass-events") +ov.data = "Seeking..." +-- Max noise (dB) and min silence duration (s) to trigger +opts = { quietness = -30, duration = 0.5 } + + +function setOptions() + local options = require 'mp.options' + options.read_options(opts) +end + +function setTime(time) + mp.set_property_number('time-pos', time) +end + +function getTime() + return mp.get_property_native('time-pos') +end + +function setSpeed(speed) + mp.set_property('speed', speed) +end + +function getSpeed() + return mp.get_property('speed') +end + +function setPause(state) + mp.set_property_bool('pause', state) +end + +function setMute(state) + mp.set_property_bool('mute', state) +end + +function initAudioFilter() + local af_table = mp.get_property_native('af') + af_table[#af_table + 1] = { + enabled = false, + label = 'silencedetect', + name = 'lavfi', + params = { graph = 'silencedetect=noise=' .. opts.quietness .. 'dB:d=' .. opts.duration } + } + mp.set_property_native('af', af_table) +end + +function initVideoFilter() + local vf_table = mp.get_property_native('vf') + vf_table[#vf_table + 1] = { + enabled = false, + label = 'blackout', + name = 'lavfi', + params = { graph = '' } + } + mp.set_property_native('vf', vf_table) +end + +function setAudioFilter(state) + local af_table = mp.get_property_native('af') + if #af_table > 0 then + for i = #af_table, 1, -1 do + if af_table[i].label == 'silencedetect' then + af_table[i].enabled = state + mp.set_property_native('af', af_table) + break + end + end + end +end + +function dim(state) + local dim = { width = 0, height = 0 } + if state == true then + dim.width = mp.get_property_native('width') + dim.height = mp.get_property_native('height') + end + return dim.width .. 'x' .. dim.height +end + +function setVideoFilter(state) + local vf_table = mp.get_property_native('vf') + if #vf_table > 0 then + for i = #vf_table, 1, -1 do + if vf_table[i].label == 'blackout' then + vf_table[i].enabled = state + vf_table[i].params = { graph = 'nullsink,color=c=black:s=' .. dim(state) } + mp.set_property_native('vf', vf_table) + break + end + end + end +end + +function silenceTrigger(name, value) + if value == '{}' or value == nil then + return + end + + local skipTime = tonumber(string.match(value, '%d+%.?%d+')) + local currTime = getTime() + + if skipTime == nil or skipTime < currTime + ONE_SECOND then + return + end + + stopSkip() + setTime(skipTime) + skip = false +end + +function setAudioTrigger(state) + if state == true then + mp.observe_property('af-metadata/silencedetect', 'string', silenceTrigger) + else + mp.unobserve_property(silenceTrigger) + end +end + +function startSkip() + ov:update() + startTime = getTime() + startSpeed = getSpeed() + -- This audio filter detects moments of silence + setAudioFilter(true) + -- This video filter makes fast-forward faster + setVideoFilter(true) + setAudioTrigger(true) + setPause(false) + setMute(true) + setSpeed(MAX_SPEED) +end + +function stopSkip() + ov:remove() + setAudioFilter(false) + setVideoFilter(false) + setAudioTrigger(false) + setMute(false) + setSpeed(startSpeed) +end + +function keypress() + skip = not skip + if skip then + startSkip() + else + stopSkip() + setTime(startTime) + end +end + +setOptions(opts) +initAudioFilter() +initVideoFilter() + +mp.add_key_binding(nil, 'skip-key', keypress) diff --git a/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt b/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt index 324050d65..2a25bc1e5 100644 --- a/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt +++ b/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt @@ -17,12 +17,14 @@ import kotlinx.coroutines.channels.Channel import okhttp3.OkHttpClient import org.jellyfin.mobile.api.DeviceProfileBuilder import org.jellyfin.mobile.bridge.ExternalPlayer +import org.jellyfin.mobile.bridge.NativePlayer import org.jellyfin.mobile.controller.ApiController import org.jellyfin.mobile.fragment.ConnectFragment import org.jellyfin.mobile.fragment.WebViewFragment import org.jellyfin.mobile.media.car.LibraryBrowser import org.jellyfin.mobile.player.PlayerEvent import org.jellyfin.mobile.player.PlayerFragment +import org.jellyfin.mobile.player.mpv.MPVPlayer import org.jellyfin.mobile.player.source.MediaSourceResolver import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.PermissionRequestHelper @@ -61,8 +63,9 @@ val applicationModule = module { // Media player helpers single { MediaSourceResolver(get()) } single { DeviceProfileBuilder() } - single { get().getDeviceProfile() } - single(named(ExternalPlayer.DEVICE_PROFILE_NAME)) { get().getExternalPlayerProfile() } + single(named(ExternalPlayer.PLAYER_NAME)) { get().getExternalPlayerProfile() } + single(named(MPVPlayer.PLAYER_NAME)) { get().getMPVPlayerProfile() } + single(named(NativePlayer.PLAYER_NAME)) { get().getExoPlayerProfile() } // ExoPlayer factories single { diff --git a/app/src/main/java/org/jellyfin/mobile/api/DeviceProfileBuilder.kt b/app/src/main/java/org/jellyfin/mobile/api/DeviceProfileBuilder.kt index 97de8ffdf..af34130b7 100644 --- a/app/src/main/java/org/jellyfin/mobile/api/DeviceProfileBuilder.kt +++ b/app/src/main/java/org/jellyfin/mobile/api/DeviceProfileBuilder.kt @@ -2,8 +2,9 @@ package org.jellyfin.mobile.api import android.media.MediaCodecList import org.jellyfin.mobile.bridge.ExternalPlayer +import org.jellyfin.mobile.bridge.NativePlayer import org.jellyfin.mobile.player.DeviceCodec -import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.player.mpv.MPVPlayer import org.jellyfin.sdk.model.api.CodecProfile import org.jellyfin.sdk.model.api.ContainerProfile import org.jellyfin.sdk.model.api.DeviceProfile @@ -21,7 +22,7 @@ class DeviceProfileBuilder { require(SUPPORTED_CONTAINER_FORMATS.size == AVAILABLE_VIDEO_CODECS.size && SUPPORTED_CONTAINER_FORMATS.size == AVAILABLE_AUDIO_CODECS.size) } - fun getDeviceProfile(): DeviceProfile { + fun getExoPlayerProfile(): DeviceProfile { val containerProfiles = ArrayList() val directPlayProfiles = ArrayList() val codecProfiles = ArrayList() @@ -66,9 +67,9 @@ class DeviceProfileBuilder { } return DeviceProfile( - name = Constants.APP_INFO_NAME, + name = NativePlayer.PLAYER_NAME, directPlayProfiles = directPlayProfiles, - transcodingProfiles = getTranscodingProfiles(), + transcodingProfiles = getExoPlayerTranscodingProfiles(), containerProfiles = containerProfiles, codecProfiles = codecProfiles, subtitleProfiles = getSubtitleProfiles(EXO_EMBEDDED_SUBTITLES, EXO_EXTERNAL_SUBTITLES), @@ -87,8 +88,32 @@ class DeviceProfileBuilder { ) } + fun getMPVPlayerProfile(): DeviceProfile = DeviceProfile( + name = MPVPlayer.PLAYER_NAME, + directPlayProfiles = listOf( + DirectPlayProfile(type = DlnaProfileType.VIDEO), + DirectPlayProfile(type = DlnaProfileType.AUDIO), + ), + transcodingProfiles = getMPVTranscodingProfiles(), + containerProfiles = emptyList(), + codecProfiles = emptyList(), + subtitleProfiles = getSubtitleProfiles(MPV_PLAYER_SUBTITLES.plus("vtt"), MPV_PLAYER_SUBTITLES), + + // TODO: remove redundant defaults after API/SDK is fixed + maxAlbumArtWidth = Int.MAX_VALUE, + maxAlbumArtHeight = Int.MAX_VALUE, + timelineOffsetSeconds = 0, + enableAlbumArtInDidl = false, + enableSingleAlbumArtLimit = false, + enableSingleSubtitleLimit = false, + requiresPlainFolders = false, + requiresPlainVideoItems = false, + enableMsMediaReceiverRegistrar = false, + ignoreTranscodeByteRangeRequests = false, + ) + fun getExternalPlayerProfile(): DeviceProfile = DeviceProfile( - name = ExternalPlayer.DEVICE_PROFILE_NAME, + name = ExternalPlayer.PLAYER_NAME, directPlayProfiles = listOf( DirectPlayProfile(type = DlnaProfileType.VIDEO), DirectPlayProfile(type = DlnaProfileType.AUDIO), @@ -145,7 +170,7 @@ class DeviceProfileBuilder { return videoCodecs to audioCodecs } - private fun getTranscodingProfiles(): List = ArrayList().apply { + private fun getExoPlayerTranscodingProfiles(): List = ArrayList().apply { add( TranscodingProfile( type = DlnaProfileType.VIDEO, @@ -207,6 +232,46 @@ class DeviceProfileBuilder { ) } + private fun getMPVTranscodingProfiles(): List = ArrayList().apply { + add( + TranscodingProfile( + type = DlnaProfileType.AUDIO, + context = EncodingContext.STREAMING, + + // TODO: remove redundant defaults after API/SDK is fixed + estimateContentLength = false, + enableMpegtsM2TsMode = false, + transcodeSeekInfo = TranscodeSeekInfo.AUTO, + copyTimestamps = false, + enableSubtitlesInManifest = false, + minSegments = 0, + segmentLength = 0, + breakOnNonKeyFrames = false, + ) + ) + add( + TranscodingProfile( + type = DlnaProfileType.VIDEO, + container = "ts", + videoCodec = "h264,h265,hevc,mpeg4,mpeg2video", + audioCodec = "mp1,mp2,mp3,aac,ac3,eac3,dts,mlp,truehd,opus,flac,vorbis", + context = EncodingContext.STREAMING, + protocol = "hls", + maxAudioChannels = "6", + + // TODO: remove redundant defaults after API/SDK is fixed + estimateContentLength = false, + enableMpegtsM2TsMode = false, + transcodeSeekInfo = TranscodeSeekInfo.AUTO, + copyTimestamps = false, + enableSubtitlesInManifest = false, + minSegments = 0, + segmentLength = 0, + breakOnNonKeyFrames = false, + ) + ) + } + private fun getSubtitleProfiles(embedded: Array, external: Array): List = ArrayList().apply { for (format in embedded) { add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EMBED)) @@ -304,5 +369,11 @@ class DeviceProfileBuilder { private val EXTERNAL_PLAYER_SUBTITLES = arrayOf( "ssa", "ass", "srt", "subrip", "idx", "sub", "vtt", "webvtt", "ttml", "pgs", "pgssub", "smi", "smil" ) + // https://github.com/mpv-player/mpv/blob/6857600c47f069aeb68232a745bc8f81d45c9967/player/external_files.c#L35 + private val MPV_PLAYER_SUBTITLES = arrayOf( + "idx", "sub", "srt", "rt", "ssa", "ass", "mks",/* "vtt", */"sup", "scc", "smi", "lrc", "pgs", + // https://ffmpeg.org/general.html#Subtitle-Formats + "aqt", "jss", "txt", "mpsub", "pjs", "sami", "stl" + ) } } diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt b/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt index 261be9b52..57bbb0bfe 100644 --- a/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt +++ b/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt @@ -47,7 +47,7 @@ class ExternalPlayer( private val appPreferences: AppPreferences by inject() private val webappFunctionChannel: WebappFunctionChannel by inject() private val mediaSourceResolver: MediaSourceResolver by inject() - private val externalPlayerProfile: DeviceProfile by inject(named(DEVICE_PROFILE_NAME)) + private val externalPlayerProfile: DeviceProfile by inject(named(PLAYER_NAME)) private val apiClient: ApiClient = get() private val videosApi: VideosApi = apiClient.videosApi @@ -297,6 +297,6 @@ class ExternalPlayer( } companion object { - const val DEVICE_PROFILE_NAME = "Android External Player" + const val PLAYER_NAME = "Android External Player" } } diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt b/app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt index 05206a389..2f0a7a086 100644 --- a/app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt +++ b/app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt @@ -18,7 +18,7 @@ class NativePlayer(private val host: NativePlayerHost) : KoinComponent { private val playerEventChannel: Channel by inject(named(PLAYER_EVENT_CHANNEL)) @JavascriptInterface - fun isEnabled() = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER + fun isEnabled() = appPreferences.videoPlayerType in arrayOf(VideoPlayerType.EXO_PLAYER, VideoPlayerType.MPV_PLAYER) @JavascriptInterface fun loadPlayer(args: String) { @@ -61,4 +61,8 @@ class NativePlayer(private val host: NativePlayerHost) : KoinComponent { fun setVolume(volume: Int) { playerEventChannel.trySend(PlayerEvent.SetVolume(volume)) } + + companion object { + const val PLAYER_NAME = Constants.APP_INFO_NAME + } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt b/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt index 3b31f5659..9506e900f 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt @@ -22,7 +22,8 @@ import java.util.Locale class PlaybackMenus( private val fragment: PlayerFragment, private val playerBinding: FragmentPlayerBinding, - private val playerControlsBinding: ExoPlayerControlViewBinding + private val playerControlsBinding: ExoPlayerControlViewBinding, + enableSkipForward: Boolean = false ) : PopupMenu.OnDismissListener { private val context = playerBinding.root.context private val previousButton: View by playerControlsBinding::previousButton @@ -32,6 +33,7 @@ class PlaybackMenus( private val subtitlesButton: ImageButton by playerControlsBinding::subtitlesButton private val speedButton: View by playerControlsBinding::speedButton private val infoButton: View by playerControlsBinding::infoButton + private val skipForwardButton: View by playerControlsBinding::skipForwardButton private val playbackInfo: TextView by playerBinding::playbackInfo private val audioStreamsMenu: PopupMenu = createAudioStreamsMenu() private val subtitlesMenu: PopupMenu = createSubtitlesMenu() @@ -74,6 +76,10 @@ class PlaybackMenus( infoButton.setOnClickListener { playbackInfo.isVisible = !playbackInfo.isVisible } + skipForwardButton.isVisible = enableSkipForward + skipForwardButton.setOnClickListener { + fragment.onSkipForward() + } playbackInfo.setOnClickListener { dismissPlaybackInfo() } diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt index 1931c0326..427e833d1 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt @@ -33,6 +33,7 @@ import org.jellyfin.mobile.api.isLandscape import org.jellyfin.mobile.bridge.PlayOptions import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding import org.jellyfin.mobile.databinding.FragmentPlayerBinding +import org.jellyfin.mobile.player.mpv.MPVPlayer import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.Constants.DEFAULT_CONTROLS_TIMEOUT_MS import org.jellyfin.mobile.utils.Constants.PIP_MAX_RATIONAL @@ -155,7 +156,7 @@ class PlayerFragment : Fragment() { } // Create playback menus - playbackMenus = PlaybackMenus(this, playerBinding, playerControlsBinding) + playbackMenus = PlaybackMenus(this, playerBinding, playerControlsBinding, viewModel.playerOrNull is MPVPlayer) // Set controller timeout suppressControllerAutoHide(false) @@ -273,6 +274,10 @@ class PlayerFragment : Fragment() { return viewModel.setPlaybackSpeed(speed) } + fun onSkipForward() { + viewModel.skipForward() + } + fun onSkipToPrevious() { viewModel.skipToPrevious() } diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt index 0c1140b39..1ba0a9dd5 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -1,5 +1,6 @@ package org.jellyfin.mobile.player +import `is`.xyz.libmpv.MPVLib import android.annotation.SuppressLint import android.app.Application import android.media.AudioAttributes @@ -27,11 +28,16 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jellyfin.mobile.AppPreferences import org.jellyfin.mobile.BuildConfig import org.jellyfin.mobile.PLAYER_EVENT_CHANNEL import org.jellyfin.mobile.model.DisplayPreferences +import org.jellyfin.mobile.bridge.ExternalPlayer +import org.jellyfin.mobile.bridge.NativePlayer +import org.jellyfin.mobile.player.mpv.MPVPlayer import org.jellyfin.mobile.player.source.JellyfinMediaSource import org.jellyfin.mobile.player.source.MediaQueueManager +import org.jellyfin.mobile.settings.VideoPlayerType import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.Constants.SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS import org.jellyfin.mobile.utils.applyDefaultAudioAttributes @@ -66,6 +72,7 @@ import java.util.concurrent.atomic.AtomicBoolean class PlayerViewModel(application: Application) : AndroidViewModel(application), KoinComponent, Player.Listener { private val apiClient: ApiClient = get() + private val appPreferences by inject() private val displayPreferencesApi: DisplayPreferencesApi = apiClient.displayPreferencesApi private val playStateApi: PlayStateApi = apiClient.playStateApi private val hlsSegmentApi: HlsSegmentApi = apiClient.hlsSegmentApi @@ -75,14 +82,20 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), val notificationHelper: PlayerNotificationHelper by lazy { PlayerNotificationHelper(this) } // Media source handling - val mediaQueueManager = MediaQueueManager(this) + val mediaQueueManager = MediaQueueManager( + viewModel = this, playerName = when (appPreferences.videoPlayerType) { + VideoPlayerType.EXO_PLAYER -> NativePlayer.PLAYER_NAME + VideoPlayerType.MPV_PLAYER -> MPVPlayer.PLAYER_NAME + else -> ExternalPlayer.PLAYER_NAME + } + ) val mediaSourceOrNull: JellyfinMediaSource? get() = mediaQueueManager.mediaQueue.value?.jellyfinMediaSource - // ExoPlayer - private val _player = MutableLiveData() + // Player + private val _player = MutableLiveData() private val _playerState = MutableLiveData() - val player: LiveData get() = _player + val player: LiveData get() = _player val playerState: LiveData get() = _playerState private val eventLogger = EventLogger(mediaQueueManager.trackSelector) private val analyticsCollector = AnalyticsCollector(Clock.DEFAULT).apply { @@ -94,9 +107,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), private var progressUpdateJob: Job? = null /** - * Returns the current ExoPlayer instance or null + * Returns the current Player instance or null */ - val playerOrNull: ExoPlayer? get() = _player.value + val playerOrNull: Player? get() = _player.value private val playerEventChannel: Channel by inject(named(PLAYER_EVENT_CHANNEL)) @@ -157,28 +170,43 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), } /** - * Setup a new [ExoPlayer] for video playback, register callbacks and set attributes + * Setup a new [Player] for video playback, register callbacks and set attributes */ fun setupPlayer() { - val renderersFactory = DefaultRenderersFactory(getApplication()).apply { - setEnableDecoderFallback(true) // Fallback only works if initialization fails, not decoding at playback time - val rendererMode = when { - fallbackPreferExtensionRenderers -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + _player.value = when (appPreferences.videoPlayerType) { + VideoPlayerType.EXO_PLAYER -> { + val renderersFactory = DefaultRenderersFactory(getApplication()).apply { + setEnableDecoderFallback(true) // Fallback only works if initialization fails, not decoding at playback time + val rendererMode = when { + fallbackPreferExtensionRenderers -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + } + setExtensionRendererMode(rendererMode) + } + ExoPlayer.Builder(getApplication(), renderersFactory, get()).apply { + setTrackSelector(mediaQueueManager.trackSelector) + setAnalyticsCollector(analyticsCollector) + }.build().apply { + addListener(this@PlayerViewModel) + applyDefaultAudioAttributes(C.CONTENT_TYPE_MOVIE) + } } - setExtensionRendererMode(rendererMode) - } - _player.value = ExoPlayer.Builder(getApplication(), renderersFactory, get()).apply { - setTrackSelector(mediaQueueManager.trackSelector) - setAnalyticsCollector(analyticsCollector) - }.build().apply { - addListener(this@PlayerViewModel) - applyDefaultAudioAttributes(C.CONTENT_TYPE_MOVIE) + VideoPlayerType.MPV_PLAYER -> { + MPVPlayer( + context = getApplication(), + requestAudioFocus = true + ).apply { + addListener(this@PlayerViewModel) + analyticsCollector.setPlayer(this, this.applicationLooper) + addListener(analyticsCollector) + } + } + else -> null } } /** - * Release the current ExoPlayer and stop/release the current MediaSession + * Release the current [Player] and stop/release the current [MediaSession] */ private fun releasePlayer() { notificationHelper.dismissNotification() @@ -194,7 +222,15 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), fun play(queueItem: MediaQueueManager.QueueItem.Loaded) { val player = playerOrNull ?: return - player.setMediaSource(queueItem.exoMediaSource) + when (player) { + is ExoPlayer -> { + player.setMediaSource(queueItem.exoMediaSource) + } + is MPVPlayer -> { + player.setMediaItem(MPVPlayer.parseMediaSource(queueItem.exoMediaSource)) + } + } + player.prepare() initialTracksSelected.set(false) @@ -332,6 +368,13 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), playerOrNull?.seekToOffset(displayPreferences.skipForwardLength) } + fun skipForward() { + val player = playerOrNull ?: return + if (player is MPVPlayer) { + player.skipForward() + } + } + fun skipToPrevious(force: Boolean = false) { val player = playerOrNull ?: return when { diff --git a/app/src/main/java/org/jellyfin/mobile/player/mpv/MPVPlayer.kt b/app/src/main/java/org/jellyfin/mobile/player/mpv/MPVPlayer.kt new file mode 100644 index 000000000..09c9f4799 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/mpv/MPVPlayer.kt @@ -0,0 +1,1543 @@ +package org.jellyfin.mobile.player.mpv + +import `is`.xyz.libmpv.MPVLib +import android.annotation.SuppressLint +import android.app.Application +import android.content.Context +import android.content.res.AssetManager +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.Parcelable +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.TextureView +import android.view.WindowManager +import androidx.core.content.getSystemService +import com.google.android.exoplayer2.BasePlayer +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.DeviceInfo +import com.google.android.exoplayer2.ExoPlaybackException +import com.google.android.exoplayer2.Format +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaMetadata +import com.google.android.exoplayer2.PlaybackParameters +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.Timeline +import com.google.android.exoplayer2.TracksInfo +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.MergingMediaSource +import com.google.android.exoplayer2.source.SingleSampleMediaSource +import com.google.android.exoplayer2.source.TrackGroup +import com.google.android.exoplayer2.source.TrackGroupArray +import com.google.android.exoplayer2.text.Cue +import com.google.android.exoplayer2.trackselection.TrackSelection +import com.google.android.exoplayer2.trackselection.TrackSelectionArray +import com.google.android.exoplayer2.trackselection.TrackSelectionParameters +import com.google.android.exoplayer2.util.Clock +import com.google.android.exoplayer2.util.FlagSet +import com.google.android.exoplayer2.util.ListenerSet +import com.google.android.exoplayer2.util.MimeTypes +import com.google.android.exoplayer2.util.Util +import com.google.android.exoplayer2.video.VideoSize +import kotlinx.parcelize.Parcelize +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.CopyOnWriteArraySet + +@Suppress("SpellCheckingInspection") +class MPVPlayer( + context: Context, + requestAudioFocus: Boolean +) : BasePlayer(), MPVLib.EventObserver, AudioManager.OnAudioFocusChangeListener { + + private val audioManager: AudioManager by lazy { context.getSystemService()!! } + private var audioFocusCallback: () -> Unit = {} + private var audioFocusRequest = AudioManager.AUDIOFOCUS_REQUEST_FAILED + private val handler = Handler(context.mainLooper) + + init { + require(context is Application) + @Suppress("DEPRECATION") + val internalMpvDir = File(context.filesDir, "mpv") + if (!internalMpvDir.exists()) { + internalMpvDir.mkdirs() + } + val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv") + if (!mpvDir.exists()) { + mpvDir.mkdirs() + } + // internal scripts + arrayOf("skip-intro.lua").forEach { fileName -> + val file = File(internalMpvDir, fileName) + val tmp = context.assets.open(fileName, AssetManager.ACCESS_STREAMING) + if (!file.exists() || file.length() != tmp.available().toLong()) { + tmp.copyTo(FileOutputStream(file, false)) + } + } + // https://github.com/mpv-android/mpv-android/commit/12d4d78 + arrayOf("mpv.conf", "subfont.ttf"/*, "cacert.pem"*/).forEach { fileName -> + val file = File(mpvDir, fileName) + if (!file.exists()) { + context.assets.open(fileName, AssetManager.ACCESS_STREAMING).copyTo(FileOutputStream(file)) + } + } + MPVLib.create(context) + MPVLib.setOptionString("config", "yes") + MPVLib.setOptionString("config-dir", mpvDir.path) + MPVLib.setOptionString("scripts", File(internalMpvDir.path, "skip-intro.lua").path) + // vo: set display fps as reported by android + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + + @Suppress("DEPRECATION") + val display = wm.defaultDisplay + val refreshRate = display.mode.refreshRate + MPVLib.setOptionString("override-display-fps", "$refreshRate") + MPVLib.setOptionString("vo-null-fps", "$refreshRate") + } + MPVLib.setOptionString("vo", "gpu") + MPVLib.setOptionString("gpu-context", "android") + MPVLib.setOptionString("ao", "audiotrack,opensles") + + MPVLib.init() + + // hardcoded options + MPVLib.setOptionString("cache", "yes") + MPVLib.setOptionString("cache-pause-initial", "yes") + MPVLib.setOptionString("force-window", "no") + MPVLib.setOptionString("keep-open", "always") + MPVLib.setOptionString("save-position-on-quit", "no") + MPVLib.setOptionString("sub-font-provider", "none") + MPVLib.setOptionString("ytdl", "no") + + MPVLib.addObserver(this) + + // Observe properties + data class Property(val name: String, @MPVLib.Format val format: Int) + arrayOf( + Property("track-list", MPVLib.MPV_FORMAT_STRING), + Property("core-idle", MPVLib.MPV_FORMAT_FLAG), + Property("eof-reached", MPVLib.MPV_FORMAT_FLAG), + Property("seekable", MPVLib.MPV_FORMAT_FLAG), + Property("time-pos", MPVLib.MPV_FORMAT_INT64), + Property("duration", MPVLib.MPV_FORMAT_INT64), + Property("demuxer-cache-time", MPVLib.MPV_FORMAT_INT64), + Property("speed", MPVLib.MPV_FORMAT_DOUBLE) + ).forEach { (name, format) -> + MPVLib.observeProperty(name, format) + } + + if (requestAudioFocus) { + @Suppress("DEPRECATION") + audioFocusRequest = audioManager.requestAudioFocus( + /* listener= */ this, + /* streamType= */ AudioManager.STREAM_MUSIC, + /* durationHint= */ AudioManager.AUDIOFOCUS_GAIN + ) + if (audioFocusRequest != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + MPVLib.setPropertyBoolean("pause", true) + } + } + } + + // Listeners and notification. + @Suppress("DEPRECATION") + private val listeners: ListenerSet = ListenerSet( + context.mainLooper, + Clock.DEFAULT + ) { listener: Player.EventListener, flags: FlagSet -> + listener.onEvents( /* player= */this, Player.Events(flags)) + } + private val videoListeners = CopyOnWriteArraySet() + + // Internal state. + private var internalMediaItems: List? = null + private var internalMediaItem: MediaItem? = null + @Player.State + private var playbackState: Int = Player.STATE_IDLE + private var currentPlayWhenReady: Boolean = false + @Player.RepeatMode + private val repeatMode: Int = REPEAT_MODE_OFF + private var trackGroupArray: TrackGroupArray = TrackGroupArray.EMPTY + private var trackSelectionArray: TrackSelectionArray = TrackSelectionArray() + private var playbackParameters: PlaybackParameters = PlaybackParameters.DEFAULT + + // MPV Custom + private var isPlayerReady: Boolean = false + private var isSeekable: Boolean = false + private var currentPositionMs: Long? = null + private var currentDurationMs: Long? = null + private var currentCacheDurationMs: Long? = null + private var currentTracks: List = emptyList() + private var initialCommands = mutableListOf>() + private var initialSeekTo: Long = 0L + + // mpv events + override fun eventProperty(property: String) { + // Nothing to do... + } + + override fun eventProperty(property: String, value: String) { + handler.post { + when (property) { + "track-list" -> { + val (tracks, newTrackGroupArray, newTrackSelectionArray) = getMPVTracks(value) + currentTracks = tracks + if (isPlayerReady) { + if (newTrackGroupArray != trackGroupArray || newTrackSelectionArray != trackSelectionArray) { + trackGroupArray = newTrackGroupArray + trackSelectionArray = newTrackSelectionArray + listeners.sendEvent(Player.EVENT_TRACKS_CHANGED) { listener -> + @Suppress("DEPRECATION") + listener.onTracksChanged(currentTrackGroups, currentTrackSelections) + } + } + } else { + trackGroupArray = newTrackGroupArray + trackSelectionArray = newTrackSelectionArray + } + } + } + } + } + + override fun eventProperty(property: String, value: Boolean) { + handler.post { + when (property) { + "eof-reached" -> { + if (value && isPlayerReady) { + setPlayerStateAndNotifyIfChanged( + playWhenReady = false, + playWhenReadyChangeReason = Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM, + playbackState = Player.STATE_ENDED + ) + resetInternalState() + } + } + "core-idle" -> { + if (playWhenReady) { + if (value) { + setPlayerStateAndNotifyIfChanged(playbackState = Player.STATE_BUFFERING) + } else { + setPlayerStateAndNotifyIfChanged(playbackState = Player.STATE_READY) + } + } + } + "seekable" -> { + if (isSeekable != value) { + isSeekable = value + listeners.sendEvent(Player.EVENT_TIMELINE_CHANGED) { listener -> + listener.onTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + } + } + } + } + } + } + + override fun eventProperty(property: String, value: Long) { + handler.post { + when (property) { + "time-pos" -> currentPositionMs = value * C.MILLIS_PER_SECOND + "duration" -> { + if (currentDurationMs != value * C.MILLIS_PER_SECOND) { + currentDurationMs = value * C.MILLIS_PER_SECOND + listeners.sendEvent(Player.EVENT_TIMELINE_CHANGED) { listener -> + listener.onTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + } + } + } + "demuxer-cache-time" -> currentCacheDurationMs = value * C.MILLIS_PER_SECOND + } + } + } + + override fun eventProperty(property: String, value: Double) { + handler.post { + when (property) { + "speed" -> { + playbackParameters = getPlaybackParameters().withSpeed(value.toFloat()) + listeners.sendEvent(Player.EVENT_PLAYBACK_PARAMETERS_CHANGED) {listener -> + listener.onPlaybackParametersChanged(getPlaybackParameters()) + } + } + } + } + } + + @SuppressLint("SwitchIntDef") + override fun event(@MPVLib.Event eventId: Int) { + handler.post { + when (eventId) { + MPVLib.MPV_EVENT_START_FILE -> { + if (!isPlayerReady) { + for (command in initialCommands) { + MPVLib.command(command) + } + } + } + MPVLib.MPV_EVENT_SEEK -> { + setPlayerStateAndNotifyIfChanged(playbackState = Player.STATE_BUFFERING) + listeners.sendEvent(Player.EVENT_POSITION_DISCONTINUITY) { listener -> + @Suppress("DEPRECATION") + listener.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK) + } + } + MPVLib.MPV_EVENT_PLAYBACK_RESTART -> { + if (!isPlayerReady) { + isPlayerReady = true + listeners.sendEvent(Player.EVENT_TRACKS_CHANGED) { listener -> + @Suppress("DEPRECATION") + listener.onTracksChanged(currentTrackGroups, currentTrackSelections) + } + seekTo(C.TIME_UNSET) + if (playWhenReady) { + MPVLib.setPropertyBoolean("pause", false) + } + for (videoListener in videoListeners) { + videoListener.onRenderedFirstFrame() + } + } + } + } + } + } + + override fun eventEndFile(@MPVLib.Reason reason: Int, @MPVLib.Error error: Int) { + // Nothing to do... + } + + private fun setPlayerStateAndNotifyIfChanged( + playWhenReady: Boolean = getPlayWhenReady(), + @Player.PlayWhenReadyChangeReason playWhenReadyChangeReason: Int = Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + @Player.State playbackState: Int = getPlaybackState() + ) { + var playerStateChanged = false + val wasPlaying = isPlaying + if (playbackState != getPlaybackState()) { + this.playbackState = playbackState + listeners.queueEvent(Player.EVENT_PLAYBACK_STATE_CHANGED) { listener -> + listener.onPlaybackStateChanged(playbackState) + } + playerStateChanged = true + } + if (playWhenReady != getPlayWhenReady()) { + this.currentPlayWhenReady = playWhenReady + listeners.queueEvent(Player.EVENT_PLAY_WHEN_READY_CHANGED) { listener -> + listener.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason) + } + playerStateChanged = true + } + if (playerStateChanged) { + listeners.queueEvent( /* eventFlag= */ C.INDEX_UNSET) { listener -> + @Suppress("DEPRECATION") + listener.onPlayerStateChanged(playWhenReady, playbackState) + } + } + if (wasPlaying != isPlaying) { + listeners.queueEvent(Player.EVENT_IS_PLAYING_CHANGED) { listener -> + listener.onIsPlayingChanged(isPlaying) + } + } + listeners.flushEvents() + } + + /** + * Select a [Track] or disable a [TrackType] in the current player. + * + * @param trackType The [TrackType] + * @param isExternal If track is external or embed in media + * @param index Index to select or [C.INDEX_UNSET] to disable [TrackType] + * @return true if the track is or was already selected + */ + fun selectTrack(@TrackType trackType: String, isExternal: Boolean = false, index: Int): Boolean { + if (index != C.INDEX_UNSET) { + currentTracks.firstOrNull { + it.type == trackType && (if (isExternal) it.title else "${it.ffIndex}") == "$index" + }.let { track -> + if (track != null) { + if (!track.selected) { + MPVLib.setPropertyInt(trackType, track.id) + } + } else { + return false + } + } + } else { + if (currentTracks.indexOfFirst { it.type == trackType && it.selected } != C.INDEX_UNSET) { + MPVLib.setPropertyString(trackType, "no") + } + } + return true + } + + fun skipForward() { + setPlayerStateAndNotifyIfChanged(playbackState = Player.STATE_BUFFERING) + MPVLib.command(arrayOf("script-binding", "skip-key")) + } + + // Timeline wrapper + private val timeline: Timeline = object : Timeline() { + /** + * Returns the number of windows in the timeline. + */ + override fun getWindowCount(): Int { + return 1 + } + + /** + * Populates a [com.google.android.exoplayer2.Timeline.Window] with data for the window at the specified index. + * + * @param windowIndex The index of the window. + * @param window The [com.google.android.exoplayer2.Timeline.Window] to populate. Must not be null. + * @param defaultPositionProjectionUs A duration into the future that the populated window's + * default start position should be projected. + * @return The populated [com.google.android.exoplayer2.Timeline.Window], for convenience. + */ + override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window { + val currentMediaItem = internalMediaItem ?: MediaItem.Builder().build() + return if (windowIndex == 0) window.set( + /* uid= */ 0, + /* mediaItem= */ currentMediaItem, + /* manifest= */ null, + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + /* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET, + /* isSeekable= */ isSeekable, + /* isDynamic= */ !isSeekable, + /* liveConfiguration= */ currentMediaItem.liveConfiguration, + /* defaultPositionUs= */ C.TIME_UNSET, + /* durationUs= */ Util.msToUs(currentDurationMs ?: C.TIME_UNSET), + /* firstPeriodIndex= */ windowIndex, + /* lastPeriodIndex= */ windowIndex, + /* positionInFirstPeriodUs= */ C.TIME_UNSET + ) else window + } + + /** + * Returns the number of periods in the timeline. + */ + override fun getPeriodCount(): Int { + return 1 + } + + /** + * Populates a [com.google.android.exoplayer2.Timeline.Period] with data for the period at the specified index. + * + * @param periodIndex The index of the period. + * @param period The [com.google.android.exoplayer2.Timeline.Period] to populate. Must not be null. + * @param setIds Whether [com.google.android.exoplayer2.Timeline.Period.id] and [com.google.android.exoplayer2.Timeline.Period.uid] should be populated. If false, + * the fields will be set to null. The caller should pass false for efficiency reasons unless + * the fields are required. + * @return The populated [com.google.android.exoplayer2.Timeline.Period], for convenience. + */ + override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { + return if (periodIndex == 0) period.set( + /* id= */ 0, + /* uid= */ 0, + /* windowIndex= */ periodIndex, + /* durationUs= */ Util.msToUs(currentDurationMs ?: C.TIME_UNSET), + /* positionInWindowUs= */ 0 + ) else period + } + + /** + * Returns the index of the period identified by its unique [com.google.android.exoplayer2.Timeline.Period.uid], or [ ][C.INDEX_UNSET] if the period is not in the timeline. + * + * @param uid A unique identifier for a period. + * @return The index of the period, or [C.INDEX_UNSET] if the period was not found. + */ + override fun getIndexOfPeriod(uid: Any): Int { + return if (uid == 0) 0 else C.INDEX_UNSET + } + + /** + * Returns the unique id of the period identified by its index in the timeline. + * + * @param periodIndex The index of the period. + * @return The unique id of the period. + */ + override fun getUidOfPeriod(periodIndex: Int): Any { + return if (periodIndex == 0) 0 else C.INDEX_UNSET + } + } + + // OnAudioFocusChangeListener implementation. + + /** + * Called on the listener to notify it the audio focus for this listener has been changed. + * The focusChange value indicates whether the focus was gained, + * whether the focus was lost, and whether that loss is transient, or whether the new focus + * holder will hold it for an unknown amount of time. + * When losing focus, listeners can use the focus change information to decide what + * behavior to adopt when losing focus. A music player could for instance elect to lower + * the volume of its music stream (duck) for transient focus losses, and pause otherwise. + * @param focusChange the type of focus change, one of [AudioManager.AUDIOFOCUS_GAIN], + * [AudioManager.AUDIOFOCUS_LOSS], [AudioManager.AUDIOFOCUS_LOSS_TRANSIENT] + * and [AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK]. + */ + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + val oldAudioFocusCallback = audioFocusCallback + val wasPlaying = isPlaying + MPVLib.setPropertyBoolean("pause", true) + setPlayerStateAndNotifyIfChanged( + playWhenReady = false, + playWhenReadyChangeReason = Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS + ) + audioFocusCallback = { + oldAudioFocusCallback() + if (wasPlaying) MPVLib.setPropertyBoolean("pause", false) + } + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + MPVLib.command(arrayOf("multiply", "volume", "$AUDIO_FOCUS_DUCKING")) + audioFocusCallback = { + MPVLib.command(arrayOf("multiply", "volume", "${1f/AUDIO_FOCUS_DUCKING}")) + } + } + AudioManager.AUDIOFOCUS_GAIN -> { + audioFocusCallback() + audioFocusCallback = {} + } + } + } + + // Player implementation. + + /** + * Returns the [Looper] associated with the application thread that's used to access the + * player and on which player events are received. + */ + override fun getApplicationLooper(): Looper { + return handler.looper + } + + /** + * Registers a listener to receive all events from the player. + * + * @param listener The listener to register. + */ + override fun addListener(listener: Player.Listener) { + listeners.add(listener) + videoListeners.add(listener) + } + + /** + * Unregister a listener registered through [.addListener]. The listener will no + * longer receive events. + * + * @param listener The listener to unregister. + */ + override fun removeListener(listener: Player.Listener) { + listeners.remove(listener) + videoListeners.remove(listener) + } + + /** + * Clears the playlist and adds the specified [MediaItems][MediaItem]. + * + * @param mediaItems The new [MediaItems][MediaItem]. + * @param resetPosition Whether the playback position should be reset to the default position in + * the first [Timeline.Window]. If false, playback will start from the position defined + * by [.getCurrentWindowIndex] and [.getCurrentPosition]. + */ + override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { + internalMediaItems = mediaItems + } + + /** + * Clears the playlist and adds the specified [MediaItems][MediaItem]. + * + * @param mediaItems The new [MediaItems][MediaItem]. + * @param startWindowIndex The window index to start playback from. If [com.google.android.exoplayer2.C.INDEX_UNSET] is + * passed, the current position is not reset. + * @param startPositionMs The position in milliseconds to start playback from. If [ ][com.google.android.exoplayer2.C.TIME_UNSET] is passed, the default position of the given window is used. In any case, if + * `startWindowIndex` is set to [com.google.android.exoplayer2.C.INDEX_UNSET], this parameter is ignored and the + * position is not reset at all. + * @throws com.google.android.exoplayer2.IllegalSeekPositionException If the provided `startWindowIndex` is not within the + * bounds of the list of media items. + */ + override fun setMediaItems(mediaItems: MutableList, startWindowIndex: Int, startPositionMs: Long) { + TODO("Not yet implemented") + } + + /** + * Adds a list of media items at the given index of the playlist. + * + * @param index The index at which to add the media items. If the index is larger than the size of + * the playlist, the media items are added to the end of the playlist. + * @param mediaItems The [MediaItems][MediaItem] to add. + */ + override fun addMediaItems(index: Int, mediaItems: MutableList) { + TODO("Not yet implemented") + } + + /** + * Moves the media item range to the new index. + * + * @param fromIndex The start of the range to move. + * @param toIndex The first item not to be included in the range (exclusive). + * @param newIndex The new index of the first media item of the range. If the new index is larger + * than the size of the remaining playlist after removing the range, the range is moved to the + * end of the playlist. + */ + override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) { + TODO("Not yet implemented") + } + + /** + * Removes a range of media items from the playlist. + * + * @param fromIndex The index at which to start removing media items. + * @param toIndex The index of the first item to be kept (exclusive). If the index is larger than + * the size of the playlist, media items to the end of the playlist are removed. + */ + override fun removeMediaItems(fromIndex: Int, toIndex: Int) { + TODO("Not yet implemented") + } + + /** + * Returns the player's currently available [com.google.android.exoplayer2.Player.Commands]. + * + * + * The returned [com.google.android.exoplayer2.Player.Commands] are not updated when available commands change. Use [ ][com.google.android.exoplayer2.Player.Listener.onAvailableCommandsChanged] to get an update when the available commands + * change. + * + * + * Executing a command that is not available (for example, calling [.next] if [ ][.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM] is unavailable) will neither throw an exception nor generate + * a [.getPlayerError] player error}. + * + * + * [.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM] and [.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM] + * are unavailable if there is no such [MediaItem]. + * + * @return The currently available [com.google.android.exoplayer2.Player.Commands]. + * @see com.google.android.exoplayer2.Player.Listener.onAvailableCommandsChanged + */ + override fun getAvailableCommands(): Player.Commands { + return permanentAvailableCommands + } + + private fun resetInternalState() { + isPlayerReady = false + isSeekable = false + playbackState = Player.STATE_IDLE + currentPlayWhenReady = false + currentPositionMs = null + currentDurationMs = null + currentCacheDurationMs = null + trackGroupArray = TrackGroupArray.EMPTY + trackSelectionArray = TrackSelectionArray() + playbackParameters = PlaybackParameters.DEFAULT + initialCommands.clear() + initialSeekTo = 0L + } + + /** Prepares the player. */ + override fun prepare() { + internalMediaItems?.firstOrNull { it.localConfiguration?.uri != null }?.let { mediaItem -> + internalMediaItem = mediaItem + resetInternalState() + mediaItem.localConfiguration?.subtitleConfigurations?.forEach { subtitle -> + initialCommands.add(arrayOf( + /* command= */ "sub-add", + /* url= */ "${subtitle.uri}", + /* flags= */ "auto", + /* title= */ "${subtitle.label}", + /* lang= */ "${subtitle.language}" + )) + } + setPlayerStateAndNotifyIfChanged(playbackState = Player.STATE_BUFFERING) + MPVLib.command(arrayOf("loadfile", "${mediaItem.localConfiguration?.uri}")) + MPVLib.setPropertyBoolean("pause", true) + listeners.sendEvent(Player.EVENT_TIMELINE_CHANGED) { listener -> + listener.onTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) + } + listeners.sendEvent(Player.EVENT_MEDIA_ITEM_TRANSITION) { listener -> + listener.onMediaItemTransition(mediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) + } + } + } + + /** + * Returns the current [playback state][com.google.android.exoplayer2.Player.State] of the player. + * + * @return The current [playback state][com.google.android.exoplayer2.Player.State]. + * @see com.google.android.exoplayer2.Player.Listener.onPlaybackStateChanged + */ + override fun getPlaybackState(): Int { + return playbackState + } + + /** + * Returns the reason why playback is suppressed even though [.getPlayWhenReady] is `true`, or [.PLAYBACK_SUPPRESSION_REASON_NONE] if playback is not suppressed. + * + * @return The current [playback suppression reason][com.google.android.exoplayer2.Player.PlaybackSuppressionReason]. + * @see com.google.android.exoplayer2.Player.Listener.onPlaybackSuppressionReasonChanged + */ + override fun getPlaybackSuppressionReason(): Int { + return PLAYBACK_SUPPRESSION_REASON_NONE + } + + /** + * Returns the error that caused playback to fail. This is the same error that will have been + * reported via [com.google.android.exoplayer2.Player.Listener.onPlayerError] at the time of failure. It + * can be queried using this method until the player is re-prepared. + * + * + * Note that this method will always return `null` if [.getPlaybackState] is not + * [.STATE_IDLE]. + * + * @return The error, or `null`. + * @see com.google.android.exoplayer2.Player.Listener.onPlayerError + */ + override fun getPlayerError(): ExoPlaybackException? { + return null + } + + /** + * Sets whether playback should proceed when [.getPlaybackState] == [.STATE_READY]. + * + * + * If the player is already in the ready state then this method pauses and resumes playback. + * + * @param playWhenReady Whether playback should proceed when ready. + */ + override fun setPlayWhenReady(playWhenReady: Boolean) { + if (currentPlayWhenReady != playWhenReady) { + setPlayerStateAndNotifyIfChanged( + playWhenReady = playWhenReady, + playWhenReadyChangeReason = Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST + ) + if (isPlayerReady) { + MPVLib.setPropertyBoolean("pause", !playWhenReady) + } + } + } + + /** + * Whether playback will proceed when [.getPlaybackState] == [.STATE_READY]. + * + * @return Whether playback will proceed when ready. + * @see com.google.android.exoplayer2.Player.Listener.onPlayWhenReadyChanged + */ + override fun getPlayWhenReady(): Boolean { + return currentPlayWhenReady + } + + /** + * Sets the [com.google.android.exoplayer2.Player.RepeatMode] to be used for playback. + * + * @param repeatMode The repeat mode. + */ + override fun setRepeatMode(repeatMode: Int) { + TODO("Not yet implemented") + } + + /** + * Returns the current [com.google.android.exoplayer2.Player.RepeatMode] used for playback. + * + * @return The current repeat mode. + * @see com.google.android.exoplayer2.Player.Listener.onRepeatModeChanged + */ + override fun getRepeatMode(): Int { + return repeatMode + } + + /** + * Sets whether shuffling of windows is enabled. + * + * @param shuffleModeEnabled Whether shuffling is enabled. + */ + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + TODO("Not yet implemented") + } + + /** + * Returns whether shuffling of windows is enabled. + * + * @see com.google.android.exoplayer2.Player.Listener.onShuffleModeEnabledChanged + */ + override fun getShuffleModeEnabled(): Boolean { + return false + } + + /** + * Whether the player is currently loading the source. + * + * @return Whether the player is currently loading the source. + * @see com.google.android.exoplayer2.Player.Listener.onIsLoadingChanged + */ + override fun isLoading(): Boolean { + return false + } + + /** + * Seeks to a position specified in milliseconds in the specified window. + * + * @param windowIndex The index of the window. + * @param positionMs The seek position in the specified window, or [com.google.android.exoplayer2.C.TIME_UNSET] to seek to + * the window's default position. + * @throws com.google.android.exoplayer2.IllegalSeekPositionException If the player has a non-empty timeline and the provided + * `windowIndex` is not within the bounds of the current timeline. + */ + override fun seekTo(windowIndex: Int, positionMs: Long) { + if (windowIndex == 0) { + val seekTo = if (positionMs != C.TIME_UNSET) positionMs / C.MILLIS_PER_SECOND else initialSeekTo + if (isPlayerReady) { + MPVLib.command(arrayOf("seek", "$seekTo", "absolute")) + } else { + initialSeekTo = seekTo + } + } + } + + /** + * Attempts to set the playback parameters. Passing [PlaybackParameters.DEFAULT] resets the + * player to the default, which means there is no speed or pitch adjustment. + * + * + * Playback parameters changes may cause the player to buffer. [ ][com.google.android.exoplayer2.Player.Listener.onPlaybackParametersChanged] will be called whenever the currently + * active playback parameters change. + * + * @param playbackParameters The playback parameters. + */ + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { + if (getPlaybackParameters().speed != playbackParameters.speed) { + MPVLib.setPropertyDouble("speed", playbackParameters.speed.toDouble()) + } + } + + /** + * Returns the currently active playback parameters. + * + * @see com.google.android.exoplayer2.Player.Listener.onPlaybackParametersChanged + */ + override fun getPlaybackParameters(): PlaybackParameters { + return playbackParameters + } + + /** + * Stops playback without resetting the player. Use [.pause] rather than this method if + * the intention is to pause playback. + * + * + * Calling this method will cause the playback state to transition to [.STATE_IDLE]. The + * player instance can still be used, and [.release] must still be called on the player if + * it's no longer required. + * + * + * Calling this method does not clear the playlist, reset the playback position or the playback + * error. + */ + override fun stop() { + @Suppress("DEPRECATION") + stop(false) + } + + override fun stop(reset: Boolean) { + MPVLib.command(arrayOf("stop", "keep-playlist")) + } + + /** + * Releases the player. This method must be called when the player is no longer required. The + * player must not be used after calling this method. + */ + override fun release() { + if (audioFocusRequest == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(this) + } + resetInternalState() + MPVLib.destroy() + } + + /** + * Returns the available track groups. + * + * @see com.google.android.exoplayer2.Player.Listener.onTracksChanged + */ + override fun getCurrentTrackGroups(): TrackGroupArray { + return trackGroupArray + } + + /** + * Returns the current track selections. + * + * + * A concrete implementation may include null elements if it has a fixed number of renderer + * components, wishes to report a TrackSelection for each of them, and has one or more renderer + * components that is not assigned any selected tracks. + * + * @see com.google.android.exoplayer2.Player.Listener.onTracksChanged + */ + override fun getCurrentTrackSelections(): TrackSelectionArray { + return trackSelectionArray + } + + /** + * Returns the available tracks, as well as the tracks' support, type, and selection status. + * + * @see com.google.android.exoplayer2.Player.Listener.onTracksChanged + */ + override fun getCurrentTracksInfo(): TracksInfo { + TODO("Not yet implemented") + } + + /** + * Returns the parameters constraining the track selection. + * + * @see com.google.android.exoplayer2.Player.Listener.onTrackSelectionParametersChanged} + */ + override fun getTrackSelectionParameters(): TrackSelectionParameters { + TODO("Not yet implemented") + } + + /** + * Sets the parameters constraining the track selection. + * + * + * Unsupported parameters will be silently ignored. + * + * + * Use [.getTrackSelectionParameters] to retrieve the current parameters. For example, + * the following snippet restricts video to SD whilst keep other track selection parameters + * unchanged: + * + *
`player.setTrackSelectionParameters(
+     * player.getTrackSelectionParameters()
+     * .buildUpon()
+     * .setMaxVideoSizeSd()
+     * .build())
+    `
* + */ + override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) { + TODO("Not yet implemented") + } + + /** + * Returns the current combined [MediaMetadata], or [MediaMetadata.EMPTY] if not + * supported. + */ + override fun getMediaMetadata(): MediaMetadata { + return MediaMetadata.EMPTY + } + + /** + * Returns the playlist [MediaMetadata], as set by [ ][.setPlaylistMetadata], or [MediaMetadata.EMPTY] if not supported. + */ + override fun getPlaylistMetadata(): MediaMetadata { + TODO("Not yet implemented") + } + + /** Sets the playlist [MediaMetadata]. */ + override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) { + TODO("Not yet implemented") + } + + /** + * Returns the current [Timeline]. Never null, but may be empty. + * + * @see com.google.android.exoplayer2.Player.Listener.onTimelineChanged + */ + override fun getCurrentTimeline(): Timeline { + return timeline + } + + /** Returns the index of the period currently being played. */ + override fun getCurrentPeriodIndex(): Int { + return currentMediaItemIndex + } + + /** + * Returns the index of the current [MediaItem] in the [ timeline][.getCurrentTimeline], or the prospective index if the [current timeline][.getCurrentTimeline] is + * empty. + */ + override fun getCurrentMediaItemIndex(): Int { + return timeline.getFirstWindowIndex(shuffleModeEnabled) + } + + /** + * Returns the duration of the current content window or ad in milliseconds, or [ ][com.google.android.exoplayer2.C.TIME_UNSET] if the duration is not known. + */ + override fun getDuration(): Long { + return timeline.getWindow(currentMediaItemIndex, window).durationMs + } + + /** + * Returns the playback position in the current content window or ad, in milliseconds, or the + * prospective position in milliseconds if the [current timeline][.getCurrentTimeline] is + * empty. + */ + override fun getCurrentPosition(): Long { + return currentPositionMs ?: C.TIME_UNSET + } + + /** + * Returns an estimate of the position in the current content window or ad up to which data is + * buffered, in milliseconds. + */ + override fun getBufferedPosition(): Long { + return currentCacheDurationMs ?: contentPosition + } + + /** + * Returns an estimate of the total buffered duration from the current position, in milliseconds. + * This includes pre-buffered data for subsequent ads and windows. + */ + override fun getTotalBufferedDuration(): Long { + return bufferedPosition + } + + /** Returns whether the player is currently playing an ad. */ + override fun isPlayingAd(): Boolean { + return false + } + + /** + * If [.isPlayingAd] returns true, returns the index of the ad group in the period + * currently being played. Returns [com.google.android.exoplayer2.C.INDEX_UNSET] otherwise. + */ + override fun getCurrentAdGroupIndex(): Int { + return C.INDEX_UNSET + } + + /** + * If [.isPlayingAd] returns true, returns the index of the ad in its ad group. Returns + * [com.google.android.exoplayer2.C.INDEX_UNSET] otherwise. + */ + override fun getCurrentAdIndexInAdGroup(): Int { + return C.INDEX_UNSET + } + + /** + * If [.isPlayingAd] returns `true`, returns the content position that will be + * played once all ads in the ad group have finished playing, in milliseconds. If there is no ad + * playing, the returned position is the same as that returned by [.getCurrentPosition]. + */ + override fun getContentPosition(): Long { + return currentPosition + } + + /** + * If [.isPlayingAd] returns `true`, returns an estimate of the content position in + * the current content window up to which data is buffered, in milliseconds. If there is no ad + * playing, the returned position is the same as that returned by [.getBufferedPosition]. + */ + override fun getContentBufferedPosition(): Long { + return bufferedPosition + } + + /** Returns the attributes for audio playback. */ + override fun getAudioAttributes(): AudioAttributes { + return AudioAttributes.DEFAULT + } + + /** + * Sets the audio volume, with 0 being silence and 1 being unity gain (signal unchanged). + * + * @param audioVolume Linear output gain to apply to all audio channels. + */ + override fun setVolume(audioVolume: Float) { + TODO("Not yet implemented") + } + + /** + * Returns the audio volume, with 0 being silence and 1 being unity gain (signal unchanged). + * + * @return The linear gain applied to all audio channels. + */ + override fun getVolume(): Float { + TODO("Not yet implemented") + } + + /** + * Clears any [Surface], [SurfaceHolder], [SurfaceView] or [TextureView] + * currently set on the player. + */ + override fun clearVideoSurface() { + TODO("Not yet implemented") + } + + /** + * Clears the [Surface] onto which video is being rendered if it matches the one passed. + * Else does nothing. + * + * @param surface The surface to clear. + */ + override fun clearVideoSurface(surface: Surface?) { + TODO("Not yet implemented") + } + + /** + * Sets the [Surface] onto which video will be rendered. The caller is responsible for + * tracking the lifecycle of the surface, and must clear the surface by calling `setVideoSurface(null)` if the surface is destroyed. + * + * + * If the surface is held by a [SurfaceView], [TextureView] or [ ] then it's recommended to use [.setVideoSurfaceView], [ ][.setVideoTextureView] or [.setVideoSurfaceHolder] rather than + * this method, since passing the holder allows the player to track the lifecycle of the surface + * automatically. + * + * @param surface The [Surface]. + */ + override fun setVideoSurface(surface: Surface?) { + TODO("Not yet implemented") + } + + /** + * Sets the [SurfaceHolder] that holds the [Surface] onto which video will be + * rendered. The player will track the lifecycle of the surface automatically. + * + * @param surfaceHolder The surface holder. + */ + override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + TODO("Not yet implemented") + } + + /** + * Clears the [SurfaceHolder] that holds the [Surface] onto which video is being + * rendered if it matches the one passed. Else does nothing. + * + * @param surfaceHolder The surface holder to clear. + */ + override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + TODO("Not yet implemented") + } + + /** + * Sets the [SurfaceView] onto which video will be rendered. The player will track the + * lifecycle of the surface automatically. + * + * @param surfaceView The surface view. + */ + override fun setVideoSurfaceView(surfaceView: SurfaceView?) { + surfaceView?.holder?.addCallback(surfaceHolder) + } + + /** + * Clears the [SurfaceView] onto which video is being rendered if it matches the one passed. + * Else does nothing. + * + * @param surfaceView The texture view to clear. + */ + override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { + surfaceView?.holder?.removeCallback(surfaceHolder) + } + + /** + * Sets the [TextureView] onto which video will be rendered. The player will track the + * lifecycle of the surface automatically. + * + * @param textureView The texture view. + */ + override fun setVideoTextureView(textureView: TextureView?) { + TODO("Not yet implemented") + } + + /** + * Clears the [TextureView] onto which video is being rendered if it matches the one passed. + * Else does nothing. + * + * @param textureView The texture view to clear. + */ + override fun clearVideoTextureView(textureView: TextureView?) { + TODO("Not yet implemented") + } + + /** + * Gets the size of the video. + * + * + * The video's width and height are `0` if there is no video or its size has not been + * determined yet. + * + * @see com.google.android.exoplayer2.Player.Listener.onVideoSizeChanged + */ + override fun getVideoSize(): VideoSize { + return VideoSize.UNKNOWN + } + + /** Returns the current [Cues][Cue]. This list may be empty. */ + override fun getCurrentCues(): MutableList { + TODO("Not yet implemented") + } + + /** Gets the device information. */ + override fun getDeviceInfo(): DeviceInfo { + TODO("Not yet implemented") + } + + /** + * Gets the current volume of the device. + * + * + * For devices with [local playback][DeviceInfo.PLAYBACK_TYPE_LOCAL], the volume returned + * by this method varies according to the current [stream type][com.google.android.exoplayer2.C.StreamType]. The stream + * type is determined by [AudioAttributes.usage] which can be converted to stream type with + * [Util.getStreamTypeForAudioUsage]. + * + * + * For devices with [remote playback][DeviceInfo.PLAYBACK_TYPE_REMOTE], the volume of the + * remote device is returned. + */ + override fun getDeviceVolume(): Int { + TODO("Not yet implemented") + } + + /** Gets whether the device is muted or not. */ + override fun isDeviceMuted(): Boolean { + TODO("Not yet implemented") + } + + /** + * Sets the volume of the device. + * + * @param volume The volume to set. + */ + override fun setDeviceVolume(volume: Int) { + TODO("Not yet implemented") + } + + /** Increases the volume of the device. */ + override fun increaseDeviceVolume() { + TODO("Not yet implemented") + } + + /** Decreases the volume of the device. */ + override fun decreaseDeviceVolume() { + TODO("Not yet implemented") + } + + /** Sets the mute state of the device. */ + override fun setDeviceMuted(muted: Boolean) { + TODO("Not yet implemented") + } + + /** + * Returns the [.seekBack] increment. + * + * @return The seek back increment, in milliseconds. + * @see com.google.android.exoplayer2.Player.Listener.onSeekBackIncrementChanged + */ + override fun getSeekBackIncrement(): Long { + TODO("Not yet implemented") + } + + /** + * Returns the [.seekForward] increment. + * + * @return The seek forward increment, in milliseconds. + * @see com.google.android.exoplayer2.Player.Listener.onSeekForwardIncrementChanged + */ + override fun getSeekForwardIncrement(): Long { + TODO("Not yet implemented") + } + + /** + * Returns the maximum position for which [.seekToPrevious] seeks to the previous window, + * in milliseconds. + * + * @return The maximum seek to previous position, in milliseconds. + * @see com.google.android.exoplayer2.Player.Listener.onMaxSeekToPreviousPositionChanged + */ + override fun getMaxSeekToPreviousPosition(): Long { + return 0L + } + + private class CurrentTrackSelection( + private val currentTrackGroup: TrackGroup, + private val index: Int + ) : TrackSelection { + /** + * Returns an integer specifying the type of the selection, or [.TYPE_UNSET] if not + * specified. + * + * + * Track selection types are specific to individual applications, but should be defined + * starting from [.TYPE_CUSTOM_BASE] to ensure they don't conflict with any types that may + * be added to the library in the future. + */ + override fun getType(): Int { + return TrackSelection.TYPE_UNSET + } + + /** Returns the [TrackGroup] to which the selected tracks belong. */ + override fun getTrackGroup(): TrackGroup { + return currentTrackGroup + } + + /** Returns the number of tracks in the selection. */ + override fun length(): Int { + return if (index != C.INDEX_UNSET) 1 else 0 + } + + /** + * Returns the format of the track at a given index in the selection. + * + * @param index The index in the selection. + * @return The format of the selected track. + */ + override fun getFormat(index: Int): Format { + return currentTrackGroup.getFormat(index) + } + + /** + * Returns the index in the track group of the track at a given index in the selection. + * + * @param index The index in the selection. + * @return The index of the selected track. + */ + override fun getIndexInTrackGroup(index: Int): Int { + return index + } + + /** + * Returns the index in the selection of the track with the specified format. The format is + * located by identity so, for example, `selection.indexOf(selection.getFormat(index)) == + * index` even if multiple selected tracks have formats that contain the same values. + * + * @param format The format. + * @return The index in the selection, or [C.INDEX_UNSET] if the track with the specified + * format is not part of the selection. + */ + override fun indexOf(format: Format): Int { + return currentTrackGroup.indexOf(format) + } + + /** + * Returns the index in the selection of the track with the specified index in the track group. + * + * @param indexInTrackGroup The index in the track group. + * @return The index in the selection, or [C.INDEX_UNSET] if the track with the specified + * index is not part of the selection. + */ + override fun indexOf(indexInTrackGroup: Int): Int { + return indexInTrackGroup + } + } + + companion object { + /** + * Fraction to which audio volume is ducked on loss of audio focus + */ + private const val AUDIO_FOCUS_DUCKING = 0.5f + + private val permanentAvailableCommands: Player.Commands = Player.Commands.Builder() + .addAll( + COMMAND_PLAY_PAUSE, + COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, + COMMAND_STOP, + COMMAND_SET_SPEED_AND_PITCH, + COMMAND_GET_CURRENT_MEDIA_ITEM, + COMMAND_GET_TIMELINE, + COMMAND_CHANGE_MEDIA_ITEMS, + COMMAND_SET_VIDEO_SURFACE + ) + .build() + + private val surfaceHolder: SurfaceHolder.Callback = object : SurfaceHolder.Callback { + /** + * This is called immediately after the surface is first created. + * Implementations of this should start up whatever rendering code + * they desire. Note that only one thread can ever draw into + * a [Surface], so you should not draw into the Surface here + * if your normal rendering will be in another thread. + * + * @param holder The SurfaceHolder whose surface is being created. + */ + override fun surfaceCreated(holder: SurfaceHolder) { + MPVLib.attachSurface(holder.surface) + MPVLib.setOptionString("force-window", "yes") + MPVLib.setOptionString("vo", "gpu") + } + + /** + * This is called immediately after any structural changes (format or + * size) have been made to the surface. You should at this point update + * the imagery in the surface. This method is always called at least + * once, after [.surfaceCreated]. + * + * @param holder The SurfaceHolder whose surface has changed. + * @param format The new [android.graphics.PixelFormat] of the surface. + * @param width The new width of the surface. + * @param height The new height of the surface. + */ + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + MPVLib.setPropertyString("android-surface-size", "${width}x$height") + } + + /** + * This is called immediately before a surface is being destroyed. After + * returning from this call, you should no longer try to access this + * surface. If you have a rendering thread that directly accesses + * the surface, you must ensure that thread is no longer touching the + * Surface before returning from this function. + * + * @param holder The SurfaceHolder whose surface is being destroyed. + */ + override fun surfaceDestroyed(holder: SurfaceHolder) { + MPVLib.setOptionString("vo", "null") + MPVLib.setOptionString("force-window", "no") + MPVLib.detachSurface() + } + } + + @Parcelize + data class Track( + val id: Int, + @TrackType val type: String, + val mimeType: String = when (type) { + TrackType.VIDEO -> MimeTypes.BASE_TYPE_VIDEO + TrackType.AUDIO -> MimeTypes.BASE_TYPE_AUDIO + TrackType.SUBTITLE -> MimeTypes.BASE_TYPE_TEXT + else -> "" + }, + val title: String, + val lang: String, + val external: Boolean, + val selected: Boolean, + val externalFilename: String?, + val ffIndex: Int, + val codec: String, + val width: Int?, + val height: Int? + ) : Parcelable { + fun toFormat() : Format { + return Format.Builder() + .setId(id) + .setContainerMimeType("$mimeType/$codec") + .setSampleMimeType("$mimeType/$codec") + .setCodecs(codec) + .setWidth(width ?: Format.NO_VALUE) + .setHeight(height ?: Format.NO_VALUE) + .build() + } + companion object { + fun fromJSON(json: JSONObject): Track { + return Track( + id = json.optInt("id"), + type = json.optString("type"), + title = json.optString("title"), + lang = json.optString("lang"), + external = json.getBoolean("external"), + selected = json.getBoolean("selected"), + externalFilename = json.optString("external-filename"), + ffIndex = json.optInt("ff-index"), + codec = json.optString("codec"), + width = json.optInt("demux-w").takeIf { it > 0 }, + height = json.optInt("demux-h").takeIf { it > 0 } + ) + } + } + } + + private fun getMPVTracks(trackList: String) : Triple ,TrackGroupArray, TrackSelectionArray> { + val tracks = mutableListOf() + var trackGroupArray = TrackGroupArray.EMPTY + var trackSelectionArray = TrackSelectionArray() + + val trackListVideo = mutableListOf() + val trackListAudio = mutableListOf() + val trackListText = mutableListOf() + var indexCurrentVideo: Int = C.INDEX_UNSET + var indexCurrentAudio: Int = C.INDEX_UNSET + var indexCurrentText: Int = C.INDEX_UNSET + try { + val currentTrackList = JSONArray(trackList) + for (index in 0 until currentTrackList.length()) { + val currentTrack = Track.fromJSON(currentTrackList.getJSONObject(index)) + val currentFormat = currentTrack.toFormat() + when (currentTrack.type) { + TrackType.VIDEO -> { + tracks.add(currentTrack) + trackListVideo.add(currentFormat) + if (currentTrack.selected) { + indexCurrentVideo = trackListVideo.indexOf(currentFormat) + } + } + TrackType.AUDIO -> { + tracks.add(currentTrack) + trackListAudio.add(currentFormat) + if (currentTrack.selected) { + indexCurrentAudio = trackListAudio.indexOf(currentFormat) + } + } + TrackType.SUBTITLE -> { + tracks.add(currentTrack) + trackListText.add(currentFormat) + if (currentTrack.selected) { + indexCurrentText = trackListText.indexOf(currentFormat) + } + } + else -> continue + } + } + val trackGroups = mutableListOf() + val trackSelections = mutableListOf() + if (trackListVideo.isNotEmpty()) { + with(TrackGroup(*trackListVideo.toTypedArray())) { + trackGroups.add(this) + trackSelections.add(CurrentTrackSelection(this, indexCurrentVideo)) + } + } + if (trackListAudio.isNotEmpty()) { + with(TrackGroup(*trackListAudio.toTypedArray())) { + trackGroups.add(this) + trackSelections.add(CurrentTrackSelection(this, indexCurrentAudio)) + } + } + if (trackListText.isNotEmpty()) { + with(TrackGroup(*trackListText.toTypedArray())) { + trackGroups.add(this) + trackSelections.add(CurrentTrackSelection(this, indexCurrentText)) + } + } + if (trackGroups.isNotEmpty()) { + trackGroupArray = TrackGroupArray(*trackGroups.toTypedArray()) + trackSelectionArray = TrackSelectionArray(*trackSelections.toTypedArray()) + } + } catch (e: JSONException) {} + return Triple(tracks, trackGroupArray, trackSelectionArray) + } + + const val PLAYER_NAME = "MPV Player" + + /** + * Merges multiple [MediaSource]'s into a single [MediaItem] when required. + */ + fun parseMediaSource(mediaSource: MediaSource) : MediaItem { + return if (mediaSource !is MergingMediaSource) { + mediaSource.mediaItem + } else { + val subtitles = mutableListOf() + mediaSource.getPrivateProperty>("mediaSources")?.forEach { source -> + if (source is SingleSampleMediaSource) { + source.mediaItem.localConfiguration?.subtitleConfigurations?.forEach { subtitle -> + source.getPrivateProperty("format")?.id?.let { id -> + subtitles.add(subtitle.buildUpon().setLabel(id).build()) + } + } + } + } + mediaSource.mediaItem.buildUpon().setSubtitleConfigurations(subtitles).build() + } + } + + @Suppress("UNCHECKED_CAST") + private inline fun T.getPrivateProperty(name: String): R? = try { + T::class.java.getDeclaredField(name).apply { isAccessible = true }.get(this) as R + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/mpv/TrackType.java b/app/src/main/java/org/jellyfin/mobile/player/mpv/TrackType.java new file mode 100644 index 000000000..abc7e47c0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/mpv/TrackType.java @@ -0,0 +1,14 @@ +package org.jellyfin.mobile.player.mpv; + +import androidx.annotation.StringDef; + +@StringDef({ + TrackType.VIDEO, + TrackType.AUDIO, + TrackType.SUBTITLE, +}) +public @interface TrackType { + String VIDEO = "video"; + String AUDIO = "audio"; + String SUBTITLE = "sub"; +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/MediaQueueManager.kt b/app/src/main/java/org/jellyfin/mobile/player/source/MediaQueueManager.kt index ac425d30e..fafe5f97a 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/MediaQueueManager.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/MediaQueueManager.kt @@ -6,6 +6,7 @@ import androidx.annotation.CheckResult import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.MergingMediaSource @@ -14,10 +15,12 @@ import com.google.android.exoplayer2.source.SingleSampleMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import org.jellyfin.mobile.bridge.PlayOptions +import org.jellyfin.mobile.player.mpv.MPVPlayer import org.jellyfin.mobile.player.PlayerException import org.jellyfin.mobile.player.PlayerViewModel import org.jellyfin.mobile.utils.clearSelectionAndDisableRendererByType import org.jellyfin.mobile.utils.selectTrackByTypeAndGroup +import org.jellyfin.mobile.player.mpv.TrackType import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.operations.VideosApi @@ -28,14 +31,16 @@ import org.jellyfin.sdk.model.api.PlayMethod import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.component.inject +import org.koin.core.qualifier.named import java.util.UUID class MediaQueueManager( private val viewModel: PlayerViewModel, + playerName: String ) : KoinComponent { private val apiClient: ApiClient = get() private val mediaSourceResolver: MediaSourceResolver by inject() - private val deviceProfile: DeviceProfile by inject() + private val deviceProfile: DeviceProfile by inject(named(playerName)) private val videosApi: VideosApi = apiClient.videosApi val trackSelector = DefaultTrackSelector(viewModel.getApplication()) private val _mediaQueue: MutableLiveData = MutableLiveData() @@ -115,7 +120,7 @@ class MediaQueueManager( } /** - * Builds the [MediaSource] to be played by ExoPlayer. + * Builds the [MediaSource] to be played by Player. * * @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played. * @return A [MediaSource]. This can be the media stream of the correct type for the playback method or @@ -241,6 +246,7 @@ class MediaQueueManager( @Suppress("ReturnCount") private fun selectAudioTrack(streamIndex: Int, initial: Boolean): Boolean { val mediaSource = _mediaQueue.value?.jellyfinMediaSource ?: return false + val player = viewModel.playerOrNull val sourceIndex = mediaSource.audioStreams.binarySearchBy(streamIndex, selector = MediaStream::index) when { @@ -255,7 +261,18 @@ class MediaQueueManager( !mediaSource.selectAudioStream(sourceIndex) -> return false } - return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_AUDIO, sourceIndex) + return when (player) { + is ExoPlayer -> { + trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_AUDIO, sourceIndex) + } + is MPVPlayer -> { + player.selectTrack( + trackType = TrackType.AUDIO, + index = mediaSource.selectedAudioStream?.index ?: C.INDEX_UNSET + ) + } + else -> false + } } /** @@ -274,6 +291,7 @@ class MediaQueueManager( */ private fun selectSubtitle(streamIndex: Int, initial: Boolean): Boolean { val mediaSource = _mediaQueue.value?.jellyfinMediaSource ?: return false + val player = viewModel.playerOrNull val sourceIndex = mediaSource.subtitleStreams.binarySearchBy(streamIndex, selector = MediaStream::index) when { @@ -283,11 +301,23 @@ class MediaQueueManager( !mediaSource.selectSubtitleStream(sourceIndex) -> return false } - return when { - // Select new subtitle with suitable renderer - sourceIndex >= 0 -> trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, sourceIndex) - // No subtitle selected, clear selection overrides and disable all subtitle renderers - else -> trackSelector.clearSelectionAndDisableRendererByType(C.TRACK_TYPE_TEXT) + return when (player) { + is ExoPlayer -> { + when { + // Select new subtitle with suitable renderer + sourceIndex >= 0 -> trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, sourceIndex) + // No subtitle selected, clear selection overrides and disable all subtitle renderers + else -> trackSelector.clearSelectionAndDisableRendererByType(C.TRACK_TYPE_TEXT) + } + } + is MPVPlayer -> { + player.selectTrack( + trackType = TrackType.SUBTITLE, + isExternal = mediaSource.selectedSubtitleStream?.isExternal ?: false, + index = mediaSource.selectedSubtitleStream?.index ?: C.INDEX_UNSET + ) + } + else -> false } } diff --git a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt index cae79cc89..d7c2dbf02 100644 --- a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt @@ -75,21 +75,22 @@ class SettingsFragment : Fragment() { val videoPlayerOptions = listOf( SelectionItem(VideoPlayerType.WEB_PLAYER, R.string.video_player_web, R.string.video_player_web_description), SelectionItem(VideoPlayerType.EXO_PLAYER, R.string.video_player_integrated, R.string.video_player_native_description), + SelectionItem(VideoPlayerType.MPV_PLAYER, R.string.video_player_mpv, R.string.video_player_mpv_description), SelectionItem(VideoPlayerType.EXTERNAL_PLAYER, R.string.video_player_external, R.string.video_player_external_description), ) singleChoice(Constants.PREF_VIDEO_PLAYER_TYPE, videoPlayerOptions) { titleRes = R.string.pref_video_player_type_title initialSelection = VideoPlayerType.WEB_PLAYER defaultOnSelectionChange { selection -> - swipeGesturesPreference.enabled = selection == VideoPlayerType.EXO_PLAYER - rememberBrightnessPreference.enabled = selection == VideoPlayerType.EXO_PLAYER && swipeGesturesPreference.checked - backgroundAudioPreference.enabled = selection == VideoPlayerType.EXO_PLAYER + swipeGesturesPreference.enabled = selection in arrayOf(VideoPlayerType.EXO_PLAYER, VideoPlayerType.MPV_PLAYER) + rememberBrightnessPreference.enabled = selection in arrayOf(VideoPlayerType.EXO_PLAYER, VideoPlayerType.MPV_PLAYER) && swipeGesturesPreference.checked + backgroundAudioPreference.enabled = selection in arrayOf(VideoPlayerType.EXO_PLAYER, VideoPlayerType.MPV_PLAYER) externalPlayerChoicePreference.enabled = selection == VideoPlayerType.EXTERNAL_PLAYER } } swipeGesturesPreference = checkBox(Constants.PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES) { titleRes = R.string.pref_exoplayer_allow_brightness_volume_gesture - enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER + enabled = appPreferences.videoPlayerType in arrayOf(VideoPlayerType.EXO_PLAYER, VideoPlayerType.MPV_PLAYER) defaultValue = true defaultOnCheckedChange { checked -> rememberBrightnessPreference.enabled = checked @@ -97,7 +98,7 @@ class SettingsFragment : Fragment() { } rememberBrightnessPreference = checkBox(Constants.PREF_EXOPLAYER_REMEMBER_BRIGHTNESS) { titleRes = R.string.pref_exoplayer_remember_brightness - enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER && appPreferences.exoPlayerAllowSwipeGestures + enabled = appPreferences.videoPlayerType in arrayOf(VideoPlayerType.EXO_PLAYER, VideoPlayerType.MPV_PLAYER) && appPreferences.exoPlayerAllowSwipeGestures defaultOnCheckedChange { checked -> if (!checked) appPreferences.exoPlayerBrightness = BRIGHTNESS_OVERRIDE_NONE } @@ -105,7 +106,7 @@ class SettingsFragment : Fragment() { backgroundAudioPreference = checkBox(Constants.PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO) { titleRes = R.string.pref_exoplayer_allow_background_audio summaryRes = R.string.pref_exoplayer_allow_background_audio_summary - enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER + enabled = appPreferences.videoPlayerType in arrayOf(VideoPlayerType.EXO_PLAYER, VideoPlayerType.MPV_PLAYER) } // Generate available external player options diff --git a/app/src/main/java/org/jellyfin/mobile/settings/VideoPlayerType.java b/app/src/main/java/org/jellyfin/mobile/settings/VideoPlayerType.java index f006ad49e..c6c131105 100644 --- a/app/src/main/java/org/jellyfin/mobile/settings/VideoPlayerType.java +++ b/app/src/main/java/org/jellyfin/mobile/settings/VideoPlayerType.java @@ -6,10 +6,12 @@ @StringDef({ VideoPlayerType.WEB_PLAYER, VideoPlayerType.EXO_PLAYER, + VideoPlayerType.MPV_PLAYER, VideoPlayerType.EXTERNAL_PLAYER }) public @interface VideoPlayerType { String WEB_PLAYER = "webui"; String EXO_PLAYER = "exoplayer"; + String MPV_PLAYER = "mpvplayer"; String EXTERNAL_PLAYER = "external"; } diff --git a/app/src/main/res/drawable/ic_fast_forward_white_24dp.xml b/app/src/main/res/drawable/ic_fast_forward_white_24dp.xml new file mode 100644 index 000000000..4db9ba0fd --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_forward_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/exo_player_control_view.xml b/app/src/main/res/layout/exo_player_control_view.xml index 5db7bb434..f4fca9eda 100644 --- a/app/src/main/res/layout/exo_player_control_view.xml +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -177,6 +177,19 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toEndOf="@id/speed_button" /> + + Video player type
Web player Integrated player + MPV player External player The default HTML video player from the Web UI Based on ExoPlayer, supports more video formats and codecs, and is more integrated into the OS + Based on libmpv, supports more video, audio and subtitle formats, and is more customizable External video playback apps like MX Player and VLC Brightness and volume gestures Remember display brightness