mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 08:01:20 +02:00
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:
parent
d7b333e519
commit
6be2662d4e
84 changed files with 3987 additions and 289 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ fun SeekBar(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
interactionSource = interactionSource,
|
||||
enabled = isEnabled,
|
||||
durationMs = player.contentDuration,
|
||||
durationMs = player.duration,
|
||||
seekBack = seekBack,
|
||||
seekForward = seekForward,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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?>>,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue