Add experimental MPV player backend (#161)

Experimental MPV player backend

Related to #14, #22, & #85

You can switch to MPV in advanced settings and toggle using hardware
decoding or not.

This uses code and buildscripts from
https://github.com/mpv-android/mpv-android, plus other third party
libraries to build MPV
This commit is contained in:
damontecres 2025-11-16 18:46:25 -05:00 committed by GitHub
parent d7b333e519
commit 6be2662d4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 3987 additions and 289 deletions

View file

@ -77,9 +77,12 @@ class MainActivity : AppCompatActivity() {
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
Timber.i("MainActivity.onCreate")
super.onCreate(savedInstanceState)
Timber.i("MainActivity.onCreate")
lifecycle.addObserver(playbackLifecycleObserver)
if (savedInstanceState == null) {
appUpgradeHandler.copySubfont(false)
}
setContent {
CoilConfig(okHttpClient, false)
val appPreferences by userPreferencesDataStore.data.collectAsState(null)

View file

@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
@ -672,6 +673,30 @@ sealed interface AppPreference<T> {
title = R.string.subtitle_style,
destination = Destination.Settings(PreferenceScreenOption.SUBTITLES),
)
val PlayerBackendPref =
AppChoicePreference<PlayerBackend>(
title = R.string.player_backend,
defaultValue = PlayerBackend.EXO_PLAYER,
getter = { it.playbackPreferences.playerBackend },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { playerBackend = value }
},
displayValues = R.array.player_backend_options,
indexToValue = { PlayerBackend.forNumber(it) },
valueToIndex = { it.number },
)
val MpvHardwareDecoding =
AppSwitchPreference(
title = R.string.mpv_hardware_decoding,
defaultValue = true,
getter = { it.playbackPreferences.mpvOptions.enableHardwareDecoding },
setter = { prefs, value ->
prefs.updateMpvOptions { enableHardwareDecoding = value }
},
summary = R.string.disable_if_crash,
)
}
}
@ -752,6 +777,7 @@ val advancedPreferences =
preferences =
listOf(
AppPreference.OneClickPause,
AppPreference.GlobalContentScale,
AppPreference.SkipIntros,
AppPreference.SkipOutros,
AppPreference.SkipCommercials,
@ -762,15 +788,24 @@ val advancedPreferences =
),
),
PreferenceGroup(
title = R.string.playback_overrides,
preferences =
title = R.string.player_backend,
preferences = listOf(AppPreference.PlayerBackendPref),
conditionalPreferences =
listOf(
AppPreference.GlobalContentScale,
AppPreference.DownMixStereo,
AppPreference.Ac3Supported,
AppPreference.DirectPlayAss,
AppPreference.DirectPlayPgs,
AppPreference.FfmpegPreference,
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER },
listOf(
AppPreference.FfmpegPreference,
AppPreference.DownMixStereo,
AppPreference.Ac3Supported,
AppPreference.DirectPlayAss,
AppPreference.DirectPlayPgs,
),
),
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.MPV },
listOf(AppPreference.MpvHardwareDecoding),
),
),
),
PreferenceGroup(

View file

@ -46,6 +46,7 @@ class AppPreferencesSerializer
passOutProtectionMs =
AppPreference.PassOutProtection.defaultValue.hours.inWholeMilliseconds
showNextUpWhen = AppPreference.ShowNextUpTiming.defaultValue
playerBackend = AppPreference.PlayerBackendPref.defaultValue
overrides =
PlaybackOverrides
@ -58,6 +59,14 @@ class AppPreferencesSerializer
mediaExtensionsEnabled =
AppPreference.FfmpegPreference.defaultValue
}.build()
mpvOptions =
MpvOptions
.newBuilder()
.apply {
enableHardwareDecoding =
AppPreference.MpvHardwareDecoding.defaultValue
}.build()
}.build()
homePagePreferences =
HomePagePreferences
@ -112,6 +121,11 @@ inline fun AppPreferences.updatePlaybackOverrides(block: PlaybackOverrides.Build
overrides = overrides.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateMpvOptions(block: MpvOptions.Builder.() -> Unit): AppPreferences =
updatePlaybackPreferences {
mpvOptions = mpvOptions.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateHomePagePreferences(block: HomePagePreferences.Builder.() -> Unit): AppPreferences =
update {
homePagePreferences = homePagePreferences.toBuilder().apply(block).build()

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@ -48,6 +49,7 @@ class AppUpgradeHandler
putLong(VERSION_CODE_CURRENT_KEY, newVersionCode)
}
try {
copySubfont(true)
upgradeApp(
context,
Version.Companion.fromString(previousVersion ?: "0.0.0"),
@ -60,6 +62,27 @@ class AppUpgradeHandler
}
}
fun copySubfont(overwrite: Boolean) {
try {
val fontFileName = "subfont.ttf"
val outputFile = File(context.filesDir, fontFileName)
if (!outputFile.exists() || overwrite) {
context.assets.open(fontFileName).use { input ->
outputFile.outputStream().use { output ->
input.copyTo(output)
}
}
Timber.i("Wrote font %s to local", fontFileName)
}
// val oldFontDir = File(context.filesDir, "fonts")
// if (oldFontDir.exists()) {
// oldFontDir.deleteRecursively()
// }
} catch (ex: Exception) {
Timber.e(ex, "Exception copying subfont.tff")
}
}
companion object {
const val VERSION_NAME_PREVIOUS_KEY = "version.previous.name"
const val VERSION_CODE_PREVIOUS_KEY = "version.previous.code"

View file

@ -9,8 +9,11 @@ import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
@ -38,27 +41,45 @@ class PlayerFactory
currentPlayer?.release()
}
val extensions =
runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled
Timber.v("extensions=$extensions")
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
}
val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences }
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
val newPlayer =
ExoPlayer
.Builder(context)
.setRenderersFactory(
DefaultRenderersFactory(context)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode),
).build()
.apply {
playWhenReady = true
when (backend) {
PlayerBackend.MPV -> {
val enableHardwareDecoding =
prefs?.mpvOptions?.enableHardwareDecoding
?: AppPreference.MpvHardwareDecoding.defaultValue
MpvPlayer(context, enableHardwareDecoding)
.apply {
playWhenReady = true
}
}
PlayerBackend.EXO_PLAYER,
PlayerBackend.UNRECOGNIZED,
-> {
val extensions =
runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled
Timber.v("extensions=$extensions")
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
}
ExoPlayer
.Builder(context)
.setRenderersFactory(
DefaultRenderersFactory(context)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode),
).build()
.apply {
playWhenReady = true
}
}
}
currentPlayer = newPlayer
return newPlayer
}
@ -68,6 +89,7 @@ val Player.isReleased: Boolean
get() {
return when (this) {
is ExoPlayer -> isReleased
is MpvPlayer -> isReleased
else -> throw IllegalStateException("Unknown Player type: ${this::class.qualifiedName}")
}
}

View file

@ -24,6 +24,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
@ -51,9 +53,8 @@ class UpdateChecker
) {
companion object {
// TODO apk names
private const val ASSET_NAME = "Wholphin.apk"
private const val DEBUG_ASSET_NAME = "Wholphin-debug.apk"
private const val RELEASE_ASSET_NAME = "Wholphin-release.apk"
private const val ASSET_NAME = "Wholphin"
private const val APK_NAME = "$ASSET_NAME.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
@ -116,15 +117,10 @@ class UpdateChecker
suspend fun getLatestRelease(updateUrl: String): Release? {
return withContext(Dispatchers.IO) {
val preferredAsset =
if (PreferenceManager
.getDefaultSharedPreferences(context)
.getBoolean("updatePreferRelease", true)
) {
RELEASE_ASSET_NAME
} else {
DEBUG_ASSET_NAME
}
val preferRelease =
PreferenceManager
.getDefaultSharedPreferences(context)
.getBoolean("updatePreferRelease", true)
val request =
Request
@ -136,21 +132,15 @@ class UpdateChecker
if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.Companion.tryFromString(name)
val version = Version.tryFromString(name)
val publishedAt =
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
val downloadUrl =
result.jsonObject["assets"]
?.jsonArray
?.firstOrNull { asset ->
val assetName =
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
assetName == ASSET_NAME || assetName == preferredAsset
}?.jsonObject
?.get("browser_download_url")
?.jsonPrimitive
?.contentOrNull
?.let { assets -> getDownloadUrl(assets, preferRelease) }
Timber.v("version=$version, downloadUrl=$downloadUrl")
if (version != null) {
val notes =
if (body.isNotNullOrBlank()) {
@ -174,6 +164,35 @@ class UpdateChecker
}
}
private fun getDownloadUrl(
assets: JsonArray,
preferRelease: Boolean,
): String? {
val abiSuffix = Build.SUPPORTED_ABIS.firstOrNull().let { if (it != null) "-$it" else "" }
val releaseSuffix = if (preferRelease) "-release" else "-debug"
val preferredNames =
listOf(
"$ASSET_NAME${releaseSuffix}$abiSuffix.apk",
"$ASSET_NAME$releaseSuffix.apk",
"$ASSET_NAME.apk",
)
var preferredAsset: JsonObject? = null
outer@ for (name in preferredNames) {
for (asset in assets) {
val assetName =
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
if (name == assetName) {
preferredAsset = asset.jsonObject
break@outer
}
}
}
return preferredAsset
?.get("browser_download_url")
?.jsonPrimitive
?.contentOrNull
}
suspend fun installRelease(
release: Release,
callback: DownloadCallback,
@ -195,7 +214,7 @@ class UpdateChecker
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, ASSET_NAME)
put(MediaStore.MediaColumns.DISPLAY_NAME, APK_NAME)
put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME_TYPE)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
@ -267,7 +286,7 @@ class UpdateChecker
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloadDir.mkdirs()
val targetFile = File(downloadDir, ASSET_NAME)
val targetFile = File(downloadDir, APK_NAME)
targetFile.outputStream().use { output ->
response.body!!.byteStream().use { input ->
copyTo(input, output, callback = callback)
@ -320,7 +339,7 @@ class UpdateChecker
} else {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val targetFile = File(downloadDir, ASSET_NAME)
val targetFile = File(downloadDir, APK_NAME)
if (targetFile.exists()) {
targetFile.delete()
}

View file

@ -30,7 +30,10 @@ fun CircularProgress(modifier: Modifier = Modifier) {
* Fill the space with a loading indicator and take focus
*/
@Composable
fun LoadingPage(modifier: Modifier = Modifier) {
fun LoadingPage(
modifier: Modifier = Modifier,
focusEnabled: Boolean = true,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Box(
@ -39,7 +42,7 @@ fun LoadingPage(modifier: Modifier = Modifier) {
modifier
.fillMaxSize()
.focusRequester(focusRequester)
.focusable(),
.focusable(focusEnabled),
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
@ -224,6 +225,11 @@ fun DebugPage(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "ABIs: ${Build.SUPPORTED_ABIS.toList()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
item {
@ -237,12 +243,12 @@ fun DebugPage(
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Current server: ${viewModel.serverRepository.currentServer}",
text = "Current server: ${viewModel.serverRepository.currentServer.value}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Current user: ${viewModel.serverRepository.currentUser}",
text = "Current user: ${viewModel.serverRepository.currentUser.value}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)

View file

@ -274,7 +274,7 @@ fun SeekBar(
modifier = Modifier.fillMaxWidth(),
interactionSource = interactionSource,
enabled = isEnabled,
durationMs = player.contentDuration,
durationMs = player.duration,
seekBack = seekBack,
seekForward = seekForward,
)

View file

@ -407,6 +407,12 @@ fun PlaybackOverlay(
.padding(16.dp)
.background(AppColors.TransparentBlack50),
) {
Text(
text = "Backend: ${currentPlayback?.backend}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 8.dp),
)
Text(
text = "Play method: ${currentPlayback?.playMethod?.serialName}",
style = MaterialTheme.typography.bodySmall,

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
@ -65,6 +66,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.AspectRatios
@ -72,12 +74,14 @@ import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.stringRes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.DeviceProfile
@ -117,6 +121,7 @@ fun PlaybackPage(
LoadingState.Success -> {
val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val player = viewModel.player
val title by viewModel.title.observeAsState(null)
@ -157,6 +162,13 @@ fun PlaybackPage(
OneTimeLaunchedEffect {
player.addListener(cueListener)
if (prefs.playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.Main + ExceptionHandler()) {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
density,
)
}
}
}
DisposableEffect(Unit) {
onDispose { player.removeListener(cueListener) }
@ -166,7 +178,7 @@ fun PlaybackPage(
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
val presentationState = rememberPresentationState(player)
val presentationState = rememberPresentationState(player, false)
val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() }
@ -241,12 +253,15 @@ fun PlaybackPage(
modifier = scaledModifier,
)
if (presentationState.coverSurface) {
val isLoading by rememberPlayerLoadingState(player)
Box(
Modifier
.matchParentSize()
.background(Color.Black),
) {
LoadingPage()
if (isLoading) {
LoadingPage(focusEnabled = false)
}
}
}
@ -365,7 +380,7 @@ fun PlaybackPage(
PlaybackAction.Next -> {
// TODO focus is lost
viewModel.playUpNextUp()
viewModel.playNextUp()
}
PlaybackAction.Previous -> {
@ -458,14 +473,14 @@ fun PlaybackPage(
if (autoPlayEnabled) {
LaunchedEffect(Unit) {
if (timeLeft == 0L) {
viewModel.playUpNextUp()
viewModel.playNextUp()
} else {
while (timeLeft > 0) {
delay(1.seconds)
timeLeft--
}
if (timeLeft == 0L && autoPlayEnabled) {
viewModel.playUpNextUp()
viewModel.playNextUp()
}
}
}
@ -483,7 +498,7 @@ fun PlaybackPage(
?: AspectRatios.WIDE,
onClick = {
viewModel.reportInteraction()
viewModel.playUpNextUp()
viewModel.playNextUp()
},
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
modifier =

View file

@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.data.model.chooseStream
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -52,6 +53,7 @@ import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.TrackSupport
import com.github.damontecres.wholphin.util.checkForSupport
import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
import com.github.damontecres.wholphin.util.subtitleMimeTypes
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel
@ -77,6 +79,8 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
@ -209,7 +213,7 @@ class PlaybackViewModel
destination.forceTranscoding,
)
if (!played) {
playUpNextUp()
playNextUp()
}
if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) {
@ -364,6 +368,7 @@ class PlaybackViewModel
enableDirectStream = !forceTranscoding,
)
player.prepare()
player.play()
this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api)
Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}")
@ -378,29 +383,74 @@ class PlaybackViewModel
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
audioIndex: Int?,
subtitleIndex: Int?,
positionMs: Long = C.TIME_UNSET,
positionMs: Long = 0,
userInitiated: Boolean,
enableDirectPlay: Boolean = true,
enableDirectStream: Boolean = true,
) = withContext(Dispatchers.IO) {
val itemId = item.id
val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend
val currentPlayback = this@PlaybackViewModel.currentPlayback.value
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
// If direct playing, can try to switch tracks without playback restarting
// Except for external subtitles
// TODO there's probably no reason why we can't add external subtitles?
Timber.v("changeStreams direct play")
val source = currentPlayback.mediaSourceInfo
val externalSubtitle = source.findExternalSubtitle(subtitleIndex)
if (externalSubtitle == null) {
val result =
withContext(Dispatchers.Main) {
applyTrackSelections(
player,
playerBackend,
true,
audioIndex,
subtitleIndex,
source,
)
}
if (result.bothSelected) {
// TODO lots of duplicate code in this block
Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex")
val itemPlayback =
currentItemPlayback.copy(
sourceId = source.id?.toUUIDOrNull(),
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED,
)
if (userInitiated) {
viewModelScope.launchIO {
Timber.v("Saving user initiated item playback: %s", itemPlayback)
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = updated
}
}
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentPlayback.value =
currentPlayback.copy(
tracks = checkForSupport(player.currentTracks),
)
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
}
return@withContext
}
} else {
Timber.v("changeStreams direct play, external subtitle was requested")
}
}
// TODO
// if (currentItemPlayback.let {
// it.itemId == itemId &&
// it.audioIndex == audioIndex &&
// it.subtitleIndex == subtitleIndex
// } == true
// ) {
// Timber.i("No change in playback for changeStreams")
// return@withContext
// }
Timber.d(
"changeStreams: userInitiated=$userInitiated, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex, " +
"enableDirectPlay=$enableDirectPlay, enableDirectStream=$enableDirectStream",
"enableDirectPlay=$enableDirectPlay, enableDirectStream=$enableDirectStream, positionMs=$positionMs",
)
// TODO if the new audio or subtitle index is already in the streams (eg direct play), should toggle in the player instead
val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
@ -410,7 +460,12 @@ class PlaybackViewModel
itemId,
PlaybackInfoDto(
startTimeTicks = null,
deviceProfile = deviceProfile,
deviceProfile =
if (playerBackend == PlayerBackend.EXO_PLAYER) {
deviceProfile
} else {
mpvDeviceProfile
},
maxAudioChannels = null,
audioStreamIndex = audioIndex,
subtitleStreamIndex = subtitleIndex,
@ -453,6 +508,7 @@ class PlaybackViewModel
}
val transcodeType =
when {
// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY
source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
source.supportsDirectStream -> PlayMethod.DIRECT_STREAM
source.supportsTranscoding -> PlayMethod.TRANSCODE
@ -461,29 +517,27 @@ class PlaybackViewModel
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
Timber.v("Playback decision: $decision")
val externalSubtitleCount =
source.mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && it.isExternal } ?: 0
val externalSubtitleCount = source.externalSubtitlesCount
val externalSubtitle =
source.mediaStreams
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.isExternal && it.index == subtitleIndex }
?.let {
it.deliveryUrl?.let { deliveryUrl ->
var flags = 0
if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED)
if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT)
MediaItem.SubtitleConfiguration
.Builder(
api.createUrl(deliveryUrl).toUri(),
).setId("e:${it.index}")
.setMimeType(subtitleMimeTypes[it.codec])
.setLanguage(it.language)
.setSelectionFlags(flags)
.build()
}
source.findExternalSubtitle(subtitleIndex)?.let {
it.deliveryUrl?.let { deliveryUrl ->
var flags = 0
if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED)
if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT)
MediaItem.SubtitleConfiguration
.Builder(
api.createUrl(deliveryUrl).toUri(),
).setId("e:${it.index}")
.setMimeType(subtitleMimeTypes[it.codec])
.setLanguage(it.language)
.setLabel(it.title)
.setSelectionFlags(flags)
.build()
}
Timber.v("externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle")
}
Timber.v("subtitleIndex=$subtitleIndex, externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle")
val mediaItem =
MediaItem
@ -497,9 +551,11 @@ class PlaybackViewModel
CurrentPlayback(
item = item,
tracks = listOf(),
backend = preferences.appPreferences.playbackPreferences.playerBackend,
playMethod = transcodeType,
playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId,
mediaSourceInfo = source,
)
val itemPlayback =
currentItemPlayback.copy(
@ -536,7 +592,7 @@ class PlaybackViewModel
duration.value = source.runTimeTicks?.ticks
loading.value = LoadingState.Success
currentPlayback.value = playback
this@PlaybackViewModel.currentPlayback.value = playback
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
player.setMediaItem(
mediaItem,
@ -548,15 +604,18 @@ class PlaybackViewModel
override fun onTracksChanged(tracks: Tracks) {
Timber.v("onTracksChanged: $tracks")
if (tracks.groups.isNotEmpty()) {
applyTrackSelections(
player,
source.supportsDirectPlay,
audioIndex,
subtitleIndex,
externalSubtitleCount,
externalSubtitle != null,
)
player.removeListener(this)
val result =
applyTrackSelections(
player,
playerBackend,
source.supportsDirectPlay,
audioIndex,
subtitleIndex,
source,
)
if (result.bothSelected) {
player.removeListener(this)
}
}
}
}
@ -727,7 +786,7 @@ class PlaybackViewModel
}
}
fun playUpNextUp() {
fun playNextUp() {
playlist.value?.let {
if (it.hasNext()) {
viewModelScope.launchIO {
@ -735,7 +794,7 @@ class PlaybackViewModel
val item = it.getAndAdvance()
val played = play(item, 0)
if (!played) {
playUpNextUp()
playNextUp()
}
}
}
@ -768,7 +827,7 @@ class PlaybackViewModel
if (toPlay != null) {
val played = play(toPlay, 0)
if (!played) {
playUpNextUp()
playNextUp()
}
} else {
// TODO
@ -837,7 +896,7 @@ class PlaybackViewModel
PlaystateCommand.PAUSE -> player.pause()
PlaystateCommand.UNPAUSE -> player.play()
PlaystateCommand.NEXT_TRACK -> playUpNextUp()
PlaystateCommand.NEXT_TRACK -> playNextUp()
PlaystateCommand.PREVIOUS_TRACK -> playPrevious()
PlaystateCommand.SEEK -> it.seekPositionTicks?.ticks?.let { player.seekTo(it.inWholeMilliseconds) }
PlaystateCommand.REWIND ->
@ -1013,9 +1072,11 @@ class PlaybackViewModel
data class CurrentPlayback(
val item: BaseItem,
val tracks: List<TrackSupport>,
val backend: PlayerBackend,
val playMethod: PlayMethod,
val playSessionId: String?,
val liveStreamId: String?,
val mediaSourceInfo: MediaSourceInfo,
)
sealed interface SubtitleSearch {
@ -1044,81 +1105,205 @@ val Format.idAsInt: Int?
}
}
/**
* Returns the number of external subtitle streams there are
*/
val MediaSourceInfo.externalSubtitlesCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && it.isExternal } ?: 0
/**
* Returns the number of embedded subtitle streams there are
*/
val MediaSourceInfo.embeddedSubtitleCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && !it.isExternal } ?: 0
/**
* Returns the number of video streams there are
*/
val MediaSourceInfo.videoStreamCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.VIDEO } ?: 0
/**
* Returns the number of audio streams there are
*/
val MediaSourceInfo.audioStreamCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.AUDIO } ?: 0
/**
* Returns the [MediaStream] for the given subtitle index iff it is external
*/
fun MediaSourceInfo.findExternalSubtitle(subtitleIndex: Int?): MediaStream? =
subtitleIndex?.let {
mediaStreams
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.isExternal && it.index == subtitleIndex }
}
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
data class TrackSelectionResult(
val audioSelected: Boolean,
val subtitleSelected: Boolean,
) {
val bothSelected: Boolean = audioSelected && subtitleSelected
}
@OptIn(UnstableApi::class)
private fun applyTrackSelections(
player: Player,
playerBackend: PlayerBackend,
supportsDirectPlay: Boolean,
audioIndex: Int?,
subtitleIndex: Int?,
externalSubtitleCount: Int,
subtitleIsExternal: Boolean,
) {
if (subtitleIndex != null && subtitleIndex >= 0 && (subtitleIsExternal || supportsDirectPlay)) {
val chosenTrack =
if (subtitleIsExternal) {
player.currentTracks.groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.mapNotNull {
group.getTrackFormat(it).id
}.any { it.endsWith("e:$subtitleIndex") }
source: MediaSourceInfo,
): TrackSelectionResult {
val videoStreamCount = source.videoStreamCount
val audioStreamCount = source.audioStreamCount
val embeddedSubtitleCount = source.embeddedSubtitleCount
val externalSubtitleCount = source.externalSubtitlesCount
val paramsBuilder = player.trackSelectionParameters.buildUpon()
val tracks = player.currentTracks.groups
val subtitleSelected =
if (subtitleIndex != null && subtitleIndex >= 0) {
val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null
if (subtitleIsExternal || supportsDirectPlay) {
val chosenTrack =
if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) {
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.mapNotNull {
group.getTrackFormat(it).id
}.any { it.endsWith("e:$subtitleIndex") }
}
} else {
val indexToFind =
calculateIndexToFind(
subtitleIndex,
MediaStreamType.SUBTITLE,
playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount,
externalSubtitleCount,
subtitleIsExternal,
)
Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track")
// subtitleIndex - externalSubtitleCount + 1
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
}
Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack")
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
)
}
chosenTrack != null
} else {
val indexToFind = subtitleIndex - externalSubtitleCount + 1
player.currentTracks.groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
false
}
} else {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
true
}
val audioSelected =
if (audioIndex != null && supportsDirectPlay) {
val indexToFind =
calculateIndexToFind(
audioIndex,
MediaStreamType.AUDIO,
playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount,
externalSubtitleCount,
false,
)
val chosenTrack =
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
}
Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack")
chosenTrack?.let {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
).build()
}
} else {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
}
if (audioIndex != null && supportsDirectPlay) {
val indexToFind =
audioIndex - externalSubtitleCount + 1
val chosenTrack =
player.currentTracks.groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack")
chosenTrack?.let {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack")
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
).build()
)
}
chosenTrack != null
} else {
audioIndex == null
}
if (audioSelected && subtitleSelected) {
player.trackSelectionParameters = paramsBuilder.build()
}
return TrackSelectionResult(audioSelected, subtitleSelected)
}
/**
* Maps the server provided index to the track index based on the [PlayerBackend] and other stream information
*/
private fun calculateIndexToFind(
serverIndex: Int,
type: MediaStreamType,
playerBackend: PlayerBackend,
videoStreamCount: Int,
audioStreamCount: Int,
embeddedSubtitleCount: Int,
externalSubtitleCount: Int,
subtitleIsExternal: Boolean,
): Int =
when (playerBackend) {
PlayerBackend.EXO_PLAYER,
PlayerBackend.UNRECOGNIZED,
-> {
serverIndex - externalSubtitleCount + 1
}
// TODO MPV could use literal indexes because they are stored in the track format ID
PlayerBackend.MPV -> {
when (type) {
MediaStreamType.VIDEO -> serverIndex - externalSubtitleCount + 1
MediaStreamType.AUDIO -> serverIndex - externalSubtitleCount - videoStreamCount + 1
MediaStreamType.SUBTITLE -> {
if (subtitleIsExternal) {
serverIndex + embeddedSubtitleCount + 1
} else {
serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1
}
}
else -> throw UnsupportedOperationException("Cannot calculate index for $type")
}
}
}
}

View file

@ -0,0 +1,39 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.media3.common.Player
import androidx.media3.common.listen
import androidx.media3.common.util.UnstableApi
@UnstableApi
@Composable
fun rememberPlayerLoadingState(player: Player): PlayerLoadingState {
val state = remember(player) { PlayerLoadingState(player) }
LaunchedEffect(player) {
state.observe()
}
return state
}
@UnstableApi
class PlayerLoadingState(
private val player: Player,
) : State<Boolean> {
override var value by mutableStateOf(player.isLoading)
private set
suspend fun observe() {
value = player.isLoading
player.listen {
if (it.contains(Player.EVENT_IS_LOADING_CHANGED)) {
value = player.isLoading
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.preferences
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import kotlinx.serialization.Serializable
/**
@ -10,6 +11,12 @@ import kotlinx.serialization.Serializable
data class PreferenceGroup(
@param:StringRes val title: Int,
val preferences: List<AppPreference<out Any?>>,
val conditionalPreferences: List<ConditionalPreferences> = listOf(),
)
data class ConditionalPreferences(
val condition: (AppPreferences) -> Boolean,
val preferences: List<AppPreference<out Any?>>,
)
/**

View file

@ -43,9 +43,11 @@ import coil3.SingletonImageLoader
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.uiPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound
@ -53,9 +55,11 @@ import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun PreferencesContent(
@ -112,6 +116,22 @@ fun PreferencesContent(
visible = true
}
LaunchedEffect(preferences.playbackPreferences.playerBackend) {
if (preferences.playbackPreferences.playerBackend == PlayerBackend.MPV) {
Timber.d("Checking for libmpv")
try {
System.loadLibrary("mpv")
System.loadLibrary("player")
} catch (ex: Exception) {
Timber.w(ex, "Could not load libmpv")
showToast(context, "MPV is not supported on this device")
viewModel.preferenceDataStore.updateData {
it.updatePlaybackPreferences { playerBackend = PlayerBackend.EXO_PLAYER }
}
}
}
}
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInHorizontally { it / 2 },
@ -179,7 +199,13 @@ fun PreferencesContent(
.padding(top = 8.dp, bottom = 4.dp),
)
}
group.preferences.forEachIndexed { prefIndex, pref ->
val groupPreferences =
group.preferences +
group.conditionalPreferences
.filter { it.condition.invoke(preferences) }
.map { it.preferences }
.flatten()
groupPreferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<Any>
item {
val interactionSource = remember { MutableInteractionSource() }

View file

@ -4,6 +4,8 @@ import android.graphics.Typeface
import androidx.annotation.OptIn
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.CaptionStyleCompat
import com.github.damontecres.wholphin.R
@ -17,6 +19,8 @@ import com.github.damontecres.wholphin.preferences.SubtitlePreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.util.mpv.MPVLib
import com.github.damontecres.wholphin.util.mpv.setPropertyColor
object SubtitleSettings {
val FontSize =
@ -245,4 +249,56 @@ object SubtitleSettings {
},
)
}
fun SubtitlePreferences.applyToMpv(density: Density) {
val fo = (fontOpacity / 100.0 * 255).toInt().shl(24)
val fc = Color(fo.or(fontColor))
val bg = Color((backgroundOpacity / 100.0 * 255).toInt().shl(24).or(backgroundColor))
val edge = Color(fo.or(edgeColor))
// TODO weird, but seems to get the size to be very close to matching sizes between renderers
val fontSizePx = with(density) { fontSize.sp.toPx() * .8 }.toInt()
MPVLib.setPropertyInt("sub-font-size", fontSizePx)
MPVLib.setPropertyColor("sub-color", fc)
MPVLib.setPropertyColor("sub-outline-color", edge)
when (edgeStyle) {
EdgeStyle.EDGE_NONE,
EdgeStyle.UNRECOGNIZED,
-> {
MPVLib.setPropertyInt("sub-shadow-offset", 0)
MPVLib.setPropertyDouble("sub-outline-size", 0.0)
}
EdgeStyle.EDGE_SOLID -> {
MPVLib.setPropertyInt("sub-shadow-offset", 0)
MPVLib.setPropertyDouble("sub-outline-size", 1.15)
}
EdgeStyle.EDGE_SHADOW -> {
MPVLib.setPropertyInt("sub-shadow-offset", 4)
MPVLib.setPropertyDouble("sub-outline-size", 0.0)
}
}
// if (fontBold) {
// MPVLib.setPropertyString("sub-font", "Roboto Bold")
// } else {
// MPVLib.setPropertyString("sub-font", "Roboto Regular")
// }
MPVLib.setPropertyBoolean("sub-bold", fontBold)
MPVLib.setPropertyBoolean("sub-italic", fontItalic)
MPVLib.setPropertyColor("sub-back-color", bg)
val borderStyle =
when (backgroundStyle) {
BackgroundStyle.UNRECOGNIZED,
BackgroundStyle.BG_NONE,
-> "outline-and-shadow"
BackgroundStyle.BG_WRAP -> "opaque-box"
BackgroundStyle.BG_BOXED -> "background-box"
}
MPVLib.setPropertyString("sub-border-style", borderStyle)
}
}

View file

@ -0,0 +1,334 @@
package com.github.damontecres.wholphin.util.mpv
/*
This file is copied from https://github.com/mpv-android/mpv-android/blob/master/app/src/main/java/is/xyz/mpv/MPVLib.kt
Copyright (c) 2016 Ilya Zhuravlev
Copyright (c) 2016 sfan5 <sfan5@live.de>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import android.content.Context
import android.graphics.Bitmap
import android.view.Surface
// Wrapper for native library
@Suppress("unused")
object MPVLib {
init {
val libs = arrayOf("mpv", "player")
for (lib in libs) {
System.loadLibrary(lib)
}
}
external fun create(appctx: Context)
external fun init()
external fun destroy()
external fun attachSurface(surface: Surface)
external fun detachSurface()
external fun command(cmd: Array<out String>)
external fun setOptionString(
name: String,
value: String,
): Int
external fun grabThumbnail(dimension: Int): Bitmap?
external fun getPropertyInt(property: String): Int?
external fun setPropertyInt(
property: String,
value: Int,
)
external fun getPropertyDouble(property: String): Double?
external fun setPropertyDouble(
property: String,
value: Double,
)
external fun getPropertyBoolean(property: String): Boolean?
external fun setPropertyBoolean(
property: String,
value: Boolean,
)
external fun getPropertyString(property: String): String?
external fun setPropertyString(
property: String,
value: String,
)
external fun observeProperty(
property: String,
format: Int,
)
private val observers = mutableListOf<EventObserver>()
@JvmStatic
fun addObserver(o: EventObserver) {
synchronized(observers) {
observers.add(o)
}
}
@JvmStatic
fun removeObserver(o: EventObserver) {
synchronized(observers) {
observers.remove(o)
}
}
@JvmStatic
fun eventProperty(
property: String,
value: Long,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
}
}
@JvmStatic
fun eventProperty(
property: String,
value: Boolean,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
}
}
@JvmStatic
fun eventProperty(
property: String,
value: Double,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
}
}
@JvmStatic
fun eventProperty(
property: String,
value: String,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
}
}
@JvmStatic
fun eventProperty(property: String) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property)
}
}
}
@JvmStatic
fun event(eventId: Int) {
synchronized(observers) {
for (o in observers) {
o.event(eventId)
}
}
}
@JvmStatic
fun eventEndFile(
reason: Int,
error: Int,
) {
synchronized(observers) {
for (o in observers) {
o.eventEndFile(reason, error)
}
}
}
private val log_observers = mutableListOf<LogObserver>()
@JvmStatic
fun addLogObserver(o: LogObserver) {
synchronized(log_observers) {
log_observers.add(o)
}
}
@JvmStatic
fun removeLogObserver(o: LogObserver) {
synchronized(log_observers) {
log_observers.remove(o)
}
}
@JvmStatic
fun logMessage(
prefix: String,
level: Int,
text: String,
) {
synchronized(log_observers) {
for (o in log_observers) {
o.logMessage(prefix, level, text)
}
}
}
interface EventObserver {
fun eventProperty(property: String)
fun eventProperty(
property: String,
value: Long,
)
fun eventProperty(
property: String,
value: Boolean,
)
fun eventProperty(
property: String,
value: String,
)
fun eventProperty(
property: String,
value: Double,
)
fun event(eventId: Int)
fun eventEndFile(
reason: Int,
error: Int,
)
}
interface LogObserver {
fun logMessage(
prefix: String,
level: Int,
text: String,
)
}
object MpvFormat {
const val MPV_FORMAT_NONE: Int = 0
const val MPV_FORMAT_STRING: Int = 1
const val MPV_FORMAT_OSD_STRING: Int = 2
const val MPV_FORMAT_FLAG: Int = 3
const val MPV_FORMAT_INT64: Int = 4
const val MPV_FORMAT_DOUBLE: Int = 5
const val MPV_FORMAT_NODE: Int = 6
const val MPV_FORMAT_NODE_ARRAY: Int = 7
const val MPV_FORMAT_NODE_MAP: Int = 8
const val MPV_FORMAT_BYTE_ARRAY: Int = 9
}
object MpvEvent {
const val MPV_EVENT_NONE: Int = 0
const val MPV_EVENT_SHUTDOWN: Int = 1
const val MPV_EVENT_LOG_MESSAGE: Int = 2
const val MPV_EVENT_GET_PROPERTY_REPLY: Int = 3
const val MPV_EVENT_SET_PROPERTY_REPLY: Int = 4
const val MPV_EVENT_COMMAND_REPLY: Int = 5
const val MPV_EVENT_START_FILE: Int = 6
const val MPV_EVENT_END_FILE: Int = 7
const val MPV_EVENT_FILE_LOADED: Int = 8
@Deprecated("")
const val MPV_EVENT_IDLE: Int = 11
@Deprecated("")
const val MPV_EVENT_TICK: Int = 14
const val MPV_EVENT_CLIENT_MESSAGE: Int = 16
const val MPV_EVENT_VIDEO_RECONFIG: Int = 17
const val MPV_EVENT_AUDIO_RECONFIG: Int = 18
const val MPV_EVENT_SEEK: Int = 20
const val MPV_EVENT_PLAYBACK_RESTART: Int = 21
const val MPV_EVENT_PROPERTY_CHANGE: Int = 22
const val MPV_EVENT_QUEUE_OVERFLOW: Int = 24
const val MPV_EVENT_HOOK: Int = 25
}
object MpvLogLevel {
const val MPV_LOG_LEVEL_NONE: Int = 0
const val MPV_LOG_LEVEL_FATAL: Int = 10
const val MPV_LOG_LEVEL_ERROR: Int = 20
const val MPV_LOG_LEVEL_WARN: Int = 30
const val MPV_LOG_LEVEL_INFO: Int = 40
const val MPV_LOG_LEVEL_V: Int = 50
const val MPV_LOG_LEVEL_DEBUG: Int = 60
const val MPV_LOG_LEVEL_TRACE: Int = 70
}
object MpvEndFileReason {
const val MPV_END_FILE_REASON_EOF: Int = 0
const val MPV_END_FILE_REASON_STOP: Int = 2
const val MPV_END_FILE_REASON_QUIT: Int = 3
const val MPV_END_FILE_REASON_ERROR: Int = 4
const val MPV_END_FILE_REASON_REDIRECT: Int = 5
}
object MpvError {
const val MPV_ERROR_SUCCESS = 0
const val MPV_ERROR_EVENT_QUEUE_FULL = -1
const val MPV_ERROR_NOMEM = -2
const val MPV_ERROR_UNINITIALIZED = -3
const val MPV_ERROR_INVALID_PARAMETER = -4
const val MPV_ERROR_OPTION_NOT_FOUND = -5
const val MPV_ERROR_OPTION_FORMAT = -6
const val MPV_ERROR_OPTION_ERROR = -7
const val MPV_ERROR_PROPERTY_NOT_FOUND = -8
const val MPV_ERROR_PROPERTY_FORMAT = -9
const val MPV_ERROR_PROPERTY_UNAVAILABLE = -10
const val MPV_ERROR_PROPERTY_ERROR = -11
const val MPV_ERROR_COMMAND = -12
const val MPV_ERROR_LOADING_FAILED = -13
const val MPV_ERROR_AO_INIT_FAILED = -14
const val MPV_ERROR_VO_INIT_FAILED = -15
const val MPV_ERROR_NOTHING_TO_PLAY = -16
const val MPV_ERROR_UNKNOWN_FORMAT = -17
const val MPV_ERROR_UNSUPPORTED = -18
const val MPV_ERROR_NOT_IMPLEMENTED = -19
const val MPV_ERROR_GENERIC = -20
}
}

View file

@ -0,0 +1,46 @@
package com.github.damontecres.wholphin.util.mpv
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_DOUBLE
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_FLAG
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_INT64
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_NONE
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_STRING
object MPVProperty {
const val POSITION = "time-pos"
const val POSITION_FULL = "time-pos/full"
const val DURATION = "duration/full"
const val PAUSED = "pause"
const val PAUSED_FOR_CACHE = "paused-for-cache"
const val SPEED = "speed"
const val TRACK_LIST = "track-list"
const val ASPECT = "video-params/aspect"
const val ROTATE = "video-params/rotate"
const val TRACK_VIDEO = "current-tracks/video/image"
const val METADATA = "metadata"
const val HWDEC = "hwdec-current"
const val MUTE = "mute"
const val TRACK_AUDIO = "current-tracks/audio/selected"
const val SEEKABLE = "seekable"
const val SUBTITLE_TEXT = "sub-text"
val observedProperties =
mapOf(
POSITION to MPV_FORMAT_INT64,
// POSITION_FULL to MPV_FORMAT_DOUBLE,
DURATION to MPV_FORMAT_DOUBLE,
PAUSED to MPV_FORMAT_FLAG,
PAUSED_FOR_CACHE to MPV_FORMAT_FLAG,
SPEED to MPV_FORMAT_STRING,
TRACK_LIST to MPV_FORMAT_NONE,
ASPECT to MPV_FORMAT_DOUBLE,
ROTATE to MPV_FORMAT_DOUBLE,
TRACK_VIDEO to MPV_FORMAT_NONE,
METADATA to MPV_FORMAT_NONE,
HWDEC to MPV_FORMAT_NONE,
MUTE to MPV_FORMAT_FLAG,
TRACK_AUDIO to MPV_FORMAT_NONE,
SEEKABLE to MPV_FORMAT_FLAG,
SUBTITLE_TEXT to MPV_FORMAT_STRING,
)
}

View file

@ -0,0 +1,81 @@
package com.github.damontecres.wholphin.util.mpv
import com.github.damontecres.wholphin.util.profile.Codec
import com.github.damontecres.wholphin.util.profile.subtitleProfile
import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
val mpvDeviceProfile =
buildDeviceProfile {
name = "mpv"
// TODO
// maxStaticBitrate = maxBitrate
// maxStreamingBitrate = maxBitrate
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
videoCodec(Codec.Video.HEVC)
videoCodec(Codec.Video.H264)
audioCodec(*supportedAudioCodecs)
copyTimestamps = false
enableSubtitlesInManifest = true
}
directPlayProfile {
type = DlnaProfileType.VIDEO
container(
Codec.Container.ASF,
Codec.Container.AVI,
Codec.Container.HLS,
Codec.Container.M4V,
Codec.Container.MPEG,
Codec.Container.MPEGTS,
Codec.Container.MKV,
Codec.Container.MOV,
Codec.Container.MP4,
Codec.Container.MPG,
Codec.Container.OGM,
Codec.Container.OGV,
Codec.Container.TS,
Codec.Container.VOB,
Codec.Container.WEBM,
Codec.Container.WMV,
Codec.Container.XVID,
)
videoCodec(
Codec.Video.AV1,
Codec.Video.H264,
Codec.Video.HEVC,
Codec.Video.MPEG,
Codec.Video.MPEG2VIDEO,
Codec.Video.VP8,
Codec.Video.VP9,
)
audioCodec(*supportedAudioCodecs, Codec.Audio.WAV, Codec.Audio.OGG)
}
subtitleProfile(Codec.Subtitle.VTT, embedded = true, hls = true, external = true)
subtitleProfile(Codec.Subtitle.WEBVTT, embedded = true, hls = true, external = true)
subtitleProfile(Codec.Subtitle.SRT, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.SUBRIP, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.TTML, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.DVBSUB, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.DVDSUB, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.IDX, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.PGS, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.PGSSUB, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.ASS, encode = true, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.SSA, encode = true, embedded = true, external = true)
}

View file

@ -0,0 +1,817 @@
package com.github.damontecres.wholphin.util.mpv
import android.content.Context
import android.os.Build
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.annotation.OptIn
import androidx.compose.ui.graphics.Color
import androidx.media3.common.AudioAttributes
import androidx.media3.common.BasePlayer
import androidx.media3.common.C
import androidx.media3.common.DeviceInfo
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
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.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 androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_EOF
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_ERROR
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_STOP
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_AUDIO_RECONFIG
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_END_FILE
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_VIDEO_RECONFIG
import timber.log.Timber
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.time.Duration.Companion.seconds
/**
* This is barebones implementation of a [Player] which plays content using libmpv
*
* It doesn't support every feature or emit every event
*/
@kotlin.OptIn(ExperimentalAtomicApi::class)
@OptIn(UnstableApi::class)
class MpvPlayer(
private val context: Context,
enableHardwareDecoding: Boolean,
) : BasePlayer(),
MPVLib.EventObserver,
TrackSelector.InvalidationListener {
companion object {
private const val DEBUG = false
}
private var isPaused: Boolean = true
private var surface: Surface? = null
private val looper = Util.getCurrentOrMainLooper()
private val handler = Handler(looper)
private val listeners =
ListenerSet<Player.Listener>(
looper,
Clock.DEFAULT,
) { listener, eventFlags ->
listener.onEvents(this@MpvPlayer, Player.Events(eventFlags))
}
private val availableCommands: Player.Commands
private val trackSelector = DefaultTrackSelector(context)
private var mediaItem: MediaItem? = null
private var startPositionMs: Long = 0L
private var durationMs: Long = 0L
private var positionMs: Long = -1L
private var playbackState: Int = STATE_READY
@Volatile
var isReleased = false
private set
@Volatile
private var isLoadingFile = false
init {
Timber.v("config-dir=${context.filesDir.path}")
MPVLib.create(context)
MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", context.filesDir.path)
if (enableHardwareDecoding) {
MPVLib.setOptionString("hwdec", "mediacodec,mediacodec-copy")
MPVLib.setOptionString("vo", "gpu")
} else {
MPVLib.setOptionString("hwdec", "no")
}
MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes")
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
val cacheMegs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) 64 else 32
MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}")
MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}")
MPVLib.init()
MPVLib.setOptionString("force-window", "no")
MPVLib.setOptionString("idle", "yes")
// MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath)
MPVLib.addObserver(this)
MPVProperty.observedProperties.forEach(MPVLib::observeProperty)
availableCommands =
Player.Commands
.Builder()
.addAll(
COMMAND_PLAY_PAUSE,
COMMAND_PREPARE,
COMMAND_STOP,
COMMAND_SET_SPEED_AND_PITCH,
COMMAND_SET_SHUFFLE_MODE,
COMMAND_SET_REPEAT_MODE,
COMMAND_GET_CURRENT_MEDIA_ITEM,
COMMAND_GET_TIMELINE,
COMMAND_GET_METADATA,
// COMMAND_SET_PLAYLIST_METADATA,
COMMAND_SET_MEDIA_ITEM,
// COMMAND_CHANGE_MEDIA_ITEMS,
COMMAND_GET_TRACKS,
// COMMAND_GET_AUDIO_ATTRIBUTES,
// COMMAND_SET_AUDIO_ATTRIBUTES,
// COMMAND_GET_VOLUME,
// COMMAND_SET_VOLUME,
COMMAND_SET_VIDEO_SURFACE,
// COMMAND_GET_TEXT,
COMMAND_RELEASE,
).build()
trackSelector.init(this, DefaultBandwidthMeter.getSingletonInstance(context))
}
override fun getApplicationLooper(): Looper = looper
override fun addListener(listener: Player.Listener) {
if (DEBUG) Timber.v("addListener")
listeners.add(listener)
}
override fun removeListener(listener: Player.Listener) {
if (DEBUG) Timber.v("removeListener")
listeners.remove(listener)
}
override fun setMediaItems(
mediaItems: List<MediaItem>,
resetPosition: Boolean,
) {
throwIfReleased()
if (DEBUG) Timber.v("setMediaItems")
mediaItems.firstOrNull()?.let {
mediaItem = it
if (surface != null) {
loadFile(it)
}
}
}
override fun setMediaItems(
mediaItems: List<MediaItem>,
startIndex: Int,
startPositionMs: Long,
) {
if (DEBUG) Timber.v("setMediaItems")
this.startPositionMs = startPositionMs
setMediaItems(mediaItems.subList(startIndex, mediaItems.size), false)
}
override fun addMediaItems(
index: Int,
mediaItems: List<MediaItem>,
): Unit = throw UnsupportedOperationException()
override fun moveMediaItems(
fromIndex: Int,
toIndex: Int,
newIndex: Int,
): Unit = throw UnsupportedOperationException()
override fun replaceMediaItems(
fromIndex: Int,
toIndex: Int,
mediaItems: List<MediaItem>,
): Unit = throw UnsupportedOperationException()
override fun removeMediaItems(
fromIndex: Int,
toIndex: Int,
): Unit = throw UnsupportedOperationException()
override fun getAvailableCommands(): Player.Commands = availableCommands
override fun prepare() {
if (DEBUG) Timber.v("prepare")
durationMs = 0L
positionMs = -1L
playbackState = STATE_READY
}
override fun getPlaybackState(): Int {
if (DEBUG) Timber.v("getPlaybackState")
return playbackState
}
override fun getPlaybackSuppressionReason(): Int = Player.PLAYBACK_SUPPRESSION_REASON_NONE
override fun getPlayerError(): PlaybackException? {
TODO("Not yet implemented")
}
override fun setPlayWhenReady(playWhenReady: Boolean) {
if (isReleased) return
if (DEBUG) Timber.v("setPlayWhenReady")
if (playWhenReady) {
MPVLib.setPropertyBoolean("pause", false)
} else {
MPVLib.setPropertyBoolean("pause", true)
}
notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) {
onPlayWhenReadyChanged(
playWhenReady,
PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
)
}
}
override fun getPlayWhenReady(): Boolean {
if (DEBUG) Timber.v("getPlayWhenReady")
if (isReleased) return false
val isPaused = MPVLib.getPropertyBoolean("pause") ?: this.isPaused
return !isPaused
}
override fun setRepeatMode(repeatMode: Int) {
if (DEBUG) Timber.v("setRepeatMode")
}
override fun getRepeatMode(): Int = Player.REPEAT_MODE_OFF
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {
if (DEBUG) Timber.v("setShuffleModeEnabled")
}
override fun getShuffleModeEnabled(): Boolean = false
override fun isLoading(): Boolean = isLoadingFile
override fun getSeekBackIncrement(): Long = 10_000
override fun getSeekForwardIncrement(): Long = 30_000
override fun getMaxSeekToPreviousPosition(): Long = 10_000
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
if (DEBUG) Timber.v("setPlaybackParameters")
MPVLib.setPropertyDouble("speed", playbackParameters.speed.toDouble())
}
override fun getPlaybackParameters(): PlaybackParameters {
if (DEBUG) Timber.v("getPlaybackParameters")
val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f
return PlaybackParameters(speed)
}
override fun stop() {
if (DEBUG) Timber.v("stop")
if (isReleased) return
pause()
mediaItem = null
positionMs = -1L
durationMs = 0L
playbackState = STATE_IDLE
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) }
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_IDLE) }
}
override fun release() {
Timber.i("release")
if (!isReleased) {
MPVLib.removeObserver(this)
clearVideoSurfaceView(null)
MPVLib.destroy()
}
isReleased = true
}
override fun getCurrentTracks(): Tracks {
if (DEBUG) Timber.v("getCurrentTracks")
if (isReleased) return Tracks.EMPTY
return getTracks()
}
override fun getTrackSelectionParameters(): TrackSelectionParameters {
if (DEBUG) Timber.v("getTrackSelectionParameters")
return TrackSelectionParameters
.Builder()
.build()
}
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {
Timber.v("TrackSelection: setTrackSelectionParameters %s", parameters)
if (isReleased) return
val tracks = getTracks()
if (C.TRACK_TYPE_TEXT in parameters.disabledTrackTypes) {
// Subtitles disabled
Timber.v("TrackSelection: disabling subtitles")
MPVLib.setPropertyString("sid", "no")
}
if (C.TRACK_TYPE_AUDIO in parameters.disabledTrackTypes) {
// Audio disabled
Timber.v("TrackSelection: disabling audio")
MPVLib.setPropertyString("aid", "no")
}
Timber.v("TrackSelection: Got ${parameters.overrides.size} overrides")
parameters.overrides.forEach { (trackGroup, trackSelectionOverride) ->
val result =
tracks.groups.firstOrNull { it.mediaTrackGroup == trackGroup }?.let {
val id = it.mediaTrackGroup.getFormat(0).id
val splits = id?.split(":")
val trackId = splits?.getOrNull(1)
val propertyName =
when (it.mediaTrackGroup.type) {
C.TRACK_TYPE_AUDIO -> "aid"
C.TRACK_TYPE_VIDEO -> "vid"
C.TRACK_TYPE_TEXT -> "sid"
else -> null
}
Timber.v("TrackSelection: activating %s %s '%s'", propertyName, trackId, id)
if (trackId != null && propertyName != null) {
MPVLib.setPropertyString(propertyName, trackId)
true
} else {
false
}
}
if (result != true) {
Timber.w(
"Did not find track to select for type=%s, id=%s",
trackGroup.type,
trackGroup.getFormat(0).id,
)
}
}
}
override fun getMediaMetadata(): MediaMetadata {
if (DEBUG) Timber.v("getMediaMetadata")
return MediaMetadata.EMPTY
}
override fun getPlaylistMetadata(): MediaMetadata {
if (DEBUG) Timber.v("getPlaylistMetadata")
return MediaMetadata.EMPTY
}
override fun setPlaylistMetadata(mediaMetadata: MediaMetadata): Unit = throw UnsupportedOperationException()
override fun getCurrentTimeline(): Timeline {
if (DEBUG) Timber.v("getCurrentTimeline")
// TODO
return Timeline.EMPTY
}
override fun getCurrentPeriodIndex(): Int {
if (DEBUG) Timber.v("getCurrentPeriodIndex")
// TODO
return 0
}
override fun getCurrentMediaItemIndex(): Int {
if (DEBUG) Timber.v("getCurrentMediaItemIndex")
return 0
}
override fun getDuration(): Long {
if (DEBUG) Timber.v("getDuration")
if (isReleased) {
return durationMs
}
val duration =
MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds
?: durationMs
return duration
}
override fun getCurrentPosition(): Long {
if (DEBUG) Timber.v("getCurrentPosition")
if (isReleased) {
return positionMs
}
val position =
MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds
?: positionMs
return position
}
override fun getBufferedPosition(): Long {
if (DEBUG) Timber.v("getBufferedPosition")
return currentPosition + totalBufferedDuration
}
override fun getTotalBufferedDuration(): Long {
if (DEBUG) Timber.v("getTotalBufferedDuration")
if (isReleased) return 0
return MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds ?: 0
}
override fun isPlayingAd(): Boolean {
if (DEBUG) Timber.v("isPlayingAd")
return false
}
override fun getCurrentAdGroupIndex(): Int = C.INDEX_UNSET
override fun getCurrentAdIndexInAdGroup(): Int = C.INDEX_UNSET
override fun getContentPosition(): Long = currentPosition
override fun getContentBufferedPosition(): Long = bufferedPosition
override fun getAudioAttributes(): AudioAttributes = throw UnsupportedOperationException()
override fun setVolume(volume: Float): Unit = throw UnsupportedOperationException()
override fun getVolume(): Float = 1f
override fun clearVideoSurface(): Unit = throw UnsupportedOperationException()
override fun clearVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException()
override fun setVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException()
override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException()
override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException()
override fun setVideoSurfaceView(surfaceView: SurfaceView?) {
throwIfReleased()
if (DEBUG) Timber.v("setVideoSurfaceView")
val surface = surfaceView?.holder?.surface
if (surface != null) {
this.surface = surface
Timber.v("Queued attach")
MPVLib.attachSurface(surface)
MPVLib.setOptionString("force-window", "yes")
Timber.d("Attached surface")
mediaItem?.let(::loadFile)
if (mediaItem == null) {
Timber.w("mediaItem is null in setVideoSurfaceView")
}
} else {
clearVideoSurfaceView(null)
}
}
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {
Timber.d("clearVideoSurfaceView")
MPVLib.detachSurface()
MPVLib.setPropertyString("vo", "null")
MPVLib.setPropertyString("force-window", "no")
mediaItem = null
}
override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException()
override fun clearVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException()
override fun getVideoSize(): VideoSize {
if (DEBUG) Timber.v("getVideoSize")
if (isReleased) return VideoSize.UNKNOWN
val width = MPVLib.getPropertyInt("width")
val height = MPVLib.getPropertyInt("height")
return if (width != null && height != null) {
VideoSize(width, height)
} else {
VideoSize.UNKNOWN
}
}
override fun getSurfaceSize(): Size = throw UnsupportedOperationException()
override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO
override fun getDeviceInfo(): DeviceInfo {
if (DEBUG) Timber.v("getDeviceInfo")
return DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE).build()
}
override fun getDeviceVolume(): Int = throw UnsupportedOperationException()
override fun isDeviceMuted(): Boolean = throw UnsupportedOperationException()
@Deprecated("Deprecated in Java")
override fun setDeviceVolume(volume: Int): Unit = throw UnsupportedOperationException()
override fun setDeviceVolume(
volume: Int,
flags: Int,
): Unit = throw UnsupportedOperationException()
@Deprecated("Deprecated in Java")
override fun increaseDeviceVolume(): Unit = throw UnsupportedOperationException()
override fun increaseDeviceVolume(flags: Int): Unit = throw UnsupportedOperationException()
@Deprecated("Deprecated in Java")
override fun decreaseDeviceVolume(): Unit = throw UnsupportedOperationException()
override fun decreaseDeviceVolume(flags: Int): Unit = throw UnsupportedOperationException()
@Deprecated("Deprecated in Java")
override fun setDeviceMuted(muted: Boolean): Unit = throw UnsupportedOperationException()
override fun setDeviceMuted(
muted: Boolean,
flags: Int,
): Unit = throw UnsupportedOperationException()
override fun setAudioAttributes(
audioAttributes: AudioAttributes,
handleAudioFocus: Boolean,
): Unit = throw UnsupportedOperationException()
override fun seekTo(
mediaItemIndex: Int,
positionMs: Long,
seekCommand: Int,
isRepeatingCurrentItem: Boolean,
) {
if (DEBUG) Timber.v("seekTo")
if (isReleased) {
Timber.w("seekTo called after release")
return
}
if (mediaItemIndex == C.INDEX_UNSET) {
return
}
MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0)
}
override fun eventProperty(property: String) {
if (DEBUG) Timber.v("eventProperty: $property")
}
override fun eventProperty(
property: String,
value: Long,
) {
if (DEBUG) Timber.v("eventPropertyLong: $property=$value")
when (property) {
MPVProperty.POSITION -> positionMs = value.seconds.inWholeMilliseconds
}
}
override fun eventProperty(
property: String,
value: Boolean,
) {
if (DEBUG) Timber.v("eventPropertyBoolean: $property=$value")
when (property) {
MPVProperty.PAUSED -> {
isPaused = value
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) }
}
}
}
override fun eventProperty(
property: String,
value: String,
) {
if (DEBUG) Timber.v("eventPropertyString: $property=$value")
}
override fun eventProperty(
property: String,
value: Double,
) {
Timber.v("eventPropertyDouble: $property=$value")
when (property) {
MPVProperty.DURATION -> durationMs = value.seconds.inWholeMilliseconds
}
}
override fun event(eventId: Int) {
when (eventId) {
// MPV_EVENT_START_FILE -> {
// }
MPV_EVENT_FILE_LOADED -> {
isLoadingFile = false
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) }
Timber.d("event: MPV_EVENT_FILE_LOADED")
mediaItem?.let {
it.localConfiguration?.subtitleConfigurations?.forEach {
val url = it.uri.toString()
val title = it.label ?: "External Subtitles"
Timber.v("Adding external subtitle track '$title'")
MPVLib.command(arrayOf("sub-add", url, "auto", title))
}
}
notifyListeners(EVENT_RENDERED_FIRST_FRAME) { onRenderedFirstFrame() }
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(true) }
getTracks().let {
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
}
MPV_EVENT_PLAYBACK_RESTART -> {
Timber.d("event: MPV_EVENT_PLAYBACK_RESTART")
getTracks().let {
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
}
MPV_EVENT_AUDIO_RECONFIG -> {
Timber.d("event: MPV_EVENT_AUDIO_RECONFIG")
getTracks().let {
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
}
MPV_EVENT_VIDEO_RECONFIG -> {
Timber.d("event: MPV_EVENT_VIDEO_RECONFIG")
getTracks().let {
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
}
MPV_EVENT_END_FILE -> {
Timber.d("event: MPV_EVENT_END_FILE")
// Handled by eventEndFile
}
else -> {
Timber.v("event: $eventId")
}
}
}
override fun eventEndFile(
reason: Int,
error: Int,
) {
Timber.d("MPV_EVENT_END_FILE: %s %s", reason, error)
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) }
when (reason) {
MPV_END_FILE_REASON_EOF -> {
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
onPlaybackStateChanged(STATE_ENDED)
}
}
MPV_END_FILE_REASON_STOP -> {
// User initiated (eg stop, play next, etc)
}
MPV_END_FILE_REASON_ERROR -> {
Timber.e("libmpv error, error=%s", error)
notifyListeners(EVENT_PLAYER_ERROR) {
onPlayerError(
PlaybackException(
"libmpv error",
null,
error,
),
)
}
}
else -> {
// no-op
}
}
}
private fun loadFile(mediaItem: MediaItem) {
isLoadingFile = true
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) }
val url = mediaItem.localConfiguration?.uri.toString()
if (startPositionMs > 0) {
MPVLib.command(
arrayOf(
"loadfile",
url,
"replace",
"-1",
"start=${startPositionMs / 1000.0}",
),
)
} else {
MPVLib.command(arrayOf("loadfile", url, "replace", "-1"))
}
MPVLib.setPropertyString("vo", "gpu")
Timber.d("Called loadfile")
}
private fun throwIfReleased() {
if (isReleased) {
throw IllegalStateException("Cannot access MpvPlayer after it is released")
}
}
private fun notifyListeners(
eventId: Int,
block: Player.Listener.() -> Unit,
) {
handler.post {
listeners.queueEvent(eventId) {
block.invoke(it)
}
listeners.flushEvents()
}
}
private fun getTracks(): Tracks {
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: return Tracks.EMPTY
val groups =
(0..<trackCount).mapNotNull { idx ->
val type = MPVLib.getPropertyString("track-list/$idx/type")
val id = MPVLib.getPropertyInt("track-list/$idx/id")
val lang = MPVLib.getPropertyString("track-list/$idx/lang")
val codec = MPVLib.getPropertyString("track-list/$idx/codec")
val codecDescription = MPVLib.getPropertyString("track-list/$idx/codec-desc")
val isDefault = MPVLib.getPropertyBoolean("track-list/$idx/default") ?: false
val isForced = MPVLib.getPropertyBoolean("track-list/$idx/forced") ?: false
val isExternal = MPVLib.getPropertyBoolean("track-list/$idx/external") ?: false
val isSelected = MPVLib.getPropertyBoolean("track-list/$idx/selected") ?: false
val channelCount = MPVLib.getPropertyInt("track-list/$idx/demux-channel-count")
val title = MPVLib.getPropertyString("track-list/$idx/title")
if (type != null && id != null) {
// TODO do we need the real mimetypes?
val mimeType =
when (type) {
"video" -> MimeTypes.BASE_TYPE_VIDEO + "/todo"
"audio" -> MimeTypes.BASE_TYPE_AUDIO + "/todo"
"sub" -> MimeTypes.BASE_TYPE_TEXT + "/todo"
else -> "unknown/todo"
}
var flags = 0
if (isDefault) flags = flags or C.SELECTION_FLAG_DEFAULT
if (isForced) flags = flags or C.SELECTION_FLAG_FORCED
val builder =
Format
.Builder()
.setId("$idx:$id")
.setCodecs(codec)
.setSampleMimeType(mimeType)
.setLanguage(lang)
.setLabel(listOfNotNull(title, codecDescription).joinToString(","))
.setSelectionFlags(flags)
if (type == "video" && isSelected) {
builder.setWidth(MPVLib.getPropertyInt("width") ?: -1)
builder.setHeight(MPVLib.getPropertyInt("height") ?: -1)
}
channelCount?.let(builder::setChannelCount)
val format = builder.build()
val trackGroup = TrackGroup(format)
val group =
Tracks.Group(
trackGroup,
false,
intArrayOf(C.FORMAT_HANDLED),
booleanArrayOf(isSelected),
)
group
} else {
null
}
}
return Tracks(groups)
}
override fun onTrackSelectionsInvalidated() {
// no-op
}
var subtitleDelay: Double
get() {
if (isReleased) return 0.0
return MPVLib.getPropertyDouble("sub-delay") ?: 0.0
}
set(value) {
if (isReleased) return
MPVLib.setPropertyDouble("sub-delay", value)
}
}
fun MPVLib.setPropertyColor(
property: String,
color: Color,
) = MPVLib.setPropertyString(property, color.mpvFormat)
private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha"

View file

@ -22,7 +22,7 @@ private val downmixSupportedAudioCodecs =
Codec.Audio.MP3,
)
private val supportedAudioCodecs =
val supportedAudioCodecs =
arrayOf(
Codec.Audio.AAC,
Codec.Audio.AAC_LATM,
@ -495,7 +495,7 @@ fun createDeviceProfile(
}
// Little helper function to more easily define subtitle profiles
private fun DeviceProfileBuilder.subtitleProfile(
fun DeviceProfileBuilder.subtitleProfile(
format: String,
embedded: Boolean = false,
external: Boolean = false,